Skip to main content

magi/
graph.rs

1//! The competition graph.
2//!
3//! ```text
4//! prep ──► implement ×N ──► judge ×M (blind) ──► split? ──► deliberate ──► vote (private)
5//!                                                   │                          │
6//!                                                   └──── unanimous ───────────┤
7//!                                                                              ▼
8//!   merge ◄── gate ◄── review ×R + E2E, fix, repeat ◄── fold losers ◄──────── tally
9//! ```
10//!
11//! Every node persists before the next one starts, so a run can be resumed
12//! after a crash, a rate limit, or a reboot without re-spending the work that
13//! already landed.
14//!
15//! The design decision that matters most is *where the facilitator lives*.
16//! There is no moderator agent: magi assigns the labels, decides the
17//! presentation order, relays the transcript, and collects the final votes
18//! one-to-one. A moderator that never learns an author cannot leak one.
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36    ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51    GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52    MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, ParkCause,
53    QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory,
54    Tally, VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58    ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62/// How much verification output is kept and fed back to the fixer.
63const OUTPUT_TAIL: usize = 8_000;
64
65/// Bytes of a failing command's output kept in an event, so the reason a run
66/// stopped is readable from the report without opening `run.json`.
67const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
70/// command's pid before releasing the build cache's lease.
71const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
74/// command's pid to actually exit before giving up and releasing anyway.
75///
76/// A timeout means the process was asked to die (`kill_on_drop`,
77/// `start_kill`), not that it already has — on Windows in particular that can
78/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
79/// the instant the command returns would let the very next acquirer (this
80/// run's own next round, another run's verification, the janitor's prune)
81/// start touching the same directory while it might still be writing to it,
82/// so this polls the actual pid — real confirmation, not a fixed guess —
83/// until it is gone or this ceiling is reached. It is still not full
84/// process-tree reaping: a grandchild the timed-out process spawned and that
85/// outlives it independently is invisible to a pid check, and continuing to
86/// observe and collect *that* stays a different piece of work with its own
87/// owner. Set generously because the common case returns early the moment
88/// the pid is confirmed gone, not because every timeout pays this in full.
89const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91/// Consecutive review rounds with no tree progress (see
92/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
93/// instead of spending the rest of the round budget.
94///
95/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
96/// legitimately finds nothing left to change (its previous round's fix already
97/// covered it, and this round's reviewers re-raised only nits) looks the same
98/// as one that is spinning, for exactly one round. Two in a row is where the
99/// two stop being distinguishable, and a review round on this workload has
100/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
101/// third attempt at a tree that has not moved twice running is pure cost.
102/// This does not touch `review_rounds` itself, which stays the operator's
103/// call.
104pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
107/// a base that moved before giving up and leaving the run `Blocked` for a
108/// person.
109///
110/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
111/// that keeps moving faster than a run can catch it is not something more
112/// rebasing fixes, it is a person's call. Not the same *number as*
113/// `land_rounds` - this budget is spent before a pull request exists, land's
114/// after - but bounded for the identical reason, so it uses the same
115/// default. Counted across both call sites in [`Runner::finish_after_tally`]
116/// (once before review, once before the gate), because either one finding
117/// the base still moving is the same signal.
118const BASE_SYNC_ROUNDS: usize = 4;
119
120/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
121/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
122/// reply held no [`FixReport`].
123///
124/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
125/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
126/// "I'll pause here until the `cargo make check` background run reports
127/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
128/// No `FixReport` was ever collected from that seat, and the run moved on to
129/// the next review round regardless.
130///
131/// Bounded independently of `review_rounds` and `graph.retries`: this
132/// recovers one seat's missing report mid-round, not a new round of review or
133/// an ordinary parse retry, and must not itself become the unbounded wait the
134/// rest of this module exists to avoid.
135const MAX_FIX_CONTINUATIONS: usize = 2;
136
137/// One queued agent invocation.
138///
139/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
140/// CLI hung up on its own stream is asked again from the same job rather than
141/// rebuilt from scratch. See [`Runner::resume_undelivered`].
142#[derive(Clone)]
143struct SeatJob {
144    spec: AgentSpec,
145    seat: SeatState,
146    cwd: PathBuf,
147    prompt: String,
148    timeout: Duration,
149    allow_write: bool,
150    sessions: bool,
151    artifacts: PathBuf,
152    stem: String,
153    /// The prompt for a seat that has been handed to another roster agent
154    /// (a fresh session): everything the original seat would have
155    /// remembered. `None` when `prompt` already carries it, as the first
156    /// ranking and the implement prompt do. Never a resume-style prompt.
157    handover: Option<String>,
158}
159
160/// How the graph reads one agent invocation.
161///
162/// Quota is split out from an ordinary failure on purpose: a rate-limited call
163/// is known to fail again if retried now, so the retry loop must not spend an
164/// attempt on it. `Dropped` is split out for the opposite reason: unlike
165/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
166/// error JSON, never the agent's answer — a caller that matched only
167/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
168/// left to read that JSON as if it were usable output. `resume_undelivered`
169/// is the only caller that acts on it; everywhere else it is reported like an
170/// ordinary failure.
171enum AgentOutcome {
172    /// A usable output.
173    Ok(AgentOutput),
174    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
175    Quota(AgentOutput),
176    /// The CLI hung up on its own stream after billed work. See
177    /// [`agent::AgentOutput::work_undelivered`].
178    Dropped(AgentOutput),
179    /// Any other failure: a timeout, a bad exit code, an empty reply.
180    Failed(String),
181    /// The agent was never spawned: a shared resource it needs (the build
182    /// cache's lease) stayed busy past `[graph] timeout_resource_wait`, or the
183    /// wait was abandoned because the run is parking. Not a failure of the
184    /// agent: it must not advance a fallback chain, enter seat history, cost
185    /// quota or spend an attempt. [`RunState::resource_block`] says why.
186    ResourceBlocked(String),
187}
188
189/// A request to park the run at its next node boundary.
190///
191/// Cloning is how the request travels: the loop keeps one handle and hands a
192/// clone to each [`Runner`], and every clone points at the same flag. There
193/// is no channel because there is nothing to send - the only message is
194/// "park", it is idempotent, and a flag cannot be missed by a receiver that
195/// was not listening yet.
196///
197/// The boundary is what makes this cheap. Every node writes the run's state
198/// before the next one starts, and every node skips what is already recorded:
199/// `prep` returns early once candidates exist, `implement` asks only the seats
200/// with nothing on disk, `judge` returns early once judgements exist. So a
201/// parked run resumes into exactly the node it stopped before, and no agent
202/// work is thrown away. Killing the process mid-node, by contrast, loses
203/// whatever the seats in flight had not yet written - which for an implement
204/// wave is an hour of paid work.
205///
206/// A [`Runner`] watches two independent handles of this type - see
207/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
208/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
209/// clone covering the whole daemon's lifetime and is never asked to un-park,
210/// which is correct exactly because nothing is dispatched after it fires.
211/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
212/// that parks for an interrupted task must go on to run other tasks
213/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
214/// reusing the daemon-wide one.
215#[derive(Debug, Clone, Default)]
216pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<ParkCause>>>);
217
218impl Pause {
219    /// A pause nobody has asked for yet.
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Ask the run to park at its next node boundary. Idempotent.
226    pub fn park(&self) {
227        self.0.store(true, Ordering::SeqCst);
228    }
229
230    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
231    /// fold into the run's own `park` event - so an operator reading the run
232    /// later knows this was a deliberate interrupt rather than a shutdown or
233    /// a binary swap. The first reason recorded wins; a park already in
234    /// flight is not relabelled by a second, unrelated request.
235    pub fn park_because(&self, reason: impl Into<String>) {
236        self.park_for(ParkCause::Interrupt {
237            reason: reason.into(),
238        });
239    }
240
241    /// [`Pause::park`], recording the structured cause. The first cause
242    /// recorded wins, like [`Pause::park_because`].
243    pub fn park_for(&self, cause: ParkCause) {
244        let mut reason_guard = self
245            .1
246            .lock()
247            .unwrap_or_else(std::sync::PoisonError::into_inner);
248        if reason_guard.is_none() {
249            *reason_guard = Some(cause);
250        }
251        drop(reason_guard);
252        self.park();
253    }
254
255    /// Has a park been asked for?
256    #[must_use]
257    pub fn parked(&self) -> bool {
258        self.0.load(Ordering::SeqCst)
259    }
260
261    /// Why the park was asked for, when the caller used [`Pause::park_because`].
262    #[must_use]
263    pub fn reason(&self) -> Option<String> {
264        self.cause().map(|c| c.label())
265    }
266
267    /// The structured cause, when one was recorded.
268    #[must_use]
269    pub fn cause(&self) -> Option<ParkCause> {
270        self.1
271            .lock()
272            .unwrap_or_else(std::sync::PoisonError::into_inner)
273            .clone()
274    }
275}
276
277/// Drives one run.
278pub struct Runner {
279    /// Run state; public so the CLI can report on it.
280    pub state: RunState,
281    roles: ResolvedRoles,
282    sem: Arc<Semaphore>,
283    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
284    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
285    /// this is never the same handle as `interrupt`.
286    pause: Pause,
287    /// Set when `magi serve`'s interrupt scheduler wants this specific run
288    /// parked at its next node boundary, to let a task marked
289    /// [`crate::queue::Task::interrupt`] run alone before this one carries
290    /// on. Unlike `pause`, a fresh, unshared handle per run - see
291    /// [`Runner::watch_interrupt`].
292    interrupt: Pause,
293}
294
295/// Where the branch's own commits start: its merge base with the base branch
296/// as the remote has it now, else with the recorded `base_commit`. A branch
297/// rebased onto a base that moved past `base_commit` would otherwise count
298/// the base's commits as its own under `base_commit..branch`.
299async fn review_base(
300    repo: &Path,
301    remote: &str,
302    base_branch: &str,
303    base_commit: &str,
304    branch: &str,
305) -> String {
306    review_base_checked(repo, remote, base_branch, base_commit, branch)
307        .await
308        .0
309}
310
311/// [`review_base`] plus whether the base was read from a freshly fetched
312/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
313/// never older than `base_commit`'s view of the base), but the answer is then
314/// not trusted to rewrite a pull request's title.
315async fn review_base_checked(
316    repo: &Path,
317    remote: &str,
318    base_branch: &str,
319    base_commit: &str,
320    branch: &str,
321) -> (String, bool) {
322    let tracking = format!("{remote}/{base_branch}");
323    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
324    if git::rev_exists(repo, &tracking).await
325        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
326        && !mb.is_empty()
327    {
328        return (mb, fresh);
329    }
330    let mb = git::merge_base(repo, base_commit, branch)
331        .await
332        .ok()
333        .filter(|mb| !mb.is_empty())
334        .unwrap_or_else(|| base_commit.to_owned());
335    (mb, false)
336}
337
338/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
339/// when git cannot say or finds nothing: a stale list is better than a wrong
340/// or empty one.
341pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
342    if !is_review_run(state) {
343        return;
344    }
345    let base = review_base(
346        &state.repo,
347        &state.config.merge.remote,
348        &state.base_branch,
349        &state.base_commit,
350        branch,
351    )
352    .await;
353    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
354        && !subjects.is_empty()
355        && state.reviewed_commits.as_ref() != Some(&subjects)
356    {
357        state.reviewed_commits = Some(subjects);
358        state.save().ok();
359    }
360}
361
362/// Subjects of the base's commits between the recorded start and the branch's
363/// merge base: what a stale `base_commit..branch` would have mistaken for the
364/// branch's own work.
365async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
366    let (base, trusted) = review_base_checked(
367        &state.repo,
368        &state.config.merge.remote,
369        &state.base_branch,
370        &state.base_commit,
371        branch,
372    )
373    .await;
374    if !trusted {
375        return None;
376    }
377    git::subjects(&state.repo, &state.base_commit, &base)
378        .await
379        .ok()
380}
381
382/// May an adopted pull request's title be replaced with `computed`? Only when
383/// it is empty, magi's own shape, or a base commit's subject that leaked in;
384/// a title a person wrote stays. Never when `computed` is itself a leak.
385pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
386    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
387    if is_leak(computed) {
388        return false;
389    }
390    let cur = current.trim();
391    cur.is_empty()
392        || cur.starts_with(REVIEW_PROMPT_OPENING)
393        || cur.starts_with("chore: land candidate")
394        || cur.starts_with("magi: candidate")
395        || is_leak(cur)
396}
397
398/// The commit a run branches from: the base branch as the remote has it.
399///
400/// Two failures this replaces. A run used to branch off `HEAD` and so refused
401/// to start on a dirty tree, which made `magi serve` decline every task for as
402/// long as the operator had work in progress - most of the time. Branching off
403/// the *local* base branch fixed that and introduced a worse one: `land` merges
404/// the winner on GitHub, nothing updates the local ref, and the next run
405/// branches off a base missing everything the previous runs landed. Two tasks
406/// in a row from a phone would have had the second silently re-implementing
407/// against stale code and opening a pull request that reverted the first.
408///
409/// Only refs move here - no checkout, no local branch, no merge - so it is safe
410/// with uncommitted work in the tree. A machine with no network still starts:
411/// the fetch may fail and the local tip is used with a warning, because
412/// refusing to run offline is a worse failure than running against a base the
413/// operator can see for themselves.
414///
415/// One function, called by both entry points. Two answers to "where does a run
416/// branch from" is the kind of drift nobody notices until a diff is wrong.
417/// Bring the local `branch` in line with `<remote>/<branch>` before a review
418/// checks it out.
419///
420/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
421/// anything other than plain `git push` from this checkout (a jj colocated
422/// workspace, another clone) moves only the remote-tracking ref - so the local
423/// one can be a stale placeholder. It moves only when local is behind the remote or is an
424/// empty placeholder that diverged from it; unpushed local work is kept, and a real
425/// divergence is refused rather than guessed at.
426async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
427    let tracking = format!("{remote}/{branch}");
428    let fetched = git::fetch(repo, remote, branch).await;
429    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
430    let local_exists = git::branch_exists(repo, branch).await?;
431    if !fresh {
432        if !local_exists {
433            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
434        }
435        tracing::warn!(
436            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
437        );
438        return Ok(());
439    }
440    let remote_sha = git::rev_parse(repo, &tracking).await?;
441    if !local_exists {
442        git::git(repo, &["branch", branch, &tracking]).await?;
443        return Ok(());
444    }
445    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
446    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
447        return Ok(());
448    }
449    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
450        // Diverged. `reconcile` settles it only when it can prove nothing is
451        // lost: a local tip that is the remote's change rebased is pushed over
452        // it (lease pinned to the tip read here), a tip whose every extra
453        // commit is empty is a placeholder the remote's work replaced, and
454        // anything else is two different changes - a question for a person.
455        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
456            .await?
457        {
458            crate::reconcile::Reconciliation::Pushed => {
459                tracing::warn!(
460                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
461                    short(&local_sha),
462                    short(&remote_sha)
463                );
464                return Ok(());
465            }
466            crate::reconcile::Reconciliation::Placeholder => {}
467            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
468        }
469    }
470    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
471    if !out.ok() {
472        bail!(
473            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
474            short(&local_sha),
475            short(&remote_sha),
476            out.stderr
477        );
478    }
479    tracing::warn!(
480        "local `{branch}` was stale: fast-forwarded {} -> {}",
481        short(&local_sha),
482        short(&remote_sha)
483    );
484    Ok(())
485}
486
487async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
488    let tracking = format!("{remote}/{base_branch}");
489    let fetched = git::fetch(repo, remote, base_branch).await;
490    if let Ok(out) = &fetched
491        && out.ok()
492        && git::rev_exists(repo, &tracking).await
493    {
494        return git::rev_parse(repo, &tracking).await;
495    }
496    let why = match &fetched {
497        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
498        Ok(_) => format!("{remote} has no {base_branch}"),
499        Err(e) => e.to_string(),
500    };
501    // No fallback to the local branch: it may be behind, and branching off an
502    // old commit is the stale-checkout bug this check exists to prevent.
503    bail!(
504        "cannot read {tracking} ({why}); refusing to branch off the local \
505         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
506         base / remote in magi.toml"
507    )
508}
509
510/// Exclusive claim on one run's `magi fix` step, released on drop — including
511/// on an early return or a panic.
512///
513/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
514/// manual `magi fix` invocations against the same run are otherwise
515/// invisible to each other and would race to remove and recreate the same
516/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
517/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
518/// which is only ever reclaimed later, out of band, by
519/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
520/// `magi fix` invocation is not necessarily running under either of those, so
521/// nothing would ever sweep a lock a killed or crashed process left behind.
522/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
523/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
524/// lease use: an unreadable or unparsable pid, or a liveness query the
525/// platform cannot answer, reads as alive and the lock is left in place.
526struct FixClaim {
527    path: PathBuf,
528}
529
530impl FixClaim {
531    fn acquire(dir: &Path) -> Result<Self> {
532        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
533        let path = dir.join("fix.lock");
534        match Self::create(&path) {
535            Ok(claim) => Ok(claim),
536            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
537                if Self::reclaim_if_dead(&path) {
538                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
539                } else {
540                    bail!(
541                        "another `magi fix` is already running for this run ({} exists)",
542                        path.display()
543                    )
544                }
545            }
546            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
547        }
548    }
549
550    fn create(path: &Path) -> std::io::Result<Self> {
551        let mut f = std::fs::OpenOptions::new()
552            .write(true)
553            .create_new(true)
554            .open(path)?;
555        use std::io::Write as _;
556        // Read back by `reclaim_if_dead` on a later, stuck invocation.
557        writeln!(f, "{}", std::process::id())?;
558        Ok(Self {
559            path: path.to_owned(),
560        })
561    }
562
563    /// True if the lock named a process confirmed dead, in which case it was
564    /// also removed. Never true on an unreadable file, an unparsable pid, or
565    /// a liveness query the platform cannot answer — see this type's own doc.
566    fn reclaim_if_dead(path: &Path) -> bool {
567        let dead = std::fs::read_to_string(path)
568            .ok()
569            .and_then(|body| body.trim().parse::<u32>().ok())
570            .is_some_and(|pid| !crate::proc::pid_alive(pid));
571        dead && std::fs::remove_file(path).is_ok()
572    }
573}
574
575impl Drop for FixClaim {
576    fn drop(&mut self) {
577        let _ = std::fs::remove_file(&self.path);
578    }
579}
580
581impl Runner {
582    /// Start a fresh run against `repo`.
583    pub async fn start(
584        repo: &Path,
585        instruction: String,
586        config: Config,
587        origin: Origin,
588    ) -> Result<Self> {
589        Self::start_naming(repo, instruction, "", config, origin).await
590    }
591
592    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
593    /// searched for branch and commit references along with the instruction,
594    /// since a task may name the work it is about only in its title.
595    pub async fn start_naming(
596        repo: &Path,
597        instruction: String,
598        also_scan: &str,
599        config: Config,
600        origin: Origin,
601    ) -> Result<Self> {
602        let repo = git::toplevel(repo).await?;
603        let missing = agent::missing_programs(&config.agents);
604        if !missing.is_empty() {
605            bail!(
606                "these agent programs are not on PATH: {}. Fix the roster in \
607                 magi.toml or install them.",
608                missing.join(", ")
609            );
610        }
611        let base_branch =
612            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613                .await?;
614        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615        let roles = config.resolve_roles()?;
616        let max_parallel = config.graph.max_parallel.max(1);
617        // A task that points at work already in the repository starts from
618        // it; what the repository says about each reference is recorded.
619        let seeds = refs::resolve(
620            &repo,
621            &base_commit,
622            &config.merge.remote,
623            &format!("{also_scan}\n{instruction}"),
624        )
625        .await;
626        refs::plan(&repo, &seeds).await?;
627        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628        // Recorded before the first save, so a crash right after minting
629        // cannot leave a run with no origin. Legibility only: nothing reads it
630        // to decide anything.
631        state.origin = Some(origin);
632        for seed in &seeds {
633            state.event(
634                "seed",
635                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636            );
637        }
638        state.seeds = seeds;
639        state.event("start", format!("run {} created", state.id));
640        state.save()?;
641        Ok(Self {
642            state,
643            roles,
644            sem: Arc::new(Semaphore::new(max_parallel)),
645            pause: Pause::new(),
646            interrupt: Pause::new(),
647        })
648    }
649
650    /// Open a review-only run against work that already exists on `branch`.
651    ///
652    /// The expensive half of the graph is the implement wave — measured at
653    /// 111 and 134 internal tool-loop turns on this repository, against a
654    /// handful for a judge or a reviewer. The cheap half is worth running on
655    /// hand-written work too, and there was no way to reach it.
656    ///
657    /// No new state and no schema change are needed: a run with **one** viable
658    /// candidate and a tally already decided degrades `execute` to exactly
659    /// review → gate → merge, because `judge` skips a single-candidate field,
660    /// `deliberate` has fewer than two first choices to reconcile, `vote`
661    /// returns early, `tally` is already present and `fold_losers` has no
662    /// losers. Resuming such a run therefore does the right thing as well.
663    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664        Self::review_taking_over(repo, branch, config, None, origin).await
665    }
666
667    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
668    /// at the same task still has `branch` checked out, its worktree is
669    /// released first if that is safe (see [`crate::handover`]), and the
670    /// review refuses with the reason if it is not. `None` is a hand-run
671    /// review: it has no earlier attempts, so only a worktree of a dead run
672    /// magi recorded itself can be released.
673    pub async fn review_taking_over(
674        repo: &Path,
675        branch: &str,
676        config: Config,
677        takeover: Option<crate::handover::Takeover>,
678        origin: Origin,
679    ) -> Result<Self> {
680        let repo = git::toplevel(repo).await?;
681        let missing = agent::missing_programs(&config.agents);
682        if !missing.is_empty() {
683            bail!(
684                "these agent programs are not on PATH: {}. Fix the roster in \
685                 magi.toml or install them.",
686                missing.join(", ")
687            );
688        }
689        let base_branch =
690            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691                .await?;
692        if base_branch == branch {
693            bail!("`{branch}` is the base branch; there is nothing to review against");
694        }
695        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697        let roles = config.resolve_roles()?;
698        let max_parallel = config.graph.max_parallel.max(1);
699        let mut state = RunState::new(
700            repo.clone(),
701            base_branch,
702            base_commit.clone(),
703            String::new(),
704            config,
705        );
706        state.origin = Some(origin);
707
708        // Released before anything else touches the branch: a stale local
709        // branch is moved with `git branch -f`, which git refuses while an
710        // earlier attempt's worktree still has it checked out. Everything
711        // after this point that can fail puts the old run back.
712        // A hand-run review has no task, hence no earlier attempts, but a
713        // worktree of a dead run magi made may still be released.
714        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715            earlier: Vec::new(),
716            home: crate::run::home(),
717            choice: None,
718            task_instruction: None,
719        });
720        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
721        if let Some(released) = &released {
722            state.event(
723                "release",
724                format!(
725                    "took `{branch}` over from run {}: its worktree was released: {}",
726                    crate::run::short_of(&released.old_id),
727                    released.audit
728                ),
729            );
730        }
731        // The owner's answer to an earlier divergence question is applied
732        // here: after the release (git will not move a checked-out branch)
733        // and before the sync that would otherwise ask again.
734        if let Some(choice) = takeover.choice.as_ref()
735            && let Err(e) =
736                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
737                    .await
738        {
739            if let Some(released) = &released {
740                released.restore(&repo, branch).await;
741            }
742            return Err(e.context("applying the owner's answer about the diverged branch"));
743        }
744        // An earlier attempt of this task that published the task head, and
745        // whose work is the branch under review, hands that head over: the
746        // review run updates the same pull request. Without one (a legacy
747        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
748        // own head and its own open pull request is adopted as before.
749        for id in takeover.earlier.iter().rev() {
750            if let Ok(old) = RunState::load_under(id, &takeover.home)
751                && let Some(head) = old.pr_head.clone()
752                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
753            {
754                state.pr_head = Some(head);
755                break;
756            }
757        }
758        let opened = Self::open_review(
759            &repo,
760            branch,
761            state,
762            roles,
763            max_parallel,
764            base_commit,
765            &takeover,
766        )
767        .await;
768        if opened.is_err()
769            && let Some(released) = &released
770        {
771            released.restore(&repo, branch).await;
772        }
773        opened
774    }
775
776    /// What the newest earlier attempt that reviewed `branch` leaves for this
777    /// run (see [`crate::recovery`]). `None` for a hand-run review, which has
778    /// no earlier attempts. Reads the earlier record only: ownership of its
779    /// worktree and branch is [`crate::handover`]'s business.
780    async fn inherit(
781        repo: &Path,
782        branch: &str,
783        base_commit: &str,
784        takeover: &crate::handover::Takeover,
785    ) -> Option<crate::recovery::RecoveryRecord> {
786        use crate::recovery::{Relation, assess};
787        let newest = takeover.earlier.last()?;
788        let mut chosen = None;
789        for id in takeover.earlier.iter().rev() {
790            if let Ok(old) = RunState::load_under(id, &takeover.home)
791                && !old.reviews.is_empty()
792                && (old.winner().is_some_and(|w| w.branch == branch)
793                    || old.pr_head.as_deref() == Some(branch))
794            {
795                chosen = Some(old);
796                break;
797            }
798        }
799        let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
800            .await
801            .unwrap_or_default();
802        let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
803            Some(last) if last.head == tip => Relation::Same,
804            Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
805            _ => Relation::Diverged,
806        };
807        let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
808        Some(assess(from, chosen.as_ref(), base_commit, relation))
809    }
810
811    /// The half of [`Runner::review_taking_over`] that can fail after an
812    /// earlier attempt's worktree was released.
813    async fn open_review(
814        repo: &Path,
815        branch: &str,
816        mut state: RunState,
817        roles: ResolvedRoles,
818        max_parallel: usize,
819        base_commit: String,
820        takeover: &crate::handover::Takeover,
821    ) -> Result<Self> {
822        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
823        // The commit subjects are the closest thing to a task statement that
824        // existing work carries, and the reviewers are told as much.
825        let start = review_base(
826            repo,
827            &state.config.merge.remote,
828            &state.base_branch,
829            &base_commit,
830            branch,
831        )
832        .await;
833        let log = git::log_oneline(repo, &start, branch)
834            .await
835            .unwrap_or_default();
836        let log_text = if log.trim().is_empty() {
837            "(no commit messages)"
838        } else {
839            log.trim()
840        };
841        let mut instruction = format!(
842            "Review the work already on branch `{branch}`. There is no task \
843             statement: what the change claims to do is whatever its commits \
844             say.\n\n{log_text}"
845        );
846        // A retry of a task whose earlier attempt left reviews behind carries
847        // that attempt's evidence, and the task's own statement with it: the
848        // completion criteria are the task's, not whatever the commits claim.
849        if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
850            if let Some(task) = takeover
851                .task_instruction
852                .as_deref()
853                .filter(|t| !t.trim().is_empty())
854                && rec.reviewed_head.is_some()
855            {
856                instruction = format!(
857                    "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
858                    task.trim()
859                );
860                rec.criteria_inherited = true;
861            }
862            state.event("recovery", rec.summary());
863            state.recovery = Some(rec);
864        }
865        state.instruction = instruction;
866        state.reviewed_commits = Some(
867            git::subjects(repo, &start, branch)
868                .await
869                .unwrap_or_default(),
870        );
871
872        // An attached worktree, so the fixer's commits land on the branch under
873        // review rather than on a detached head nobody will look at again.
874        let worktree = state.worktree_root().join("under-review");
875        if let Some(parent) = worktree.parent() {
876            tokio::fs::create_dir_all(parent).await.ok();
877        }
878        let path = worktree.to_string_lossy().to_string();
879        git::git(repo, &["worktree", "add", &path, branch])
880            .await
881            .with_context(|| {
882                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
883            })?;
884
885        let setup = match ensure_setup_config(&mut state, repo).await {
886            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
887            Err(e) => Err(e),
888        };
889        if let Err(e) = setup {
890            git::worktree_remove(repo, &worktree).await.ok();
891            return Err(e);
892        }
893        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
894            .await
895            .unwrap_or(0);
896        if commits == 0 {
897            git::worktree_remove(repo, &worktree).await.ok();
898            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
899        }
900        let files = git::changed_files(&worktree, &base_commit, "HEAD")
901            .await
902            .map(|f| f.len())
903            .unwrap_or(0);
904        if files == 0
905            && let (Ok(head_tree), Ok(base_tree)) = (
906                git::tree_of(&worktree, "HEAD").await,
907                git::tree_of(&worktree, &base_commit).await,
908            )
909            && head_tree == base_tree
910        {
911            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
912            git::worktree_remove(repo, &worktree).await.ok();
913            bail!(
914                "`{branch}` at {} has a tree identical to base {}; this usually means \
915                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
916                 against `{}/{branch}`) rather than an empty change",
917                short(&head),
918                short(&base_commit),
919                state.config.merge.remote
920            );
921        }
922        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
923            .await
924            .unwrap_or_default();
925
926        state.candidates.push(Candidate {
927            index: 0,
928            label: 'A',
929            // Not an agent id on purpose: nothing in the roster wrote this, and
930            // the stats tables must not credit anyone with a win for it.
931            agent: EXISTING_BRANCH.to_owned(),
932            branch: branch.to_owned(),
933            worktree,
934            summary: String::new(),
935            stat,
936            files,
937            commits,
938            empty: false,
939            failed: None,
940            verified_noop: None,
941            duration_ms: 0,
942            folded: false,
943        });
944        state.tally = Some(Tally {
945            first_choice: BTreeMap::from([('A', 0)]),
946            borda: BTreeMap::new(),
947            winner: 'A',
948            rankings: 0,
949            unanimous_initial: false,
950            deliberated: false,
951            changed_votes: 0,
952            unanimous_final: false,
953            tie_break: None,
954            // No panel sat, so no quorum applies. Zero judges is the correct
955            // number for work that never competed, and must not be reported as
956            // a collapsed panel.
957            judges: 0,
958            present: 0,
959            quorum: 0,
960            met_quorum: true,
961            uncontested: Some("review-only run: nothing competed".to_owned()),
962        });
963        state.status = RunStatus::Reviewing;
964        state.event(
965            "start",
966            format!(
967                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
968                state.id
969            ),
970        );
971        state.save()?;
972        Ok(Self {
973            state,
974            roles,
975            sem: Arc::new(Semaphore::new(max_parallel)),
976            pause: Pause::new(),
977            interrupt: Pause::new(),
978        })
979    }
980
981    /// Reopen an existing run.
982    pub fn resume(id: &str) -> Result<Self> {
983        let state = RunState::load(id)?;
984        if let Some(to) = &state.released_to {
985            bail!(
986                "run {} cannot be resumed: its worktree was released to run {}",
987                state.short(),
988                crate::run::short_of(to)
989            );
990        }
991        let roles = state.config.resolve_roles()?;
992        let max_parallel = state.config.graph.max_parallel.max(1);
993        Ok(Self {
994            state,
995            roles,
996            sem: Arc::new(Semaphore::new(max_parallel)),
997            pause: Pause::new(),
998            interrupt: Pause::new(),
999        })
1000    }
1001
1002    /// Walk the graph to a terminal state, skipping nodes already recorded.
1003    ///
1004    /// Every way a run is driven - the queue loop, `magi run`, a resume from
1005    /// the phone - ends here, so this is the one place a run that ended
1006    /// Blocked / Stalled / Failed, or died with an error, is announced to the
1007    /// notification centre. Best-effort: see [`crate::notices::raise`].
1008    pub async fn execute(&mut self) -> Result<()> {
1009        let result = self.execute_graph().await;
1010        self.mark_driver_exited();
1011        let ended = if result.is_err() {
1012            Some(crate::notices::run_stopped(&self.state.id, &self.state))
1013        } else {
1014            crate::notices::run_ended(&self.state)
1015        };
1016        if let Some(notice) = ended {
1017            crate::notices::raise(notice);
1018        }
1019        result
1020    }
1021
1022    /// Record that this process no longer drives the run, so its pid (a
1023    /// daemon's outlives the run) is not read as a live driver.
1024    ///
1025    /// Written onto the record as it is on disk, never this copy: another
1026    /// process may have resumed the run (recording its own pid and clearing
1027    /// the flag) or released its worktree since this copy was read, and
1028    /// saving over that would mark a running driver dead. Only a record still
1029    /// naming this process as the driver is touched.
1030    fn mark_driver_exited(&mut self) {
1031        self.state.driver_exited = true;
1032        let pid = std::process::id();
1033        let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1034            return;
1035        };
1036        let Ok(mut disk) = RunState::load(&self.state.id) else {
1037            return;
1038        };
1039        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1040            return;
1041        }
1042        disk.driver_exited = true;
1043        if let Err(e) = disk.save() {
1044            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1045        }
1046    }
1047
1048    /// Validate ownership and persist the driver claim in one critical section.
1049    fn claim_driver(&mut self) -> Result<()> {
1050        let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1051        let disk = RunState::load(&self.state.id)?;
1052        crate::pr_head::ensure_owner(&disk)?;
1053        // Moving again, so it is no longer parked. Set before the walk rather
1054        // than in `resume`, so every way of re-entering the graph clears it
1055        // and a card cannot claim a run is waiting to be resumed while the
1056        // agents are already working.
1057        self.state.parked = false;
1058        self.state.resource_wait = None;
1059        self.state.resource_block = None;
1060        // Any seat this state still lists as answering belongs to whatever
1061        // process last drove this run — this one included, if it crashed
1062        // mid-wave. Cleared and flushed immediately, before anything else
1063        // runs, so a resume can never show a seat as live when nothing is
1064        // asking it anything yet; the node that actually dispatches the next
1065        // wave repopulates it.
1066        self.state.clear_active();
1067        // Recorded in the same spot, and flushed together with the clear
1068        // above: this is the pid a reader checks (`RunState::liveness`) when
1069        // no daemon claim exists to answer "is a process still driving this
1070        // run" — a plain `magi run` / `magi review` typed into a terminal
1071        // claims nothing there. Always overwritten, never only-if-absent, so
1072        // a resumed run's stale pid from a previous, possibly-dead process
1073        // can never survive into this one's own report. Unlike
1074        // `clear_active`, this changes on every single `execute()` call, so
1075        // the save below is now unconditional rather than only-if-cleared.
1076        //
1077        // `driver_started_at` is recorded in the same breath, from this same
1078        // pid, so `liveness` can tell a live pid that is genuinely still us
1079        // apart from one the OS has since handed to an unrelated process —
1080        // see that field's own doc for why the pid alone is not enough.
1081        // A resume that raced a takeover: the record on disk says the worktree
1082        // was handed to a later run after this copy was read. Saving over it
1083        // would erase that and drive a run with nothing to run in.
1084        if let Some(to) = &disk.released_to {
1085            bail!(
1086                "run {} cannot continue: its worktree was released to run {}",
1087                self.state.short(),
1088                crate::run::short_of(to)
1089            );
1090        }
1091        let pid = std::process::id();
1092        self.state.driver_pid = Some(pid);
1093        self.state.driver_started_at = crate::proc::process_started_at(pid);
1094        self.state.driver_exited = false;
1095        self.state.begin_pass();
1096        self.state.save()?;
1097        Ok(())
1098    }
1099
1100    async fn execute_graph(&mut self) -> Result<()> {
1101        self.claim_driver()?;
1102        // Publication already passed review and gate. Resume only that node:
1103        // syncing to a newer base here could change the verified commit.
1104        if self.state.pr_publication_pending {
1105            self.merge().await?;
1106            if self.resource_halt()? {
1107                return Ok(());
1108            }
1109            self.state.save()?;
1110            self.settle_questions();
1111            return Ok(());
1112        }
1113        // A run that already lost its quorum never resumes into the verdict
1114        // machinery: `deliberate` and `vote` would otherwise clobber the
1115        // stalled marker back to Voting and the run would keep going past a
1116        // verdict that is no longer trustworthy. Everything already recorded is
1117        // kept, so the run stays resumable (or foldable) for a human to pick up.
1118        //
1119        // On --resume the run gets one chance to repair itself: the seats a
1120        // rate limit took out are re-asked. If their quota has since reset and
1121        // the quorum is restored, the run picks up and finishes; otherwise it
1122        // stays stale and still-resumable for a later retry. If it does not
1123        // recover, the returned status stays `Stalled` and nothing was
1124        // clobbered (the recovery only mutates entries for the lost seats).
1125        if self.state.status == RunStatus::Stalled {
1126            if self.recover_stall().await? {
1127                self.finish_after_tally().await?;
1128            } else {
1129                // Still below quorum: persist the marker and stay resumable.
1130                self.state.save()?;
1131            }
1132            return Ok(());
1133        }
1134        // A run parked inside `land` - watching CI, mid fix-round, or
1135        // waiting on the owner's merge approval - resumes directly into it,
1136        // never back through `prep`. Everything before `merge` already
1137        // concluded; that is the only way `status` reaches `Landing` in the
1138        // first place. Re-walking `review_loop` first would also be actively
1139        // wrong: its own status recomputation (see its doc) treats any
1140        // clean round as reason to set `status` to `Gating`, which would
1141        // clobber this marker before `merge` ever ran, and this run would
1142        // never find its way back into `land` at all.
1143        if self.state.status == RunStatus::Landing {
1144            self.run_land().await?;
1145            // `run_land` may have settled the run right here - CI came back
1146            // green and the PR merged, say - without ever passing back
1147            // through `merge`'s own trailing call. Whatever it left `status`
1148            // as is what this has to read.
1149            self.settle_questions();
1150            return Ok(());
1151        }
1152        // A run parked at `merge` on the owner's word about a withheld PR text
1153        // resumes straight into `merge`. Re-walking the tail would sync to the
1154        // base again (possibly rebasing, possibly spending the fixer) after the
1155        // gate had passed, and then push a tree nobody reviewed or gated.
1156        if self.state.github_text.is_some()
1157            && self.state.merge.is_none()
1158            && self.state.status == RunStatus::Gating
1159        {
1160            self.merge().await?;
1161            if self.resource_halt()? {
1162                return Ok(());
1163            }
1164            self.state.save()?;
1165            return Ok(());
1166        }
1167        self.prep().await?;
1168        if self.park_here()? {
1169            return Ok(());
1170        }
1171        self.advise().await?;
1172        if self.park_here()? {
1173            return Ok(());
1174        }
1175        self.implement().await?;
1176        if self.park_here()? {
1177            return Ok(());
1178        }
1179        // `after_implement` already saved the state and settled any open
1180        // questions when it set this; nothing later in the graph has
1181        // anything to judge.
1182        if self.state.status == RunStatus::VerifiedNoop {
1183            return Ok(());
1184        }
1185        self.judge().await?;
1186        if self.park_here()? {
1187            return Ok(());
1188        }
1189        self.deliberate().await?;
1190        if self.park_here()? {
1191            return Ok(());
1192        }
1193        self.vote().await?;
1194        if self.park_here()? {
1195            return Ok(());
1196        }
1197        self.tally()?;
1198        // A verdict that lost its quorum is not trustworthy: do not review,
1199        // gate, or merge on it. Everything already done is kept, so the run
1200        // stays resumable (or foldable); the human can replace the agent that
1201        // ran out of quota and pick it up.
1202        if self.state.status == RunStatus::Stalled {
1203            // Persist the stalled marker now — the normal end-of-execute save
1204            // below is below this early return, and without it a resumed run
1205            // would reload a pre-tally status and keep going.
1206            self.state.save()?;
1207            return Ok(());
1208        }
1209        self.finish_after_tally().await?;
1210        Ok(())
1211    }
1212
1213    /// A wave could not start its agents because a shared resource stayed busy
1214    /// (see [`RunState::resource_block`]): end the node without recording any
1215    /// result, leaving the run `Blocked` and resumable. No seat history,
1216    /// handover, quota loss or candidate state was written for it.
1217    fn stop_for_resource(&mut self) -> Result<()> {
1218        // A wait abandoned because the run is parking stays a park.
1219        if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1220        {
1221            self.state.status = RunStatus::Blocked;
1222        }
1223        self.state.save()
1224    }
1225
1226    /// Stop walking the graph if a node ended on a resource block.
1227    fn resource_halt(&mut self) -> Result<bool> {
1228        if self.state.resource_block.is_none() {
1229            return Ok(false);
1230        }
1231        self.stop_for_resource()?;
1232        Ok(true)
1233    }
1234
1235    /// Park here if asked to, recording it in the run's own timeline.
1236    ///
1237    /// Returns whether the caller should stop walking the graph. The state is
1238    /// saved either way by the node that just finished; this adds the event so
1239    /// the operator's card says why a run that is neither finished nor moving
1240    /// is sitting where it is.
1241    fn park_here(&mut self) -> Result<bool> {
1242        let parking = self.pause.parked() || self.interrupt.parked();
1243        if !parking && self.state.resource_block.is_some() {
1244            self.stop_for_resource()?;
1245            return Ok(true);
1246        }
1247        // Either handle asking is enough - see `Pause`'s own doc for why
1248        // they are never the same one. `interrupt` is checked second so a
1249        // reason it carries is preferred in the message below over a plain
1250        // shutdown park racing it at the same boundary.
1251        if !self.pause.parked() && !self.interrupt.parked() {
1252            return Ok(false);
1253        }
1254        let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1255        let why = match cause.as_ref().map(ParkCause::label) {
1256            Some(reason) => format!(
1257                "parked after `{}` ({reason}) — resume to carry on from here",
1258                self.state.status.as_str()
1259            ),
1260            None => format!(
1261                "parked after `{}` — resume to carry on from here",
1262                self.state.status.as_str()
1263            ),
1264        };
1265        self.state.event("park", why);
1266        self.state
1267            .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1268        self.state.parked = true;
1269        self.state.save()?;
1270        Ok(true)
1271    }
1272
1273    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1274    pub fn on_pause(&mut self, pause: Pause) {
1275        self.pause = pause;
1276    }
1277
1278    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1279    /// scheduler asking this one run - and no other - to park so a task
1280    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1281    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1282    /// handle.
1283    pub fn watch_interrupt(&mut self, pause: Pause) {
1284        self.interrupt = pause;
1285    }
1286
1287    /// Abandon this run's own open questions, once `status` has actually
1288    /// settled rather than merely paused.
1289    ///
1290    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1291    /// either back up with the candidates, the review round and the seat
1292    /// sessions already on disk, so a question an implementer asked mid-round
1293    /// may still get a real answer read by a real resume. Only the statuses
1294    /// `resumable` excludes are actually final: the run merged, it reached
1295    /// `Ready` with nothing left to do, it failed outright with no
1296    /// established point to continue from, or every candidate agreed, with
1297    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1298    /// every one of those the seat that asked is gone for good, exactly like
1299    /// the run being deleted under `magi run rm` - so the same cleanup
1300    /// applies, worded for what actually happened instead of "the run was
1301    /// deleted".
1302    ///
1303    /// Best-effort and silent on success: called from every place `status`
1304    /// can land on one of those three, including ones a resumed run revisits,
1305    /// so it must cost nothing when there was nothing open to begin with.
1306    fn settle_questions(&mut self) {
1307        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1308            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1309        }
1310    }
1311
1312    /// The tail of the graph after a trustworthy tally: fold losers, review,
1313    /// gate, merge, and persist.
1314    async fn finish_after_tally(&mut self) -> Result<()> {
1315        // Before the losers are folded: their branches are what the graft
1316        // reads from.
1317        self.graft().await?;
1318        if self.resource_halt()? {
1319            return Ok(());
1320        }
1321        self.fold_losers().await?;
1322        // Before review starts, and again right before the gate: a run's
1323        // review rounds can themselves take long enough for the base to move
1324        // a second time, and the gate is the one node whose "green" gets
1325        // acted on.
1326        self.sync_to_base().await?;
1327        if self.state.status == RunStatus::AlreadyInBase {
1328            return Ok(());
1329        }
1330        self.review_loop().await?;
1331        if self.resource_halt()? {
1332            return Ok(());
1333        }
1334        self.sync_to_base().await?;
1335        if self.state.status == RunStatus::AlreadyInBase {
1336            return Ok(());
1337        }
1338        self.gate().await?;
1339        self.merge().await?;
1340        if self.resource_halt()? {
1341            return Ok(());
1342        }
1343        self.state.save()?;
1344        Ok(())
1345    }
1346
1347    // ---------------------------------------------------------------- prep
1348
1349    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1350    /// run with the step and its output in the event; it is never swallowed.
1351    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1352    /// resume starts over instead of finding candidates and missing seats.
1353    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1354        let result = match ensure_setup_config(&mut self.state, repo).await {
1355            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1356            Err(e) => Err(e),
1357        };
1358        if let Err(e) = result {
1359            git::worktree_remove(repo, wt).await.ok();
1360            if in_prep {
1361                self.rollback_prep(repo).await;
1362            }
1363            self.state.status = RunStatus::Blocked;
1364            self.state.event(
1365                "setup",
1366                format!("worktree setup failed in {}: {e:#}", wt.display()),
1367            );
1368            self.state.save()?;
1369            return Err(e);
1370        }
1371        Ok(())
1372    }
1373
1374    /// Undo everything `prep` created, so a resume re-runs it whole.
1375    async fn rollback_prep(&mut self, repo: &Path) {
1376        let root = self.state.worktree_root();
1377        for c in std::mem::take(&mut self.state.candidates) {
1378            git::worktree_remove(repo, &c.worktree).await.ok();
1379            git::branch_delete(repo, &c.branch).await.ok();
1380        }
1381        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1382        for j in 1..=seats {
1383            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1384                .await
1385                .ok();
1386        }
1387        for k in 1..=self.state.config.graph.advisors {
1388            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1389                .await
1390                .ok();
1391        }
1392    }
1393
1394    async fn prep(&mut self) -> Result<()> {
1395        if !self.state.candidates.is_empty() {
1396            return Ok(());
1397        }
1398        self.state.status = RunStatus::Prep;
1399        let repo = self.state.repo.clone();
1400        let base = self.state.base_commit.clone();
1401        let plan = refs::plan(&repo, &self.state.seeds).await?;
1402        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1403        let root = self.state.worktree_root();
1404        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1405
1406        // The hook is the write-time half of the blindness contract; the
1407        // presentation filter in `blind` is the half that cannot be bypassed.
1408        let hooks_dir = self.state.dir().join("hooks");
1409        if self.state.config.blind.commit_msg_hook {
1410            std::fs::create_dir_all(&hooks_dir)
1411                .with_context(|| format!("create {}", hooks_dir.display()))?;
1412            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1413            let path = hooks_dir.join("commit-msg");
1414            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1415            make_executable(&path)?;
1416            // Ref-counted rather than a plain idempotent set: with more than
1417            // one run able to be in flight in the same repository at once
1418            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1419            // true?" check cannot tell "another run of mine still needs
1420            // this" from "nobody does", and the run that happens to finish
1421            // first would disable the hook out from under a sibling still
1422            // relying on it.
1423            git::acquire_worktree_config(&repo).await?;
1424            self.state.enabled_worktree_config = true;
1425        }
1426
1427        // `[worktree] setup` hides its products per worktree, which needs the
1428        // same per-worktree config the hook does. Held until fold, like it.
1429        ensure_setup_config(&mut self.state, &repo).await?;
1430
1431        for (index, (spec, label)) in self
1432            .roles
1433            .implementers
1434            .clone()
1435            .into_iter()
1436            .zip(labels)
1437            .enumerate()
1438        {
1439            let branch = self.state.branch_for(label);
1440            let worktree = root.join(format!("cand-{label}"));
1441            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1442            if self.state.config.blind.commit_msg_hook {
1443                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1444            }
1445            git::local_exclude(&worktree, "/.magi/").await?;
1446            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1447                // Nothing of this prep may survive: a resume must start over,
1448                // not find half the candidates and skip the rest.
1449                git::worktree_remove(&repo, &worktree).await.ok();
1450                git::branch_delete(&repo, &branch).await.ok();
1451                self.rollback_prep(&repo).await;
1452                self.state.status = RunStatus::Blocked;
1453                self.state
1454                    .event("prep", format!("worktree setup failed: {e:#}"));
1455                self.state.save()?;
1456                return Err(e);
1457            }
1458            for pick in &plan.picks {
1459                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1460                    self.state.status = RunStatus::Blocked;
1461                    self.state
1462                        .event("prep", format!("cannot apply referenced commit: {e}"));
1463                    self.state.save()?;
1464                    return Err(e);
1465                }
1466            }
1467            self.state.candidates.push(Candidate {
1468                index,
1469                label,
1470                agent: spec.id.clone(),
1471                branch,
1472                worktree,
1473                summary: String::new(),
1474                stat: String::new(),
1475                files: 0,
1476                commits: 0,
1477                empty: false,
1478                failed: None,
1479                verified_noop: None,
1480                duration_ms: 0,
1481                folded: false,
1482            });
1483        }
1484
1485        for j in 1..=self.roles.judges.len() {
1486            let wt = root.join(format!("judge-{j}"));
1487            if !wt.exists() {
1488                git::worktree_add_detached(&repo, &wt, &base).await?;
1489                self.setup_seat_worktree(&repo, &wt, true).await?;
1490            }
1491        }
1492
1493        // Disposable, detached checkouts for the design-deliberation stage's
1494        // advisor seats — the same shape as the judges' above, at the same
1495        // base commit, since advisors also only ever read. Sized off the
1496        // configured count directly rather than a resolved roster: unlike
1497        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1498        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1499        // `prep` has no `ResolvedRoles` field to read a count from here.
1500        if self.state.config.graph.advise {
1501            for k in 1..=self.state.config.graph.advisors {
1502                let wt = root.join(format!("advisor-{k}"));
1503                if !wt.exists() {
1504                    git::worktree_add_detached(&repo, &wt, &base).await?;
1505                    self.setup_seat_worktree(&repo, &wt, true).await?;
1506                }
1507            }
1508        }
1509
1510        // A judge cannot tell it is looking at its own patch — the seats keep
1511        // separate conversations — but a panel that shares agents with the
1512        // field is less independent than it looks, and that is worth saying out
1513        // loud once per run rather than leaving it in the config.
1514        let authors: Vec<&str> = self
1515            .roles
1516            .implementers
1517            .iter()
1518            .map(|a| a.id.as_str())
1519            .collect();
1520        let overlap: Vec<String> = self
1521            .roles
1522            .judges
1523            .iter()
1524            .enumerate()
1525            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1526            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1527            .collect();
1528        if !overlap.is_empty() {
1529            let note = format!(
1530                "{} also authored a candidate; blind, but the panel is less \
1531                 independent than {} distinct agents would be",
1532                overlap.join(", "),
1533                self.roles.judges.len()
1534            );
1535            self.state.event("prep", note);
1536        }
1537
1538        self.state.event(
1539            "prep",
1540            format!(
1541                "{} candidates, {} judges, base {} ({})",
1542                self.state.candidates.len(),
1543                self.roles.judges.len(),
1544                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1545                self.state.base_branch
1546            ),
1547        );
1548        self.state.status = RunStatus::Implementing;
1549        self.state.save()?;
1550        Ok(())
1551    }
1552
1553    // -------------------------------------------------------------- advise
1554
1555    /// The design-deliberation stage: independent, read-only advisor seats
1556    /// each sketch a design before any implementer touches the repository,
1557    /// and (when at least one produced a usable proposal) a synthesis seat
1558    /// blends them into a brief `implement` carries in every candidate's
1559    /// prompt.
1560    ///
1561    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1562    /// advisors` is the proposal count. Everything here is best-effort and
1563    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1564    /// that cannot reach quota, or a synthesis seat that produced nothing
1565    /// usable all leave `implement` exactly as it was before this stage
1566    /// existed — the task instruction alone — rather than failing the whole
1567    /// competition over an enrichment stage. Every outcome is still recorded
1568    /// as an event, so a run that got nothing from this stage says why.
1569    ///
1570    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1571    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1572    /// resumed run whose stage failed would re-run it, and re-spend the
1573    /// agent calls, on every reentry before `implement`.
1574    ///
1575    /// Also skipped once any candidate shows implementation progress — the
1576    /// exact predicate `implement` itself uses to decide a candidate is no
1577    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1578    /// is not enough: a run created by an older binary that predates this
1579    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1580    /// an already-`Implementing`-or-later run under this build would
1581    /// otherwise walk straight back through `prep` (a no-op once candidates
1582    /// exist) into this node and spawn every advisor seat against worktrees
1583    /// `prep` never recreated — after implementation has already started,
1584    /// which is exactly the invariant this stage exists to guarantee.
1585    async fn advise(&mut self) -> Result<()> {
1586        let implement_untouched = self
1587            .state
1588            .candidates
1589            .iter()
1590            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1591        if !self.state.config.graph.advise || self.state.advise_attempted {
1592            return Ok(());
1593        }
1594        if !implement_untouched {
1595            self.state.event(
1596                "advise",
1597                "skipping the design-deliberation stage: at least one \
1598                 candidate already shows implementation progress, so this \
1599                 run is past the point the stage exists to run before"
1600                    .to_owned(),
1601            );
1602            self.state.advise_attempted = true;
1603            self.state.save()?;
1604            return Ok(());
1605        }
1606        let run_id = self.state.id.clone();
1607        let prompts = self.state.config.prompts.clone();
1608        let instruction = self.state.instruction.clone();
1609        let language = self.state.config.graph.language.clone();
1610        let root = self.state.worktree_root();
1611        let n = self.state.config.graph.advisors;
1612        let where_recorded = self.state.dir().join("run.json");
1613
1614        let seats = match self.state.config.advisors() {
1615            Ok(seats) if !seats.is_empty() => seats,
1616            Ok(_) => {
1617                self.state.event(
1618                    "advise",
1619                    format!(
1620                        "[graph] advisors is 0; skipping the design-deliberation \
1621                         stage and continuing without a synthesis brief (see {})",
1622                        where_recorded.display()
1623                    ),
1624                );
1625                self.state.advise_attempted = true;
1626                self.state.save()?;
1627                return Ok(());
1628            }
1629            Err(e) => {
1630                self.state.event(
1631                    "advise",
1632                    format!(
1633                        "could not resolve advisor seats ({e:#}); continuing \
1634                         without a design-deliberation brief (see {})",
1635                        where_recorded.display()
1636                    ),
1637                );
1638                self.state.advise_attempted = true;
1639                self.state.save()?;
1640                return Ok(());
1641            }
1642        };
1643
1644        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1645        let artifacts = agent::artifacts_dir(&self.state.dir());
1646        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1647
1648        let mut jobs = Vec::new();
1649        for (i, spec) in seats.iter().cloned().enumerate() {
1650            let seat_key = format!("advisor-{}", i + 1);
1651            let seat = self.seat(&seat_key, &spec.id);
1652            jobs.push(SeatJob {
1653                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1654                spec,
1655                seat,
1656                cwd: worktrees[i % worktrees.len()].clone(),
1657                timeout,
1658                allow_write: false,
1659                sessions: false,
1660                artifacts: artifacts.clone(),
1661                stem: seat_key,
1662                handover: None,
1663            });
1664        }
1665
1666        self.state.event(
1667            "advise",
1668            format!(
1669                "{} advisor seat(s) sketching a design in parallel",
1670                jobs.len()
1671            ),
1672        );
1673        let mut quota_losses = Vec::new();
1674        let cache = self.state.config.cache_dir();
1675        let ctx = WaveCtx {
1676            carry_seats: false,
1677            pauses: Some((&self.pause, &self.interrupt)),
1678            run: &run_id,
1679            node: "advise",
1680            prompts: &prompts,
1681            cache: cache.as_deref(),
1682            round: None,
1683        };
1684        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1685        let results = ask_json_wave::<Proposal>(
1686            jobs,
1687            Arc::clone(&self.sem),
1688            self.state.config.graph.retries,
1689            &advisor_roster,
1690            &ctx,
1691            &mut quota_losses,
1692            &mut self.state,
1693            &|p: &Proposal| p.validate(),
1694        )
1695        .await;
1696        if self.state.resource_block.is_some() {
1697            return self.stop_for_resource();
1698        }
1699        self.state.quota.extend(quota_losses);
1700
1701        let mut records = Vec::with_capacity(results.len());
1702        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1703            let agent_id = seat.agent.clone();
1704            self.state.seats.insert(seat.key.clone(), seat);
1705            match res {
1706                Ok((proposal, out)) => {
1707                    self.state
1708                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1709                    records.push(advise::AdvisorRecord::proposed(
1710                        i + 1,
1711                        agent_id,
1712                        proposal,
1713                        out.duration_ms,
1714                    ));
1715                }
1716                Err(e) => {
1717                    self.state.event(
1718                        "advise",
1719                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1720                    );
1721                    records.push(advise::AdvisorRecord::failed(
1722                        i + 1,
1723                        agent_id,
1724                        e.to_string(),
1725                    ));
1726                }
1727            }
1728        }
1729
1730        let mut advice = advise::Advice {
1731            records,
1732            synthesis: None,
1733        };
1734        if advice.proposals().is_empty() {
1735            self.state.event(
1736                "advise",
1737                "no advisor produced a usable proposal; continuing without a \
1738                 synthesis brief"
1739                    .to_owned(),
1740            );
1741        } else {
1742            match self
1743                .synthesize_brief(
1744                    &advice,
1745                    &instruction,
1746                    &language,
1747                    &worktrees[0],
1748                    &artifacts,
1749                    &run_id,
1750                    &prompts,
1751                    cache.as_deref(),
1752                )
1753                .await
1754            {
1755                Ok(Some(text)) => {
1756                    self.state.event(
1757                        "advise",
1758                        "synthesized a design brief for the implementer".to_owned(),
1759                    );
1760                    advice.synthesis = Some(text);
1761                }
1762                Ok(None) => {
1763                    self.state.event(
1764                        "advise",
1765                        "the synthesis seat produced nothing usable; continuing \
1766                         without a design brief"
1767                            .to_owned(),
1768                    );
1769                }
1770                Err(e) => {
1771                    self.state.event(
1772                        "advise",
1773                        format!("could not synthesize a design brief: {e:#}"),
1774                    );
1775                }
1776            }
1777        }
1778        advise::apply_reflection(&mut advice);
1779
1780        self.state.advice = Some(advice);
1781        self.state.advise_attempted = true;
1782        self.state.save()?;
1783        Ok(())
1784    }
1785
1786    /// The synthesis seat: reads every advisor's proposal and blends them
1787    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1788    /// out of [`Runner::advise`] only for readability — it is not called
1789    /// anywhere else.
1790    ///
1791    /// Picked the same way [`crate::talk`]'s standing conversation and
1792    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1793    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1794    /// order (a claude seat, else the first runnable agent in roster order)
1795    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1796    /// in [`crate::config`] for why a dedicated field exists here at all.
1797    #[allow(clippy::too_many_arguments)]
1798    async fn synthesize_brief(
1799        &mut self,
1800        advice: &advise::Advice,
1801        instruction: &str,
1802        language: &str,
1803        cwd: &Path,
1804        artifacts: &Path,
1805        run_id: &str,
1806        prompts: &Prompts,
1807        cache: Option<&Path>,
1808    ) -> Result<Option<String>> {
1809        let chain = agent::pick_chain(
1810            &self.state.config.agents,
1811            self.state.config.roles.synthesizer.as_ref(),
1812            &agent::installed,
1813            "synthesizer",
1814        )?;
1815        let proposals = advice.proposals();
1816        let mut prompt = prompt::with_overlay(
1817            prompt::synthesize_brief(instruction, &proposals, language),
1818            prompts.overlay("advise"),
1819        );
1820        if cache.is_some() {
1821            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1822            // below — see `prompt::build_cache_note`'s doc for why telling a
1823            // read-only seat to build through the shared cache is exactly how
1824            // a sandbox's write refusal gets misread as a defect.
1825            prompt.push('\n');
1826            prompt.push_str(&prompt::build_cache_note("advise", false));
1827        }
1828        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1829        // Each id is tried once, in order; a quota hit, error or unusable
1830        // answer moves to the next. The seat is single-turn (`sessions:
1831        // false`) and the prompt is the whole context, so a fallback agent
1832        // needs nothing carried over.
1833        let mut last = None;
1834        for (n, spec) in chain.iter().enumerate() {
1835            if n > 0 {
1836                self.state
1837                    .event("advise", format!("synthesis falling back to {}", spec.id));
1838            }
1839            let mut seat = self.seat("advise-synthesis", &spec.id);
1840            let outcome = agent::invoke(
1841                spec,
1842                &mut seat,
1843                &Invocation {
1844                    cwd,
1845                    prompt: &prompt,
1846                    timeout,
1847                    allow_write: false,
1848                    unsandboxed: false,
1849                    sessions: false,
1850                    artifacts,
1851                    stem: &if n == 0 {
1852                        "advise-synthesis".to_owned()
1853                    } else {
1854                        format!("advise-synthesis-{}", spec.id)
1855                    },
1856                    run: run_id,
1857                    node: "advise",
1858                    cache_dir: None,
1859                    attachments: &[],
1860                    writable: &[],
1861                },
1862            )
1863            .await;
1864            if outcome.is_ok() {
1865                self.state.seats.insert(seat.key.clone(), seat);
1866            }
1867            let advance = agent::chain_advances(&outcome);
1868            last = Some(outcome);
1869            if !advance {
1870                break;
1871            }
1872        }
1873        // Exhausted: the last attempt's result is what a single failed seat
1874        // would have produced.
1875        let out = last.expect("a chain holds at least one agent")?;
1876        if !out.usable() {
1877            return Ok(None);
1878        }
1879        let text =
1880            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1881        Ok((!text.trim().is_empty()).then_some(text))
1882    }
1883
1884    // ----------------------------------------------------------- implement
1885
1886    async fn implement(&mut self) -> Result<()> {
1887        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1888        // agent files with `magi task add` name the run that paid for it. The
1889        // prompt overlay is cloned alongside it because the waves borrow it
1890        // while `self` is mutably borrowed by the node's own bookkeeping.
1891        let run_id = self.state.id.clone();
1892        let prompts = self.state.config.prompts.clone();
1893        let todo: Vec<usize> = self
1894            .state
1895            .candidates
1896            .iter()
1897            .enumerate()
1898            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1899            .map(|(i, _)| i)
1900            .collect();
1901        if todo.is_empty() {
1902            return self.after_implement();
1903        }
1904        self.state.status = RunStatus::Implementing;
1905
1906        let language = self.state.config.graph.language.clone();
1907        let github_language = self.state.config.graph.github_language.clone();
1908        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1909        let sessions = self.state.config.graph.sessions;
1910        let artifacts = agent::artifacts_dir(&self.state.dir());
1911        // The design-deliberation stage's blended brief, when `advise` found
1912        // one — carried into every implementer's prompt the same way
1913        // regardless of which candidate it is.
1914        let brief = self
1915            .state
1916            .advice
1917            .as_ref()
1918            .and_then(|a| a.synthesis.as_deref())
1919            .map(str::to_owned);
1920        let attachments = self.state.attachments.clone();
1921
1922        let mut jobs = Vec::new();
1923        for &i in &todo {
1924            let (index, label, worktree) = {
1925                let c = &self.state.candidates[i];
1926                (c.index, c.label, c.worktree.clone())
1927            };
1928            let spec = self.roles.implementers[index].clone();
1929            let seat_key = format!("impl-{label}");
1930            let seat = self.seat(&seat_key, &spec.id);
1931            let instruction = seeded_instruction(&self.state);
1932            jobs.push(SeatJob {
1933                spec,
1934                seat,
1935                prompt: prompt::implement_in(
1936                    &instruction,
1937                    &worktree.to_string_lossy(),
1938                    &language,
1939                    &github_language,
1940                    brief.as_deref(),
1941                    &attachments,
1942                ),
1943                cwd: worktree,
1944                timeout,
1945                allow_write: true,
1946                sessions,
1947                artifacts: artifacts.clone(),
1948                stem: format!("impl-{label}"),
1949                handover: None,
1950            });
1951        }
1952
1953        self.state.event(
1954            "implement",
1955            format!("{} candidates in parallel", jobs.len()),
1956        );
1957        // Kept so a seat whose CLI hung up can be asked again from the same
1958        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1959        // can update a seat's own entry once a fallback agent takes it over —
1960        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1961        // whichever agent actually answered, not the one that quota'd out.
1962        let mut sent = jobs.clone();
1963        let cache = self.state.config.cache_dir();
1964        let ctx = WaveCtx {
1965            carry_seats: false,
1966            pauses: Some((&self.pause, &self.interrupt)),
1967            run: &run_id,
1968            node: "implement",
1969            prompts: &prompts,
1970            cache: cache.as_deref(),
1971            round: None,
1972        };
1973        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1974        if wave_blocked(&results) {
1975            return self.stop_for_resource();
1976        }
1977        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1978            .await;
1979        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1980            .await;
1981        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1982            .await;
1983        if self.state.resource_block.is_some() {
1984            return self.stop_for_resource();
1985        }
1986
1987        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1988            let seat_key = seat.key.clone();
1989            // A quota fallback (`resume_seat_handovers`) may have handed this
1990            // seat to a different agent than the one `prep` recorded on the
1991            // candidate; the stats tables and any later fixer-defaults-to-
1992            // winner's-author lookup must credit whoever actually answered —
1993            // unless every fallback also quota'd out, in which case nobody
1994            // actually answered and crediting the last agent tried would
1995            // erase every earlier agent's own quota loss from the stats
1996            // tables instead of just this one seat's.
1997            let agent = seat.agent.clone();
1998            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1999            self.state.seats.insert(seat.key.clone(), seat);
2000            let label = self.state.candidates[i].label;
2001            let worktree = self.state.candidates[i].worktree.clone();
2002            let base = self.state.base_commit.clone();
2003
2004            let (summary, duration, failed, verified_claim) = match out {
2005                AgentOutcome::Ok(o) => {
2006                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2007                    let failed = (!o.usable()).then(|| {
2008                        if o.timed_out {
2009                            "agent timed out".to_owned()
2010                        } else {
2011                            format!("agent exited with {:?}", o.exit_code)
2012                        }
2013                    });
2014                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2015                    (text, o.duration_ms, failed, verified_claim)
2016                }
2017                // Left un-resumed by `resume_undelivered` (a dirty tree
2018                // already rescues the work, or there was no session left to
2019                // resume into) — reported like the ordinary failure it is,
2020                // never as if `o.text` (the CLI's raw error JSON) were an
2021                // answer.
2022                AgentOutcome::Dropped(o) => {
2023                    let why = o
2024                        .dropped
2025                        .as_ref()
2026                        .map(|d| d.why.as_str())
2027                        .unwrap_or("the CLI ended the stream without delivering its answer");
2028                    (
2029                        String::new(),
2030                        o.duration_ms,
2031                        Some(format!("the CLI dropped the stream ({why})")),
2032                        None,
2033                    )
2034                }
2035                AgentOutcome::Quota(o) => {
2036                    self.state.quota.push(QuotaLoss {
2037                        seat: seat_key,
2038                        node: "implement".to_owned(),
2039                        at: Timestamp::now(),
2040                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2041                    });
2042                    (
2043                        String::new(),
2044                        o.duration_ms,
2045                        Some("rate limited (quota); produced no change".to_owned()),
2046                        None,
2047                    )
2048                }
2049                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2050                    (String::new(), 0, Some(e), None)
2051                }
2052            };
2053
2054            // Rescue anything the agent edited but never committed: an
2055            // uncommitted candidate would silently be an empty one.
2056            let rescued = match git::rescue_commit(
2057                &worktree,
2058                &format!("magi: candidate {label} (uncommitted work)"),
2059            )
2060            .await
2061            {
2062                Ok(r) => {
2063                    self.state.note_withheld("implement", &r.withheld);
2064                    r.committed
2065                }
2066                Err(_) => false,
2067            };
2068            let commits = git::commits_ahead(&worktree, &base, "HEAD")
2069                .await
2070                .unwrap_or(0);
2071            let patch = git::diff(&worktree, &base, "HEAD")
2072                .await
2073                .unwrap_or_default();
2074            let stat = git::diff_stat(&worktree, &base, "HEAD")
2075                .await
2076                .unwrap_or_default();
2077            let files = git::changed_files(&worktree, &base, "HEAD")
2078                .await
2079                .map(|f| f.len())
2080                .unwrap_or(0);
2081            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2082
2083            let c = &mut self.state.candidates[i];
2084            if !exhausted_the_fallback_chain {
2085                c.agent = agent;
2086            }
2087            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2088            c.stat = stat;
2089            c.files = files;
2090            c.commits = commits;
2091            c.duration_ms = duration;
2092            c.empty = commits == 0 || patch.trim().is_empty();
2093            // An agent that failed but still produced a committed change stays
2094            // in the running: the patch is what gets judged, not the exit code.
2095            c.failed = match failed {
2096                Some(_) if c.empty => failed,
2097                _ => None,
2098            };
2099            // Only an empty candidate can be a verified no-op: a claim next
2100            // to a real patch is not what the marker is for, and `c.failed`
2101            // being `Some` here already implies `verified_claim` was never
2102            // set (see the guard above the match that produced it).
2103            c.verified_noop = if c.empty { verified_claim } else { None };
2104            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2105                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2106                (None, true, Some(_), _) => {
2107                    format!("candidate {label}: no change produced (agent-verified no-op)")
2108                }
2109                (None, true, None, _) => format!("candidate {label}: no change produced"),
2110                (None, false, _, true) => {
2111                    format!(
2112                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2113                    )
2114                }
2115                (None, false, _, false) => {
2116                    format!("candidate {label}: {files} files, {commits} commits")
2117                }
2118            };
2119            self.state.event("implement", note);
2120            self.state.save()?;
2121        }
2122
2123        self.after_implement()
2124    }
2125
2126    /// Ask again, once, for work a CLI did and then failed to hand over.
2127    ///
2128    /// [`agent::dropped_stream`] recognises the one shape observed: an error
2129    /// status with an empty response and a usage report showing output tokens,
2130    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
2131    /// seven minutes and 14,267 output tokens that arrived as an empty
2132    /// candidate, because `agy`'s own subscriber fell behind and hung up.
2133    ///
2134    /// Two conditions, and both matter:
2135    ///
2136    /// - **Only when the tree is untouched.** Often the agent has already
2137    ///   written its files and only the closing message was lost; the rescue
2138    ///   commit below picks that up and there is nothing to ask for. Re-asking
2139    ///   then would pay for a second implementation of work already on disk.
2140    /// - **Once.** A CLI that drops one stream can drop the next, and this
2141    ///   node is the most expensive in the graph.
2142    ///
2143    /// The re-ask is a resume, not a re-run: `has_context` is true because the
2144    /// dropped reply still carried its `conversation_id`, so the seat is asked
2145    /// to finish what it was doing rather than sent the whole task again. It
2146    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2147    /// node's - for the same reason a re-ranked judge does: restating finished
2148    /// work is not the work.
2149    ///
2150    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2151    /// same way until it resets, while an abandoned conversation is still
2152    /// there to be picked up.
2153    async fn resume_undelivered(
2154        &mut self,
2155        results: &mut [(usize, SeatState, AgentOutcome)],
2156        sent: &[SeatJob],
2157        prompts: &Prompts,
2158        run_id: &str,
2159    ) {
2160        for (wi, seat, out) in results.iter_mut() {
2161            let Some(dropped) = (match &*out {
2162                AgentOutcome::Dropped(o) => o.dropped.clone(),
2163                _ => None,
2164            }) else {
2165                continue;
2166            };
2167            let Some(job) = sent.get(*wi) else { continue };
2168            // Already on disk? Then only the closing message was lost.
2169            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2170                self.state.event(
2171                    "implement",
2172                    format!(
2173                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2174                         work is in the tree",
2175                        seat.key, dropped.output_tokens, dropped.why
2176                    ),
2177                );
2178                continue;
2179            }
2180            // The re-ask only makes sense as a resume: `resume_after_drop`
2181            // says nothing about the task, trusting the seat to still hold it.
2182            // Without a session to resume — sessions disabled, or this CLI's
2183            // drop shape happened not to carry a session id — that prompt
2184            // would open a brand-new conversation with no context at all,
2185            // which is worse than leaving this as the ordinary failure it
2186            // already is.
2187            if !has_context(&job.spec, seat, job.sessions) {
2188                self.state.event(
2189                    "implement",
2190                    format!(
2191                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2192                         is no session left to resume",
2193                        seat.key, dropped.output_tokens, dropped.why
2194                    ),
2195                );
2196                continue;
2197            }
2198            self.state.event(
2199                "implement",
2200                format!(
2201                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2202                     conversation",
2203                    seat.key, dropped.output_tokens, dropped.why
2204                ),
2205            );
2206            let mut retry = job.clone();
2207            retry.seat = seat.clone();
2208            retry.prompt = prompt::resume_after_drop(&dropped.why);
2209            retry.timeout = retry_budget(job.timeout, true);
2210            retry.stem = format!("{}-resume", job.stem);
2211            let cache = self.state.config.cache_dir();
2212            let ctx = WaveCtx {
2213                carry_seats: false,
2214                pauses: Some((&self.pause, &self.interrupt)),
2215                run: run_id,
2216                node: "implement",
2217                prompts,
2218                cache: cache.as_deref(),
2219                round: None,
2220            };
2221            let (resumed_seat, resumed) =
2222                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2223            *seat = resumed_seat;
2224            *out = resumed;
2225        }
2226    }
2227
2228    /// Fall an implement seat through to the next untried agent in the
2229    /// implementer roster when it lost to quota — or, since the handover was
2230    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2231    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2232    /// quota path itself being unchanged — instead of leaving the
2233    /// seat's loss final the moment one agent's account runs dry.
2234    ///
2235    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2236    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2237    /// truncates to the single slot rotation picked, so a solo task whose one
2238    /// implementer hits quota mid-run used to have nothing else to try. This
2239    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2240    /// unrotated roster — which is the only place the *other* candidates in
2241    /// the machine's roster still exist once `implementers` has been cut down
2242    /// to size.
2243    ///
2244    /// Walks forward from just past the seat's own original position in the
2245    /// roster, never wrapping back to the front: a later candidate slot (say
2246    /// `beta`, the roster's second entry) must fall through to the *next*
2247    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2248    /// almost certainly a different candidate's own agent already — and once
2249    /// the roster's tail is exhausted there is nothing left to fall through
2250    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2251    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2252    /// let this retry that id forever. The loop keeps falling through until
2253    /// an attempt lands something other than `Quota` or the roster's tail
2254    /// runs out of untried ids, at which point the seat is left exactly as
2255    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2256    /// `QuotaLoss` recorded, the candidate failed/empty.
2257    ///
2258    /// `sent` is taken mutably and updated with the fallback agent's spec:
2259    /// `resume_unconfirmed_commands`, which runs after this and also reads
2260    /// `sent`, must see whichever agent actually ended up answering the seat
2261    /// — reading the stale, original spec there would check session
2262    /// eligibility against the wrong CLI and could hand a fallback agent's
2263    /// session id to the agent that just lost the seat to quota.
2264    ///
2265    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2266    /// — `self.seat` only reuses state when the agent id is unchanged, so
2267    /// handing it a different id already gets this for free. Reusing the old
2268    /// seat would resume a different CLI's session as if it were a
2269    /// continuation of this one.
2270    ///
2271    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2272    /// a quota loss cuts an agent off mid-turn, so anything already in the
2273    /// tree is unfinished work, not a completed candidate a re-ask would pay
2274    /// for twice. A dirty tree is rescued into a commit first (the same
2275    /// neutral-identity rescue `implement`'s own outcome loop gives every
2276    /// candidate) so the next agent starts clean.
2277    ///
2278    /// The new agent gets the implementer's full prompt and full
2279    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2280    /// it has no session and no context, and is implementing the task from
2281    /// nothing, unlike a resumed drop which is only restating work already
2282    /// done.
2283    ///
2284    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2285    /// `implement` event, never into `self.state.quota` — that is what
2286    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2287    /// go unspent, and a seat that ultimately recovered on its second or
2288    /// third agent is not the stalled panel that check exists to catch. Only
2289    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2290    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2291    /// outcome loop already has — this helper never pushes to it itself.
2292    async fn resume_seat_handovers(
2293        &mut self,
2294        results: &mut [(usize, SeatState, AgentOutcome)],
2295        sent: &mut [SeatJob],
2296        prompts: &Prompts,
2297        run_id: &str,
2298    ) {
2299        let instruction = seeded_instruction(&self.state);
2300        let language = self.state.config.graph.language.clone();
2301        let github_language = self.state.config.graph.github_language.clone();
2302        let brief = self
2303            .state
2304            .advice
2305            .as_ref()
2306            .and_then(|a| a.synthesis.as_deref())
2307            .map(str::to_owned);
2308        let attachments = self.state.attachments.clone();
2309        for (wi, seat, out) in results.iter_mut() {
2310            // Who holds the other candidate seats of this wave right now
2311            // (earlier handovers already written back to `sent`).
2312            let others: BTreeSet<String> = sent
2313                .iter()
2314                .enumerate()
2315                .filter(|(j, _)| j != wi)
2316                .map(|(_, j)| j.spec.id.clone())
2317                .collect();
2318            let Some(job) = sent.get_mut(*wi) else {
2319                continue;
2320            };
2321            // Where the seat's own original agent sits in the roster — the
2322            // fallback walk starts just past here, never at the front, so a
2323            // later candidate slot's quota loss does not fall back onto an
2324            // earlier slot's own agent.
2325            let start = self
2326                .roles
2327                .implementer_roster
2328                .iter()
2329                .position(|s| s.id == job.spec.id)
2330                .unwrap_or(0);
2331            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2332            let mut fallback_attempt = 0usize;
2333            let mut prev: Option<FailClass> = None;
2334            while let Some(cur) = FailClass::of(&*out) {
2335                if !should_hand_over(prev.as_ref(), &cur) {
2336                    break;
2337                }
2338                let Some(next) =
2339                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2340                        .cloned()
2341                else {
2342                    break;
2343                };
2344                tried.insert(next.id.clone());
2345                fallback_attempt += 1;
2346
2347                if let Ok(r) = git::rescue_commit(
2348                    &job.cwd,
2349                    &format!(
2350                        "magi: candidate {} (uncommitted work before {} fallback)",
2351                        seat.key,
2352                        if cur == FailClass::Quota {
2353                            "quota"
2354                        } else {
2355                            "handover"
2356                        }
2357                    ),
2358                )
2359                .await
2360                {
2361                    self.state.note_withheld("implement", &r.withheld);
2362                }
2363
2364                record_handover(
2365                    &mut self.state,
2366                    "implement",
2367                    &seat.key,
2368                    &seat.agent,
2369                    &next.id,
2370                    &cur,
2371                    &fail_reason(&*out),
2372                );
2373                prev = Some(cur.clone());
2374
2375                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2376                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2377                // Kept in sync on `sent` itself, not just the local retry: a
2378                // later helper (`resume_unconfirmed_commands`) reads `sent`
2379                // after this one returns and must see whichever agent is now
2380                // occupying the seat, not the one that just quota'd out —
2381                // otherwise it would judge session/continuation eligibility
2382                // by the wrong CLI and could resend a fallback's session id
2383                // to the agent that lost it the seat in the first place.
2384                job.spec = next.clone();
2385                let mut retry = job.clone();
2386                retry.seat = new_seat;
2387                retry.prompt = prompt::implement_in(
2388                    &instruction,
2389                    &job.cwd.to_string_lossy(),
2390                    &language,
2391                    &github_language,
2392                    brief.as_deref(),
2393                    &attachments,
2394                );
2395                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2396                let cache = self.state.config.cache_dir();
2397                let ctx = WaveCtx {
2398                    carry_seats: false,
2399                    pauses: Some((&self.pause, &self.interrupt)),
2400                    run: run_id,
2401                    node: "implement",
2402                    prompts,
2403                    cache: cache.as_deref(),
2404                    round: None,
2405                };
2406                let (fallback_seat, fallback_out) = run_one(
2407                    retry,
2408                    Arc::clone(&self.sem),
2409                    &ctx,
2410                    &mut self.state,
2411                    fallback_attempt,
2412                )
2413                .await;
2414                *seat = fallback_seat;
2415                *out = fallback_out;
2416            }
2417        }
2418    }
2419
2420    /// Ask an implement seat's own CLI to confirm what it started, once, when
2421    /// its reply reported a command whose completion status it never
2422    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2423    /// that does and does not mean.
2424    ///
2425    /// The completion contract this task asks for, extended to `implement`
2426    /// with the same signal `continue_fix_report` reads for the fixer,
2427    /// rather than a keyword search over the reply or a hard requirement on
2428    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2429    /// a candidate's CLI turn ended cleanly while a test run it had started
2430    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2431    /// named in it at all is untouched by this: `commands` is empty, so
2432    /// there is nothing to be unconfirmed.
2433    ///
2434    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2435    /// this is not about recovering edits that might already be on disk, it
2436    /// is about a result the seat itself never vouched for, which resuming
2437    /// asks for regardless of what the tree already holds. Bounded to one
2438    /// attempt for the same reason `resume_undelivered` is — this is the
2439    /// most expensive node in the graph — and a seat that still cannot
2440    /// confirm on that attempt is left as whatever its (possibly still
2441    /// unconfirmed) reply says; this does not invent a new "failed" reason
2442    /// for a candidate that otherwise produced a real, committed change.
2443    async fn resume_unconfirmed_commands(
2444        &mut self,
2445        results: &mut [(usize, SeatState, AgentOutcome)],
2446        sent: &[SeatJob],
2447        prompts: &Prompts,
2448        run_id: &str,
2449    ) {
2450        for (wi, seat, out) in results.iter_mut() {
2451            let AgentOutcome::Ok(o) = &*out else {
2452                continue;
2453            };
2454            if !has_unconfirmed_command(&o.commands) {
2455                continue;
2456            }
2457            let Some(job) = sent.get(*wi) else { continue };
2458            if !has_context(&job.spec, seat, job.sessions) {
2459                self.state.event(
2460                    "implement",
2461                    format!(
2462                        "{}: the reply named a command whose own CLI never confirmed the exit \
2463                         status of, but there is no session left to resume",
2464                        seat.key
2465                    ),
2466                );
2467                continue;
2468            }
2469            self.state.event(
2470                "implement",
2471                format!(
2472                    "{}: the reply named a command whose own CLI never confirmed the exit \
2473                     status of; resuming the conversation",
2474                    seat.key
2475                ),
2476            );
2477            let mut retry = job.clone();
2478            retry.seat = seat.clone();
2479            retry.prompt = prompt::resume_incomplete(
2480                "a command in your last reply had no confirmed exit status",
2481            );
2482            retry.timeout = retry_budget(job.timeout, true);
2483            retry.stem = format!("{}-confirm", job.stem);
2484            let cache = self.state.config.cache_dir();
2485            let ctx = WaveCtx {
2486                carry_seats: false,
2487                pauses: Some((&self.pause, &self.interrupt)),
2488                run: run_id,
2489                node: "implement",
2490                prompts,
2491                cache: cache.as_deref(),
2492                round: None,
2493            };
2494            let (resumed_seat, resumed) =
2495                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2496            *seat = resumed_seat;
2497            *out = resumed;
2498        }
2499    }
2500
2501    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2502    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2503    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2504    /// that motivated this.
2505    ///
2506    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2507    /// own nudge loop already covers for judge/review/vote seats, and not a
2508    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2509    /// implement seats: here the CLI turn genuinely finished while the node's
2510    /// own work — the fixer's account of what it did — had not. Gated purely
2511    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2512    /// reply, never on any wording in it, so a fixer whose valid, first-try
2513    /// `FixReport` happens to mention having waited on a background test is
2514    /// never resumed — the `Ok(report)` branch at the call site returns
2515    /// before this is ever invoked.
2516    ///
2517    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2518    /// attempt ([`retry_budget`]), nothing attempted once the session is
2519    /// gone, and a quota hit ends the loop immediately rather than retrying a
2520    /// rate limit that fails the same way again.
2521    async fn continue_fix_report(
2522        &mut self,
2523        mut seat: SeatState,
2524        parse_err: String,
2525        job: &SeatJob,
2526        prompts: &Prompts,
2527        run_id: &str,
2528        round: usize,
2529    ) -> (
2530        SeatState,
2531        Option<FixReport>,
2532        Option<String>,
2533        ContinuationRecord,
2534    ) {
2535        let mut last_err = parse_err;
2536        let mut cumulative_wait_ms = 0u64;
2537        let mut attempts = 0usize;
2538        loop {
2539            if !has_context(&job.spec, &seat, job.sessions) {
2540                self.state.event(
2541                    "fix",
2542                    format!(
2543                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2544                         session left to resume into"
2545                    ),
2546                );
2547                let outcome = if attempts == 0 {
2548                    ContinuationOutcome::NoSession
2549                } else {
2550                    ContinuationOutcome::Exhausted
2551                };
2552                return (
2553                    seat,
2554                    None,
2555                    Some(format!("unparsable fix report: {last_err}")),
2556                    ContinuationRecord {
2557                        attempts,
2558                        cumulative_wait_ms,
2559                        outcome,
2560                    },
2561                );
2562            }
2563            if attempts >= MAX_FIX_CONTINUATIONS {
2564                self.state.event(
2565                    "fix",
2566                    format!(
2567                        "round {round}: fixer's reply still had no adoption report after \
2568                         {attempts} continuation(s) ({last_err}); giving up"
2569                    ),
2570                );
2571                return (
2572                    seat,
2573                    None,
2574                    Some(format!(
2575                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2576                    )),
2577                    ContinuationRecord {
2578                        attempts,
2579                        cumulative_wait_ms,
2580                        outcome: ContinuationOutcome::Exhausted,
2581                    },
2582                );
2583            }
2584            attempts += 1;
2585            self.state.event(
2586                "fix",
2587                format!(
2588                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2589                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2590                ),
2591            );
2592            let mut retry = job.clone();
2593            retry.seat = seat.clone();
2594            retry.prompt = prompt::resume_incomplete(&last_err);
2595            retry.timeout = retry_budget(job.timeout, true);
2596            retry.stem = format!("{}-continue{attempts}", job.stem);
2597            let cache = self.state.config.cache_dir();
2598            let ctx = WaveCtx {
2599                carry_seats: false,
2600                pauses: Some((&self.pause, &self.interrupt)),
2601                run: run_id,
2602                node: "fix",
2603                prompts,
2604                cache: cache.as_deref(),
2605                round: Some(round),
2606            };
2607            let (resumed_seat, resumed_out) = run_one(
2608                retry,
2609                Arc::clone(&self.sem),
2610                &ctx,
2611                &mut self.state,
2612                attempts,
2613            )
2614            .await;
2615            seat = resumed_seat;
2616            match resumed_out {
2617                AgentOutcome::Ok(o) => {
2618                    cumulative_wait_ms += o.duration_ms;
2619                    match verdict::extract_json::<FixReport>(&o.text) {
2620                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2621                            self.state.event(
2622                                "fix",
2623                                format!(
2624                                    "round {round}: fixer's adoption report recovered after \
2625                                     {attempts} continuation(s)"
2626                                ),
2627                            );
2628                            return (
2629                                seat,
2630                                Some(report),
2631                                None,
2632                                ContinuationRecord {
2633                                    attempts,
2634                                    cumulative_wait_ms,
2635                                    outcome: ContinuationOutcome::Resumed,
2636                                },
2637                            );
2638                        }
2639                        // The report parsed, but this same reply's own
2640                        // CommandEvidence — the identical record `state.jobs`
2641                        // renders — names a command whose CLI never
2642                        // confirmed an exit status. Read together, that is
2643                        // not a resolved answer: keep nudging rather than
2644                        // accept a report standing next to a command the
2645                        // seat's own CLI cannot vouch for.
2646                        Ok(_) => {
2647                            last_err = "the reply parsed, but it reported a command whose own CLI \
2648                                 never confirmed an exit status"
2649                                .to_owned();
2650                        }
2651                        Err(e) => last_err = e.to_string(),
2652                    }
2653                }
2654                AgentOutcome::Quota(o) => {
2655                    cumulative_wait_ms += o.duration_ms;
2656                    self.state.quota.push(QuotaLoss {
2657                        seat: seat.key.clone(),
2658                        node: "fix".to_owned(),
2659                        at: Timestamp::now(),
2660                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2661                    });
2662                    self.state.event(
2663                        "fix",
2664                        format!(
2665                            "round {round}: continuation rate limited (quota); not retrying now"
2666                        ),
2667                    );
2668                    return (
2669                        seat,
2670                        None,
2671                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2672                        ContinuationRecord {
2673                            attempts,
2674                            cumulative_wait_ms,
2675                            outcome: ContinuationOutcome::QuotaLost,
2676                        },
2677                    );
2678                }
2679                AgentOutcome::Dropped(o) => {
2680                    cumulative_wait_ms += o.duration_ms;
2681                    let why = o
2682                        .dropped
2683                        .as_ref()
2684                        .map(|d| d.why.as_str())
2685                        .unwrap_or("the CLI ended the stream without delivering its answer");
2686                    last_err = format!("the CLI dropped the stream ({why})");
2687                }
2688                AgentOutcome::Failed(e) => last_err = e,
2689                // The continuation was never started; the report stays lost
2690                // and the caller records that, without any handover.
2691                AgentOutcome::ResourceBlocked(e) => {
2692                    return (
2693                        seat,
2694                        None,
2695                        Some(format!("the continuation was not started: {e}")),
2696                        ContinuationRecord {
2697                            attempts: attempts.saturating_sub(1),
2698                            cumulative_wait_ms,
2699                            outcome: ContinuationOutcome::NoSession,
2700                        },
2701                    );
2702                }
2703            }
2704        }
2705    }
2706
2707    fn after_implement(&mut self) -> Result<()> {
2708        // Scan every candidate patch once the set is complete.
2709        if self.state.leaks.is_empty() {
2710            let cfg = self.state.config.blind.clone();
2711            let mut leaks = Vec::new();
2712            for c in &self.state.candidates {
2713                let Some(patch) =
2714                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2715                else {
2716                    continue;
2717                };
2718                leaks.extend(blind::scan(
2719                    &format!("candidate {} patch", c.label),
2720                    &patch,
2721                    &cfg.vendor_tokens,
2722                ));
2723            }
2724            if !leaks.is_empty() {
2725                let summary = leaks
2726                    .iter()
2727                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2728                    .collect::<Vec<_>>()
2729                    .join(", ");
2730                match cfg.on_leak {
2731                    LeakPolicy::Fail => {
2732                        self.state.status = RunStatus::Failed;
2733                        self.state
2734                            .event("blind", format!("vendor text in a patch: {summary}"));
2735                        self.state.leaks = leaks;
2736                        self.state.save()?;
2737                        self.settle_questions();
2738                        bail!(
2739                            "blind.on_leak = \"fail\" and vendor text reached a \
2740                             judged patch: {summary}"
2741                        );
2742                    }
2743                    LeakPolicy::Redact => self.state.event(
2744                        "blind",
2745                        format!("redacting vendor text for judging: {summary}"),
2746                    ),
2747                    LeakPolicy::Warn => self.state.event(
2748                        "blind",
2749                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2750                    ),
2751                }
2752                self.state.leaks = leaks;
2753            }
2754        }
2755
2756        if self.state.viable().is_empty() {
2757            if self.state.all_candidates_verified_noop() {
2758                // Every candidate agreed, with evidence the adoption guard
2759                // accepted, that nothing belongs in this worktree. That is
2760                // not the same fact as a candidate that simply failed to
2761                // write anything, and settling it as an ordinary `Failed`
2762                // (see `SCHEMA`'s doc for schema 10) is what let two of
2763                // task 391f's attempts burn a retry each re-discovering the
2764                // same already-landed fix. Terminal either way, so `judge`
2765                // must never run over an empty candidate set — unlike the
2766                // `Failed` branch below this returns `Ok`, not an error:
2767                // nothing here failed.
2768                self.state.status = RunStatus::VerifiedNoop;
2769                self.state.save()?;
2770                self.settle_questions();
2771                return Ok(());
2772            }
2773            self.state.status = RunStatus::Failed;
2774            self.state.save()?;
2775            self.settle_questions();
2776            bail!("no candidate produced a change; nothing to judge");
2777        }
2778        self.state.status = RunStatus::Judging;
2779        self.state.save()?;
2780        Ok(())
2781    }
2782
2783    // --------------------------------------------------------------- judge
2784
2785    async fn judge(&mut self) -> Result<()> {
2786        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2787        // agent files with `magi task add` name the run that paid for it. The
2788        // prompt overlay is cloned alongside it because the waves borrow it
2789        // while `self` is mutably borrowed by the node's own bookkeeping.
2790        let run_id = self.state.id.clone();
2791        let prompts = self.state.config.prompts.clone();
2792        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2793            return Ok(());
2794        }
2795        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2796        if viable.len() == 1 {
2797            // Recorded so this is a one-time event: `judgements` stays empty
2798            // either way, which without this flag is indistinguishable from
2799            // "not yet judged" on the next reentry — and status is left
2800            // untouched, so a later node's conclusion (e.g. `Blocked` after
2801            // the review budget ran out) survives a resume instead of being
2802            // clobbered back to `Judging` by this node running again.
2803            self.state.judge_skipped = true;
2804            self.state.event(
2805                "judge",
2806                format!(
2807                    "only candidate {} produced a change; judging skipped",
2808                    viable[0].label
2809                ),
2810            );
2811            self.state.save()?;
2812            return Ok(());
2813        }
2814        self.state.status = RunStatus::Judging;
2815
2816        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2817        let language = self.state.config.graph.language.clone();
2818        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2819        let sessions = self.state.config.graph.sessions;
2820        let artifacts = agent::artifacts_dir(&self.state.dir());
2821        let root = self.state.worktree_root();
2822        let base_short = short(&self.state.base_commit);
2823
2824        let mut jobs = Vec::new();
2825        let mut orders = Vec::new();
2826        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2827            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2828            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2829            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2830            let seat_key = format!("judge-{}", j + 1);
2831            let seat = self.seat(&seat_key, &spec.id);
2832            jobs.push(SeatJob {
2833                prompt: prompt::judge(
2834                    &self.state.instruction,
2835                    &views,
2836                    self.roles.judges.len(),
2837                    &base_short,
2838                    &language,
2839                ),
2840                spec,
2841                seat,
2842                cwd: root.join(format!("judge-{}", j + 1)),
2843                timeout,
2844                allow_write: false,
2845                sessions,
2846                artifacts: artifacts.clone(),
2847                stem: format!("judge-{}", j + 1),
2848                handover: None,
2849            });
2850        }
2851
2852        self.state.event(
2853            "judge",
2854            format!(
2855                "{} judges ranking {} candidates blind",
2856                jobs.len(),
2857                viable.len()
2858            ),
2859        );
2860        let labels_for_check = labels.clone();
2861        let mut quota_losses = Vec::new();
2862        let cache = self.state.config.cache_dir();
2863        let ctx = WaveCtx {
2864            carry_seats: false,
2865            pauses: Some((&self.pause, &self.interrupt)),
2866            run: &run_id,
2867            node: "judge",
2868            prompts: &prompts,
2869            cache: cache.as_deref(),
2870            round: None,
2871        };
2872        let results = ask_json_wave::<Ranking>(
2873            jobs,
2874            Arc::clone(&self.sem),
2875            self.state.config.graph.retries,
2876            &self.roles.judge_roster,
2877            &ctx,
2878            &mut quota_losses,
2879            &mut self.state,
2880            &move |r: &Ranking| r.validate(&labels_for_check),
2881        )
2882        .await;
2883        if self.state.resource_block.is_some() {
2884            return self.stop_for_resource();
2885        }
2886        self.state.quota.extend(quota_losses);
2887
2888        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2889            let agent_id = seat.agent.clone();
2890            self.state.seats.insert(seat.key.clone(), seat);
2891            let mut record = Judgement {
2892                judge: j + 1,
2893                seat: format!("judge-{}", j + 1),
2894                agent: agent_id,
2895                ranking: Vec::new(),
2896                reasons: BTreeMap::new(),
2897                confidence: None,
2898                strengths: BTreeMap::new(),
2899                order: orders[j].clone(),
2900                failed: None,
2901                duration_ms: 0,
2902            };
2903            match res {
2904                Ok((ranking, out)) => {
2905                    record.ranking = ranking.normalized();
2906                    record.reasons = ranking.reasons;
2907                    record.strengths = ranking.strengths;
2908                    record.confidence = ranking.confidence;
2909                    record.duration_ms = out.duration_ms;
2910                    self.state.event(
2911                        "judge",
2912                        format!(
2913                            "judge {} ranked {}",
2914                            j + 1,
2915                            record.ranking.iter().collect::<String>()
2916                        ),
2917                    );
2918                }
2919                Err(e) => {
2920                    record.failed = Some(e.to_string());
2921                    self.state
2922                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2923                }
2924            }
2925            self.state.judgements.push(record);
2926            self.state.save()?;
2927        }
2928        Ok(())
2929    }
2930
2931    // ---------------------------------------------------------- deliberate
2932
2933    async fn deliberate(&mut self) -> Result<()> {
2934        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2935        // agent files with `magi task add` name the run that paid for it. The
2936        // prompt overlay is cloned alongside it because the waves borrow it
2937        // while `self` is mutably borrowed by the node's own bookkeeping.
2938        let run_id = self.state.id.clone();
2939        let prompts = self.state.config.prompts.clone();
2940        if !self.state.deliberation.is_empty() {
2941            return Ok(());
2942        }
2943        let tops: Vec<char> = self
2944            .state
2945            .judgements
2946            .iter()
2947            .filter_map(|j| j.ranking.first().copied())
2948            .collect();
2949        let rounds = self.state.config.graph.deliberate_rounds;
2950        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2951            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2952                self.state.event(
2953                    "deliberate",
2954                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2955                );
2956            }
2957            self.state.status = RunStatus::Voting;
2958            self.state.save()?;
2959            return Ok(());
2960        }
2961
2962        self.state.status = RunStatus::Deliberating;
2963        self.state.event(
2964            "deliberate",
2965            format!(
2966                "split: first choices were {} — opening {rounds} round(s)",
2967                tops.iter().collect::<String>()
2968            ),
2969        );
2970
2971        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2972        let language = self.state.config.graph.language.clone();
2973        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2974        let sessions = self.state.config.graph.sessions;
2975        let artifacts = agent::artifacts_dir(&self.state.dir());
2976        let root = self.state.worktree_root();
2977        let base_short = short(&self.state.base_commit);
2978
2979        // Judges argue in sequence so that a turn can answer the one before it;
2980        // that is the difference between deliberation and three parallel
2981        // monologues.
2982        for round in 1..=rounds {
2983            let mut turns: Vec<DeliberationTurn> = Vec::new();
2984            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2985                if self.state.judgements[j].failed.is_some() {
2986                    continue;
2987                }
2988                let seat_key = format!("judge-{}", j + 1);
2989                let spec = self.occupant(&seat_key, spec);
2990                let mut seat = self.seat(&seat_key, &spec.id);
2991                let transcript = self.transcript(&turns, j);
2992                let build = |context: Option<&str>| {
2993                    prompt::deliberate(
2994                        &self.state.instruction,
2995                        context,
2996                        &transcript,
2997                        round,
2998                        rounds,
2999                        &language,
3000                    )
3001                };
3002                let block = self.candidate_block(&viable, &base_short);
3003                let full = build(Some(&block));
3004                let text = if has_context(&spec, &seat, sessions) {
3005                    build(None)
3006                } else {
3007                    full.clone()
3008                };
3009                let job = SeatJob {
3010                    spec,
3011                    seat: seat.clone(),
3012                    prompt: text,
3013                    cwd: root.join(format!("judge-{}", j + 1)),
3014                    timeout,
3015                    allow_write: false,
3016                    sessions,
3017                    artifacts: artifacts.clone(),
3018                    stem: format!("delib-{round}-judge-{}", j + 1),
3019                    handover: Some(full),
3020                };
3021                let cache = self.state.config.cache_dir();
3022                let ctx = WaveCtx {
3023                    carry_seats: false,
3024                    pauses: Some((&self.pause, &self.interrupt)),
3025                    run: &run_id,
3026                    node: "deliberate",
3027                    prompts: &prompts,
3028                    cache: cache.as_deref(),
3029                    round: None,
3030                };
3031                // A turn is never nudged (`retries` 0): a failed seat is
3032                // handed to the next roster agent, which gets the full
3033                // context. An empty answer is a turn, not a failure.
3034                let mut losses = Vec::new();
3035                let mut results = ask_wave_with::<String>(
3036                    vec![job],
3037                    Arc::clone(&self.sem),
3038                    0,
3039                    &self.roles.judge_roster,
3040                    &ctx,
3041                    &mut losses,
3042                    &mut self.state,
3043                    &|text: &str| {
3044                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3045                    },
3046                )
3047                .await;
3048                self.state.quota.extend(losses);
3049                let (updated, res, _) = results.pop().expect("one job in, one result out");
3050                seat = updated;
3051                let agent_id = seat.agent.clone();
3052                self.state.seats.insert(seat.key.clone(), seat);
3053                let body = match res {
3054                    Ok((body, _)) => body,
3055                    // Skip the seat; a CLI's raw error JSON is never read as
3056                    // this judge's position.
3057                    Err(e) => {
3058                        self.state
3059                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3060                        continue;
3061                    }
3062                };
3063                let tentative = verdict::extract_json::<Position>(&body)
3064                    .ok()
3065                    .and_then(|p| p.tentative)
3066                    .and_then(|s| s.trim().chars().next())
3067                    .map(|c| c.to_ascii_uppercase());
3068                self.state.event(
3069                    "deliberate",
3070                    format!(
3071                        "round {round}: judge {} now favours {}",
3072                        j + 1,
3073                        tentative.map_or("—".to_owned(), |c| c.to_string())
3074                    ),
3075                );
3076                turns.push(DeliberationTurn {
3077                    judge: j + 1,
3078                    agent: agent_id,
3079                    body: blind::sanitize_prose(&body, &self.state.config.blind),
3080                    tentative,
3081                });
3082            }
3083            self.state
3084                .deliberation
3085                .push(DeliberationRound { round, turns });
3086            self.state.save()?;
3087        }
3088
3089        self.state.status = RunStatus::Voting;
3090        self.state.save()?;
3091        Ok(())
3092    }
3093
3094    // ---------------------------------------------------------------- vote
3095
3096    async fn vote(&mut self) -> Result<()> {
3097        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3098        // agent files with `magi task add` name the run that paid for it. The
3099        // prompt overlay is cloned alongside it because the waves borrow it
3100        // while `self` is mutably borrowed by the node's own bookkeeping.
3101        let run_id = self.state.id.clone();
3102        let prompts = self.state.config.prompts.clone();
3103        if !self.state.votes.is_empty() {
3104            return Ok(());
3105        }
3106        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3107        if viable.len() == 1 {
3108            return Ok(());
3109        }
3110        self.state.status = RunStatus::Voting;
3111
3112        let language = self.state.config.graph.language.clone();
3113        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3114        let sessions = self.state.config.graph.sessions;
3115        let artifacts = agent::artifacts_dir(&self.state.dir());
3116        let root = self.state.worktree_root();
3117        let base_short = short(&self.state.base_commit);
3118        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3119
3120        let mut jobs = Vec::new();
3121        let mut seats_at = Vec::new();
3122        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3123            if self
3124                .state
3125                .judgements
3126                .get(j)
3127                .is_some_and(|r| r.failed.is_some())
3128            {
3129                continue;
3130            }
3131            let seat_key = format!("judge-{}", j + 1);
3132            let spec = self.occupant(&seat_key, spec);
3133            let seat = self.seat(&seat_key, &spec.id);
3134            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3135            let text = if has_context(&spec, &seat, sessions) {
3136                prompt::final_vote(&viable, &language)
3137            } else {
3138                full.clone()
3139            };
3140            jobs.push(SeatJob {
3141                spec,
3142                seat,
3143                prompt: text,
3144                cwd: root.join(format!("judge-{}", j + 1)),
3145                timeout,
3146                allow_write: false,
3147                sessions,
3148                artifacts: artifacts.clone(),
3149                stem: format!("vote-judge-{}", j + 1),
3150                handover: Some(full),
3151            });
3152            seats_at.push(j);
3153        }
3154
3155        self.state.event(
3156            "vote",
3157            format!(
3158                "collecting {} final votes one by one, privately",
3159                jobs.len()
3160            ),
3161        );
3162        let allowed = viable.clone();
3163        let mut quota_losses = Vec::new();
3164        let cache = self.state.config.cache_dir();
3165        let ctx = WaveCtx {
3166            carry_seats: false,
3167            pauses: Some((&self.pause, &self.interrupt)),
3168            run: &run_id,
3169            node: "vote",
3170            prompts: &prompts,
3171            cache: cache.as_deref(),
3172            round: None,
3173        };
3174        let results = ask_json_wave::<FinalVote>(
3175            jobs,
3176            Arc::clone(&self.sem),
3177            self.state.config.graph.retries,
3178            &self.roles.judge_roster,
3179            &ctx,
3180            &mut quota_losses,
3181            &mut self.state,
3182            &move |v: &FinalVote| match v.label() {
3183                Some(c) if allowed.contains(&c) => Ok(()),
3184                other => bail!("vote {other:?} is not one of {allowed:?}"),
3185            },
3186        )
3187        .await;
3188        if self.state.resource_block.is_some() {
3189            return self.stop_for_resource();
3190        }
3191        self.state.quota.extend(quota_losses);
3192
3193        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3194            let agent_id = seat.agent.clone();
3195            self.state.seats.insert(seat.key.clone(), seat);
3196            let initial = self
3197                .state
3198                .judgements
3199                .get(j)
3200                .and_then(|r| r.ranking.first().copied());
3201            let mut record = VoteRecord {
3202                judge: j + 1,
3203                agent: agent_id,
3204                vote: None,
3205                reason: String::new(),
3206                changed: false,
3207            };
3208            match res {
3209                Ok((v, _)) => {
3210                    record.vote = v.label();
3211                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3212                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3213                    self.state.event(
3214                        "vote",
3215                        format!(
3216                            "judge {} voted {}{}",
3217                            j + 1,
3218                            record.vote.unwrap_or('?'),
3219                            if record.changed { " (changed)" } else { "" }
3220                        ),
3221                    );
3222                }
3223                Err(e) => {
3224                    self.state
3225                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3226                }
3227            }
3228            self.state.votes.push(record);
3229            self.state.save()?;
3230        }
3231        Ok(())
3232    }
3233
3234    // --------------------------------------------------------------- tally
3235
3236    fn tally(&mut self) -> Result<()> {
3237        if self.state.tally.is_some() {
3238            return Ok(());
3239        }
3240        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3241        let tops: Vec<char> = self
3242            .state
3243            .judgements
3244            .iter()
3245            .filter_map(|j| j.ranking.first().copied())
3246            .collect();
3247        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3248
3249        // A judge whose private vote failed still counted once, in the initial
3250        // ranking; using it beats discarding a whole seat.
3251        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3252        let mut cast: Vec<char> = Vec::new();
3253        for (i, j) in self.state.judgements.iter().enumerate() {
3254            let vote = self
3255                .state
3256                .votes
3257                .iter()
3258                .find(|v| v.judge == i + 1)
3259                .and_then(|v| v.vote)
3260                .or_else(|| j.ranking.first().copied());
3261            if let Some(v) = vote {
3262                *first_choice.entry(v).or_insert(0) += 1;
3263                cast.push(v);
3264            }
3265        }
3266
3267        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3268        for j in &self.state.judgements {
3269            let n = j.ranking.len();
3270            for (pos, label) in j.ranking.iter().enumerate() {
3271                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3272            }
3273        }
3274
3275        let best = first_choice.values().copied().max().unwrap_or(0);
3276        let mut leaders: Vec<char> = first_choice
3277            .iter()
3278            .filter(|(_, v)| **v == best)
3279            .map(|(k, _)| *k)
3280            .collect();
3281        let mut tie_break = None;
3282        if leaders.len() > 1 {
3283            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3284            let borda_leaders: Vec<char> = leaders
3285                .iter()
3286                .copied()
3287                .filter(|l| borda[l] == top_borda)
3288                .collect();
3289            tie_break = Some(if borda_leaders.len() == 1 {
3290                format!(
3291                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3292                    leaders.len()
3293                )
3294            } else {
3295                format!(
3296                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3297                    leaders.len()
3298                )
3299            });
3300            leaders = borda_leaders;
3301            leaders.sort_unstable();
3302        }
3303        let winner = *leaders
3304            .first()
3305            .or(viable.first())
3306            .context("no candidate to declare a winner from")?;
3307
3308        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3309        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3310        let deliberated = !self.state.deliberation.is_empty();
3311
3312        // Whose verdict is this? A rate-limited seat is absent even if it
3313        // ranked before the limit hit, so presence is measured against the
3314        // recorded losses, not just "did a ranking ever appear".
3315        let quota_seats: std::collections::BTreeSet<&str> =
3316            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3317        let mut present = 0usize;
3318        for (i, j) in self.state.judgements.iter().enumerate() {
3319            if quota_seats.contains(j.seat.as_str()) {
3320                continue;
3321            }
3322            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3323            let voted = self
3324                .state
3325                .votes
3326                .iter()
3327                .any(|v| v.judge == i + 1 && v.vote.is_some());
3328            if ranked || voted {
3329                present += 1;
3330            }
3331        }
3332        // Strict majority of the configured panel. A bare majority is real
3333        // signal we can act on, while a minority verdict must never stand in
3334        // for a healthy one. A one-candidate run needs no panel at all, and
3335        // `judges` stays `0` rather than the roster size a panel that never
3336        // sat would otherwise be credited with.
3337        let needs_quorum = viable.len() > 1;
3338        let judges_total = if needs_quorum {
3339            self.roles.judges.len()
3340        } else {
3341            0
3342        };
3343        let quorum = if needs_quorum {
3344            judges_total / 2 + 1
3345        } else {
3346            0
3347        };
3348        let met_quorum = !needs_quorum || present >= quorum;
3349        let uncontested = (!needs_quorum).then(|| {
3350            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3351        });
3352
3353        self.state.event(
3354            "tally",
3355            match &uncontested {
3356                Some(reason) => format!("winner {winner} — {reason}"),
3357                None => format!(
3358                    "winner {winner} — votes {} | initial {} | {} changed | \
3359                     {present}/{judges_total} judges{}",
3360                    first_choice
3361                        .iter()
3362                        .map(|(k, v)| format!("{k}:{v}"))
3363                        .collect::<Vec<_>>()
3364                        .join(" "),
3365                    if unanimous_initial {
3366                        "unanimous"
3367                    } else {
3368                        "split"
3369                    },
3370                    changed_votes,
3371                    if met_quorum {
3372                        String::new()
3373                    } else {
3374                        format!(" — below quorum ({quorum} required)")
3375                    },
3376                ),
3377            },
3378        );
3379        if !met_quorum {
3380            self.state.event(
3381                "stall",
3382                format!(
3383                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3384                     the run stops here, resumable"
3385                ),
3386            );
3387        }
3388        self.state.tally = Some(Tally {
3389            first_choice,
3390            borda,
3391            winner,
3392            rankings: tops.len(),
3393            unanimous_initial,
3394            deliberated,
3395            changed_votes,
3396            unanimous_final,
3397            tie_break,
3398            judges: judges_total,
3399            present,
3400            quorum,
3401            met_quorum,
3402            uncontested,
3403        });
3404        self.state.status = if met_quorum {
3405            RunStatus::Reviewing
3406        } else {
3407            RunStatus::Stalled
3408        };
3409        self.state.save()?;
3410        Ok(())
3411    }
3412
3413    // ------------------------------------------------------------- recover
3414
3415    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3416    /// resumed toward completion once the transient cause clears.
3417    ///
3418    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3419    /// it toward the quorum, which is exactly the set of seats whose absence
3420    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3421    /// not depend on which node happened to hit the limit), or an ordinary
3422    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3423    /// seat is never disturbed.
3424    ///
3425    /// A seat that now answers with a usable ranking is "recovered": its
3426    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3427    /// `tally` counts it present again), and its vote re-collected. A seat that
3428    /// still fails keeps its loss and stays absent.
3429    ///
3430    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3431    /// to review/gate/merge), `false` when it is still below quorum (the run
3432    /// stays `Stalled`, still resumable for a later retry).
3433    #[allow(clippy::too_many_lines)]
3434    async fn recover_stall(&mut self) -> Result<bool> {
3435        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3436        // agent files with `magi task add` name the run that paid for it. The
3437        // prompt overlay is cloned alongside it because the waves borrow it
3438        // while `self` is mutably borrowed by the node's own bookkeeping.
3439        let run_id = self.state.id.clone();
3440        let prompts = self.state.config.prompts.clone();
3441        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3442        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3443        // is what keeps a non-quota collapse — or a quota loss recorded at the
3444        // deliberate node — from being a permanent dead-end on `--resume`.
3445        let quota_seats: BTreeSet<&str> =
3446            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3447        let absent: Vec<String> = self
3448            .state
3449            .judgements
3450            .iter()
3451            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3452            .map(|j| j.seat.clone())
3453            .collect();
3454        if absent.is_empty() {
3455            return Ok(false);
3456        }
3457        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3458        if viable.len() <= 1 {
3459            return Ok(false);
3460        }
3461        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3462        let language = self.state.config.graph.language.clone();
3463        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3464        let sessions = self.state.config.graph.sessions;
3465        let artifacts = agent::artifacts_dir(&self.state.dir());
3466        let root = self.state.worktree_root();
3467        let base_short = short(&self.state.base_commit);
3468        let candidates: Vec<Candidate> = viable.clone();
3469
3470        // Map each absent seat key to its 0-based position in `roles.judges`.
3471        let mut positions: Vec<usize> = absent
3472            .iter()
3473            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3474            .collect();
3475        if positions.is_empty() {
3476            return Ok(false);
3477        }
3478        positions.sort_unstable();
3479        positions.dedup();
3480
3481        // Re-rank the lost seats, one blind prompt each.
3482        let mut judge_jobs = Vec::new();
3483        for &j in &positions {
3484            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3485            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3486            let seat_key = format!("judge-{}", j + 1);
3487            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3488            let seat = self.seat(&seat_key, &spec.id);
3489            judge_jobs.push(SeatJob {
3490                spec,
3491                seat,
3492                prompt: prompt::judge(
3493                    &self.state.instruction,
3494                    &views,
3495                    self.roles.judges.len(),
3496                    &base_short,
3497                    &language,
3498                ),
3499                cwd: root.join(seat_key),
3500                timeout,
3501                allow_write: false,
3502                sessions,
3503                artifacts: artifacts.clone(),
3504                stem: format!("judge-{}-recover", j + 1),
3505                handover: None,
3506            });
3507        }
3508
3509        let labels_for_check = labels.clone();
3510        let mut judge_losses = Vec::new();
3511        let retries = self.state.config.graph.retries;
3512        let cache = self.state.config.cache_dir();
3513        let ctx = WaveCtx {
3514            carry_seats: false,
3515            pauses: Some((&self.pause, &self.interrupt)),
3516            run: &run_id,
3517            node: "judge",
3518            prompts: &prompts,
3519            cache: cache.as_deref(),
3520            round: None,
3521        };
3522        let results = ask_json_wave::<Ranking>(
3523            judge_jobs,
3524            Arc::clone(&self.sem),
3525            retries,
3526            &self.roles.judge_roster,
3527            &ctx,
3528            &mut judge_losses,
3529            &mut self.state,
3530            &move |r: &Ranking| r.validate(&labels_for_check),
3531        )
3532        .await;
3533        if self.state.resource_block.is_some() {
3534            self.stop_for_resource()?;
3535            return Ok(false);
3536        }
3537
3538        // Refresh the judgement of every seat that ranked again.
3539        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3540        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3541            let agent_id = seat.agent.clone();
3542            self.state.seats.insert(seat.key.clone(), seat);
3543            let record = &mut self.state.judgements[j];
3544            match res {
3545                Ok((ranking, out)) => {
3546                    record.agent = agent_id;
3547                    record.ranking = ranking.normalized();
3548                    record.reasons = ranking.reasons;
3549                    record.strengths = ranking.strengths;
3550                    record.confidence = ranking.confidence;
3551                    record.failed = None;
3552                    record.duration_ms = out.duration_ms;
3553                    recovered.insert(j);
3554                    self.state.event(
3555                        "recover",
3556                        format!("judge {} ranked again after the limit", j + 1),
3557                    );
3558                }
3559                Err(e) => {
3560                    self.state
3561                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3562                }
3563            }
3564        }
3565
3566        // Re-ask the votes of the seats that recovered a ranking.
3567        let mut vote_jobs = Vec::new();
3568        let mut vote_pos: Vec<usize> = Vec::new();
3569        for &j in &recovered {
3570            let seat_key = format!("judge-{}", j + 1);
3571            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3572            let seat = self.seat(&seat_key, &spec.id);
3573            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3574            let text = if has_context(&spec, &seat, sessions) {
3575                prompt::final_vote(&labels, &language)
3576            } else {
3577                full.clone()
3578            };
3579            vote_jobs.push(SeatJob {
3580                spec,
3581                seat,
3582                prompt: text,
3583                cwd: root.join(seat_key),
3584                timeout,
3585                allow_write: false,
3586                sessions,
3587                artifacts: artifacts.clone(),
3588                stem: format!("vote-judge-{}-recover", j + 1),
3589                handover: Some(full),
3590            });
3591            vote_pos.push(j);
3592        }
3593        let allowed = labels.clone();
3594        let mut vote_losses = Vec::new();
3595        let vote_retries = self.state.config.graph.retries;
3596        let vote_cache = self.state.config.cache_dir();
3597        let ctx = WaveCtx {
3598            carry_seats: false,
3599            pauses: Some((&self.pause, &self.interrupt)),
3600            run: &run_id,
3601            node: "vote",
3602            prompts: &prompts,
3603            cache: vote_cache.as_deref(),
3604            round: None,
3605        };
3606        let votes = ask_json_wave::<FinalVote>(
3607            vote_jobs,
3608            Arc::clone(&self.sem),
3609            vote_retries,
3610            &self.roles.judge_roster,
3611            &ctx,
3612            &mut vote_losses,
3613            &mut self.state,
3614            &move |v: &FinalVote| match v.label() {
3615                Some(c) if allowed.contains(&c) => Ok(()),
3616                other => bail!("vote {other:?} is not one of {allowed:?}"),
3617            },
3618        )
3619        .await;
3620        if self.state.resource_block.is_some() {
3621            self.stop_for_resource()?;
3622            return Ok(false);
3623        }
3624        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3625            let agent_id = seat.agent.clone();
3626            self.state.seats.insert(seat.key.clone(), seat);
3627            match res {
3628                Ok((v, _)) => {
3629                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3630                        rec.vote = v.label();
3631                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3632                    } else {
3633                        self.state.votes.push(VoteRecord {
3634                            judge: j + 1,
3635                            agent: agent_id,
3636                            vote: v.label(),
3637                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3638                            changed: false,
3639                        });
3640                    }
3641                    self.state.event(
3642                        "recover",
3643                        format!("judge {} voted again after the limit", j + 1),
3644                    );
3645                }
3646                Err(e) => {
3647                    self.state
3648                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3649                }
3650            }
3651        }
3652
3653        // A seat that ranked again is present even if its re-vote failed —
3654        // `tally` falls back to the initial ranking's first choice — so clear
3655        // its quota loss. Seats that still fail keep theirs and stay absent.
3656        let recovered_keys: BTreeSet<String> = recovered
3657            .iter()
3658            .map(|&j| format!("judge-{}", j + 1))
3659            .collect();
3660        self.state
3661            .quota
3662            .retain(|q| !recovered_keys.contains(&q.seat));
3663        // A seat that hit the limit again is a fresh loss, not the old one:
3664        // replace the stale entry so the history stays one-per-seat and the
3665        // daemon can tell this attempt's loss from a previous session's.
3666        for loss in judge_losses.into_iter().chain(vote_losses) {
3667            if recovered_keys.contains(&loss.seat) {
3668                continue;
3669            }
3670            self.state.quota.retain(|q| q.seat != loss.seat);
3671            self.state.quota.push(loss);
3672        }
3673
3674        // Recompute the verdict from the refreshed panel.
3675        self.state.tally = None;
3676        self.tally()?;
3677        Ok(self
3678            .state
3679            .tally
3680            .as_ref()
3681            .map(|t| t.met_quorum)
3682            .unwrap_or(false))
3683    }
3684
3685    // ---------------------------------------------------------------- graft
3686
3687    /// Port the strengths the judges attributed to the losing candidates onto
3688    /// the winner, once, between the tally and the review loop.
3689    ///
3690    /// Best-effort by construction: it returns nothing and fails nothing. A
3691    /// skip, a fixer that failed or hit quota, a round that changed nothing
3692    /// and a result over the size limit ([`graft::limit`]) all leave the
3693    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
3694    /// run carries on as if the phase were off. See [`graft`] for the
3695    /// decisions and `AGENTS.md` for the invariants.
3696    ///
3697    /// The one exception is a rollback that could not be confirmed: the
3698    /// rejected commit may still be the winner's tip, so the run is blocked
3699    /// (resumable; the record stays `Running`, which makes a resume retry the
3700    /// restore) instead of reviewing, gating and merging it.
3701    async fn graft(&mut self) -> Result<()> {
3702        let Err(e) = self.graft_inner().await else {
3703            return Ok(());
3704        };
3705        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3706            self.state.status = RunStatus::Blocked;
3707            self.state.event("graft", format!("blocked: {e:#}"));
3708            self.state.save()?;
3709            return Err(e);
3710        }
3711        // An error after the `Running` record was saved may have left the
3712        // fixer's work in the tree: the record must not stay unsettled
3713        // (a later resume would reset past the review fixes), so confirm the
3714        // restore now, or block.
3715        if let Some(rec) = self.state.graft.clone()
3716            && !rec.outcome.settled()
3717        {
3718            if let Some(winner) = self.state.winner().cloned() {
3719                if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3720                    self.state.status = RunStatus::Blocked;
3721                    self.state.event("graft", format!("blocked: {re:#}"));
3722                    self.state.save()?;
3723                    return Err(re);
3724                }
3725            }
3726            if let Some(g) = self.state.graft.as_mut() {
3727                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3728            }
3729        }
3730        self.state.event("graft", format!("skipped: {e:#}"));
3731        if let Err(e) = self.state.save() {
3732            tracing::warn!("save after graft: {e:#}");
3733        }
3734        Ok(())
3735    }
3736
3737    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
3738    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
3739    async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3740        let restored = git::restore_to(wt, pre).await;
3741        let tip = git::rev_parse(wt, "HEAD").await;
3742        match (restored, tip) {
3743            (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3744            (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3745            (Ok(()), Ok(tip)) => {
3746                Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3747            }
3748            (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3749        }
3750    }
3751
3752    /// A skip is a run event, recorded once however often the node is re-entered.
3753    fn graft_skipped(&mut self, why: &str) {
3754        let message = format!("skipped: {why}");
3755        if !self
3756            .state
3757            .events
3758            .iter()
3759            .any(|e| e.node == "graft" && e.message == message)
3760        {
3761            self.state.event("graft", message);
3762        }
3763    }
3764
3765    async fn graft_inner(&mut self) -> Result<()> {
3766        // A round that was started and never finished (crash, kill): the
3767        // fixer may have left anything in the tree, so put the winner back.
3768        if let Some(rec) = self.state.graft.clone()
3769            && !rec.outcome.settled()
3770        {
3771            // Reviews exist only if the graft ended without its record being
3772            // settled (a swallowed error): the tree has moved on since, and
3773            // a reset to `pre` would discard the review fixes.
3774            if self.state.reviews.is_empty()
3775                && let Some(winner) = self.state.winner().cloned()
3776            {
3777                Self::graft_restore(&winner.worktree, &rec.pre).await?;
3778            }
3779            if let Some(g) = self.state.graft.as_mut() {
3780                g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3781            }
3782            self.state.event(
3783                "graft",
3784                "rolled back: the previous round was interrupted; the winner is untouched",
3785            );
3786            self.state.save()?;
3787            return Ok(());
3788        }
3789        let plan = match graft::plan(&self.state) {
3790            Ok(plan) => plan,
3791            Err(skip) => {
3792                if skip != graft::Skip::AlreadyDone {
3793                    self.graft_skipped(skip.reason());
3794                    self.state.save()?;
3795                }
3796                return Ok(());
3797            }
3798        };
3799        let Some(winner) = self.state.winner().cloned() else {
3800            return Ok(());
3801        };
3802        let wt = winner.worktree.clone();
3803        if !git::is_clean(&wt).await? {
3804            self.graft_skipped("the winner's worktree has uncommitted changes");
3805            self.state.save()?;
3806            return Ok(());
3807        }
3808        let pre = git::rev_parse(&wt, "HEAD").await?;
3809        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3810        else {
3811            self.graft_skipped("the winner's diff could not be measured");
3812            self.state.save()?;
3813            return Ok(());
3814        };
3815        let limit = graft::limit(winner_lines);
3816        // Saved before the fixer is called: a stop from here on is repaired
3817        // by the branch above on the next entry.
3818        self.state.graft = Some(GraftRecord {
3819            pre: pre.clone(),
3820            post: None,
3821            lines: None,
3822            limit: Some(limit),
3823            agent: None,
3824            outcome: GraftOutcome::Running,
3825            requested: graft::requested(&plan),
3826            adoption: None,
3827            adoption_error: None,
3828        });
3829        self.state.event(
3830            "graft",
3831            format!(
3832                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3833                plan.strengths
3834                    .iter()
3835                    .map(|(l, _, _)| l.to_string())
3836                    .collect::<Vec<_>>()
3837                    .join(", "),
3838                plan.winner
3839            ),
3840        );
3841        self.state.save()?;
3842
3843        let fix_prompt = prompt::graft(
3844            &self.state.instruction,
3845            plan.winner,
3846            &plan.strengths,
3847            limit,
3848            &self.state.config.graph.language,
3849        );
3850        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3851        let sessions = self.state.config.graph.sessions;
3852        let artifacts = agent::artifacts_dir(&self.state.dir());
3853        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3854        let (_, seat, out) = self
3855            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3856                SeatJob {
3857                    prompt: fix_prompt.clone(),
3858                    spec,
3859                    seat,
3860                    cwd: wt.clone(),
3861                    timeout,
3862                    allow_write: true,
3863                    sessions,
3864                    artifacts: artifacts.clone(),
3865                    stem: "graft".to_owned(),
3866                    handover: None,
3867                }
3868            })
3869            .await;
3870        // Never spawned: nothing changed (`HEAD == pre` is confirmed), so the
3871        // record goes back to "not started" - not `RolledBack`, which would
3872        // settle it - and the run stops resumably. The seat, the chain and
3873        // the quota are untouched.
3874        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
3875            Self::graft_restore(&wt, &pre).await?;
3876            self.state.graft = None;
3877            self.state.save()?;
3878            return self.stop_for_resource();
3879        }
3880        let mut why: Option<String> = None;
3881        match &out {
3882            AgentOutcome::Ok(o) if agent::output_advances(o) => {
3883                why = Some("the fixer produced nothing usable".to_owned());
3884            }
3885            AgentOutcome::Ok(_) => {}
3886            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3887            AgentOutcome::Quota(o) => {
3888                self.state.quota.push(QuotaLoss {
3889                    seat: seat.key.clone(),
3890                    node: "graft".to_owned(),
3891                    at: Timestamp::now(),
3892                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3893                });
3894                why = Some("rate limited (quota)".to_owned());
3895            }
3896            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
3897                why = Some(format!(
3898                    "the fixer failed: {}",
3899                    e.lines()
3900                        .next()
3901                        .unwrap_or("")
3902                        .chars()
3903                        .take(160)
3904                        .collect::<String>()
3905                ));
3906            }
3907        }
3908        let agent_id = seat.agent.clone();
3909        self.state.seats.insert(seat.key.clone(), seat);
3910
3911        let mut post = None;
3912        let mut lines = None;
3913        if why.is_none() {
3914            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3915            if let Ok(r) = &rescued {
3916                self.state.note_withheld("graft", &r.withheld);
3917            }
3918            let head = git::rev_parse(&wt, "HEAD").await?;
3919            // What is measured must be what gets reviewed and gated: a failed
3920            // rescue, or anything still uncommitted (withheld lockfiles
3921            // aside, which are never part of the tree), rejects the graft.
3922            let dirty = match &rescued {
3923                Ok(r) => git::status_porcelain(&wt)
3924                    .await
3925                    .map(|st| {
3926                        st.lines().any(|l| {
3927                            !r.withheld
3928                                .iter()
3929                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
3930                        })
3931                    })
3932                    .unwrap_or(true),
3933                Err(_) => true,
3934            };
3935            if dirty {
3936                why = Some("the fixer left changes that could not be committed".to_owned());
3937            } else if head == pre {
3938                why = Some("the fixer changed nothing".to_owned());
3939            } else {
3940                lines = git::changed_lines(&wt, &pre, &head).await;
3941                if graft::within_budget(lines, limit) {
3942                    post = Some(head);
3943                } else {
3944                    why = Some(format!(
3945                        "{} changed lines against a limit of {limit}",
3946                        lines.map_or_else(
3947                            || "an unmeasurable number of".to_owned(),
3948                            |l| l.to_string()
3949                        )
3950                    ));
3951                }
3952            }
3953        }
3954        let (adoption, adoption_error) = match &out {
3955            AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
3956            _ => (None, None),
3957        };
3958        let requested = graft::requested(&plan);
3959        let outcome = match why {
3960            None => {
3961                self.state.event(
3962                    "graft",
3963                    format!(
3964                        "applied: {} changed lines (limit {limit})",
3965                        lines.unwrap_or(0)
3966                    ),
3967                );
3968                GraftOutcome::Applied
3969            }
3970            Some(why) => {
3971                // Not settled until the restore is confirmed: the record
3972                // stays `Running`, so a resume retries it.
3973                Self::graft_restore(&wt, &pre).await?;
3974                self.state.event(
3975                    "graft",
3976                    format!("rolled back: {why}; the winner is untouched"),
3977                );
3978                GraftOutcome::RolledBack(why)
3979            }
3980        };
3981        self.state.graft = Some(GraftRecord {
3982            pre,
3983            post,
3984            lines,
3985            limit: Some(limit),
3986            agent: Some(agent_id),
3987            outcome,
3988            requested,
3989            adoption,
3990            adoption_error,
3991        });
3992        self.state.save()?;
3993        Ok(())
3994    }
3995
3996    // ----------------------------------------------------------------- fold
3997
3998    async fn fold_losers(&mut self) -> Result<()> {
3999        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4000            return Ok(());
4001        };
4002        let repo = self.state.repo.clone();
4003        let mut folded = Vec::new();
4004        for i in 0..self.state.candidates.len() {
4005            let c = &self.state.candidates[i];
4006            if c.label == winner || c.folded {
4007                continue;
4008            }
4009            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4010            git::worktree_remove(&repo, &wt).await.ok();
4011            git::branch_delete(&repo, &branch).await.ok();
4012            self.state.candidates[i].folded = true;
4013            folded.push(label.to_string());
4014        }
4015        // The judges are finished; their checkouts are pure cost from here.
4016        let root = self.state.worktree_root();
4017        for j in 1..=self.roles.judges.len() {
4018            let wt = root.join(format!("judge-{j}"));
4019            if wt.exists() {
4020                git::worktree_remove(&repo, &wt).await.ok();
4021            }
4022        }
4023        // The design-deliberation stage is finished by the time a tally
4024        // exists — same reasoning as the judges above.
4025        if self.state.config.graph.advise {
4026            for k in 1..=self.state.config.graph.advisors {
4027                let wt = root.join(format!("advisor-{k}"));
4028                if wt.exists() {
4029                    git::worktree_remove(&repo, &wt).await.ok();
4030                }
4031            }
4032        }
4033        if !folded.is_empty() {
4034            self.state
4035                .event("fold", format!("folded candidates {}", folded.join(", ")));
4036            self.state.save()?;
4037        }
4038        Ok(())
4039    }
4040
4041    // ------------------------------------------------------------ base sync
4042
4043    /// Land the winner's tree on the current tip of `<remote>/<base>` before
4044    /// anything verifies it.
4045    ///
4046    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
4047    /// read whatever is checked out in the winner's worktree. Left alone that
4048    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
4049    /// it when the run *branched* - and a run takes long enough that the base
4050    /// has usually moved by the time it gets here. A gate that ran there
4051    /// answers "green on the commit this run started from", not "green on
4052    /// what is about to land", and the difference showed up three times in
4053    /// one day as a green run whose merge would have reverted a file another
4054    /// pull request had already landed.
4055    ///
4056    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
4057    /// `land::Step::Rebase` calls, rather than a second implementation of the
4058    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
4059    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
4060    /// doc warns about ("two answers to a question nobody notices until a
4061    /// diff is wrong").
4062    ///
4063    /// A conflict is not the end of the road: the standing rebase is handed
4064    /// to the fixer seat, at most `graph.review_rounds` times, counted in
4065    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
4066    /// land). Once it finishes, review and the gate run as usual on the
4067    /// rebased tree, which is where a breakage the new base caused is caught
4068    /// by the ordinary gate-fix round. magi resolves nothing itself.
4069    ///
4070    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
4071    /// `state.base_sync.attempts`, is how many times the base is *rebased
4072    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
4073    /// *conflict* was given to a fixer. When the fixer cannot finish the
4074    /// rebase the branch is restored, `state.base_sync.conflict` is set with
4075    /// what was tried (rounds spent, paths still conflicted) and the branch
4076    /// and worktree stay exactly as they were - untouched, for a person to
4077    /// look at - which is also what makes re-entering this function
4078    /// afterwards a no-op instead of a second attempt at the same wall. A
4079    /// push failure ends the same way.
4080    async fn sync_to_base(&mut self) -> Result<()> {
4081        if self.state.status == RunStatus::AlreadyInBase {
4082            return Ok(());
4083        }
4084        let conflicted = self
4085            .state
4086            .base_sync
4087            .as_ref()
4088            .is_some_and(|s| s.conflict.is_some());
4089        let Some(winner) = self.state.winner().cloned() else {
4090            return Ok(());
4091        };
4092
4093        let repo = self.state.repo.clone();
4094        let remote = self.state.config.merge.remote.clone();
4095        let base_branch = self.state.base_branch.clone();
4096        let tracking = format!("{remote}/{base_branch}");
4097
4098        git::fetch(&repo, &remote, &base_branch).await.ok();
4099        // No network, or the remote never had this branch: the run already
4100        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
4101        // otherwise), and one that got this far is not blocked by a fetch
4102        // that fails now. It just has no newer tip to compare against.
4103        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4104            return Ok(());
4105        };
4106
4107        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4108        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4109        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4110
4111        // Before any rebase, and before a recorded conflict is honoured: a
4112        // branch whose change reached the base under other commit ids has
4113        // nothing to rebase and nothing to conflict with, and a run that
4114        // already stopped on that phantom conflict recovers here on resume.
4115        // `behind == 0` with head == tip is a branch the base has since taken
4116        // in whole, whether or not a conflict was ever recorded: the ancestry
4117        // proof must still run (`classify` ignores a head still on the start
4118        // commit).
4119        if (behind > 0 || conflicted || head == tip)
4120            && self
4121                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4122                .await?
4123        {
4124            return Ok(());
4125        }
4126        if conflicted {
4127            return Ok(());
4128        }
4129
4130        if behind == 0 {
4131            // A fixer-finished rebase moves the branch ref before the
4132            // winner's worktree is told (`sync_to_head` below). A run that
4133            // died in between resumes here with `behind == 0` and a tree still
4134            // holding the pre-rebase files, which review and the gate would
4135            // then read. That state is exactly: HEAD moved off the tip the
4136            // rebase started from, yet the tree is still identical to that
4137            // tip. A tree with edits of its own differs from it, so nothing
4138            // is thrown away.
4139            if let Some(from) = self
4140                .state
4141                .rebase_fixes
4142                .iter()
4143                .rev()
4144                .find_map(|r| r.from.clone())
4145                && from != head
4146                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4147                    .await
4148                    .is_ok_and(|o| o.ok())
4149            {
4150                git::sync_to_head(&winner.worktree).await?;
4151            }
4152            // An earlier attempt may have rebased the branch locally and died
4153            // before pushing it (only the fresh-rebase arm below pushes).
4154            // Publish it now, so the plain push at PR time is not refused as
4155            // a non-fast-forward. A run already holding a recorded conflict
4156            // never reaches here; that case is out of scope.
4157            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4158            if let Some(why) = &conflict {
4159                self.state.status = RunStatus::Blocked;
4160                self.state.event("land", why.clone());
4161            }
4162            self.state.base_sync = Some(BaseSync {
4163                tip,
4164                behind: 0,
4165                attempts,
4166                conflict,
4167                already_in: None,
4168            });
4169            self.state.save()?;
4170            return Ok(());
4171        }
4172
4173        if attempts >= BASE_SYNC_ROUNDS {
4174            let why = format!(
4175                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4176                 rebase(s); rebasing again would only race it",
4177                winner.branch
4178            );
4179            self.state.status = RunStatus::Blocked;
4180            self.state.base_sync = Some(BaseSync {
4181                tip,
4182                behind,
4183                attempts,
4184                conflict: Some(why.clone()),
4185                already_in: None,
4186            });
4187            self.state.event("land", why);
4188            self.state.save()?;
4189            return Ok(());
4190        }
4191
4192        self.state.event(
4193            "land",
4194            format!(
4195                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4196                winner.branch
4197            ),
4198        );
4199        self.state.save()?;
4200
4201        // The remote's copy of the branch, read now and only if the fetch
4202        // really succeeded (a stale tracking ref must never pin a lease). It is
4203        // pushed over after a rebase only when it is a commit this branch
4204        // already contains, by ancestry or by patch (an earlier rebase of ours
4205        // that never reached the remote): anything else is somebody else's work.
4206        let branch_tracking = format!("{remote}/{}", winner.branch);
4207        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4208        let remote_tip = if self.state.task_pr_branch().is_none()
4209            && matches!(&fetched_branch, Ok(o) if o.ok())
4210        {
4211            git::rev_parse(&repo, &branch_tracking).await.ok()
4212        } else {
4213            None
4214        };
4215        // A remote tip this branch does not contain is somebody else's work:
4216        // rebasing would leave a local tip that can never be pushed. Stop
4217        // before touching anything and say so.
4218        if let Some(theirs) = &remote_tip
4219            && !git::is_ancestor(&repo, theirs, &head).await
4220            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4221                .await
4222                .is_ok_and(|missing| missing.is_empty())
4223        {
4224            let why = format!(
4225                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4226                 them",
4227                short(theirs),
4228                winner.branch
4229            );
4230            self.state.status = RunStatus::Blocked;
4231            self.state.base_sync = Some(BaseSync {
4232                tip,
4233                behind,
4234                attempts,
4235                conflict: Some(why.clone()),
4236                already_in: None,
4237            });
4238            self.state.event("land", why);
4239            self.state.save()?;
4240            return Ok(());
4241        }
4242
4243        let scratch = self.state.dir().join("base-sync");
4244        let rebased = match crate::rebase::rebase_with_fixer(
4245            &mut self.state,
4246            &scratch,
4247            &winner.branch,
4248            &tracking,
4249        )
4250        .await
4251        {
4252            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4253            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4254            Err(e) => Err(e),
4255        };
4256        let attempts = attempts + 1;
4257        match rebased {
4258            Ok(None) => {
4259                // The branch ref moved, but a worktree that already had it
4260                // checked out (the winner's) was not told; sync its index and
4261                // files before anything reads them.
4262                git::sync_to_head(&winner.worktree).await?;
4263                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4264                let mut conflict = None;
4265                if let Some(pinned) = &remote_tip {
4266                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4267                    match pushed {
4268                        Ok(o) if o.ok() => self.state.event(
4269                            "land",
4270                            format!("pushed rebased {} to {remote}", winner.branch),
4271                        ),
4272                        Ok(o) => {
4273                            conflict = Some(format!(
4274                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4275                                winner.branch,
4276                                short(pinned),
4277                                o.stderr.chars().take(600).collect::<String>()
4278                            ));
4279                        }
4280                        Err(e) => {
4281                            conflict = Some(format!(
4282                                "rebased {} locally but could not push it: {e:#}",
4283                                winner.branch
4284                            ));
4285                        }
4286                    }
4287                }
4288                if let Some(why) = &conflict {
4289                    self.state.status = RunStatus::Blocked;
4290                    self.state.event("land", why.clone());
4291                }
4292                self.state.base_sync = Some(BaseSync {
4293                    tip: tip.clone(),
4294                    behind: 0,
4295                    attempts,
4296                    conflict,
4297                    already_in: None,
4298                });
4299                self.state
4300                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4301            }
4302            Ok(Some(conflict)) => {
4303                let why = format!(
4304                    "{} conflicts with {tracking} and did not rebase: {}",
4305                    winner.branch,
4306                    conflict.chars().take(600).collect::<String>()
4307                );
4308                self.state.status = RunStatus::Blocked;
4309                self.state.base_sync = Some(BaseSync {
4310                    tip,
4311                    behind,
4312                    attempts,
4313                    conflict: Some(why.clone()),
4314                    already_in: None,
4315                });
4316                self.state.event("land", why);
4317            }
4318            Err(e) => {
4319                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4320                self.state.status = RunStatus::Blocked;
4321                self.state.base_sync = Some(BaseSync {
4322                    tip,
4323                    behind,
4324                    attempts,
4325                    conflict: Some(why.clone()),
4326                    already_in: None,
4327                });
4328                self.state.event("land", why);
4329            }
4330        }
4331        self.state.save()?;
4332        Ok(())
4333    }
4334
4335    /// Push a branch an earlier attempt rebased locally but never published,
4336    /// pinned to the remote tip read right after a successful fetch. Returns
4337    /// the reason when the run must stop; `None` when there was nothing to do
4338    /// (no remote copy, the same tip, or a remote copy this branch already
4339    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4340    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4341        // Task competitions publish only after the PR text gate, to their
4342        // public head; never resurrect a candidate branch on the remote.
4343        if self.state.task_pr_branch().is_some() {
4344            return None;
4345        }
4346        let repo = self.state.repo.clone();
4347        let remote = self.state.config.merge.remote.clone();
4348        let fetched = git::fetch(&repo, &remote, branch).await;
4349        if !matches!(&fetched, Ok(o) if o.ok()) {
4350            return None;
4351        }
4352        let branch_tracking = format!("{remote}/{branch}");
4353        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4354        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4355            return None;
4356        }
4357        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4358            .await
4359            .is_ok_and(|missing| missing.is_empty())
4360        {
4361            return Some(format!(
4362                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4363                 them",
4364                short(&theirs)
4365            ));
4366        }
4367        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4368            Ok(o) if o.ok() => {
4369                self.state
4370                    .event("land", format!("pushed rebased {branch} to {remote}"));
4371                None
4372            }
4373            Ok(o) => Some(format!(
4374                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4375                 someone may have pushed): {}",
4376                short(&theirs),
4377                o.stderr.chars().take(600).collect::<String>()
4378            )),
4379            Err(e) => Some(format!(
4380                "{branch} is rebased locally but could not be pushed: {e:#}"
4381            )),
4382        }
4383    }
4384
4385    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4386    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4387    /// whether it did.
4388    ///
4389    /// Checked only when the base is ahead of the branch. A failing check is
4390    /// "not proven" - the ordinary rebase path then decides - never a reason to
4391    /// stop the run.
4392    ///
4393    /// The remote copy of the branch is held to the same standard as the local
4394    /// one: if it carries a tip this worktree does not, that tip must itself be
4395    /// proven in the base, or nothing is settled (a pull request would
4396    /// otherwise be closed over commits nobody checked). The pull request is
4397    /// closed *before* the terminal status is saved; if that fails for a
4398    /// reason other than a refusal (no network, a `gh` error) the run is left
4399    /// `Blocked` with the reason as its conflict, which a resume retries -
4400    /// the same recovery a phantom conflict gets.
4401    async fn settle_already_in(
4402        &mut self,
4403        branch: &str,
4404        tip: &str,
4405        head: &str,
4406        attempts: usize,
4407        behind: usize,
4408    ) -> Result<bool> {
4409        let repo = self.state.repo.clone();
4410        let remote = self.state.config.merge.remote.clone();
4411        let start = self.state.base_commit.clone();
4412        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4413            Ok(Some(e)) => e,
4414            Ok(None) => return Ok(false),
4415            Err(e) => {
4416                tracing::warn!("already-in-base check for {branch}: {e:#}");
4417                return Ok(false);
4418            }
4419        };
4420        let mut verified = vec![head.to_owned()];
4421        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4422        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4423        if matches!(&fetched, Ok(o) if o.ok())
4424            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4425            && theirs != head
4426        {
4427            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4428                Ok(Some(_)) => verified.push(theirs),
4429                _ => return Ok(false),
4430            }
4431        }
4432        let base_branch = self.state.base_branch.clone();
4433        let message = format!(
4434            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4435             land",
4436            evidence.names(),
4437            evidence.proof.as_str()
4438        );
4439        let closed =
4440            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4441                .await;
4442        match closed {
4443            Ok(Ok(url)) => self
4444                .state
4445                .event("land", format!("closed {url}: superseded on {base_branch}")),
4446            Ok(Err(why)) => self
4447                .state
4448                .event("land", format!("did not close a pull request: {why}")),
4449            Err(e) => {
4450                let why = format!(
4451                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4452                     not be closed ({e:#}); resume to retry"
4453                );
4454                self.state.status = RunStatus::Blocked;
4455                self.state.base_sync = Some(BaseSync {
4456                    tip: tip.to_owned(),
4457                    behind,
4458                    attempts,
4459                    conflict: Some(why.clone()),
4460                    already_in: None,
4461                });
4462                self.state.event("land", why);
4463                self.state.save()?;
4464                return Ok(true);
4465            }
4466        }
4467        self.state.status = RunStatus::AlreadyInBase;
4468        self.state.base_sync = Some(BaseSync {
4469            tip: tip.to_owned(),
4470            behind,
4471            attempts,
4472            conflict: None,
4473            already_in: Some(evidence),
4474        });
4475        self.state.event("land", message);
4476        self.state.save()?;
4477        self.settle_questions();
4478        Ok(true)
4479    }
4480
4481    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4482    /// last landed the winner on, once it has run, else the commit the run
4483    /// branched from.
4484    ///
4485    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4486    /// and `vote` all happen before there is a winner to rebase, so they
4487    /// compare every candidate against the branch point on purpose, and a
4488    /// base that moves after they are already done cannot change an answer
4489    /// they already gave.
4490    fn landing_base(&self) -> String {
4491        self.state
4492            .base_sync
4493            .as_ref()
4494            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4495    }
4496
4497    // ------------------------------------------------------- operator fix
4498
4499    /// Route specific, already-recorded review findings to a fixer for a
4500    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4501    /// entry point.
4502    ///
4503    /// Distinct from `review_loop`'s own fix step in three ways: it never
4504    /// runs a reviewer wave, it never spends review-round budget, and what
4505    /// happened is recorded as an [`OperatorFixRequest`] appended to
4506    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4507    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4508    /// and vote must never be rewritten to look like a manufactured blocking
4509    /// verdict.
4510    ///
4511    /// Only meaningful once review has actually concluded: `Ready` (handed
4512    /// off with findings still open, or simply concluded clean while minor
4513    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4514    /// gate failed). Everything else is refused: a run still in progress
4515    /// should simply be resumed, and a `Merged` run's branch has already
4516    /// landed — reopening *this* run's own record cannot change that, so the
4517    /// answer there is a fresh `magi review <branch>`.
4518    ///
4519    /// A real commit here re-verifies through a fresh, ordinary review-only
4520    /// run on the same branch ([`Self::review`]) rather than reopening this
4521    /// run's own `review_loop`: once any round in this run's history went
4522    /// clean, `review_conclusion` treats that as permanent by design (the
4523    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4524    /// way to force one more genuine reviewer wave out of *this* run without
4525    /// either rewriting history or weakening that guarantee for every other
4526    /// caller. A review-only run costs nothing extra — no implementation, no
4527    /// judging, no vote — and exercises the exact same review → verify →
4528    /// gate → (human) merge path, unmodified.
4529    pub async fn fix_selected(
4530        &mut self,
4531        ids: &[String],
4532        reason: &str,
4533        allow_stale: bool,
4534    ) -> Result<()> {
4535        let reason = reason.trim();
4536        if reason.is_empty() {
4537            bail!("a fix request needs a reason — that is the operator's own record of why");
4538        }
4539        if ids.is_empty() {
4540            bail!("no finding id given");
4541        }
4542        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4543            bail!(
4544                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4545                 has already concluded — can be given a targeted fix. A run still \
4546                 in progress should simply be resumed; a `merged` run's branch has \
4547                 already landed, so its answer is a fresh `magi review <branch>`, \
4548                 not reopening this run's own record",
4549                self.state.id,
4550                self.state.status.as_str()
4551            );
4552        }
4553        let Some(winner) = self.state.winner().cloned() else {
4554            bail!("run {} has no winning candidate to fix", self.state.id);
4555        };
4556        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4557            bail!(
4558                "branch `{}` no longer exists; this run cannot be extended",
4559                winner.branch
4560            );
4561        }
4562        let home = crate::run::home();
4563        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4564            bail!(
4565                "run {} is currently being worked on by another magi process",
4566                self.state.id
4567            );
4568        }
4569        // Held for the rest of this call, including the follow-up review
4570        // below: two `magi fix` invocations against the same run must not
4571        // both reach the worktree manipulation further down, which would
4572        // otherwise race to remove and recreate the same directory — see
4573        // [`FixClaim`]'s own doc.
4574        let _claim = FixClaim::acquire(&self.state.dir())?;
4575
4576        // Resolve every id before spending anything — an unknown id refuses
4577        // the whole request rather than silently dropping it — and dedup
4578        // while keeping the operator's own order.
4579        let mut seen = BTreeSet::new();
4580        let mut findings = Vec::new();
4581        let mut missing = Vec::new();
4582        for id in ids {
4583            if !seen.insert(id.clone()) {
4584                continue;
4585            }
4586            match self.state.finding(id) {
4587                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4588                    id: f.id.clone(),
4589                    severity: f.severity,
4590                    reviewer_vote: rec.vote,
4591                    round: round.round,
4592                    round_head: round.head.clone(),
4593                    reviewer: rec.reviewer,
4594                    agent: rec.agent.clone(),
4595                    file: f.file.clone(),
4596                    line: f.line,
4597                    title: f.title.clone(),
4598                    detail: f.detail.clone(),
4599                    outcome: OperatorFixOutcome::Pending,
4600                }),
4601                None => missing.push(id.clone()),
4602            }
4603        }
4604        if !missing.is_empty() {
4605            bail!(
4606                "unknown finding id(s): {}; nothing was changed",
4607                missing.join(", ")
4608            );
4609        }
4610
4611        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4612        let stale_details: Vec<(String, String)> = findings
4613            .iter()
4614            .filter(|f| f.round_head != head_at_request)
4615            .map(|f| (f.id.clone(), f.round_head.clone()))
4616            .collect();
4617        let stale = !stale_details.is_empty();
4618        if stale && !allow_stale {
4619            bail!(
4620                "the branch has moved since some finding(s) were raised — {} — now \
4621                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4622                stale_details
4623                    .iter()
4624                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4625                    .collect::<Vec<_>>()
4626                    .join(", "),
4627                short(&head_at_request)
4628            );
4629        }
4630
4631        let request = OperatorFixRequest {
4632            requested_at: Timestamp::now(),
4633            reason: reason.to_owned(),
4634            findings,
4635            head_at_request: head_at_request.clone(),
4636            allow_stale,
4637            stale,
4638            fix: None,
4639            result_head: None,
4640            follow_up_review_run: None,
4641        };
4642        self.state.event(
4643            "fix",
4644            format!(
4645                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4646                request.findings.len(),
4647                request
4648                    .findings
4649                    .iter()
4650                    .map(|f| f.id.as_str())
4651                    .collect::<Vec<_>>()
4652                    .join(", "),
4653            ),
4654        );
4655        // Recorded now, before any worktree work or the fixer call itself —
4656        // and re-saved at each checkpoint below: a crash at any point after
4657        // this (mid fixer call, mid follow-up review) must not lose the fact
4658        // that this was requested, for which findings, and why. Everything
4659        // past this point reads and writes through `request_index` rather
4660        // than a local variable, since `request` itself is moved here.
4661        self.state.operator_fixes.push(request);
4662        self.state.save()?;
4663        let request_index = self.state.operator_fixes.len() - 1;
4664
4665        // A fresh, dedicated worktree for this one call, never the winner's
4666        // own worktree in place: that one may already be gone (folded away),
4667        // and reusing it in place would leave the branch checked out there
4668        // when the follow-up review below tries to check it out again. Freed
4669        // immediately after, either way — but only once confirmed clean:
4670        // `worktree_remove` is a `git worktree remove --force`, which would
4671        // otherwise discard uncommitted work left there by the operator or
4672        // another process before this had a chance to even look at it.
4673        if winner.worktree.exists() {
4674            // Lockfiles a rescue commit withheld stay untracked on purpose and
4675            // are already recorded; they are not the operator's work to protect.
4676            let dirty = git::git(
4677                &winner.worktree,
4678                &["status", "--porcelain", "--untracked-files=all"],
4679            )
4680            .await?;
4681            let only_withheld = dirty.lines().all(|l| {
4682                l.strip_prefix("?? ")
4683                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4684            });
4685            if !only_withheld {
4686                bail!(
4687                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4688                     discard them first",
4689                    winner.worktree.display()
4690                );
4691            }
4692            git::worktree_remove(&self.state.repo, &winner.worktree)
4693                .await
4694                .ok();
4695        }
4696        let fix_worktree = self.state.worktree_root().join("operator-fix");
4697        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4698        git::git(
4699            &self.state.repo,
4700            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4701        )
4702        .await
4703        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4704        if !git::is_clean(&fix_worktree).await? {
4705            git::worktree_remove(&self.state.repo, &fix_worktree)
4706                .await
4707                .ok();
4708            bail!(
4709                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4710                winner.branch
4711            );
4712        }
4713        let repo_for_setup = self.state.repo.clone();
4714        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4715            Ok(()) => {
4716                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4717            }
4718            Err(e) => Err(e),
4719        };
4720        if let Err(e) = setup {
4721            git::worktree_remove(&self.state.repo, &fix_worktree)
4722                .await
4723                .ok();
4724            return Err(e);
4725        }
4726
4727        let run_id = self.state.id.clone();
4728        let prompts = self.state.config.prompts.clone();
4729        let language = self.state.config.graph.language.clone();
4730        let sessions = self.state.config.graph.sessions;
4731        let artifacts = agent::artifacts_dir(&self.state.dir());
4732        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4733            .findings
4734            .iter()
4735            .map(|f| Finding {
4736                id: f.id.clone(),
4737                severity: f.severity,
4738                file: f.file.clone(),
4739                line: f.line,
4740                title: f.title.clone(),
4741                detail: f.detail.clone(),
4742            })
4743            .collect();
4744        let fix_prompt = prompt::operator_fix_in(
4745            &self.state.instruction,
4746            &finding_list,
4747            reason,
4748            &stale_details,
4749            &head_at_request,
4750            &language,
4751            &self.state.config.graph.github_language,
4752        );
4753        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4754        let (job, seat, out) = self
4755            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4756                prompt: fix_prompt.clone(),
4757                spec,
4758                seat,
4759                cwd: fix_worktree.clone(),
4760                timeout,
4761                allow_write: true,
4762                sessions,
4763                artifacts: artifacts.clone(),
4764                stem: "operator-fix".to_owned(),
4765                handover: None,
4766            })
4767            .await;
4768        let agent_id = seat.agent.clone();
4769
4770        let mut fix = FixRecord {
4771            agent: agent_id,
4772            addressed: Vec::new(),
4773            rejected: Vec::new(),
4774            notes: String::new(),
4775            committed: false,
4776            failed: None,
4777            duration_ms: 0,
4778            continuation: None,
4779        };
4780        let mut final_seat = seat.clone();
4781        match out {
4782            AgentOutcome::Ok(o) => {
4783                fix.duration_ms = o.duration_ms;
4784                let parsed = verdict::extract_json::<FixReport>(&o.text);
4785                let incomplete_reason = match &parsed {
4786                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4787                        "the reply parsed, but it reported a command whose own CLI \
4788                         never confirmed an exit status"
4789                            .to_owned(),
4790                    ),
4791                    Ok(_) => None,
4792                    Err(e) => Some(e.to_string()),
4793                };
4794                match incomplete_reason {
4795                    None => {
4796                        let report = parsed.expect("checked Ok above");
4797                        fix.addressed = report.addressed;
4798                        fix.rejected = report.rejected;
4799                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4800                    }
4801                    Some(reason) => {
4802                        let (resumed_seat, resolved, failure, cont) = self
4803                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4804                            .await;
4805                        fix.duration_ms += cont.cumulative_wait_ms;
4806                        fix.continuation = Some(cont);
4807                        final_seat = resumed_seat;
4808                        match resolved {
4809                            Some(report) => {
4810                                fix.addressed = report.addressed;
4811                                fix.rejected = report.rejected;
4812                                fix.notes =
4813                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4814                            }
4815                            None => fix.failed = failure,
4816                        }
4817                    }
4818                }
4819            }
4820            AgentOutcome::Dropped(o) => {
4821                fix.duration_ms = o.duration_ms;
4822                let why = o
4823                    .dropped
4824                    .as_ref()
4825                    .map(|d| d.why.as_str())
4826                    .unwrap_or("the CLI ended the stream without delivering its answer");
4827                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4828            }
4829            AgentOutcome::Quota(o) => {
4830                self.state.quota.push(QuotaLoss {
4831                    seat: final_seat.key.clone(),
4832                    node: "fix".to_owned(),
4833                    at: Timestamp::now(),
4834                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4835                });
4836                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4837            }
4838            AgentOutcome::Failed(e) => fix.failed = Some(e),
4839            AgentOutcome::ResourceBlocked(e) => {
4840                fix.failed = Some(format!("the fixer was not started: {e}"));
4841            }
4842        }
4843        if fix.continuation.is_none() {
4844            fix.continuation = Some(ContinuationRecord::not_needed());
4845        }
4846        self.state.seats.insert(final_seat.key.clone(), final_seat);
4847
4848        let rescue_message = format!(
4849            "magi: operator-selected fix ({}) (uncommitted work)",
4850            self.state.operator_fixes[request_index]
4851                .findings
4852                .iter()
4853                .map(|f| f.id.as_str())
4854                .collect::<Vec<_>>()
4855                .join(", ")
4856        );
4857        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4858            self.state.note_withheld("fix", &r.withheld);
4859        }
4860        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4861        fix.committed = after != head_at_request;
4862        git::worktree_remove(&self.state.repo, &fix_worktree)
4863            .await
4864            .ok();
4865
4866        self.state.event(
4867            "fix",
4868            match &fix.failed {
4869                Some(reason) => format!(
4870                    "operator fix: adoption report was lost ({reason}); {}",
4871                    if fix.committed {
4872                        "committed"
4873                    } else {
4874                        "NO new commit"
4875                    }
4876                ),
4877                None => format!(
4878                    "operator fix: {} addressed, {} rejected, {}",
4879                    fix.addressed.len(),
4880                    fix.rejected.len(),
4881                    if fix.committed {
4882                        "committed"
4883                    } else {
4884                        "NO new commit"
4885                    }
4886                ),
4887            },
4888        );
4889
4890        // Every selected finding gets an outcome — never left `Pending` once
4891        // the fixer's own turn is over. A report that never came back at all
4892        // marks every one of them `Unreported`, not silently "not addressed":
4893        // quota, a dropped stream, or an exhausted continuation are gaps in
4894        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4895        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4896        for f in &mut self.state.operator_fixes[request_index].findings {
4897            f.outcome = if fix.failed.is_some() {
4898                OperatorFixOutcome::Unreported
4899            } else if fix.addressed.contains(&f.id) {
4900                OperatorFixOutcome::Addressed
4901            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4902                OperatorFixOutcome::Rejected { why: r.why.clone() }
4903            } else {
4904                OperatorFixOutcome::Unreported
4905            };
4906        }
4907
4908        let committed = fix.committed;
4909        if committed {
4910            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4911        }
4912        self.state.operator_fixes[request_index].fix = Some(fix);
4913        // Saved again now that the fixer's own outcome is final, on top of
4914        // the save right after the request was first pushed above.
4915        self.state.save()?;
4916
4917        if committed {
4918            self.state.event(
4919                "fix",
4920                format!(
4921                    "operator fix committed {}; opening a follow-up review-only run",
4922                    short(&after)
4923                ),
4924            );
4925            // The operator asked for the fix, and the follow-up serves whatever
4926            // task the run it follows served.
4927            let origin =
4928                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4929            match Self::review(
4930                &self.state.repo,
4931                &winner.branch,
4932                self.state.config.clone(),
4933                origin,
4934            )
4935            .await
4936            {
4937                Ok(mut follow_up) => {
4938                    follow_up.state.event(
4939                        "start",
4940                        format!(
4941                            "requested by an operator fix on run {} for finding(s) {}",
4942                            self.state.id,
4943                            self.state.operator_fixes[request_index]
4944                                .findings
4945                                .iter()
4946                                .map(|f| f.id.as_str())
4947                                .collect::<Vec<_>>()
4948                                .join(", "),
4949                        ),
4950                    );
4951                    follow_up.state.save()?;
4952                    let follow_up_id = follow_up.state.id.clone();
4953                    // The follow-up is a run like any other: it belongs to the
4954                    // task of the run it follows, or to one filed for it.
4955                    let adopted = match crate::direct::adopt(
4956                        &follow_up.state,
4957                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4958                    ) {
4959                        Ok(a) => a,
4960                        Err(e) => {
4961                            // An ownerless run must not spend agent calls; it
4962                            // stays saved, and `magi run --resume` adopts it.
4963                            self.state.event(
4964                                "fix",
4965                                format!(
4966                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4967                                ),
4968                            );
4969                            self.state.save()?;
4970                            return Ok(());
4971                        }
4972                    };
4973                    let executed = follow_up.execute().await;
4974                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4975                    if let Some(a) = adopted {
4976                        a.finish(&follow_up.state, executed);
4977                    }
4978                    if let Some(e) = failure {
4979                        self.state.event(
4980                            "fix",
4981                            format!(
4982                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4983                            ),
4984                        );
4985                    }
4986                    self.state.operator_fixes[request_index].follow_up_review_run =
4987                        Some(follow_up_id);
4988                }
4989                Err(e) => {
4990                    self.state.event(
4991                        "fix",
4992                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4993                    );
4994                }
4995            }
4996            self.state.save()?;
4997        }
4998
4999        Ok(())
5000    }
5001
5002    // --------------------------------------------------------------- review
5003
5004    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
5005    /// names, in order, each at most once, moving on only when the call
5006    /// advances (an error, a quota hit or nothing usable - the same decision
5007    /// point `agent::chain_advances` is for the other chained roles).
5008    ///
5009    /// Each agent gets its own seat from `Runner::seat`: the same agent
5010    /// continues its conversation (the winner's own implementer seat when it
5011    /// is the winner's author, now that the competition is over), another
5012    /// takes a fresh one so the full prompt is sent again. A handover is
5013    /// recorded under `node`, which is what makes the fallback stick for the
5014    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
5015    /// is returned, so an exhausted chain reads exactly like a single failed
5016    /// fixer: one quota loss, the same wording, the same refund. An earlier
5017    /// attempt's edits are left in the tree and judged, with the final
5018    /// attempt's, by what git says afterwards.
5019    ///
5020    /// Returns the job that produced the outcome, for `continue_fix_report`.
5021    async fn ask_fixer(
5022        &mut self,
5023        winner: &Candidate,
5024        node: &'static str,
5025        round: Option<usize>,
5026        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5027    ) -> (SeatJob, SeatState, AgentOutcome) {
5028        let attempts = fixer::attempts(&self.state, &self.roles, winner);
5029        self.ask_fixer_among(attempts, node, round, None, build)
5030            .await
5031    }
5032
5033    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
5034    /// worktree and a commit: before the next agent is asked after one that
5035    /// did not satisfy the call, the worktree is put back on that commit, so
5036    /// a failed agent's partial edits are not carried into the next one.
5037    async fn ask_fixer_among(
5038        &mut self,
5039        attempts: Vec<(AgentSpec, String)>,
5040        node: &'static str,
5041        round: Option<usize>,
5042        restore: Option<(&Path, &str)>,
5043        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5044    ) -> (SeatJob, SeatState, AgentOutcome) {
5045        let run_id = self.state.id.clone();
5046        let prompts = self.state.config.prompts.clone();
5047        let cache = self.state.config.cache_dir();
5048        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5049        let mut last = None;
5050        for (i, (spec, key)) in attempts.into_iter().enumerate() {
5051            let seat = self.seat(&key, &spec.id);
5052            let mut job = build(spec.clone(), seat);
5053            if i > 0 {
5054                job.stem = format!("{}-{}", job.stem, spec.id);
5055            }
5056            let ctx = WaveCtx {
5057                carry_seats: false,
5058                pauses: Some((&self.pause, &self.interrupt)),
5059                run: &run_id,
5060                node,
5061                prompts: &prompts,
5062                cache: cache.as_deref(),
5063                round,
5064            };
5065            let (seat, out) =
5066                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5067            // A resource block never advances the chain: the chosen agent
5068            // was not asked, so it must be the one asked again on resume.
5069            let advances = match &out {
5070                AgentOutcome::Ok(o) => agent::output_advances(o),
5071                AgentOutcome::ResourceBlocked(_) => false,
5072                _ => true,
5073            };
5074            if let Some(next) = ids.get(i + 1)
5075                && advances
5076            {
5077                self.state.seats.insert(seat.key.clone(), seat.clone());
5078                let class =
5079                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5080                let reason = match &out {
5081                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5082                    other => fail_reason(other),
5083                };
5084                record_handover(
5085                    &mut self.state,
5086                    node,
5087                    &seat.key,
5088                    &spec.id,
5089                    next,
5090                    &class,
5091                    &reason,
5092                );
5093                // A failed agent's edits must not reach the next one, and a
5094                // restore that did not work ends the chain: the call fails
5095                // and the caller's own (confirmed) restore decides.
5096                if let Some((wt, rev)) = restore
5097                    && let Err(e) = git::restore_to(wt, rev).await
5098                {
5099                    let why =
5100                        format!("could not restore the worktree before the next fixer: {e:#}");
5101                    tracing::warn!("{why}");
5102                    last = Some((job, seat, AgentOutcome::Failed(why)));
5103                    break;
5104                }
5105                continue;
5106            }
5107            last = Some((job, seat, out));
5108            break;
5109        }
5110        last.expect("the fixer chain always has an entry")
5111    }
5112
5113    /// The one repair a verification-only failure earns (see
5114    /// [`crate::recovery`]), before the first review round of a run that took
5115    /// a branch over.
5116    ///
5117    /// Saved as started *before* the fixer is called, so a resume never
5118    /// repairs twice; a start found on re-entry is rolled back to the commit
5119    /// it began from. A failed or unusable fixer leaves the tree as it was and
5120    /// the review goes on over it, so the first round's own verification
5121    /// reports the failure again. The failure is never carried into a round as
5122    /// a result: verification is re-run on whatever commit this leaves.
5123    async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5124        use crate::recovery::{Mode, Repair};
5125        if !self.state.reviews.is_empty() {
5126            return Ok(());
5127        }
5128        let Some(mut rec) = self.state.recovery.clone() else {
5129            return Ok(());
5130        };
5131        if rec.mode != Mode::VerifyRepair {
5132            return Ok(());
5133        }
5134        let wt = winner.worktree.clone();
5135        let head = git::rev_parse(&wt, "HEAD").await?;
5136        match rec.repair.clone() {
5137            Repair::Done { .. } => return Ok(()),
5138            Repair::Started { before } => {
5139                // Interrupted: the unfinished edits are not trusted.
5140                // An unconfirmed rollback blocks the run with `Started` kept,
5141                // so a resume retries it; the tree is never reviewed as is.
5142                if let Err(e) = Self::graft_restore(&wt, &before).await {
5143                    return self
5144                        .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5145                        .await;
5146                }
5147                rec.repair = Repair::Done {
5148                    before: before.clone(),
5149                    after: before,
5150                    committed: false,
5151                    failed: Some("interrupted; rolled back".to_owned()),
5152                };
5153                self.state
5154                    .event("recovery", "the repair was interrupted and rolled back");
5155                self.state.recovery = Some(rec);
5156                self.state.save()?;
5157                return Ok(());
5158            }
5159            Repair::NotStarted => {}
5160        }
5161        if crate::recovery::recheck(&mut rec, &head) {
5162            self.state.event("recovery", rec.summary());
5163            self.state.recovery = Some(rec);
5164            self.state.save()?;
5165            return Ok(());
5166        }
5167        rec.repair = Repair::Started {
5168            before: head.clone(),
5169        };
5170        self.state.recovery = Some(rec.clone());
5171        self.state.save()?;
5172
5173        let language = self.state.config.graph.language.clone();
5174        let sessions = self.state.config.graph.sessions;
5175        let artifacts = agent::artifacts_dir(&self.state.dir());
5176        let fix_prompt = prompt::recovery_fix(
5177            &self.state.instruction,
5178            &rec,
5179            self.state.config.graph.review_rounds,
5180            &language,
5181        );
5182        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5183        self.state.event(
5184            "recovery",
5185            format!(
5186                "repair: the fixer starts from the failed verification on {}",
5187                short(&head)
5188            ),
5189        );
5190        let (_job, seat, out) = self
5191            .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5192                prompt: fix_prompt.clone(),
5193                spec,
5194                seat,
5195                cwd: wt.clone(),
5196                timeout,
5197                allow_write: true,
5198                sessions,
5199                artifacts: artifacts.clone(),
5200                stem: "fix-recovery".to_owned(),
5201                handover: None,
5202            })
5203            .await;
5204        // Never spawned: nothing changed, so the repair goes back to "not
5205        // started" and the run stops resumably, as for a graft.
5206        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5207            if let Err(e) = Self::graft_restore(&wt, &head).await {
5208                return self
5209                    .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5210                    .await;
5211            }
5212            rec.repair = Repair::NotStarted;
5213            self.state.recovery = Some(rec);
5214            self.state.save()?;
5215            return self.stop_for_resource();
5216        }
5217        let failed = match &out {
5218            AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5219            AgentOutcome::Quota(o) => {
5220                self.state.quota.push(QuotaLoss {
5221                    seat: seat.key.clone(),
5222                    node: "fix".to_owned(),
5223                    at: Timestamp::now(),
5224                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5225                });
5226                Some("rate limited (quota); the fixer could not run".to_owned())
5227            }
5228            AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5229            AgentOutcome::Failed(e) => Some(e.clone()),
5230        };
5231        self.state.seats.insert(seat.key.clone(), seat);
5232        let failed = match failed {
5233            // The agent's partial edits are not kept.
5234            Some(why) => {
5235                if let Err(e) = git::restore_to(&wt, &head).await {
5236                    return self
5237                        .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5238                        .await;
5239                }
5240                Some(why)
5241            }
5242            None => {
5243                if let Ok(r) = git::rescue_commit(
5244                    &wt,
5245                    "magi: recovery repair of the failed verification (uncommitted work)",
5246                )
5247                .await
5248                {
5249                    self.state.note_withheld("fix", &r.withheld);
5250                }
5251                None
5252            }
5253        };
5254        let after = git::rev_parse(&wt, "HEAD").await?;
5255        self.state.event(
5256            "recovery",
5257            match &failed {
5258                Some(why) => format!("repair: nothing kept ({why})"),
5259                None if after != head => {
5260                    format!("repair: committed {}", short(&after))
5261                }
5262                None => "repair: the fixer made no commit".to_owned(),
5263            },
5264        );
5265        rec.repair = Repair::Done {
5266            before: head.clone(),
5267            committed: after != head,
5268            after,
5269            failed,
5270        };
5271        self.state.recovery = Some(rec);
5272        self.state.save()?;
5273        Ok(())
5274    }
5275
5276    /// A repair whose rollback could not be confirmed: the run is blocked and
5277    /// resumable, and the unconfirmed state is never reviewed.
5278    async fn stop_recovery(
5279        &mut self,
5280        mut rec: crate::recovery::RecoveryRecord,
5281        head: &str,
5282        why: String,
5283    ) -> Result<()> {
5284        // `Started` stays recorded: a resume retries the restore.
5285        rec.repair = crate::recovery::Repair::Started {
5286            before: head.to_owned(),
5287        };
5288        self.state.recovery = Some(rec);
5289        self.state
5290            .event("recovery", format!("repair could not be undone: {why}"));
5291        self.state.status = RunStatus::Blocked;
5292        self.state.save()?;
5293        bail!("recovery repair could not be rolled back: {why}")
5294    }
5295
5296    async fn review_loop(&mut self) -> Result<()> {
5297        // A base that would not rebase is a person's decision, not a review
5298        // round: nothing here would change the answer, and reviewers and a
5299        // fixer would be spending real budget on a tree that cannot land
5300        // regardless of what they find.
5301        if self
5302            .state
5303            .base_sync
5304            .as_ref()
5305            .is_some_and(|s| s.conflict.is_some())
5306        {
5307            return Ok(());
5308        }
5309        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
5310        // agent files with `magi task add` name the run that paid for it. The
5311        // prompt overlay is cloned alongside it because the waves borrow it
5312        // while `self` is mutably borrowed by the node's own bookkeeping.
5313        let run_id = self.state.id.clone();
5314        let prompts = self.state.config.prompts.clone();
5315        let Some(winner) = self.state.winner().cloned() else {
5316            return Ok(());
5317        };
5318        let max_rounds = self.state.config.graph.review_rounds;
5319        // A clean round, an exhausted round budget, or a stalled tree (see
5320        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
5321        // they are recorded — recomputed here, not read off `status`, so a
5322        // reentry into a run that already stopped restates the identical
5323        // verdict instead of silently handing back whatever an earlier node
5324        // in this same walk clobbered `status` to (a solo-candidate
5325        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
5326        // below runs an empty range once the budget is spent, and would
5327        // otherwise fall through without touching `status` at all.
5328        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5329            // A reentry after a crash between the last round's save and
5330            // `stop_reviewing` reaches the hand-off here, not there.
5331            if status == RunStatus::Gating {
5332                self.record_contested_handoff();
5333            }
5334            self.state.status = status;
5335            self.state.save()?;
5336            return Ok(());
5337        }
5338        self.state.status = RunStatus::Reviewing;
5339        // A last recorded round whose own verification never resolved
5340        // (`ResourceBlocked` — the shared build cache, not the patch) is
5341        // never a concluded round, whatever the round budget says: starting
5342        // a fresh round on top of it would spend a whole new reviewer wave
5343        // re-reading an unchanged patch instead of just retrying the one
5344        // check that actually needs it, and once the budget is spent the
5345        // loop below has nothing left to do at all (its range is empty).
5346        // Retry that check directly instead, exactly the same retry
5347        // `stop_reviewing` already does for its own catch-up case.
5348        if self
5349            .state
5350            .reviews
5351            .last()
5352            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5353        {
5354            let shell = self.state.config.shell();
5355            return self
5356                .stop_reviewing(
5357                    "the last round's own verification never resolved",
5358                    &shell,
5359                    &winner.worktree,
5360                )
5361                .await;
5362        }
5363
5364        self.recovery_repair(&winner).await?;
5365        if self.resource_halt()? {
5366            return Ok(());
5367        }
5368
5369        let repo = self.state.repo.clone();
5370        let root = self.state.worktree_root();
5371        let language = self.state.config.graph.language.clone();
5372        let sessions = self.state.config.graph.sessions;
5373        let artifacts = agent::artifacts_dir(&self.state.dir());
5374        let base = self.landing_base();
5375        let base_short = short(&base);
5376        let reviewers = self.roles.reviewers.clone();
5377        let shell = self.state.config.shell();
5378
5379        for round in (self.state.reviews.len() + 1)..=max_rounds {
5380            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5381            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5382            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5383            // The prior round's own record, already persisted — never a
5384            // hand-carried variable of just its failing output: that is
5385            // exactly what let a round's e2e result drift out of sync with
5386            // which commit it was actually about (see `SCHEMA`'s doc for
5387            // schema 8). Judged against `head`, the commit reviewers are
5388            // about to look at now, so the summary always reads as "an
5389            // earlier head" here — this round's own patch has not been
5390            // checked yet.
5391            let prev_verification = self
5392                .state
5393                .reviews
5394                .last()
5395                .and_then(|r| r.verification_summary(&head));
5396            // Only the first round of a run that took a branch over: later
5397            // rounds have this run's own history.
5398            let prior_section = match (&self.state.recovery, round) {
5399                (Some(rec), 1) if self.state.reviews.is_empty() => {
5400                    let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5401                        (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5402                            Some(crate::recovery::head_of(
5403                                &git::diff(&winner.worktree, reviewed, "HEAD")
5404                                    .await
5405                                    .unwrap_or_default(),
5406                                crate::recovery::SINCE_DIFF_MAX,
5407                            ))
5408                        }
5409                        _ => None,
5410                    };
5411                    Some(prompt::recovery_review(rec, since.as_deref()))
5412                }
5413                _ => None,
5414            };
5415
5416            // Each reviewer gets its own detached checkout of exactly this
5417            // commit: nobody can perturb the winner's tree, and the fixer can
5418            // keep working without racing a reviewer.
5419            let mut jobs = Vec::new();
5420            for (r, spec) in reviewers.iter().cloned().enumerate() {
5421                let wt = root.join(format!("review-{}", r + 1));
5422                // `clean -fdx` in a reset wipes setup's products, so every
5423                // round sets the seat up again.
5424                if wt.exists() {
5425                    git::reset_detached(&wt, &head).await?;
5426                } else {
5427                    git::worktree_add_detached(&repo, &wt, &head).await?;
5428                }
5429                self.setup_seat_worktree(&repo, &wt, false).await?;
5430                let seat_key = format!("review-{}", r + 1);
5431                // The seat starts the round on whoever answered it last, not
5432                // on the agent the spec names, so a failure is not re-paid.
5433                let spec = pick_start_spec(
5434                    &self.roles.reviewer_roster,
5435                    spec,
5436                    self.state.seat_history.get(&seat_key),
5437                );
5438                let seat = self.seat(&seat_key, &spec.id);
5439                jobs.push(SeatJob {
5440                    prompt: prompt::review(&prompt::ReviewCtx {
5441                        instruction: &self.state.instruction,
5442                        branch: &winner.branch,
5443                        base_short: &base_short,
5444                        stat: &stat,
5445                        patch: &patch,
5446                        verification: prev_verification.as_ref(),
5447                        prior: prior_section.as_deref(),
5448                        reviewers: reviewers.len(),
5449                        round,
5450                        rounds: max_rounds,
5451                        // A review-only run has no rankings, so nothing
5452                        // competed for this patch and the reviewer is told so.
5453                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5454                        lens: Lens::for_seat(r),
5455                        language: &language,
5456                        github_language: &self.state.config.graph.github_language,
5457                    }),
5458                    spec,
5459                    seat,
5460                    cwd: wt,
5461                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5462                    allow_write: false,
5463                    sessions,
5464                    artifacts: artifacts.clone(),
5465                    stem: format!("review-{round}-{}", r + 1),
5466                    handover: None,
5467                });
5468            }
5469
5470            self.state.event(
5471                "review",
5472                format!(
5473                    "round {round}: {} reviewers on {}",
5474                    jobs.len(),
5475                    short(&head)
5476                ),
5477            );
5478            let mut quota_losses = Vec::new();
5479            let review_retries = self.state.config.graph.retries;
5480            let review_cache = self.state.config.cache_dir();
5481            let ctx = WaveCtx {
5482                carry_seats: true,
5483                pauses: Some((&self.pause, &self.interrupt)),
5484                run: &run_id,
5485                node: "review",
5486                prompts: &prompts,
5487                cache: review_cache.as_deref(),
5488                round: Some(round),
5489            };
5490            let results = ask_json_wave::<Review>(
5491                jobs,
5492                Arc::clone(&self.sem),
5493                review_retries,
5494                &self.roles.reviewer_roster,
5495                &ctx,
5496                &mut quota_losses,
5497                &mut self.state,
5498                &|_: &Review| Ok(()),
5499            )
5500            .await;
5501            if self.state.resource_block.is_some() {
5502                return self.stop_for_resource();
5503            }
5504            // Counted before the move below: how many of *this* round's
5505            // reviewer seats were lost to their own rate limit, as opposed to
5506            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5507            let round_quota_missing = quota_losses.len();
5508            self.state.quota.extend(quota_losses);
5509
5510            let mut records = Vec::new();
5511            let mut all_findings = Vec::new();
5512            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5513                let agent_id = seat.agent.clone();
5514                self.state.seats.insert(seat.key.clone(), seat);
5515                let mut record = ReviewRecord {
5516                    reviewer: r + 1,
5517                    agent: agent_id,
5518                    summary: String::new(),
5519                    findings: Vec::new(),
5520                    vote: None,
5521                    failed: None,
5522                    duration_ms: 0,
5523                    // Set for both outcomes: `failed: Some(_)` with
5524                    // `attempts > 0` is a seat every retry still lost, not a
5525                    // recovered one — only `failed: None` with `attempts > 0`
5526                    // reads as "answered after a nudge" (see this field's own
5527                    // doc).
5528                    attempts,
5529                };
5530                match res {
5531                    Ok((review, out)) => {
5532                        // Sanitized here, at the point every other piece of
5533                        // agent prose in this file is (candidate summaries,
5534                        // deliberation turns, vote reasons): a reviewer's own
5535                        // words are the one thing about it that could name
5536                        // it, and reconsideration below broadcasts this same
5537                        // summary and these same findings to every other
5538                        // seat on the panel.
5539                        record.summary =
5540                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5541                        record.vote = Some(review.vote);
5542                        record.duration_ms = out.duration_ms;
5543                        for (n, mut f) in review.findings.into_iter().enumerate() {
5544                            // ids are magi's, never the agent's: the fixer's
5545                            // adoption report is keyed by them.
5546                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5547                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5548                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5549                            // `file` is agent-supplied prose too, never
5550                            // checked against the real tree — the same
5551                            // exposure `title`/`detail` above have, just in
5552                            // a field easy to forget because it looks like a
5553                            // path rather than free text.
5554                            f.file = f
5555                                .file
5556                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5557                            all_findings.push(f.clone());
5558                            record.findings.push(f);
5559                        }
5560                        self.state.event(
5561                            "review",
5562                            format!(
5563                                "round {round}: reviewer {} voted {} with {} finding(s)",
5564                                r + 1,
5565                                review.vote.label(),
5566                                record.findings.len()
5567                            ),
5568                        );
5569                    }
5570                    Err(e) => {
5571                        record.failed = Some(e.to_string());
5572                        self.state.event(
5573                            "review",
5574                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5575                        );
5576                    }
5577                }
5578                records.push(record);
5579            }
5580
5581            // Tally the round's votes and, if they split, spend the one
5582            // round of reconsideration the split -> deliberate -> revote
5583            // shape `judge`/`vote` use for the panel, sized down to what a
5584            // read-only review round can afford: one round, and a revote
5585            // rather than an argument, because the panel already wrote its
5586            // reasoning down as findings the first time around.
5587            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5588            let vote_split =
5589                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5590            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5591            if vote_split {
5592                self.state.event(
5593                    "review",
5594                    format!(
5595                        "round {round}: votes split ({}) — one round of reconsideration",
5596                        initial_votes
5597                            .iter()
5598                            .map(|v| v.label())
5599                            .collect::<Vec<_>>()
5600                            .join(", ")
5601                    ),
5602                );
5603                // Seats read every seat's findings and votes, still numbered
5604                // and never named — the same anonymity `review` itself keeps.
5605                let panel: Vec<ReviewSeatReport<'_>> = records
5606                    .iter()
5607                    .filter_map(|r| {
5608                        r.vote.map(|vote| ReviewSeatReport {
5609                            reviewer: r.reviewer,
5610                            vote,
5611                            summary: &r.summary,
5612                            findings: &r.findings,
5613                        })
5614                    })
5615                    .collect();
5616
5617                let mut jobs = Vec::new();
5618                let mut seats_at = Vec::new();
5619                for (r, spec) in reviewers.iter().cloned().enumerate() {
5620                    // A seat with no initial vote has nothing to reconsider
5621                    // from and stays absent, the same as it stayed absent
5622                    // from `panel` above.
5623                    if records[r].vote.is_none() {
5624                        continue;
5625                    }
5626                    let wt = root.join(format!("review-{}", r + 1));
5627                    let seat_key = format!("review-{}", r + 1);
5628                    let spec = self.occupant(&seat_key, spec);
5629                    let seat = self.seat(&seat_key, &spec.id);
5630                    // A seat with no live session has already forgotten the
5631                    // initial review's prompt — restate the patch it is
5632                    // voting on, the same as `deliberate`/`vote` do for a
5633                    // judge in the same position.
5634                    // The panel already carries this seat's own review and
5635                    // vote, so restating the patch makes the prompt whole for
5636                    // a seat handed to another agent.
5637                    let build = |with_patch: bool| {
5638                        prompt::review_reconsider(&ReviewReconsiderCtx {
5639                            instruction: &self.state.instruction,
5640                            reviewer: r + 1,
5641                            lens: Lens::for_seat(r),
5642                            panel: &panel,
5643                            patch: with_patch.then_some(ReviewPatch {
5644                                branch: &winner.branch,
5645                                base_short: &base_short,
5646                                stat: &stat,
5647                                patch: &patch,
5648                            }),
5649                            round,
5650                            rounds: max_rounds,
5651                            language: &language,
5652                        })
5653                    };
5654                    let full = build(true);
5655                    let prompt = if has_context(&spec, &seat, sessions) {
5656                        build(false)
5657                    } else {
5658                        full.clone()
5659                    };
5660                    jobs.push(SeatJob {
5661                        prompt,
5662                        spec,
5663                        seat,
5664                        cwd: wt,
5665                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5666                        allow_write: false,
5667                        sessions,
5668                        artifacts: artifacts.clone(),
5669                        stem: format!("review-{round}-reconsider-{}", r + 1),
5670                        handover: Some(full),
5671                    });
5672                    seats_at.push(r);
5673                }
5674
5675                let mut recon_quota_losses = Vec::new();
5676                let recon_cache = self.state.config.cache_dir();
5677                let recon_ctx = WaveCtx {
5678                    carry_seats: true,
5679                    pauses: Some((&self.pause, &self.interrupt)),
5680                    run: &run_id,
5681                    node: "review",
5682                    prompts: &prompts,
5683                    cache: recon_cache.as_deref(),
5684                    round: Some(round),
5685                };
5686                let recon_results = ask_json_wave::<ReviewRevote>(
5687                    jobs,
5688                    Arc::clone(&self.sem),
5689                    review_retries,
5690                    &self.roles.reviewer_roster,
5691                    &recon_ctx,
5692                    &mut recon_quota_losses,
5693                    &mut self.state,
5694                    &|_: &ReviewRevote| Ok(()),
5695                )
5696                .await;
5697                if self.state.resource_block.is_some() {
5698                    return self.stop_for_resource();
5699                }
5700                self.state.quota.extend(recon_quota_losses);
5701
5702                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5703                    let agent_id = seat.agent.clone();
5704                    self.state.seats.insert(seat.key.clone(), seat);
5705                    let mut rec = ReviewRevoteRecord {
5706                        reviewer: r + 1,
5707                        agent: agent_id,
5708                        vote: None,
5709                        reason: String::new(),
5710                        failed: None,
5711                    };
5712                    match res {
5713                        Ok((rv, _)) => {
5714                            rec.vote = Some(rv.vote);
5715                            rec.reason =
5716                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5717                            self.state.event(
5718                                "review",
5719                                format!(
5720                                    "round {round}: reviewer {} revoted {}",
5721                                    r + 1,
5722                                    rv.vote.label()
5723                                ),
5724                            );
5725                        }
5726                        Err(e) => {
5727                            rec.failed = Some(e.to_string());
5728                            self.state.event(
5729                                "review",
5730                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5731                            );
5732                        }
5733                    }
5734                    reconsideration.push(rec);
5735                }
5736            } else if initial_votes.len() > 1 {
5737                self.state.event(
5738                    "review",
5739                    format!(
5740                        "round {round}: votes agreed ({}) — no reconsideration",
5741                        initial_votes[0].label()
5742                    ),
5743                );
5744            }
5745
5746            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5747            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5748            // A round that already has a blocking finding and a round left to
5749            // try is going back to the fixer no matter what `verify.e2e`
5750            // says, so running it first only spends the loop's slowest step
5751            // (minutes, for a Rust repo's full test suite) on a head about
5752            // to be rewritten. Deferred, never skipped: `verify.e2e` still
5753            // runs once a round has no blocking findings left (see
5754            // `round_is_clean`, which a deferred — empty — `e2e` can never
5755            // satisfy since `blocking` is nonzero whenever this branch is
5756            // taken), and `stop_reviewing` forces a real run before it will
5757            // ever read a deferred round as green.
5758            let defer_e2e =
5759                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5760            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5761                let reason =
5762                    format!("{blocking} blocking finding(s) already required a fix this round");
5763                self.state.event(
5764                    "verify",
5765                    format!(
5766                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5767                         {}); it will run once a round has none left",
5768                        short(&head)
5769                    ),
5770                );
5771                (Vec::new(), false, true, Some(reason))
5772            } else {
5773                let e2e_commands = self.state.config.verify.e2e.clone();
5774                let cache_dir = self.state.config.cache_dir();
5775                let context = format!("round {round}");
5776                let (e2e, verify_retried) = with_cache_lease(
5777                    &mut self.state,
5778                    cache_dir.as_deref(),
5779                    "e2e",
5780                    "e2e",
5781                    &winner.worktree,
5782                    &head,
5783                    verify_timeout,
5784                    &context,
5785                    |state, budget| {
5786                        let shell = shell.clone();
5787                        let e2e_commands = e2e_commands.clone();
5788                        let worktree = winner.worktree.clone();
5789                        let context = context.clone();
5790                        async move {
5791                            run_e2e_with_retry(
5792                                state,
5793                                &shell,
5794                                &e2e_commands,
5795                                &worktree,
5796                                budget,
5797                                &context,
5798                            )
5799                            .await
5800                        }
5801                    },
5802                )
5803                .await;
5804                (e2e, verify_retried, false, None)
5805            };
5806
5807            let expected = records.len();
5808            let answered = records.iter().filter(|r| r.failed.is_none()).count();
5809            let incomplete = answered < expected;
5810            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5811            let policy = self.state.config.graph.incomplete_review;
5812            let clean = round_is_clean(
5813                blocking,
5814                e2e_ok,
5815                answered,
5816                expected,
5817                round_quota_missing,
5818                policy,
5819            );
5820
5821            let mut round_record = ReviewRound {
5822                round,
5823                head: head.clone(),
5824                verified_head: None,
5825                verified_at: None,
5826                reviews: records,
5827                e2e,
5828                verify_retried,
5829                e2e_deferred,
5830                e2e_defer_reason,
5831                fix: None,
5832                blocking,
5833                answered,
5834                expected,
5835                clean,
5836                progressed: false,
5837                vote_split,
5838                reconsideration,
5839                verdict: None,
5840            };
5841            // The final vote per seat is its revote where reconsideration
5842            // ran and answered, its initial vote otherwise — the same
5843            // fallback `tally` uses for a judge whose private vote failed.
5844            round_record.verdict = ReviewVote::worst(
5845                round_record
5846                    .final_votes()
5847                    .into_iter()
5848                    .map(|(_, _, vote)| vote),
5849            );
5850            // Which commit and when magi actually attempted to check —
5851            // known the moment a command was dispatched against `head`,
5852            // whether or not it finished: a resource-blocked attempt still
5853            // targeted a specific commit at a specific time, and leaving
5854            // that unrecorded is exactly what made `verification_summary`
5855            // report a fresh attempt as "commit unknown ... recorded before
5856            // this was tracked", indistinguishable from a genuinely old,
5857            // untracked record. Only a deferred or unconfigured round never
5858            // ran at all and has nothing to record — see
5859            // `ReviewRound::verified_head`'s own doc.
5860            if !matches!(
5861                round_record.e2e_status(),
5862                E2eStatus::Deferred | E2eStatus::NotConfigured
5863            ) {
5864                round_record.verified_head = Some(head.clone());
5865                round_record.verified_at = Some(Timestamp::now());
5866            }
5867            let this_round_verification = round_record.verification_summary(&head);
5868
5869            if incomplete {
5870                let missing: Vec<String> = round_record
5871                    .reviews
5872                    .iter()
5873                    .filter(|r| r.failed.is_some())
5874                    .map(|r| format!("review-{}", r.reviewer))
5875                    .collect();
5876                self.state.event(
5877                    "review",
5878                    format!(
5879                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5880                        missing.join(", ")
5881                    ),
5882                );
5883            }
5884
5885            if clean {
5886                self.state.event(
5887                    "review",
5888                    if incomplete && policy == IncompleteReviewPolicy::Warn {
5889                        format!(
5890                            "round {round}: clean (warn policy, incomplete panel) — no \
5891                             blocking findings from the seats that answered, verification green"
5892                        )
5893                    } else if incomplete {
5894                        format!(
5895                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5896                             quorum) — no blocking findings from the seats that answered, \
5897                             verification green",
5898                            expected - answered
5899                        )
5900                    } else {
5901                        format!("round {round}: clean — no blocking findings, verification green")
5902                    },
5903                );
5904                self.state.reviews.push(round_record);
5905                self.state.status = RunStatus::Gating;
5906                self.state.save()?;
5907                return Ok(());
5908            }
5909
5910            // Nothing was raised and verification passed, but not every seat
5911            // answered and `round_is_clean` still refused to call it clean —
5912            // either a seat is missing for a reason other than its own quota
5913            // (a crash, a timeout, unparsable output — worth another try), or
5914            // every seat that could have answered lost its quota and nobody
5915            // is left to decide on: re-review rather than send the fixer
5916            // after a round with nothing to fix.
5917            if incomplete && blocking == 0 && e2e_ok {
5918                self.state.reviews.push(round_record);
5919                self.state.save()?;
5920                if round == max_rounds {
5921                    self.state.status = RunStatus::Blocked;
5922                    self.state.event(
5923                        "review",
5924                        format!(
5925                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5926                             refusing to call it clean",
5927                            expected - answered
5928                        ),
5929                    );
5930                    return Ok(());
5931                }
5932                continue;
5933            }
5934
5935            // Nothing for the fixer to act on (`blocking == 0`) and the only
5936            // reason this round is not clean is that magi itself never got
5937            // a command to run — the shared build cache, not the patch (see
5938            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5939            // the fixer would invite a change to appease contention that has
5940            // nothing to do with the diff, and would leave this attempt
5941            // sitting in the next round's prompt as if it were about an
5942            // earlier, superseded commit rather than what it actually is:
5943            // the same head, still waiting to be checked. Wait for it the
5944            // same way the final round's own contention is already handled,
5945            // whatever round this happens to be.
5946            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5947                self.state.reviews.push(round_record);
5948                return self
5949                    .stop_reviewing(
5950                        "the round's own verification could not run",
5951                        &shell,
5952                        &winner.worktree,
5953                    )
5954                    .await;
5955            }
5956
5957            if round == max_rounds {
5958                self.state.reviews.push(round_record);
5959                return self
5960                    .stop_reviewing(
5961                        &format!(
5962                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5963                        ),
5964                        &shell,
5965                        &winner.worktree,
5966                    )
5967                    .await;
5968            }
5969
5970            // Fix. The winner's own implementer seat continues its conversation:
5971            // the competition is over, so context is pure benefit now.
5972            let blocking_findings: Vec<_> = all_findings
5973                .iter()
5974                .filter(|f| f.severity.blocks())
5975                .cloned()
5976                .collect();
5977            let fix_prompt = prompt::fix_in(
5978                &self.state.instruction,
5979                &blocking_findings,
5980                this_round_verification.as_ref(),
5981                round,
5982                max_rounds,
5983                &language,
5984                &self.state.config.graph.github_language,
5985            );
5986            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5987            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5988            let (job, seat, out) = self
5989                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5990                    prompt: fix_prompt.clone(),
5991                    spec,
5992                    seat,
5993                    cwd: winner.worktree.clone(),
5994                    timeout,
5995                    allow_write: true,
5996                    sessions,
5997                    artifacts: artifacts.clone(),
5998                    stem: format!("fix-{round}"),
5999                    handover: None,
6000                })
6001                .await;
6002            // Not asked: the round is not recorded (so a resume repeats it),
6003            // no fixer was handed over to, and the seat is left as it was.
6004            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6005                return self.stop_for_resource();
6006            }
6007            let agent_id = seat.agent.clone();
6008
6009            let mut fix = FixRecord {
6010                agent: agent_id,
6011                addressed: Vec::new(),
6012                rejected: Vec::new(),
6013                notes: String::new(),
6014                committed: false,
6015                failed: None,
6016                duration_ms: 0,
6017                continuation: None,
6018            };
6019            let mut continuation = ContinuationRecord::not_needed();
6020            let mut final_seat = seat.clone();
6021            match out {
6022                AgentOutcome::Ok(o) => {
6023                    fix.duration_ms = o.duration_ms;
6024                    let parsed = verdict::extract_json::<FixReport>(&o.text);
6025                    // A parsed report standing next to a command this same
6026                    // reply's own CLI never confirmed the exit status of is
6027                    // not a resolved answer — the identical `CommandEvidence`
6028                    // `state.jobs` renders, read here instead of only on
6029                    // display, per the completion judgment and the shown
6030                    // record needing to agree.
6031                    let incomplete_reason = match &parsed {
6032                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6033                            "the reply parsed, but it reported a command whose own CLI never \
6034                             confirmed an exit status"
6035                                .to_owned(),
6036                        ),
6037                        Ok(_) => None,
6038                        Err(e) => Some(e.to_string()),
6039                    };
6040                    match incomplete_reason {
6041                        None => {
6042                            let report = parsed.expect("checked Ok above");
6043                            fix.addressed = report.addressed;
6044                            fix.rejected = report.rejected;
6045                            fix.notes =
6046                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
6047                        }
6048                        Some(reason) => {
6049                            let (resumed_seat, resolved, failure, cont) = self
6050                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6051                                .await;
6052                            fix.duration_ms += cont.cumulative_wait_ms;
6053                            continuation = cont;
6054                            final_seat = resumed_seat;
6055                            match resolved {
6056                                Some(report) => {
6057                                    fix.addressed = report.addressed;
6058                                    fix.rejected = report.rejected;
6059                                    fix.notes = blind::sanitize_prose(
6060                                        &report.notes,
6061                                        &self.state.config.blind,
6062                                    );
6063                                }
6064                                None => fix.failed = failure,
6065                            }
6066                        }
6067                    }
6068                }
6069                // The CLI's raw error JSON is not a fix report to parse.
6070                AgentOutcome::Dropped(o) => {
6071                    fix.duration_ms = o.duration_ms;
6072                    let why = o
6073                        .dropped
6074                        .as_ref()
6075                        .map(|d| d.why.as_str())
6076                        .unwrap_or("the CLI ended the stream without delivering its answer");
6077                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6078                }
6079                AgentOutcome::Quota(o) => {
6080                    self.state.quota.push(QuotaLoss {
6081                        seat: final_seat.key.clone(),
6082                        node: "fix".to_owned(),
6083                        at: Timestamp::now(),
6084                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6085                    });
6086                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6087                }
6088                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6089            }
6090            fix.continuation = Some(continuation);
6091            self.state.seats.insert(final_seat.key.clone(), final_seat);
6092            if let Ok(r) = git::rescue_commit(
6093                &winner.worktree,
6094                &format!("magi: review round {round} fixes (uncommitted work)"),
6095            )
6096            .await
6097            {
6098                self.state.note_withheld("fix", &r.withheld);
6099            }
6100            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6101            fix.committed = after != before;
6102            // Judged by what `git` says moved against base, never by the
6103            // fixer's own `addressed`/`rejected` count — see
6104            // `ReviewRound::progressed`. Propagated with `?`, the same as the
6105            // `patch` snapshot above: swallowing this error would default
6106            // `diff_after` to empty, which almost always differs from a
6107            // non-empty `patch` and reads as "progressed" — exactly backwards
6108            // for a `git` failure the stagnation check cannot see through.
6109            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6110            let progressed = diff_after != patch;
6111            let commit_note = if fix.committed {
6112                "committed"
6113            } else {
6114                "NO new commit"
6115            };
6116            let tree_note = if progressed {
6117                "changed vs base"
6118            } else {
6119                "unchanged vs base"
6120            };
6121            self.state.event(
6122                "fix",
6123                match &fix.failed {
6124                    // Distinct on purpose from "0 addressed, 0 rejected": the
6125                    // fixer's own diff still landed (blocking counts do keep
6126                    // falling round over round), only its adoption report did
6127                    // not come back, so this must never read like every
6128                    // finding was reviewed and declined.
6129                    Some(reason) => {
6130                        format!(
6131                            "round {round}: fixer's adoption report was lost ({reason}); \
6132                             {commit_note}, tree {tree_note}"
6133                        )
6134                    }
6135                    None => format!(
6136                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6137                         {tree_note}{}",
6138                        fix.addressed.len(),
6139                        fix.rejected.len(),
6140                        if continuation.outcome == ContinuationOutcome::Resumed {
6141                            format!(
6142                                " (adoption report recovered after {} continuation(s))",
6143                                continuation.attempts
6144                            )
6145                        } else {
6146                            String::new()
6147                        },
6148                    ),
6149                },
6150            );
6151            round_record.fix = Some(fix);
6152            round_record.progressed = progressed;
6153            self.state.reviews.push(round_record);
6154            self.state.save()?;
6155
6156            // The fixer's own report never came back this round, even after
6157            // `continue_fix_report`'s own budget was spent on it — not an
6158            // ordinary "no report" (dropped stream, quota, plain failure),
6159            // which already reads that way and is left to the existing round
6160            // budget. Stopping here, rather than opening another round, is
6161            // what keeps a next reviewer/fixer wave from ever being
6162            // dispatched onto `winner.worktree` while whatever the seat's
6163            // last call may still have running there is unaccounted for: no
6164            // process liveness check exists (and none is being added — see
6165            // AGENTS.md/this task's own scope), so the only way to honour
6166            // "nothing starts before a valid report returns" is to not start
6167            // anything further on this worktree from this run at all.
6168            if matches!(
6169                continuation.outcome,
6170                ContinuationOutcome::Exhausted
6171                    | ContinuationOutcome::QuotaLost
6172                    | ContinuationOutcome::NoSession
6173            ) {
6174                return self
6175                    .stop_reviewing(
6176                        "the fixer's adoption report never came back, even after resuming its \
6177                         own seat; refusing to start another round against the same worktree \
6178                         while that is unresolved",
6179                        &shell,
6180                        &winner.worktree,
6181                    )
6182                    .await;
6183            }
6184
6185            let streak = self
6186                .state
6187                .reviews
6188                .iter()
6189                .rev()
6190                .take_while(|r| !r.progressed)
6191                .count();
6192            if streak >= STAGNANT_LIMIT {
6193                return self
6194                    .stop_reviewing(
6195                        &format!(
6196                            "the tree has not moved against base for {streak} round(s) in a row"
6197                        ),
6198                        &shell,
6199                        &winner.worktree,
6200                    )
6201                    .await;
6202            }
6203        }
6204        Ok(())
6205    }
6206
6207    /// Decide, from the last recorded round's own verification, whether
6208    /// stopping the review loop is a hand-off or a genuine block.
6209    ///
6210    /// Called once the loop has given up trying — the round budget is spent,
6211    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
6212    /// findings still open, never while a round is still clean or the
6213    /// incomplete-panel case handled inline above. Gate and e2e are facts
6214    /// about the tree; a lingering review finding is an opinion, and this
6215    /// workload's own `magi stats` puts reviewer precision low enough
6216    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
6217    /// must not by itself stand between a green, verified change and the
6218    /// human who decides what to do with it. A red e2e is not an opinion, so
6219    /// that case still blocks, with the failing command and a tail of its
6220    /// output recorded here rather than left in `run.json` for someone to go
6221    /// find.
6222    ///
6223    /// A round that deferred its own e2e (see [`Config::graph`]'s
6224    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
6225    /// only because nothing ran, and treating an empty list as a passing one
6226    /// here is exactly the "deferred painted green" bug this function exists
6227    /// to not have. When the last round's own verification never resolved —
6228    /// deferred on purpose, or a real attempt the shared build cache blocked
6229    /// — this makes (or retries) the real run, on the actual worktree this
6230    /// loop is about to stop touching, before deciding anything. A
6231    /// resource-blocked attempt is likewise never read as either green or
6232    /// red: it is evidence about the machine, not the patch (see
6233    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
6234    /// blocked cache leaves this call without deciding rather than guessing
6235    /// — the caller retries on a later reentry.
6236    /// Record, once, that the review loop handed off over a blocking finding
6237    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
6238    /// `land` asks the owner even with `land_approval` off. Called from every
6239    /// path that concludes `Gating`; a reentry keeps the first record.
6240    fn record_contested_handoff(&mut self) {
6241        if self.state.contested_handoff.is_some() {
6242            return;
6243        }
6244        let Some(contested) = self
6245            .state
6246            .reviews
6247            .last()
6248            .and_then(ReviewRound::contested_handoff)
6249        else {
6250            return;
6251        };
6252        self.state.event(
6253            "review",
6254            format!(
6255                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6256                 wait for the owner's approval",
6257                contested.findings.len(),
6258                contested.rejecters.len()
6259            ),
6260        );
6261        self.state.contested_handoff = Some(contested);
6262    }
6263
6264    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6265        let round_idx = self.state.reviews.len() - 1;
6266        // A deferred round and a resource-blocked one are the same shape
6267        // here: neither has a real result yet, and both get one more
6268        // attempt. Read off `e2e_status` — the single source for this —
6269        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
6270        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
6271        // still retries instead of being read as a settled result the
6272        // instant it stops being empty.
6273        let needs_catchup_run = matches!(
6274            self.state.reviews[round_idx].e2e_status(),
6275            E2eStatus::Deferred | E2eStatus::ResourceBlocked
6276        );
6277        if needs_catchup_run {
6278            let round = self.state.reviews[round_idx].round;
6279            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6280            let commands = self.state.config.verify.e2e.clone();
6281            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6282            let cache_dir = self.state.config.cache_dir();
6283            let context = format!(
6284                "round {round}: verification unresolved, catching up before the final decision"
6285            );
6286            let (outcomes, verify_retried) = with_cache_lease(
6287                &mut self.state,
6288                cache_dir.as_deref(),
6289                "e2e",
6290                "e2e",
6291                worktree,
6292                &attempted_head,
6293                timeout,
6294                &context,
6295                |state, budget| {
6296                    let shell = shell.to_vec();
6297                    let commands = commands.clone();
6298                    let context = context.clone();
6299                    async move {
6300                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6301                            .await
6302                    }
6303                },
6304            )
6305            .await;
6306            let last = &mut self.state.reviews[round_idx];
6307            last.e2e = outcomes;
6308            last.verify_retried = verify_retried;
6309            // Always the commit and time this attempt actually targeted,
6310            // whether or not it happens to equal the reviewed `head` and
6311            // whether or not a command finished — see
6312            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
6313            // attempt is recorded too, so a later reader sees "attempted
6314            // again at T2" rather than silence.
6315            last.verified_head = Some(attempted_head);
6316            last.verified_at = Some(Timestamp::now());
6317            if verify_inconclusive(&last.e2e) {
6318                // Still not a real result: `e2e_deferred` is left exactly
6319                // as it was, so `needs_catchup_run` above reads
6320                // `ResourceBlocked` (via `e2e_status`, which checks
6321                // `resource_blocked` before `e2e_deferred`) and retries
6322                // again on the next reentry, rather than recording
6323                // contention as a red e2e and blocking the run on it.
6324                self.state.save()?;
6325                return Ok(());
6326            }
6327            last.e2e_deferred = false;
6328        }
6329        let last = &self.state.reviews[round_idx];
6330        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6331
6332        match last.e2e_status() {
6333            E2eStatus::Failed => {
6334                let red: Vec<String> = last
6335                    .e2e
6336                    .iter()
6337                    .filter(|o| !o.ok())
6338                    .map(|o| {
6339                        format!(
6340                            "`{}` -> {:?}\n{}",
6341                            o.command,
6342                            o.code,
6343                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6344                        )
6345                    })
6346                    .collect();
6347                self.state
6348                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6349                self.state.status = RunStatus::Blocked;
6350            }
6351            // `needs_catchup_run` above already retried once this call; if
6352            // it is still blocked, this is magi's own admission it could
6353            // not get a command to run, never a verdict on the patch — the
6354            // run is left exactly where a later reentry can retry again.
6355            E2eStatus::ResourceBlocked => {
6356                self.state.event(
6357                    "review",
6358                    format!(
6359                        "{why}; e2e could not run (shared build cache unavailable); not \
6360                         deciding yet"
6361                    ),
6362                );
6363            }
6364            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6365                self.state.event(
6366                    "review",
6367                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6368                );
6369                self.record_contested_handoff();
6370                self.state.status = RunStatus::Gating;
6371            }
6372        }
6373        self.state.save()?;
6374        Ok(())
6375    }
6376
6377    // ----------------------------------------------------------------- gate
6378
6379    async fn gate(&mut self) -> Result<()> {
6380        // Judged by the review record itself, not by `status`: a solo
6381        // candidate's `judge`/`deliberate` skip rewrites `status` on every
6382        // reentry (see `judge`), and trusting it here is exactly how a run
6383        // that exhausted its review budget got gated and merged a second
6384        // time around. `review_conclusion` recomputes the review loop's own
6385        // verdict from the round records themselves — `Gating` for a clean
6386        // round or a hand-off (see `stop_reviewing`), anything else means the
6387        // loop is still going or genuinely blocked.
6388        // A base the winner could not be replayed onto is a decision, not a
6389        // round: there is no landing tree to gate. Read as its own record for
6390        // the same reason the review verdict is.
6391        if self.state.status == RunStatus::Failed
6392            || self
6393                .state
6394                .base_sync
6395                .as_ref()
6396                .is_some_and(|s| s.conflict.is_some())
6397            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6398                != Some(RunStatus::Gating)
6399        {
6400            return Ok(());
6401        }
6402        if self.state.gate_ran {
6403            // `review_loop` derives its conclusion from the clean review
6404            // record on every reentry and therefore puts a completed run back
6405            // in `Gating`. A recorded gate is a stronger, terminal fact:
6406            // retain its original command output (or lack of any, for a repo
6407            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
6408            // doc) and restore `Blocked` on a real failure rather than
6409            // pretending the command is still running or running it a second
6410            // time. `gate_ran == false` remains the only shape — unattempted,
6411            // or a resource-blocked retry — that may still need to execute a
6412            // command.
6413            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6414                self.state.status = RunStatus::Blocked;
6415                self.state.save()?;
6416            }
6417            return Ok(());
6418        }
6419        let Some(winner) = self.state.winner().cloned() else {
6420            return Ok(());
6421        };
6422        self.state.status = RunStatus::Gating;
6423        let mut outcomes = self.run_gate(&winner).await?;
6424        loop {
6425            // A resource-blocked outcome means the gate command never actually
6426            // ran - the shared build cache could not be acquired or confirmed
6427            // fresh in time - which is evidence about the machine, not about
6428            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
6429            // Recording it as a red gate would mark a run `Blocked` on nothing
6430            // but contention magi has already logged; leaving `self.state.gate`
6431            // empty and `self.state.gate_ran` false instead keeps the shape
6432            // this function already treats as "still needs to run" (see the
6433            // early-return above), so the next call retries the command
6434            // rather than concluding anything.
6435            if verify_inconclusive(&outcomes) {
6436                self.state.save()?;
6437                return Ok(());
6438            }
6439            if outcomes.iter().all(CommandOutcome::ok) {
6440                break;
6441            }
6442            match self.gate_fix_round(&winner, &outcomes).await? {
6443                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6444                GateFix::Stop => break,
6445                GateFix::Defer => {
6446                    self.state.save()?;
6447                    return Ok(());
6448                }
6449            }
6450        }
6451        let passed = outcomes.iter().all(CommandOutcome::ok);
6452        self.state.gate = outcomes;
6453        self.state.gate_ran = true;
6454        self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6455        if !passed {
6456            self.state.status = RunStatus::Blocked;
6457            let spent = self.state.gate_fixes.len();
6458            self.state.event(
6459                "gate",
6460                if spent == 0 {
6461                    "gate failed; not merging".to_owned()
6462                } else {
6463                    format!("gate failed after {spent} gate-fix round(s); not merging")
6464                },
6465            );
6466        }
6467        self.state.save()?;
6468        Ok(())
6469    }
6470
6471    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6472    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6473    /// after review is clean and never on a candidate awaiting judging.
6474    ///
6475    /// Never fails the run: a non-zero exit or timeout is a warning and a
6476    /// recorded outcome, and the gate remains the single arbiter. Nothing
6477    /// configured means nothing happens - no event, no commit. `commit_all`
6478    /// commits any leftover change under the neutral identity and returns
6479    /// `false` when the tree is clean, so no empty commit is ever made.
6480    async fn run_pre_gate(&mut self, winner: &Candidate) {
6481        let commands = self.state.config.verify.pre_gate.clone();
6482        if commands.is_empty() {
6483            return;
6484        }
6485        let shell = self.state.config.shell();
6486        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6487        let (outcomes, _) = run_commands(
6488            &mut self.state,
6489            "pre_gate",
6490            "pre_gate",
6491            0,
6492            &shell,
6493            &commands,
6494            &winner.worktree,
6495            timeout,
6496        )
6497        .await;
6498        for o in &outcomes {
6499            if !o.ok() {
6500                tracing::warn!(
6501                    "pre_gate `{}` failed ({:?}); the gate decides",
6502                    o.command,
6503                    o.code
6504                );
6505            }
6506            self.state.event(
6507                "pre_gate",
6508                format!(
6509                    "`{}` -> {}",
6510                    o.command,
6511                    if o.ok() {
6512                        "pass".to_owned()
6513                    } else {
6514                        format!(
6515                            "FAIL ({:?})\n{}",
6516                            o.code,
6517                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6518                        )
6519                    }
6520                ),
6521            );
6522        }
6523        self.state.pre_gate = outcomes;
6524        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6525            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6526                Ok(head) => {
6527                    self.state
6528                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
6529                    self.state.pre_gate_commit = Some(head);
6530                }
6531                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6532            },
6533            Ok(false) => {}
6534            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6535        }
6536        if let Err(e) = self.state.save() {
6537            tracing::warn!("could not persist the pre_gate record: {e:#}");
6538        }
6539    }
6540
6541    /// Run `verify.gate` once against the winner's current tree, logging one
6542    /// event per command. Empty when nothing is configured.
6543    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6544        self.run_pre_gate(winner).await;
6545        let shell = self.state.config.shell();
6546        let gate_commands = self.state.config.verify.gate.clone();
6547        // Zero commands has nothing to run and nothing that could touch the
6548        // shared build cache, so it never needs a lease: `Config::cache_dir`
6549        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
6550        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
6551        // otherwise queue behind an unrelated run's lease and come back
6552        // resource-blocked - `gate_ran` would stay false on nothing but
6553        // cache contention, for a step that had nothing to check in the
6554        // first place.
6555        let outcomes = if gate_commands.is_empty() {
6556            Vec::new()
6557        } else {
6558            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6559            let cache_dir = self.state.config.cache_dir();
6560            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6561            let (outcomes, _) = with_cache_lease(
6562                &mut self.state,
6563                cache_dir.as_deref(),
6564                "gate",
6565                "gate",
6566                &winner.worktree,
6567                &head,
6568                timeout,
6569                "final gate",
6570                |state, budget| {
6571                    let shell = shell.clone();
6572                    let gate_commands = gate_commands.clone();
6573                    let worktree = winner.worktree.clone();
6574                    async move {
6575                        let (outcomes, timed_out_pids) = run_commands(
6576                            state,
6577                            "gate",
6578                            "gate",
6579                            0,
6580                            &shell,
6581                            &gate_commands,
6582                            &worktree,
6583                            budget,
6584                        )
6585                        .await;
6586                        (outcomes, false, timed_out_pids)
6587                    }
6588                },
6589            )
6590            .await;
6591            outcomes
6592        };
6593        if outcomes.is_empty() {
6594            // Nothing configured to check — distinct from every other
6595            // silence in this run's event log, since an empty `gate` alone
6596            // no longer says whether the gate ran at all (see
6597            // `RunState::gate_ran`'s own doc).
6598            self.state.event(
6599                "gate",
6600                "no gate commands configured; nothing to check, passing",
6601            );
6602        }
6603        for o in &outcomes {
6604            self.state.event(
6605                "gate",
6606                format!(
6607                    "`{}` -> {}",
6608                    o.command,
6609                    if o.ok() {
6610                        "pass".to_owned()
6611                    } else {
6612                        format!(
6613                            "FAIL ({:?})\n{}",
6614                            o.code,
6615                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6616                        )
6617                    }
6618                ),
6619            );
6620        }
6621        Ok(outcomes)
6622    }
6623
6624    /// One bounded fix round for a failing gate.
6625    ///
6626    /// The fixer is told the failure came from the gate itself, not from a
6627    /// reviewer, and is shown the failed commands, their exit codes and a tail
6628    /// of their output - whatever `[verify].gate` holds, nothing here knows
6629    /// what those commands run. Only a normal non-zero exit that printed
6630    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6631    /// command or a full disk says nothing about the code, and a fixer sent
6632    /// after it can only appease the machine. The round is judged by what git
6633    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6634    /// again before the gate does, so a fix cannot trade a green gate for a
6635    /// red e2e unnoticed.
6636    async fn gate_fix_round(
6637        &mut self,
6638        winner: &Candidate,
6639        outcomes: &[CommandOutcome],
6640    ) -> Result<GateFix> {
6641        let cap = self.state.config.graph.gate_fix_rounds;
6642        let spent = self.state.gate_fixes.len();
6643        if spent >= cap {
6644            if cap > 0 {
6645                self.state.event(
6646                    "gate",
6647                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6648                );
6649            }
6650            return Ok(GateFix::Stop);
6651        }
6652        if !gate_fixable(outcomes) {
6653            self.state.event(
6654                "gate",
6655                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6656                 command or similar); not spending a fix round on it",
6657            );
6658            return Ok(GateFix::Stop);
6659        }
6660        let min_free = self.state.config.disk.min_free_bytes;
6661        if min_free > 0 {
6662            match crate::disk::free_bytes(&winner.worktree) {
6663                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6664                Ok(free) => {
6665                    self.state.event(
6666                        "gate",
6667                        format!(
6668                            "only {free} bytes free ({min_free} required by `[disk] \
6669                             min_free_bytes`); not spending a fix round on a failure the disk \
6670                             may explain"
6671                        ),
6672                    );
6673                    return Ok(GateFix::Stop);
6674                }
6675                Err(e) => {
6676                    self.state.event(
6677                        "gate",
6678                        format!("free disk space could not be measured ({e:#}); no fix round"),
6679                    );
6680                    return Ok(GateFix::Stop);
6681                }
6682            }
6683        }
6684
6685        let attempt = spent + 1;
6686        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6687        let base = self.landing_base();
6688        let fix_prompt = prompt::gate_fix_in(
6689            &self.state.instruction,
6690            &failed,
6691            attempt,
6692            cap,
6693            &self.state.config.graph.language,
6694            &self.state.config.graph.github_language,
6695        );
6696        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6697        let sessions = self.state.config.graph.sessions;
6698        let artifacts = agent::artifacts_dir(&self.state.dir());
6699        self.state.event(
6700            "gate",
6701            format!("gate failed; gate-fix round {attempt} of {cap}"),
6702        );
6703        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6704        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6705        let (_, seat, out) = self
6706            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6707                prompt: fix_prompt.clone(),
6708                spec,
6709                seat,
6710                cwd: winner.worktree.clone(),
6711                timeout,
6712                allow_write: true,
6713                sessions,
6714                artifacts: artifacts.clone(),
6715                stem: format!("gate-fix-{attempt}"),
6716                handover: None,
6717            })
6718            .await;
6719        // The fixer was never asked: no round is spent (nothing is recorded)
6720        // and a later reentry asks the same agent again.
6721        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6722            return Ok(GateFix::Defer);
6723        }
6724        let mut record = GateFixRecord {
6725            agent: seat.agent.clone(),
6726            failed,
6727            notes: String::new(),
6728            committed: false,
6729            error: None,
6730        };
6731        match out {
6732            AgentOutcome::Ok(o) => {
6733                // A missing report is not a failed fix: the round is judged
6734                // by the tree below, and the report only carries prose.
6735                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6736                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6737                }
6738            }
6739            AgentOutcome::Dropped(_) => {
6740                record.error = Some("the CLI dropped the stream".to_owned());
6741            }
6742            AgentOutcome::Quota(o) => {
6743                self.state.quota.push(QuotaLoss {
6744                    seat: seat.key.clone(),
6745                    node: "gate-fix".to_owned(),
6746                    at: Timestamp::now(),
6747                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6748                });
6749                record.error = Some("rate limited (quota); fixer could not run".to_owned());
6750            }
6751            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6752        }
6753        self.state.seats.insert(seat.key.clone(), seat);
6754        if let Ok(r) = git::rescue_commit(
6755            &winner.worktree,
6756            &format!("magi: gate fix {attempt} (uncommitted work)"),
6757        )
6758        .await
6759        {
6760            self.state.note_withheld("gate-fix", &r.withheld);
6761        }
6762        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6763        record.committed = after != before;
6764        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6765        let note = record.error.clone();
6766        self.state.gate_fixes.push(record);
6767        self.state.save()?;
6768        if !changed {
6769            self.state.event(
6770                "gate",
6771                match note {
6772                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6773                    None => format!("gate-fix round {attempt}: the tree did not change"),
6774                },
6775            );
6776            return Ok(GateFix::Stop);
6777        }
6778        self.state.event(
6779            "gate",
6780            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6781        );
6782
6783        let commands = self.state.config.verify.e2e.clone();
6784        if !commands.is_empty() {
6785            let shell = self.state.config.shell();
6786            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6787            let cache_dir = self.state.config.cache_dir();
6788            let context = format!("gate-fix round {attempt}");
6789            let (e2e, _) = with_cache_lease(
6790                &mut self.state,
6791                cache_dir.as_deref(),
6792                "e2e",
6793                "e2e",
6794                &winner.worktree,
6795                &after,
6796                timeout,
6797                &context,
6798                |state, budget| {
6799                    let shell = shell.clone();
6800                    let commands = commands.clone();
6801                    let context = context.clone();
6802                    let worktree = winner.worktree.clone();
6803                    async move {
6804                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6805                            .await
6806                    }
6807                },
6808            )
6809            .await;
6810            if verify_inconclusive(&e2e) {
6811                return Ok(GateFix::Defer);
6812            }
6813            if e2e.iter().any(|o| !o.ok()) {
6814                self.state.event(
6815                    "gate",
6816                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6817                );
6818                return Ok(GateFix::Stop);
6819            }
6820        }
6821        Ok(GateFix::Retry)
6822    }
6823
6824    // ---------------------------------------------------------------- merge
6825
6826    /// One read-only rewrite by the summary's author; a text the gate still
6827    /// withholds goes to the owner (see `github_text`) and the fixed fallback
6828    /// applies on their word or on silence. `None` means the run is parked on
6829    /// that question and nothing may be pushed or opened yet.
6830    async fn guarded_pr_message(
6831        &mut self,
6832        winner: &Candidate,
6833        facts: Option<&BranchFacts>,
6834        posting: bool,
6835    ) -> Result<Option<PrMessage>> {
6836        let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6837        let head = match final_head.as_deref() {
6838            Some(h) => crate::pr_outcome::FinalHead::At(h),
6839            None => crate::pr_outcome::FinalHead::Unknown,
6840        };
6841        // Only a pull request that is about to be posted pays for a summary.
6842        let summarized = if posting && is_review_run(&self.state) {
6843            Some(self.summarize_change(winner, final_head.as_deref()).await)
6844        } else {
6845            None
6846        };
6847        // A busy shared resource is not a permanent cause: publishing an
6848        // "unavailable" description would stick (a later head move never
6849        // edits the body). Stop before anything is pushed or opened; the
6850        // run is `Blocked` and a resume asks the summary again.
6851        if self.state.resource_block.is_some() {
6852            self.state.event(
6853                "pr-summary",
6854                "a shared resource was busy; nothing was published, resume to summarize again",
6855            );
6856            return Ok(None);
6857        }
6858        let note = match (&summarized, &self.state.change_summary) {
6859            (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
6860            (Some(Err(why)), _) => ChangeNote::Unavailable(why),
6861            (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
6862            (None, _) => ChangeNote::NotAsked,
6863        };
6864        let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
6865        if !posting {
6866            let identity = crate::scrub::Identity::current();
6867            return Ok(Some(PrMessage {
6868                title: crate::scrub::scrub(&pr.title, &identity),
6869                body: crate::scrub::scrub(&pr.body, &identity),
6870            }));
6871        }
6872        // This very text was already rejected and asked about: no second
6873        // judge call, rewrite or question, just carry the record on.
6874        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6875        if let Some(known) = self
6876            .state
6877            .github_text
6878            .clone()
6879            .filter(|g| g.fingerprint == raw_fp)
6880        {
6881            return self.settle_withheld(winner, &pr, known).await;
6882        }
6883        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6884        let mut violations =
6885            crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
6886        let identity = crate::scrub::Identity::current();
6887        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6888            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6889            && !violations.contains(&crate::github_text::Violation::SensitiveData)
6890        {
6891            violations.push(crate::github_text::Violation::SensitiveData);
6892        }
6893        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
6894        // only a language violation starts one; sensitive data alone goes
6895        // straight to the owner, with the redacted text as the fallback.
6896        let has_language = violations
6897            .iter()
6898            .any(|v| *v != crate::github_text::Violation::SensitiveData);
6899        if self.state.config.graph.github_text_guard && !violations.is_empty() {
6900            self.state.event(
6901                "github-text",
6902                if has_language {
6903                    format!("description rejected: {violations:?}; requesting one rewrite")
6904                } else {
6905                    format!("description rejected: {violations:?}; asking the owner")
6906                },
6907            );
6908            let mut rewritten = false;
6909            let mut rejected_rewrite = None;
6910            if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
6911            {
6912                let output = text;
6913                if let Some(title) = summary_title(&output) {
6914                    // Judge the redacted candidate that will actually be posted.
6915                    // Sensitive data alone must not discard a useful rewrite.
6916                    let raw_body = summary_without_title(&output);
6917                    let clean_title = crate::scrub::scrub(&title, &identity);
6918                    let mut body = crate::scrub::scrub(&raw_body, &identity);
6919                    let redacted = clean_title != title || body != raw_body;
6920                    let title = clean_title;
6921                    let marker = format!("magi:run/{}", self.state.id);
6922                    if !body.trim().is_empty() && !body.contains(&marker) {
6923                        body.push_str(&format!("\n\n{marker}"));
6924                    }
6925                    let rewrite = PrMessage {
6926                        title: title.clone(),
6927                        body: body.clone(),
6928                    };
6929                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6930                    let mut failed = crate::github_text::check_for(
6931                        &self.state.config.graph,
6932                        &title,
6933                        &body,
6934                        redecision,
6935                    );
6936                    if (!crate::github_text::shareable(&title)
6937                        || !crate::github_text::shareable(&body))
6938                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
6939                    {
6940                        failed.push(crate::github_text::Violation::SensitiveData);
6941                    }
6942                    if !title.trim().is_empty() && !body.trim().is_empty() && !failed.is_empty() {
6943                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
6944                            title: title.clone(),
6945                            body: body.clone(),
6946                            failed: failed
6947                                .iter()
6948                                .map(|v| crate::github_text::category(*v).to_owned())
6949                                .collect(),
6950                        });
6951                    }
6952                    if !title.trim().is_empty() && !body.trim().is_empty() && failed.is_empty() {
6953                        decision = redecision;
6954                        pr = PrMessage { title, body };
6955                        rewritten = true;
6956                        self.state
6957                            .event("github-text", "description rewrite passed");
6958                        if redacted {
6959                            self.state.event(
6960                                "github-text",
6961                                "sensitive data removed from description rewrite",
6962                            );
6963                        }
6964                    }
6965                }
6966            }
6967            if !rewritten {
6968                self.state.event(
6969                    "github-text",
6970                    "description rewrite unavailable or rejected; asking the owner",
6971                );
6972                if let Some(w) =
6973                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6974                {
6975                    // A different text than the one asked about before: that
6976                    // question no longer describes anything.
6977                    self.retire_github_text_question("the withheld text changed");
6978                    let record = crate::run::GithubTextAsk {
6979                        rewrite: rejected_rewrite,
6980                        fingerprint: raw_fp,
6981                        title: w.title,
6982                        body: w.body,
6983                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6984                        question: None,
6985                        asks: 0,
6986                        resolved: false,
6987                        chosen_title: None,
6988                        choice: None,
6989                    };
6990                    return self.settle_withheld(winner, &pr, record).await;
6991                }
6992            }
6993        }
6994        let (title, body) =
6995            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6996        Ok(Some(PrMessage { title, body }))
6997    }
6998
6999    /// Describe the whole change of a review-only run from its final diff.
7000    ///
7001    /// The summary is tied to the final commit and to the merge base of the
7002    /// resolved remote base; a record for any other pair is dropped and the
7003    /// agent asked again, so a description never outlives the diff it was
7004    /// written from. The seat is read-only and the prompt tells it to read the
7005    /// diff and the code around it. `[roles] synthesizer` selects the agents
7006    /// (unset: the default pick), each asked at most once, advancing on an
7007    /// error, quota, unusable answer or a reply with no `TITLE:` line. `Err`
7008    /// carries the reason it is unavailable; nothing is invented then.
7009    async fn summarize_change(
7010        &mut self,
7011        winner: &Candidate,
7012        head: Option<&str>,
7013    ) -> std::result::Result<(), String> {
7014        let Some(head) = head else {
7015            return Err("the final commit could not be read".to_owned());
7016        };
7017        let remote = self.state.config.merge.remote.clone();
7018        let tracking = format!("{remote}/{}", self.state.base_branch);
7019        if !git::rev_exists(&winner.worktree, &tracking).await {
7020            return Err(format!("the remote base `{tracking}` could not be read"));
7021        }
7022        let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7023            Ok(b) if !b.is_empty() => b,
7024            _ => return Err("the merge base with the remote base could not be read".to_owned()),
7025        };
7026        if self
7027            .state
7028            .change_summary
7029            .as_ref()
7030            .is_some_and(|c| c.head == head && c.base == base)
7031        {
7032            return Ok(());
7033        }
7034        if self.state.change_summary.take().is_some() {
7035            self.state.event(
7036                "pr-summary",
7037                "the final commit or base changed; the earlier change summary was discarded",
7038            );
7039        }
7040        let chain = match agent::pick_chain(
7041            &self.state.config.agents,
7042            self.state.config.roles.synthesizer.as_ref(),
7043            &agent::installed,
7044            "synthesizer",
7045        ) {
7046            Ok(chain) => chain,
7047            Err(e) => {
7048                tracing::warn!("no agent for the change summary: {e:#}");
7049                self.state.event("pr-summary", "no summary agent available");
7050                return Err("no agent was available to summarize it".to_owned());
7051            }
7052        };
7053        let subjects = git::subjects(&winner.worktree, &base, head)
7054            .await
7055            .unwrap_or_default();
7056        let stat = git::diff_stat(&winner.worktree, &base, head)
7057            .await
7058            .unwrap_or_default();
7059        let stat: String = stat.chars().take(6_000).collect();
7060        let prompt = crate::prompt::change_summary_in(
7061            &base,
7062            head,
7063            &subjects,
7064            stat.trim(),
7065            &self.state.config.graph.github_language,
7066        );
7067        let sessions = self.state.config.graph.sessions;
7068        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7069        let prompts = self.state.config.prompts.clone();
7070        let cache = self.state.config.cache_dir();
7071        let run = self.state.id.clone();
7072        for (i, spec) in chain.into_iter().enumerate() {
7073            let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7074            let job = SeatJob {
7075                spec: spec.clone(),
7076                seat,
7077                cwd: winner.worktree.clone(),
7078                prompt: prompt.clone(),
7079                timeout,
7080                allow_write: false,
7081                sessions,
7082                artifacts: agent::artifacts_dir(&self.state.dir()),
7083                stem: if i == 0 {
7084                    "change-summary".to_owned()
7085                } else {
7086                    format!("change-summary-{}", spec.id)
7087                },
7088                handover: None,
7089            };
7090            let ctx = WaveCtx {
7091                run: &run,
7092                node: "pr-summary",
7093                prompts: &prompts,
7094                cache: cache.as_deref(),
7095                round: None,
7096                carry_seats: false,
7097                pauses: Some((&self.pause, &self.interrupt)),
7098            };
7099            let (seat, outcome) =
7100                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7101            self.state.seats.insert(seat.key.clone(), seat);
7102            let text = match outcome {
7103                AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7104                AgentOutcome::ResourceBlocked(why) => {
7105                    // No agent was asked: the run stops resumably before
7106                    // anything is published (see `guarded_pr_message`).
7107                    if self.state.resource_block.is_none() {
7108                        self.state.resource_block = Some(crate::run::ResourceBlock {
7109                            node: "pr-summary".to_owned(),
7110                            kind: "shared resource".to_owned(),
7111                            detail: why,
7112                            at: Timestamp::now(),
7113                        });
7114                    }
7115                    return Err("a shared resource was busy".to_owned());
7116                }
7117                _ => continue,
7118            };
7119            let blind = &self.state.config.blind;
7120            let Some(title) = summary_title(&text) else {
7121                continue;
7122            };
7123            let body: String = summary_without_title(&text)
7124                .lines()
7125                .filter(|l| !l.contains("magi:run/"))
7126                .collect::<Vec<_>>()
7127                .join("\n");
7128            let body = blind::sanitize_prose(body.trim(), blind);
7129            let title = blind::sanitize_prose(&title, blind);
7130            if body.trim().is_empty() || title.trim().is_empty() {
7131                continue;
7132            }
7133            // The branch must still be the one the agent was shown.
7134            if git::rev_parse(&winner.worktree, "HEAD")
7135                .await
7136                .ok()
7137                .as_deref()
7138                != Some(head)
7139            {
7140                return Err("the branch moved while the summary was written".to_owned());
7141            }
7142            self.state.change_summary = Some(crate::run::ChangeSummary {
7143                head: head.to_owned(),
7144                base,
7145                title,
7146                body,
7147            });
7148            self.state.event(
7149                "pr-summary",
7150                format!("change summary written by {} for the final diff", spec.id),
7151            );
7152            if let Err(e) = self.state.save() {
7153                tracing::warn!("could not save the change summary: {e:#}");
7154            }
7155            return Ok(());
7156        }
7157        self.state
7158            .event("pr-summary", "no agent produced a change summary");
7159        Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7160    }
7161
7162    /// One read-only rewrite request for a withheld description. The
7163    /// implementer's own conversation is reused only when it exists and can be
7164    /// resumed; otherwise the `[roles] language_judge` chain (or the default
7165    /// pick) writes it in a separate conversation. The prompt carries the whole
7166    /// text, so no agent needs any memory. Each agent is asked at most once and
7167    /// the chain advances on an error, quota, unusable answer or a reply with
7168    /// no `TITLE:` line. Nothing is recorded as a handover.
7169    async fn rewrite_pr_text(
7170        &mut self,
7171        winner: &Candidate,
7172        pr: &PrMessage,
7173        violations: &[crate::github_text::Violation],
7174    ) -> Option<String> {
7175        let prompt = format!(
7176            "Rewrite only the following pull request description into English. The posting gate reported {violations:?}. Keep every substantive detail (file names, commands, numbers, findings, code blocks and <details> sections) and translate the prose. 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{}",
7177            pr.title, pr.body
7178        );
7179        let sessions = self.state.config.graph.sessions;
7180        let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7181        // (spec, seat, resumed)
7182        let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7183        let key = format!("impl-{}", winner.label);
7184        if let Some(spec) = self
7185            .state
7186            .config
7187            .agents
7188            .iter()
7189            .find(|a| a.id == winner.agent)
7190            .cloned()
7191            && let Some(existing) = self.state.seats.get(&key)
7192            && existing.agent == spec.id
7193            && agent::has_session(spec.kind, existing, sessions)
7194        {
7195            attempts.push((spec, existing.clone(), true));
7196        } else {
7197            let chain = agent::pick_chain(
7198                &self.state.config.agents,
7199                self.state.config.roles.language_judge.as_ref(),
7200                &agent::installed,
7201                "language_judge",
7202            );
7203            match chain {
7204                Ok(chain) => {
7205                    for spec in chain {
7206                        let seat = crate::fixer::seat_for(
7207                            &mut self.state,
7208                            "github-text-rewrite",
7209                            &spec.id,
7210                        );
7211                        attempts.push((spec, seat, false));
7212                    }
7213                }
7214                Err(e) => {
7215                    tracing::warn!("no agent for the description rewrite: {e:#}");
7216                    self.state
7217                        .event("github-text", "no rewrite agent available");
7218                    return None;
7219                }
7220            }
7221        }
7222        let prompts = self.state.config.prompts.clone();
7223        let cache = self.state.config.cache_dir();
7224        let run = self.state.id.clone();
7225        for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7226            let job = SeatJob {
7227                spec: spec.clone(),
7228                seat,
7229                cwd: winner.worktree.clone(),
7230                prompt: prompt.clone(),
7231                timeout: retry_budget(timeout_full, resumed),
7232                allow_write: false,
7233                sessions,
7234                artifacts: agent::artifacts_dir(&self.state.dir()),
7235                stem: if i == 0 {
7236                    "github-text-rewrite".to_owned()
7237                } else {
7238                    format!("github-text-rewrite-{}", spec.id)
7239                },
7240                handover: None,
7241            };
7242            let ctx = WaveCtx {
7243                run: &run,
7244                node: "github-text",
7245                prompts: &prompts,
7246                cache: cache.as_deref(),
7247                round: None,
7248                carry_seats: false,
7249                pauses: Some((&self.pause, &self.interrupt)),
7250            };
7251            let (seat, outcome) =
7252                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7253            self.state.seats.insert(seat.key.clone(), seat);
7254            match outcome {
7255                AgentOutcome::Ok(o)
7256                    if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7257                {
7258                    return Some(o.text);
7259                }
7260                AgentOutcome::ResourceBlocked(_) => return None,
7261                _ => {}
7262            }
7263        }
7264        None
7265    }
7266
7267    /// Abandon the standing posting-gate question, if any.
7268    fn retire_github_text_question(&mut self, why: &str) {
7269        let Some(id) = self
7270            .state
7271            .github_text
7272            .as_ref()
7273            .and_then(|g| g.question.clone())
7274        else {
7275            return;
7276        };
7277        let _ = ask::Questions::open().update(&id, |q| {
7278            q.abandon(why);
7279            Ok(())
7280        });
7281    }
7282
7283    /// Carry a withheld text to its end: file the question, park while it is
7284    /// open, and on an answer, silence or timeout pick the text to post. The
7285    /// decision is saved before it is used, so it applies once.
7286    async fn settle_withheld(
7287        &mut self,
7288        winner: &Candidate,
7289        pr: &PrMessage,
7290        mut g: crate::run::GithubTextAsk,
7291    ) -> Result<Option<PrMessage>> {
7292        use crate::github_text as gt;
7293        let store = ask::Questions::open();
7294        let timeout = self.state.config.graph.answer_timeout;
7295        if !g.resolved {
7296            let mut standing = g
7297                .question
7298                .as_ref()
7299                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7300            if let Some(q) = standing.as_ref()
7301                && q.status.open()
7302                && gt::expired(q, timeout)
7303            {
7304                let why = format!("no answer within {}s of asking", timeout.max(1));
7305                standing = store
7306                    .update(&q.id, |q| {
7307                        q.abandon(&why);
7308                        Ok(())
7309                    })
7310                    .ok()
7311                    .map(|(q, ())| q);
7312            }
7313            let mut ask_again: Option<Option<Vec<String>>> = None;
7314            match standing {
7315                // Recorded but never written (a stop between the two saves),
7316                // or removed: ask again without spending the retry.
7317                None if g.asks <= 1 => {
7318                    g.asks = 0;
7319                    ask_again = Some(None);
7320                }
7321                None => {
7322                    g.resolved = true;
7323                    g.chosen_title = None;
7324                    g.choice = Some(GithubTextChoice::Fallback);
7325                }
7326                Some(q) if q.status.open() => {
7327                    self.park_on_github_text(&g)?;
7328                    return Ok(None);
7329                }
7330                Some(q) => match gt::read_reply(&q) {
7331                    gt::Reply::Fallback => {
7332                        g.resolved = true;
7333                        g.chosen_title = None;
7334                        g.choice = Some(GithubTextChoice::Fallback);
7335                    }
7336                    gt::Reply::Original => {
7337                        g.resolved = true;
7338                        g.chosen_title = None;
7339                        g.choice = Some(GithubTextChoice::Original);
7340                    }
7341                    gt::Reply::Rewrite => {
7342                        g.resolved = true;
7343                        g.chosen_title = None;
7344                        g.choice = Some(if g.rewrite.is_some() {
7345                            GithubTextChoice::Rewrite
7346                        } else {
7347                            GithubTextChoice::Fallback
7348                        });
7349                    }
7350                    gt::Reply::Title(text) => {
7351                        let first = text
7352                            .lines()
7353                            .map(str::trim)
7354                            .find(|l| !l.is_empty())
7355                            .unwrap_or("");
7356                        let decision = if first.is_empty() {
7357                            None
7358                        } else {
7359                            let candidate = PrMessage {
7360                                title: first.to_owned(),
7361                                body: String::new(),
7362                            };
7363                            self.judge_pr_language(&winner.worktree, &candidate).await
7364                        };
7365                        match gt::vet_title(
7366                            &text,
7367                            decision,
7368                            self.state.config.graph.github_english(),
7369                        ) {
7370                            Ok(title) => {
7371                                g.resolved = true;
7372                                g.chosen_title = Some(title);
7373                                g.choice = Some(GithubTextChoice::MyTitle);
7374                            }
7375                            Err(cats) if g.asks < 2 => {
7376                                ask_again = Some(Some(
7377                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7378                                ))
7379                            }
7380                            Err(_) => {
7381                                g.resolved = true;
7382                                g.chosen_title = None;
7383                                g.choice = Some(GithubTextChoice::Fallback);
7384                            }
7385                        }
7386                    }
7387                },
7388            }
7389            if let Some(rejected) = ask_again {
7390                // A retry (after a rejected replacement title) is still about
7391                // the original text: the same ORIGINAL / FALLBACK comparison.
7392                let w = gt::Withheld {
7393                    title: g.title,
7394                    body: g.body,
7395                    categories: g.categories.clone(),
7396                };
7397                // Named by the original text's fingerprint alone, so a resume
7398                // rewrites the same file and the detail stays identical.
7399                let artifact = match crate::run::write_artifact(
7400                    &self.state,
7401                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
7402                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7403                ) {
7404                    Ok(p) => Some(p),
7405                    Err(e) => {
7406                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
7407                        None
7408                    }
7409                };
7410                let (detail, comparison) = gt::question_content(
7411                    &w,
7412                    &pr.title,
7413                    &pr.body,
7414                    &self.state.id,
7415                    rejected.as_deref(),
7416                    artifact.as_deref(),
7417                    g.rewrite.as_ref(),
7418                );
7419                let mut q = ask::Question::new(
7420                    self.state.id.clone(),
7421                    gt::ASK_NODE.to_owned(),
7422                    gt::ASK_SEAT.to_owned(),
7423                    gt::question_summary(&self.state.config.graph.language, &w),
7424                    detail,
7425                    gt::question_choices(&w, g.rewrite.as_ref()),
7426                );
7427                q.posting_comparison = Some(comparison);
7428                q.answer_timeout = timeout;
7429                // Recorded before it is written: a stop in between leaves a
7430                // record naming a question the resume then files again.
7431                g.question = Some(q.id.clone());
7432                g.asks += 1;
7433                self.state.github_text = Some(g.clone());
7434                self.state.event(
7435                    "github-text",
7436                    format!("asking the owner about the withheld text ({})", q.short()),
7437                );
7438                self.state.save()?;
7439                store
7440                    .put(&mut q)
7441                    .context("file the posting-gate question")?;
7442                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7443                    tracing::warn!(
7444                        "could not notify about posting-gate question {}: {e:#}",
7445                        q.short()
7446                    );
7447                }
7448                self.park_on_github_text(&g)?;
7449                return Ok(None);
7450            }
7451        }
7452        let resolved = g.chosen_title.clone();
7453        let original = g.choice == Some(GithubTextChoice::Original);
7454        // Field names only: the event log never records the text.
7455        self.state.event(
7456            "github-text",
7457            if original {
7458                format!(
7459                    "posting the original text for: {}",
7460                    match (g.title, g.body) {
7461                        (true, true) => "title, description",
7462                        (true, false) => "title",
7463                        _ => "description",
7464                    }
7465                )
7466            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7467                "posting the saved rewrite title and description".to_owned()
7468            } else if resolved.is_some() {
7469                "posting the owner's replacement title".to_owned()
7470            } else {
7471                "posting the neutral text for the withheld fields".to_owned()
7472            },
7473        );
7474        self.state.github_text = Some(g.clone());
7475        self.state.save()?;
7476        if g.choice == Some(GithubTextChoice::Rewrite)
7477            && let Some(r) = g.rewrite.as_ref()
7478        {
7479            return Ok(Some(PrMessage {
7480                title: r.title.clone(),
7481                body: r.body.clone(),
7482            }));
7483        }
7484        let w = gt::Withheld {
7485            title: g.title,
7486            body: g.body,
7487            categories: g.categories.clone(),
7488        };
7489        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7490        if let Some(t) = resolved {
7491            title = t;
7492        }
7493        // Every withheld field is now neutral or vetted English.
7494        let english = gt::LanguageDecision {
7495            title_english: true,
7496            body_english: true,
7497            body_complete: true,
7498        };
7499        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7500        if original {
7501            // The owner's explicit choice overrides the gate for the fields it
7502            // was made for; the others keep what the gate produced.
7503            if g.title {
7504                title = pr.title.clone();
7505            }
7506            if g.body {
7507                body = pr.body.clone();
7508            }
7509        }
7510        Ok(Some(PrMessage { title, body }))
7511    }
7512
7513    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7514        self.state.github_text = Some(g.clone());
7515        self.state.end_pass_parked(ParkCause::Question);
7516        self.state.parked = true;
7517        self.state.event(
7518            "github-text",
7519            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7520        );
7521        self.state.save()
7522    }
7523
7524    /// Ask `[roles] language_judge` about this text and record which source
7525    /// decided; the text itself is never recorded.
7526    async fn judge_pr_language(
7527        &mut self,
7528        cwd: &Path,
7529        pr: &PrMessage,
7530    ) -> Option<crate::github_text::LanguageDecision> {
7531        self.state.config.roles.language_judge.as_ref()?;
7532        if !self.state.config.graph.language_gate() {
7533            return None;
7534        }
7535        let decision =
7536            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7537        self.state.event(
7538            "github-text",
7539            if decision.is_some() {
7540                "language decided by the language judge"
7541            } else {
7542                "language judge unavailable; using built-in heuristics"
7543            },
7544        );
7545        decision
7546    }
7547
7548    async fn merge(&mut self) -> Result<()> {
7549        // Same reasoning as `gate`: ask the review and gate records directly
7550        // rather than `status`, which a solo-candidate `judge`/`deliberate`
7551        // skip can rewrite on reentry to something that no longer says
7552        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
7553        // so a hand-off (open findings, green verification) reaches merge
7554        // exactly like a genuinely clean round does.
7555        //
7556        // A run resumed mid-`land` never reaches here at all: `execute`
7557        // recognises `RunStatus::Landing` before it even calls `prep`, and
7558        // routes straight to `run_land` instead. That has to happen a level
7559        // up from this function, not with a check in here, because
7560        // `review_loop`'s own status recomputation (see its doc) runs
7561        // *before* `merge` on every reentry and would otherwise overwrite
7562        // the `Landing` marker with `Gating` before this node ever saw it.
7563        if self
7564            .state
7565            .base_sync
7566            .as_ref()
7567            .is_some_and(|s| s.conflict.is_some())
7568            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7569                != Some(RunStatus::Gating)
7570            // `gate_ran == false` is not "passed" - `gate` leaves it false
7571            // both before it has ever run and when its last attempt was
7572            // resource-blocked (see `Runner::gate`'s own doc), and neither is
7573            // permission to merge on nothing but the review record. Only a
7574            // gate that actually ran - zero commands configured and
7575            // vacuously passed, or one or more that all exited 0 - may
7576            // proceed; `RunState::gate_status` is the single place that
7577            // reading is computed.
7578            || !self.state.gate_status().ok()
7579        {
7580            return Ok(());
7581        }
7582        // This node's own record, not `status`: `status == Ready` is not
7583        // unique to the harmless `MergeMode::None` path this line was
7584        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
7585        // run's PR was closed without merging — and on that run `mode` is
7586        // still `Pr`, so a reentry that fell through here would push and
7587        // open a second pull request. `self.state.merge` is set exactly once
7588        // this node (or `land`) has already produced a verdict, under every
7589        // mode, which is what "already done" actually means here.
7590        if self.state.merge.is_some() && !self.state.pr_publication_pending {
7591            return Ok(());
7592        }
7593        let Some(winner) = self.state.winner().cloned() else {
7594            return Ok(());
7595        };
7596        let repo = self.state.repo.clone();
7597        let base = self.state.base_branch.clone();
7598        let mode = self.state.config.merge.mode;
7599        let style = self.state.config.merge.style;
7600        let facts = if is_review_run(&self.state) {
7601            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7602            let start = review_base(
7603                &repo,
7604                &self.state.config.merge.remote,
7605                &base,
7606                &self.state.base_commit,
7607                &winner.branch,
7608            )
7609            .await;
7610            branch_facts(&repo, &start, &winner.branch).await
7611        } else {
7612            None
7613        };
7614        // `None` when the base could not be freshly read: then an adopted
7615        // pull request's title is left alone.
7616        let leaked = if is_review_run(&self.state) {
7617            leaked_subjects(&self.state, &winner.branch).await
7618        } else {
7619            Some(Vec::new())
7620        };
7621        // `None`: parked on the owner's word about a withheld title or body.
7622        // Nothing has been pushed or opened, and `merge` stays unrecorded so
7623        // the resume arrives here again.
7624        let Some(pr) = self
7625            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7626            .await?
7627        else {
7628            return Ok(());
7629        };
7630        let message = pr.commit_message();
7631
7632        let outcome = match mode {
7633            MergeMode::None => MergeOutcome {
7634                mode,
7635                ok: true,
7636                detail: manual_merge_command(style, &repo, &winner.branch, &message),
7637                empty: false,
7638            },
7639            MergeMode::Pr | MergeMode::Local
7640                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7641            {
7642                MergeOutcome {
7643                    mode,
7644                    ok: false,
7645                    detail: empty_candidate_detail(&self.state, &base),
7646                    empty: true,
7647                }
7648            }
7649            MergeMode::Local => {
7650                let on = git::current_branch(&repo).await?;
7651                if on.as_deref() != Some(base.as_str()) {
7652                    MergeOutcome {
7653                        mode,
7654                        ok: false,
7655                        detail: format!(
7656                            "{} has {} checked out, not the base branch {base}",
7657                            repo.display(),
7658                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
7659                        ),
7660                        empty: false,
7661                    }
7662                } else if !git::is_clean(&repo).await? {
7663                    MergeOutcome {
7664                        mode,
7665                        ok: false,
7666                        detail: format!("{} is dirty; refusing to merge", repo.display()),
7667                        empty: false,
7668                    }
7669                } else {
7670                    let out = match style {
7671                        MergeStyle::Merge => {
7672                            git::merge_no_ff(&repo, &winner.branch, &message).await?
7673                        }
7674                        MergeStyle::Squash => {
7675                            git::merge_squash(&repo, &winner.branch, &message).await?
7676                        }
7677                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7678                    };
7679                    MergeOutcome {
7680                        mode,
7681                        ok: out.ok(),
7682                        detail: if out.ok() { out.stdout } else { out.stderr },
7683                        empty: false,
7684                    }
7685                }
7686            }
7687            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7688                self.state.pr_publication_pending = true;
7689                self.state.save()?;
7690                let out =
7691                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7692                self.state.pr_publication_pending = out.is_err();
7693                MergeOutcome {
7694                    mode,
7695                    ok: out.is_ok(),
7696                    detail: match out {
7697                        Ok(url) => url,
7698                        Err(e) => format!("{e:#}"),
7699                    },
7700                    empty: false,
7701                }
7702            }
7703            MergeMode::Pr => {
7704                let remote = self.state.config.merge.remote.clone();
7705                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7706                if !pushed.ok() {
7707                    MergeOutcome {
7708                        mode,
7709                        ok: false,
7710                        detail: pushed.stderr,
7711                        empty: false,
7712                    }
7713                } else {
7714                    // A retry or resume of a run whose branch already has an
7715                    // open pull request adopts it rather than failing on a
7716                    // duplicate. Only this winner branch into this base:
7717                    // `branch_for` derives the name from the run id, so a
7718                    // different run's pull request never matches.
7719                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7720                    let out = match pr_merge_plan(found) {
7721                        PrPlan::Create => {
7722                            gh_pr_create(
7723                                &winner.worktree,
7724                                &base,
7725                                &winner.branch,
7726                                &pr.title,
7727                                &pr.body,
7728                            )
7729                            .await
7730                        }
7731                        PrPlan::Adopt { url, title } => {
7732                            self.state
7733                                .event("merge", format!("Pr: adopted open pull request {url}"));
7734                            if self.state.github_text.as_ref().is_some_and(|g| {
7735                                g.resolved
7736                                    && g.choice == Some(GithubTextChoice::Rewrite)
7737                                    && g.rewrite.is_some()
7738                            }) {
7739                                // The owner chose the entire pair, even if the
7740                                // existing title already matches or was edited.
7741                                land::set_pr_text_verbatim(
7742                                    &winner.worktree,
7743                                    &url,
7744                                    &pr.title,
7745                                    &pr.body,
7746                                )
7747                                .await
7748                                .map(|()| url)
7749                            } else {
7750                                if title != pr.title
7751                                    && (!is_review_run(&self.state)
7752                                        || leaked
7753                                            .as_deref()
7754                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7755                                    && let Err(e) =
7756                                        if self.state.github_text.as_ref().is_some_and(|g| {
7757                                            g.resolved
7758                                                && g.title
7759                                                && g.choice == Some(GithubTextChoice::Original)
7760                                        }) {
7761                                            // The owner chose to post this title unmodified.
7762                                            land::set_pr_title_verbatim(
7763                                                &winner.worktree,
7764                                                &url,
7765                                                &pr.title,
7766                                            )
7767                                            .await
7768                                        } else {
7769                                            land::set_pr_title(
7770                                                &mut self.state,
7771                                                &winner.worktree,
7772                                                &url,
7773                                                &pr.title,
7774                                            )
7775                                            .await
7776                                        }
7777                                {
7778                                    tracing::warn!("could not refresh title of {url}: {e:#}");
7779                                    self.state
7780                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
7781                                }
7782                                Ok(url)
7783                            }
7784                        }
7785                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7786                    };
7787                    match out {
7788                        Ok(url) => MergeOutcome {
7789                            mode,
7790                            ok: true,
7791                            detail: url,
7792                            empty: false,
7793                        },
7794                        Err(e) => MergeOutcome {
7795                            mode,
7796                            ok: false,
7797                            detail: e.to_string(),
7798                            empty: false,
7799                        },
7800                    }
7801                }
7802            }
7803        };
7804
7805        self.state.status = match (mode, outcome.ok) {
7806            (MergeMode::None, _) => RunStatus::Ready,
7807            (_, true) => RunStatus::Merged,
7808            (_, false) => RunStatus::Blocked,
7809        };
7810        self.state.event(
7811            "merge",
7812            format!(
7813                "{:?}: {}",
7814                mode,
7815                outcome.detail.lines().next().unwrap_or("")
7816            ),
7817        );
7818        self.state.merge = Some(outcome);
7819        self.state.save()?;
7820
7821        // The PR is open and the run would historically stop here, leaving the
7822        // operator to watch checks, feed review comments back to a fixer, and
7823        // merge. That was done by hand six times in one session before this
7824        // existed. Opt-in, because merging is the one irreversible thing magi
7825        // can do to a repository.
7826        if self.state.config.graph.land
7827            && mode == MergeMode::Pr
7828            && self.state.status == RunStatus::Merged
7829        {
7830            self.run_land().await?;
7831        }
7832        // `run_land` may have left `status` at `Landing` - still waiting on
7833        // CI or the owner's approval, not actually settled - so this has to
7834        // read whatever `status` ended up as here, not the `Merged` this
7835        // function set a few lines up.
7836        self.settle_questions();
7837        Ok(())
7838    }
7839
7840    /// Enter `land`.
7841    ///
7842    /// Shared between a fresh run's first pass through [`Runner::merge`] and
7843    /// a resumed run's re-entry. `land::land` itself is what serialises the
7844    /// two git-mutating moments inside the loop — the rebase push and
7845    /// `gh pr merge` — per repository (see its own doc); nothing here needs
7846    /// to hold a lock across the whole call, and doing so would serialise
7847    /// this run's CI wait against a *different* run's land-approval resume
7848    /// in the same repository, which is exactly the "must not wait on
7849    /// another task" property the daemon's slot-freeing exists to give.
7850    async fn run_land(&mut self) -> Result<()> {
7851        crate::pr_head::ensure_owner(&self.state)?;
7852        let url = self
7853            .state
7854            .merge
7855            .as_ref()
7856            .map(|m| m.detail.clone())
7857            .unwrap_or_default();
7858        let url = url.lines().next().unwrap_or("").trim().to_owned();
7859        if !url.starts_with("http") {
7860            return Ok(());
7861        }
7862        // A land failure is not a lost run: the work is on a branch and the
7863        // pull request is open, which is exactly where a human takes over.
7864        match land::land(&mut self.state, &url).await {
7865            Ok(pr) if self.state.parked => {
7866                // `land` already saved the parked marker; nothing here
7867                // overrides `status` back to a terminal value while an
7868                // approval is still outstanding.
7869                let _ = pr;
7870            }
7871            Ok(pr) => {
7872                self.state.status = match pr.state {
7873                    land::PrLifecycle::Merged => RunStatus::Merged,
7874                    _ => RunStatus::Blocked,
7875                };
7876                // Downstream of a confirmed merge only - see
7877                // `bump::should_release_bump`'s own doc for why this one
7878                // check covers all three of `land`'s success paths.
7879                // Best-effort: the run already landed, so a failure here
7880                // (the decision call, `gh`, `cargo`) is recorded and never
7881                // turns a landed run into a failed one.
7882                if bump::should_release_bump(self.state.status)
7883                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7884                {
7885                    // The event is the run's own record. Not-eligible cases
7886                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
7887                    // `Err` is a bump that was tried and failed:
7888                    // `after_merge` itself raises the operator notice for
7889                    // that, whether or not a release PR exists yet.
7890                    self.state
7891                        .event("bump", format!("release bump skipped: {e:#}"));
7892                }
7893                // Independent of the bump, and best-effort in the same way:
7894                // findings the merge left open become follow-up tasks.
7895                if self.state.status == RunStatus::Merged {
7896                    crate::followup::after_merge(&mut self.state, &pr.url).await;
7897                }
7898                self.state.save()?;
7899            }
7900            Err(e) => {
7901                self.state.status = RunStatus::Blocked;
7902                self.state.event("land", format!("gave up: {e}"));
7903                self.state.save()?;
7904            }
7905        }
7906        Ok(())
7907    }
7908
7909    // -------------------------------------------------------------- helpers
7910
7911    /// Fetch or create a seat, keeping its conversation across nodes.
7912    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7913        if let Some(existing) = self.state.seats.get(key)
7914            && existing.agent == agent
7915        {
7916            return existing.clone();
7917        }
7918        // A seat that changes agent mints its session id from the agent too,
7919        // like a handover: the old agent's uuid is already taken by the CLI.
7920        let fresh = if self.state.seats.contains_key(key) {
7921            handover_seat(key, agent, self.state.next_seat_seed())
7922        } else {
7923            SeatState::new(key, agent, self.state.seed)
7924        };
7925        self.state.seats.insert(key.to_owned(), fresh.clone());
7926        fresh
7927    }
7928
7929    /// The agent now holding seat `key`: `spec`, unless a handover moved the
7930    /// seat to another roster agent, in which case that agent. Nodes that
7931    /// continue a seat's conversation (deliberation, the votes, a reviewer's
7932    /// reconsideration) must keep talking to whoever answered it, not slip
7933    /// back to the agent that failed it.
7934    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7935        match self.state.seats.get(key) {
7936            Some(s) if s.agent != spec.id => {
7937                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7938            }
7939            _ => spec,
7940        }
7941    }
7942
7943    /// A candidate rendered for judging, with the leak policy applied.
7944    fn view(&self, c: &Candidate) -> CandidateView {
7945        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7946            .unwrap_or_default();
7947        let (patch, _) = blind::sanitize_patch(
7948            &format!("candidate {} patch", c.label),
7949            &raw,
7950            &self.state.config.blind,
7951        );
7952        CandidateView {
7953            label: c.label,
7954            branch: c.branch.clone(),
7955            summary: c.summary.clone(),
7956            stat: c.stat.clone(),
7957            patch,
7958        }
7959    }
7960
7961    /// The full candidate set as prompt text, for seats with no live session.
7962    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7963        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7964        prompt::judge(
7965            "(see above)",
7966            &views,
7967            self.roles.judges.len(),
7968            base_short,
7969            "en",
7970        )
7971    }
7972
7973    /// The final-vote prompt with everything a seat that has no session of its
7974    /// own needs: the candidates, the seat's own ranking and reasons, and the
7975    /// anonymised deliberation it took part in (only when there was one, so a
7976    /// handed-over seat never sees more than the seat it replaces did). The
7977    /// `Final vote` heading stays first.
7978    fn vote_prompt_full(
7979        &self,
7980        j: usize,
7981        labels: &[char],
7982        language: &str,
7983        candidates: &[Candidate],
7984        base_short: &str,
7985    ) -> String {
7986        let mut text = format!(
7987            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7988            prompt::final_vote(labels, language),
7989            self.state.instruction,
7990            self.candidate_block(candidates, base_short)
7991        );
7992        if let Some(own) = self
7993            .state
7994            .judgements
7995            .get(j)
7996            .filter(|r| !r.ranking.is_empty())
7997        {
7998            let reasons = own
7999                .reasons
8000                .iter()
8001                .map(|(k, v)| format!("- {k}: {v}"))
8002                .collect::<Vec<_>>()
8003                .join("\n");
8004            text.push_str(&format!(
8005                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8006                own.ranking.iter().collect::<String>(),
8007                if reasons.is_empty() {
8008                    ""
8009                } else {
8010                    ", because:\n"
8011                }
8012            ));
8013        }
8014        if !self.state.deliberation.is_empty() {
8015            text.push_str("\n# What was argued before this vote\n");
8016            for t in self.transcript(&[], j) {
8017                text.push_str(&format!(
8018                    "\n## {}{}\n\n{}\n",
8019                    t.who,
8020                    if t.is_self { " (you)" } else { "" },
8021                    t.body.trim()
8022                ));
8023            }
8024        }
8025        text
8026    }
8027
8028    /// Anonymised transcript for judge `self_idx`.
8029    ///
8030    /// The initial rankings are always the opening statements. Seeding them
8031    /// only when no turn had been taken yet meant every judge after the first
8032    /// argued against a single voice instead of against the actual split — the
8033    /// disagreement is the information, so it is always on the table.
8034    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8035        let mut turns = Vec::new();
8036        for j in &self.state.judgements {
8037            if j.ranking.is_empty() {
8038                continue;
8039            }
8040            let reasons = j
8041                .reasons
8042                .iter()
8043                .map(|(k, v)| format!("- {k}: {v}"))
8044                .collect::<Vec<_>>()
8045                .join("\n");
8046            turns.push(Turn {
8047                who: format!("Judge {} (opening ranking)", j.judge),
8048                is_self: j.judge == self_idx + 1,
8049                body: format!(
8050                    "Ranked {}{}{reasons}",
8051                    j.ranking.iter().collect::<String>(),
8052                    if reasons.is_empty() {
8053                        ""
8054                    } else {
8055                        ", because:\n"
8056                    }
8057                ),
8058            });
8059        }
8060        for t in self
8061            .state
8062            .deliberation
8063            .iter()
8064            .flat_map(|r| r.turns.iter())
8065            .chain(current)
8066        {
8067            turns.push(Turn {
8068                who: format!("Judge {}", t.judge),
8069                is_self: t.judge == self_idx + 1,
8070                body: t.body.clone(),
8071            });
8072        }
8073        turns
8074    }
8075}
8076
8077/// Does this seat still hold the context a follow-up prompt would rely on?
8078fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8079    agent::has_session(spec.kind, seat, sessions)
8080}
8081
8082/// The next entry in `roster` after `start`, never wrapping back to the
8083/// front, whose id is not in `tried` yet.
8084///
8085/// Starts one past `start` rather than at the front of `roster`: `start` is
8086/// the seat's own original position, and a seat whose candidate slot already
8087/// sits on the roster's second entry must fall through to the third next, not
8088/// restart at the first — which is very likely a different candidate's own
8089/// agent already. Never wraps back past `start`, for the same reason: an
8090/// entry earlier in the roster than the seat's own position is almost
8091/// certainly some *other* candidate slot's own agent, and once the tail of
8092/// the roster is exhausted there are no more untried agents for *this* seat
8093/// to fall through to — the caller's fallback chain ends there, exactly as
8094/// "no further untried agents remain in the list for that seat" asks for.
8095///
8096/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
8097/// the same id twice (an operator's `roles.implementers` typo, or a
8098/// `[[agents]]` list reused across roles) must not let
8099/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
8100/// over `roster` either finds an untried id or runs out, so this always
8101/// terminates regardless of duplicates.
8102fn next_untried_in_roster<'a>(
8103    roster: &'a [AgentSpec],
8104    start: usize,
8105    tried: &BTreeSet<String>,
8106) -> Option<&'a AgentSpec> {
8107    roster
8108        .get(start + 1..)?
8109        .iter()
8110        .find(|s| !tried.contains(&s.id))
8111}
8112
8113/// The successor for a seat, shared by all four seat kinds (implement, judge,
8114/// review, advise). `others` holds the ids that *currently* occupy the other
8115/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
8116/// sees them), so roster entries beyond the seat count act as spares: a failed
8117/// seat goes to an agent no other seat holds whenever the roster permits.
8118///
8119/// `carried` is `Some` only for the review loop's carried failure history.
8120/// Order: (1) forward from `start`, never wrapping, untried, not a carried
8121/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
8122/// whole roster: untried and not another seat's occupant; (3) the plain walk
8123/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
8124/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
8125/// a better outcome than an empty seat, and it keeps the answer to "is there
8126/// a successor at all" exactly what it was before occupants were considered,
8127/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
8128/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
8129/// carried failure is a cheaper bet than doubling an agent on the panel, and
8130/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
8131/// the same round are handled one at a time, so a seat later in the batch
8132/// sees an earlier one's new occupant but not yet its own freed agent.
8133fn pick_successor<'a>(
8134    roster: &'a [AgentSpec],
8135    start: usize,
8136    tried: &BTreeSet<String>,
8137    carried: Option<&BTreeSet<String>>,
8138    others: &BTreeSet<String>,
8139) -> Option<&'a AgentSpec> {
8140    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8141    roster
8142        .get(start + 1..)
8143        .and_then(|tail| {
8144            tail.iter()
8145                .filter(free)
8146                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8147        })
8148        .or_else(|| {
8149            carried
8150                .is_some()
8151                .then(|| roster.iter().find(free))
8152                .flatten()
8153        })
8154        .or_else(|| match carried {
8155            Some(c) => next_for_seat(roster, start, tried, c),
8156            None => next_untried_in_roster(roster, start, tried),
8157        })
8158}
8159
8160/// The next agent for a seat that carries its failure history across rounds
8161/// (the review loop). `round_tried` is this round's own bound and starts
8162/// empty every round; `carried_failed` only decides priority.
8163///
8164/// First: an id walking forward from `start`, never wrapping, that is neither
8165/// tried this round nor failed in an earlier one. Only when that is exhausted
8166/// does it rescue: the first roster id (in roster order, so this one *does*
8167/// look before `start`) not yet tried this round, which is by then a carried
8168/// failure. Each id is rescued at most once per round, so it cannot loop.
8169fn next_for_seat<'a>(
8170    roster: &'a [AgentSpec],
8171    start: usize,
8172    round_tried: &BTreeSet<String>,
8173    carried_failed: &BTreeSet<String>,
8174) -> Option<&'a AgentSpec> {
8175    roster
8176        .get(start + 1..)?
8177        .iter()
8178        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8179        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8180}
8181
8182/// Where a reviewer seat starts a round: the agent that last answered it when
8183/// it is still on the roster and not marked failed, else the spec's own agent
8184/// unless it failed, else the next roster agent that has not failed, else the
8185/// spec's own agent again (the whole roster failed). Ids no longer on the
8186/// roster are ignored. An empty roster has no handover, so the spec stands.
8187fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8188    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8189        return spec;
8190    };
8191    let ok = |id: &str| !h.failed.contains(id);
8192    if let Some(last) = h.last_ok.as_deref()
8193        && ok(last)
8194        && let Some(s) = roster.iter().find(|s| s.id == last)
8195    {
8196        return s.clone();
8197    }
8198    if ok(&spec.id) {
8199        return spec;
8200    }
8201    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8202    roster
8203        .get(from + 1..)
8204        .into_iter()
8205        .flatten()
8206        .chain(roster.iter())
8207        .find(|s| ok(&s.id))
8208        .cloned()
8209        .unwrap_or(spec)
8210}
8211
8212/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
8213/// seed so a CLI that mints its session id up front (`--session-id`) never
8214/// reuses the uuid the previous agent already opened under the same seat key.
8215pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8216    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8217}
8218
8219/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
8220/// so the classifier below and the code that builds the message cannot drift.
8221const TIMED_OUT: &str = "timed out";
8222
8223impl FailClass {
8224    /// `None` for an answer; otherwise how the turn failed.
8225    fn of(out: &AgentOutcome) -> Option<Self> {
8226        match out {
8227            AgentOutcome::Ok(_) => None,
8228            AgentOutcome::Quota(_) => Some(Self::Quota),
8229            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8230            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8231            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8232            // Not a failure of the agent. Callers check for it first; `None`
8233            // here means "no class to hand over on".
8234            AgentOutcome::ResourceBlocked(_) => None,
8235        }
8236    }
8237
8238    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
8239    fn stem_word(&self) -> &'static str {
8240        match self {
8241            Self::Quota => "quota",
8242            _ => "handover",
8243        }
8244    }
8245}
8246
8247/// The message's first line with its variable parts removed — digit runs and
8248/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
8249/// as one kind of failure.
8250fn failure_signature(msg: &str) -> String {
8251    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8252    let mut out = Vec::new();
8253    for word in line.split_whitespace() {
8254        if word.contains('/') || word.contains('\\') {
8255            out.push("<path>".to_owned());
8256            continue;
8257        }
8258        let mut w = String::new();
8259        let mut in_digits = false;
8260        for c in word.chars() {
8261            if c.is_ascii_digit() {
8262                if !in_digits {
8263                    w.push('#');
8264                }
8265                in_digits = true;
8266            } else {
8267                in_digits = false;
8268                w.push(c);
8269            }
8270        }
8271        out.push(w);
8272    }
8273    out.join(" ").chars().take(120).collect()
8274}
8275
8276/// Whether a seat that just failed with `cur` may go to the next roster agent.
8277/// A quota or a timeout always may. Any other failure may not when the agent
8278/// before it failed the same way: an error the prompt causes would otherwise
8279/// walk the whole roster. `prev` is the class of the immediately preceding
8280/// agent's failure, so a quota or timeout in between breaks the run of
8281/// identical failures by itself.
8282fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8283    match cur {
8284        FailClass::Quota | FailClass::Timeout => true,
8285        FailClass::Other(_) => prev != Some(cur),
8286    }
8287}
8288
8289/// A short human reason for a failed outcome, for the handover record.
8290fn fail_reason(out: &AgentOutcome) -> String {
8291    match out {
8292        AgentOutcome::Ok(_) => String::new(),
8293        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8294        AgentOutcome::Dropped(o) => format!(
8295            "the CLI dropped the stream ({})",
8296            o.dropped
8297                .as_ref()
8298                .map(|d| d.why.as_str())
8299                .unwrap_or("it ended without delivering its answer")
8300        ),
8301        AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8302            e.lines().next().unwrap_or("").chars().take(160).collect()
8303        }
8304    }
8305}
8306
8307/// Note one handover in the run: the structured record and, in the timeline,
8308/// the sentence a person reads. A quota keeps the wording it always had.
8309pub(crate) fn record_handover(
8310    state: &mut RunState,
8311    node: &str,
8312    seat: &str,
8313    from: &str,
8314    to: &str,
8315    class: &FailClass,
8316    reason: &str,
8317) {
8318    let message = if *class == FailClass::Quota {
8319        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8320    } else {
8321        format!("{seat}: handed over {from} -> {to} ({reason})")
8322    };
8323    state.event(node, message);
8324    state.handovers.push(Handover {
8325        at: Timestamp::now(),
8326        node: node.to_owned(),
8327        seat: seat.to_owned(),
8328        from: from.to_owned(),
8329        to: to.to_owned(),
8330        reason: reason.to_owned(),
8331    });
8332}
8333
8334/// Did this reply report running a command whose own CLI never confirmed an
8335/// exit status?
8336///
8337/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
8338/// command *finished* (see that type's own doc), so this can only be `true`
8339/// for a command whose completion event carried no readable exit code — not
8340/// for one that simply is not mentioned at all. That is the one signal this
8341/// crate can read, from the same record `state.jobs` renders, about a reply
8342/// standing next to work its own CLI cannot vouch for finishing; it is
8343/// deliberately not a check on the exit code's *value* (a fixer legitimately
8344/// runs a command that fails mid-iteration before it succeeds) and not a
8345/// guess at a command still running in the background (which emits no event
8346/// at all, and so leaves no evidence here to find).
8347fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8348    commands.iter().any(|c| c.exit_code.is_none())
8349}
8350
8351/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
8352/// trusted as a verified no-op — the adoption guard's own text-level half.
8353///
8354/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
8355/// timed out): a marker only earns the benefit of the doubt from a turn the
8356/// CLI itself vouches for finishing properly, the same house style
8357/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
8358/// *fix* report to for `commands`. A candidate that timed out, exited
8359/// non-zero, or left a command unconfirmed is read as the ordinary loss it
8360/// is, whatever prose it wrote — this returns `None` before it ever looks at
8361/// `text`. The remaining guards (the tree really is empty, the evidence is
8362/// non-empty) are the caller's: this only reads what the reply *claimed*.
8363fn verified_noop_claim(
8364    usable: bool,
8365    commands: &[agent::CommandEvidence],
8366    text: &str,
8367) -> Option<String> {
8368    (usable && !has_unconfirmed_command(commands))
8369        .then(|| verdict::verified_noop(text))
8370        .flatten()
8371}
8372
8373fn short(commit: &str) -> String {
8374    commit.chars().take(7).collect()
8375}
8376
8377fn make_executable(path: &Path) -> Result<()> {
8378    #[cfg(unix)]
8379    {
8380        use std::os::unix::fs::PermissionsExt as _;
8381        let mut perms = std::fs::metadata(path)?.permissions();
8382        perms.set_mode(0o755);
8383        std::fs::set_permissions(path, perms)?;
8384    }
8385    #[cfg(not(unix))]
8386    {
8387        let _ = path;
8388    }
8389    Ok(())
8390}
8391
8392/// What every seat in one batch shares: where the answers are attributed, the
8393/// prompt overlay they inherit, and the build cache they are told to use.
8394///
8395/// A struct rather than four more parameters: `wave` also needs the run's
8396/// state (to record who is answering right now) and the attempt number, and
8397/// eight positional arguments is both unreadable and a clippy error.
8398struct WaveCtx<'a> {
8399    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
8400    /// paid for it.
8401    run: &'a str,
8402    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
8403    node: &'a str,
8404    prompts: &'a Prompts,
8405    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
8406    cache: Option<&'a Path>,
8407    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
8408    /// `JobRecord::round`. `None` for every other node.
8409    round: Option<usize>,
8410    /// Carry each seat's failed-agent history across waves (the review loop
8411    /// only): start-of-round priority and handover choice read
8412    /// [`RunState::seat_history`], and every answer or failure writes it.
8413    carry_seats: bool,
8414    /// The run's park / interrupt requests, so a wait for a shared resource
8415    /// can be abandoned the moment the operator asks to park.
8416    pauses: Option<(&'a Pause, &'a Pause)>,
8417}
8418
8419/// Run one job, honouring the parallelism budget.
8420async fn run_one(
8421    job: SeatJob,
8422    sem: Arc<Semaphore>,
8423    ctx: &WaveCtx<'_>,
8424    state: &mut RunState,
8425    attempt: usize,
8426) -> (SeatState, AgentOutcome) {
8427    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8428        .await
8429        .pop()
8430        .expect("one job in, one result out");
8431    (seat, out)
8432}
8433
8434/// Run every job concurrently, capped by the semaphore, preserving order.
8435///
8436/// Every seat in the batch is recorded into [`RunState::active`] before the
8437/// wave starts and cleared as each answer lands, so the run's own record says
8438/// who is still being waited on rather than only who finished.
8439async fn wave(
8440    jobs: Vec<SeatJob>,
8441    sem: Arc<Semaphore>,
8442    ctx: &WaveCtx<'_>,
8443    state: &mut RunState,
8444    attempt: usize,
8445) -> Vec<(usize, SeatState, AgentOutcome)> {
8446    let WaveCtx {
8447        run,
8448        node,
8449        prompts,
8450        cache,
8451        round,
8452        carry_seats: _,
8453        pauses,
8454    } = *ctx;
8455    for job in &jobs {
8456        state.seat_started(node, &job.seat.key, job.timeout, attempt);
8457    }
8458    if let Err(e) = state.save() {
8459        // A failed persist of "who is answering right now" must not abort the
8460        // wave: the seats are already being asked, and the alternative is
8461        // losing the answers to save a status line nobody may even be
8462        // watching.
8463        tracing::warn!("could not persist in-progress seats: {e:#}");
8464    }
8465    // Hold the shared build cache's lease for the whole batch, not per job:
8466    // several candidates (an implement wave) or a fixer legitimately share
8467    // one cache concurrently within this run, and that stays untouched — a
8468    // single lease taken once for the whole wave and released once it is
8469    // done is what stops a *different* borrower (another run's own wave, its
8470    // e2e/gate, a human's `magi review`) from interleaving a build into the
8471    // same directory while this one is in flight. Best-effort, not
8472    // all-or-nothing: a wave that cannot get the lease within its own
8473    // longest job's budget still runs — an hour of paid implementer calls is
8474    // not thrown away over cache contention — but every write-allowed seat
8475    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
8476    // below), the same fallback a read-only seat always gets, rather than
8477    // building into a directory this run was never granted. The identity
8478    // record is still invalidated below either way, so the next tracked
8479    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
8480    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8481    // The wait for the lease has its own bound, `timeout_resource_wait`; it is
8482    // never borrowed from a job's agent budget (and never charged to it either,
8483    // see below). The agent's clock starts in `agent::invoke`, at spawn.
8484    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8485    let cache_guard = if let Some(cache_dir) = cache {
8486        if jobs_had_a_writer {
8487            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8488            let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8489            match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8490            {
8491                Ok(Some(g)) => Some(g),
8492                // Parking: leave without spawning anything. The run parks at
8493                // the next boundary and the seats are asked afresh on resume.
8494                Ok(None) => {
8495                    state.resource_block = Some(crate::run::ResourceBlock {
8496                        node: node.to_owned(),
8497                        kind: "shared build cache".to_owned(),
8498                        detail: "the run is parking".to_owned(),
8499                        at: Timestamp::now(),
8500                    });
8501                    return block_wave(
8502                        jobs,
8503                        state,
8504                        node,
8505                        attempt,
8506                        "parked while waiting for the shared build cache",
8507                    );
8508                }
8509                // An unreadable lease is not contention: keep the old
8510                // fallback (no shared cache for this wave's seats).
8511                Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8512                Err(e) => {
8513                    // Writing seats may not build into a cache this run does
8514                    // not hold, and an agent that cannot be given one is not
8515                    // asked at all: the run stops resumably instead.
8516                    let why = format!("{e:#}");
8517                    state.resource_block = Some(crate::run::ResourceBlock {
8518                        node: node.to_owned(),
8519                        kind: "shared build cache".to_owned(),
8520                        detail: why.clone(),
8521                        at: Timestamp::now(),
8522                    });
8523                    state.event(
8524                        "resource",
8525                        format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8526                    );
8527                    return block_wave(jobs, state, node, attempt, &why);
8528                }
8529            }
8530        } else {
8531            None
8532        }
8533    } else {
8534        None
8535    };
8536    // Every job keeps its whole budget: the wait above was not the agent's.
8537    // Restart the seats' clocks so "started" means "being spawned now".
8538    state.seats_spawning(node, attempt);
8539    if let Err(e) = state.save() {
8540        tracing::warn!("could not persist the start of a wave: {e:#}");
8541    }
8542    // Queueing behind this wave's own seats for a parallelism slot is normal
8543    // (each of them is bounded by its own agent timeout); only a wait beyond
8544    // all of them plus the resource limit means the slots never came back.
8545    let slot_limit = resource_limit
8546        + jobs
8547            .iter()
8548            .map(|j| j.timeout)
8549            .max()
8550            .unwrap_or_default()
8551            .saturating_mul(jobs.len() as u32);
8552    let mut set = tokio::task::JoinSet::new();
8553    // Seats announce themselves here the moment they hold a parallelism
8554    // permit, i.e. just before their CLI is spawned.
8555    let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8556    let overlay = prompts.overlay(node);
8557    for (i, mut job) in jobs.into_iter().enumerate() {
8558        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8559        if cache.is_some() {
8560            job.prompt.push('\n');
8561            job.prompt
8562                .push_str(&prompt::build_cache_note(node, job.allow_write));
8563        }
8564        let sem = Arc::clone(&sem);
8565        let run = run.to_owned();
8566        let node = node.to_owned();
8567        // Only implementers were told about the task's attachments, so only
8568        // their seats get the directory widened for reading.
8569        let attachments = if node == "implement" {
8570            state.attachments.clone()
8571        } else {
8572            Vec::new()
8573        };
8574        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
8575        // `prompt::build_cache_note`'s doc for why setting it anyway is
8576        // exactly how a sandboxed reviewer's write refusal got reported as a
8577        // defect in the patch, not a property of its own seat. And a
8578        // write-allowed one is handed it only when the lease above was
8579        // actually acquired: a wave that could not get it (`cache_guard` is
8580        // `None`, see its own comment) must not send seats to build into a
8581        // directory this run does not hold - that is the exact concurrent,
8582        // unmanaged-write race this module exists to prevent, not something
8583        // "proceeding anyway" is allowed to reintroduce.
8584        let cache = cache
8585            .filter(|_| job.allow_write && cache_guard.is_some())
8586            .map(Path::to_path_buf);
8587        let spawned_tx = spawned_tx.clone();
8588        set.spawn(async move {
8589            // A wait for a parallelism slot is scheduling, not the agent: it
8590            // is bounded by its own limit and ends as a resource block, never
8591            // as a timeout of the agent that was not started.
8592            let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
8593                Ok(p) => p,
8594                Err(_) => {
8595                    return (
8596                        i,
8597                        job.seat,
8598                        AgentOutcome::ResourceBlocked(format!(
8599                            "no parallel slot became free within {}s",
8600                            slot_limit.as_secs()
8601                        )),
8602                    );
8603                }
8604            };
8605            spawned_tx.send(job.seat.key.clone()).ok();
8606            let mut seat = job.seat;
8607            let out = agent::invoke(
8608                &job.spec,
8609                &mut seat,
8610                &Invocation {
8611                    cwd: &job.cwd,
8612                    prompt: &job.prompt,
8613                    timeout: job.timeout,
8614                    allow_write: job.allow_write,
8615                    unsandboxed: false,
8616                    sessions: job.sessions,
8617                    artifacts: &job.artifacts,
8618                    stem: &job.stem,
8619                    run: &run,
8620                    node: &node,
8621                    cache_dir: cache.as_deref(),
8622                    attachments: &attachments,
8623                    writable: &[],
8624                },
8625            )
8626            .await;
8627            let out = match out {
8628                Ok(o) if o.usable() => AgentOutcome::Ok(o),
8629                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
8630                // Billed work the CLI failed to hand over is not an ordinary
8631                // failure, but its text is the CLI's raw error JSON, not an
8632                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
8633                // read it as one by forgetting to check. `usable()` is always
8634                // false here (dropped implies an empty response), so this has
8635                // to be checked before the catch-all `Failed` below or the
8636                // one shape this exists for is lost with the rest.
8637                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
8638                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
8639                Ok(o) => AgentOutcome::Failed(format!(
8640                    "exited with {:?} and no usable output",
8641                    o.exit_code
8642                )),
8643                Err(e) => AgentOutcome::Failed(e.to_string()),
8644            };
8645            (i, seat, out)
8646        });
8647    }
8648    drop(spawned_tx);
8649    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
8650    loop {
8651        let joined = tokio::select! {
8652            Some(key) = spawned_rx.recv() => {
8653                // The clock of this seat starts now, not when it queued.
8654                state.seat_spawned(&key);
8655                if let Err(e) = state.save() {
8656                    tracing::warn!("could not persist a seat's start: {e:#}");
8657                }
8658                continue;
8659            }
8660            joined = set.join_next() => joined,
8661        };
8662        let Some(joined) = joined else {
8663            break;
8664        };
8665        let (i, seat, out) = match joined {
8666            Ok(v) => v,
8667            // No seat to clear: a panicked task never reported which one it
8668            // was. The defensive sweep below this loop is what stops that
8669            // seat's `active` entry from surviving forever.
8670            Err(e) => {
8671                tracing::error!("agent task panicked: {e}");
8672                continue;
8673            }
8674        };
8675        state.seat_finished(&seat.key);
8676        if let AgentOutcome::ResourceBlocked(why) = &out
8677            && state.resource_block.is_none()
8678        {
8679            state.resource_block = Some(crate::run::ResourceBlock {
8680                node: node.to_owned(),
8681                kind: "parallel slot".to_owned(),
8682                detail: why.clone(),
8683                at: Timestamp::now(),
8684            });
8685            state.event("resource", format!("{node}: no agent was started - {why}"));
8686        }
8687        record_jobs(state, node, round, &seat.key, &out);
8688        if let Err(e) = state.save() {
8689            tracing::warn!("could not persist a seat's completion: {e:#}");
8690        }
8691        if collected.len() <= i {
8692            collected.resize_with(i + 1, || None);
8693        }
8694        collected[i] = Some((i, seat, out));
8695    }
8696    // Belt-and-braces for the panic branch above: every seat this exact batch
8697    // started shares this `(node, attempt)` pair, and every seat that finished
8698    // normally already cleared itself, so anything left tagged with it here
8699    // can only be a panicked task's leftover. Cleared unconditionally rather
8700    // than left to read as still answering forever.
8701    if state
8702        .active
8703        .values()
8704        .any(|a| a.node == node && a.attempt == attempt)
8705    {
8706        state
8707            .active
8708            .retain(|_, a| !(a.node == node && a.attempt == attempt));
8709        if let Err(e) = state.save() {
8710            tracing::warn!("could not persist the end of a wave: {e:#}");
8711        }
8712    }
8713    // Whether or not the lease above was actually held, several worktrees
8714    // may just have built into the cache with nothing here able to name one
8715    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
8716    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
8717    // clean it might not have strictly needed; trusting a stale match would
8718    // cost it a wrong answer.
8719    if let Some(cache_dir) = cache
8720        && jobs_had_a_writer
8721    {
8722        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
8723    }
8724    if let Some(guard) = cache_guard {
8725        guard.release();
8726    }
8727    collected.into_iter().flatten().collect()
8728}
8729
8730/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
8731/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
8732/// this is data collection, not the fix-specific completion contract in
8733/// [`Runner::continue_fix_report`], and applies regardless of which node
8734/// asked.
8735///
8736/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
8737/// evidence from; `Failed` does not, and correctly contributes nothing — a
8738/// timeout or crash is not itself evidence about a command the seat may have
8739/// started.
8740fn record_jobs(
8741    state: &mut RunState,
8742    node: &str,
8743    round: Option<usize>,
8744    seat: &str,
8745    out: &AgentOutcome,
8746) {
8747    let commands: &[agent::CommandEvidence] = match out {
8748        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
8749        AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
8750    };
8751    let checked_at = Timestamp::now();
8752    for c in commands {
8753        state.jobs.push(JobRecord {
8754            node: node.to_owned(),
8755            round,
8756            seat: seat.to_owned(),
8757            id: c.id.clone(),
8758            description: c.description.clone(),
8759            checked_at,
8760            status: match c.exit_code {
8761                Some(0) => JobStatus::Completed,
8762                Some(_) => JobStatus::Failed,
8763                None => JobStatus::Unknown,
8764            },
8765            exit_code: c.exit_code,
8766            result_summary: c.result_summary.clone(),
8767            source: c.source.clone(),
8768        });
8769    }
8770}
8771
8772/// Is a review round clean, given how many reviewer seats answered against
8773/// how many the round expected?
8774///
8775/// A seat that never answered (timeout, crash, unparsable output) is not a
8776/// seat that read the patch and found nothing — treating it as such is
8777/// exactly the bug this function exists to close. Under the default `block`
8778/// policy a missing seat can never be clean; `warn` still requires the seats
8779/// that *did* answer to have found nothing blocking and verification to be
8780/// green.
8781///
8782/// `quota_missing` narrows that `block` default for exactly one cause of
8783/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
8784/// a session limit lifts by the very next round buys nothing — the seat is
8785/// asked again with the same quota — so once every missing seat is accounted
8786/// for by a quota loss (and at least one seat *did* answer, so a decision has
8787/// something to rest on) the round is decided on the panel that could answer,
8788/// same as `warn` would. A panel that lost every seat to quota is not
8789/// decided here: `answered == 0` falls through to the existing `block`
8790/// fallback so a fully collapsed panel still waits rather than landing on no
8791/// review at all.
8792fn round_is_clean(
8793    blocking: usize,
8794    e2e_ok: bool,
8795    answered: usize,
8796    expected: usize,
8797    quota_missing: usize,
8798    policy: IncompleteReviewPolicy,
8799) -> bool {
8800    if blocking != 0 || !e2e_ok {
8801        return false;
8802    }
8803    if answered == expected || policy == IncompleteReviewPolicy::Warn {
8804        return true;
8805    }
8806    answered > 0 && expected - answered <= quota_missing
8807}
8808
8809/// The review loop's own conclusion, derived entirely from its persisted
8810/// round records and the round budget that produced them — never from
8811/// `status`, so a reentry (or `gate`/`merge` reading it independently)
8812/// recomputes the identical answer regardless of what an earlier node in the
8813/// same walk, or a previous walk, did to `status`.
8814///
8815/// `None` while more rounds remain to try, including when review never ran
8816/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
8817/// gone clean, or the budget is spent, or the tree has stopped moving (see
8818/// [`STAGNANT_LIMIT`]), the answer is one of two things:
8819///
8820/// - An incomplete panel that raised nothing is missing input, not a
8821///   verified tree — never a hand-off candidate, whatever verification said
8822///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
8823/// - Otherwise, green e2e on the last round hands off (see
8824///   [`Runner::stop_reviewing`]); red e2e blocks.
8825///
8826/// A last round whose own verification is still `ResourceBlocked` — magi
8827/// itself never got a command to run, not evidence the patch is broken —
8828/// is neither: this returns `None` for it too, the same as "more rounds
8829/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
8830/// handling of that shape) instead of this cheap recomputation guessing a
8831/// verdict a real attempt never produced.
8832fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8833    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8834        return Some(RunStatus::Gating);
8835    }
8836    let last = reviews.last()?;
8837    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8838    if reviews.len() < max_rounds && !stagnant {
8839        return None;
8840    }
8841    if last.incomplete() && last.blocking == 0 {
8842        return Some(RunStatus::Blocked);
8843    }
8844    if last.e2e_status() == E2eStatus::ResourceBlocked {
8845        return None;
8846    }
8847    Some(if last.e2e.iter().all(CommandOutcome::ok) {
8848        RunStatus::Gating
8849    } else {
8850        RunStatus::Blocked
8851    })
8852}
8853
8854/// How long a re-ask may take, given the budget the first attempt had.
8855///
8856/// A `nudged` retry is a request to restate an answer the seat has already
8857/// worked out: it carries no new work, so it does not deserve the original
8858/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
8859/// 133 seconds while a third sat for over ten minutes on a resumed session
8860/// holding 410 KB of prior output - and because the retry had inherited the
8861/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
8862/// judging round whose other seats were long finished.
8863///
8864/// A quarter of the budget, with a floor so that a deliberately short timeout
8865/// does not collapse to nothing. A retry that re-sends the whole prompt
8866/// (because the seat kept no context) is the original job again, and keeps the
8867/// original budget.
8868pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8869    if nudged {
8870        (full / 4).max(Duration::from_secs(120)).min(full)
8871    } else {
8872        full
8873    }
8874}
8875
8876/// Run a wave and parse each reply, re-asking the seats whose reply was
8877/// unusable.
8878///
8879/// The re-ask is a nudge rather than the whole prompt again when the seat still
8880/// holds its conversation, which is the difference between a cheap retry and
8881/// paying for the entire candidate set twice.
8882///
8883/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
8884/// successor in `roster` is handed to it instead of being re-asked: the
8885/// handover is the retry. A seat with no successor left (a single-agent
8886/// roster, the roster's tail) is nudged as before, up to `retries` times. So
8887/// the asks to one seat in one node number at most
8888/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
8889/// is asked once (a dropped stream is nudged first), and only the last agent
8890/// of the chain gets the `retries` same-agent nudges. Each roster agent is
8891/// tried at most once per seat, walking forward from the seat's own position and never wrapping
8892/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
8893/// other failure stops the chain when the previous agent failed the same way
8894/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
8895/// [`has_context`] is false and the job's own full prompt and full budget are
8896/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
8897/// intermediate ones are not losses) and is returned as a failure like any
8898/// other absent seat — the caller decides whether the panel still has a
8899/// quorum. An empty `roster` disables handover: failures are nudged as they
8900/// always were, and a quota is simply lost. A reply that fails to parse or
8901/// validate is the prompt's doing and is only ever nudged, never handed over.
8902///
8903/// The returned [`SeatState`] names the agent that answered (or tried last).
8904#[allow(clippy::too_many_arguments)]
8905async fn ask_json_wave<T>(
8906    jobs: Vec<SeatJob>,
8907    sem: Arc<Semaphore>,
8908    retries: usize,
8909    roster: &[AgentSpec],
8910    ctx: &WaveCtx<'_>,
8911    losses: &mut Vec<QuotaLoss>,
8912    state: &mut RunState,
8913    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8914) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8915where
8916    T: serde::de::DeserializeOwned + Send + 'static,
8917{
8918    ask_wave_with(
8919        jobs,
8920        sem,
8921        retries,
8922        roster,
8923        ctx,
8924        losses,
8925        state,
8926        &|text: &str| {
8927            let v = verdict::extract_json::<T>(text)?;
8928            validate(&v)?;
8929            Ok(v)
8930        },
8931    )
8932    .await
8933}
8934
8935/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
8936/// a node whose answer is prose (deliberation) shares the same handover,
8937/// failure classification, quota bookkeeping and bounds instead of a copy.
8938///
8939/// A seat handed to another roster agent is sent the job's `handover` prompt
8940/// (when it has one) rather than `prompt`: the new agent has no session, so a
8941/// resume-style prompt would be incomplete. That holds for the handover ask
8942/// and for every nudge to that agent whose `has_context` is false.
8943#[allow(clippy::too_many_arguments)]
8944async fn ask_wave_with<T>(
8945    jobs: Vec<SeatJob>,
8946    sem: Arc<Semaphore>,
8947    retries: usize,
8948    roster: &[AgentSpec],
8949    ctx: &WaveCtx<'_>,
8950    losses: &mut Vec<QuotaLoss>,
8951    state: &mut RunState,
8952    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8953) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8954where
8955    T: Send + 'static,
8956{
8957    let n = jobs.len();
8958    let originals: Vec<SeatJob> = jobs;
8959    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8960    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8961    // Nudges each seat's *current* agent has taken — 0 for a first-ask
8962    // answer, N once it has gone through N nudges. Read back once this
8963    // returns, so a caller building a history record (`ReviewRecord`) can
8964    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
8965    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
8966    // field's own doc.
8967    let mut nudges: Vec<usize> = vec![0; n];
8968    // The agent now occupying each seat, the ids it has already been through,
8969    // where in the roster the walk began, the class of the last failure, and
8970    // the stem word of a handover not yet asked (full prompt, full budget).
8971    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8972    let mut tried: Vec<BTreeSet<String>> = specs
8973        .iter()
8974        .map(|s| BTreeSet::from([s.id.clone()]))
8975        .collect();
8976    let starts: Vec<usize> = specs
8977        .iter()
8978        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8979        .collect();
8980    let carry = ctx.carry_seats && !roster.is_empty();
8981    // Carried across rounds: ids that failed the seat earlier, and how the
8982    // last failure went (so a repeat of it is not handed over again).
8983    let carried: Vec<BTreeSet<String>> = originals
8984        .iter()
8985        .map(|j| {
8986            state
8987                .seat_history
8988                .get(&j.seat.key)
8989                .filter(|_| carry)
8990                .map(|h| h.failed.clone())
8991                .unwrap_or_default()
8992        })
8993        .collect();
8994    let mut prev: Vec<Option<FailClass>> = originals
8995        .iter()
8996        .map(|j| {
8997            state
8998                .seat_history
8999                .get(&j.seat.key)
9000                .filter(|_| carry)
9001                .and_then(|h| h.last_fail.clone())
9002        })
9003        .collect();
9004    let next_agent =
9005        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9006            let others: BTreeSet<String> = specs
9007                .iter()
9008                .enumerate()
9009                .filter(|(j, _)| *j != i)
9010                .map(|(_, s)| s.id.clone())
9011                .collect();
9012            pick_successor(
9013                roster,
9014                starts[i],
9015                tried,
9016                carry.then(|| &carried[i]),
9017                &others,
9018            )
9019            .cloned()
9020        };
9021    let mut fresh: Vec<Option<String>> = vec![None; n];
9022    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9023    let mut pending: Vec<usize> = (0..n).collect();
9024
9025    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
9026    // asks: an agent with a successor is asked once and handed over, and only
9027    // a seat with no successor spends `retries` nudges on the same agent. This
9028    // only guarantees the loop's own termination whatever those say.
9029    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9030    for round in 0..max_rounds {
9031        if pending.is_empty() {
9032            break;
9033        }
9034        let mut batch = Vec::with_capacity(pending.len());
9035        let mut renudged: Vec<&str> = Vec::new();
9036        for &i in &pending {
9037            let src = &originals[i];
9038            // A seat now held by another agent than the job named has no
9039            // session of its own: it gets the full-context prompt whenever
9040            // it is asked in full (the handover ask, a nudge it cannot
9041            // resume).
9042            let full: &str = match &src.handover {
9043                Some(h) if specs[i].id != src.spec.id => h,
9044                _ => &src.prompt,
9045            };
9046            // The prompt and the budget are one decision: a nudge restates
9047            // finished work, a re-sent prompt redoes it.
9048            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9049                (
9050                    full.to_owned(),
9051                    src.timeout,
9052                    format!("{}-{word}-{}", src.stem, specs[i].id),
9053                )
9054            } else if nudges[i] == 0 {
9055                (full.to_owned(), src.timeout, src.stem.clone())
9056            } else {
9057                renudged.push(src.seat.key.as_str());
9058                let why = done[i]
9059                    .as_ref()
9060                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
9061                    .unwrap_or_else(|| "no parsable answer".to_owned());
9062                let nudge = prompt::nudge(&why);
9063                let nudged = has_context(&specs[i], &seats[i], src.sessions);
9064                let prompt = if nudged {
9065                    nudge
9066                } else {
9067                    format!("{full}\n\n---\n\n{nudge}")
9068                };
9069                (
9070                    prompt,
9071                    retry_budget(src.timeout, nudged),
9072                    format!("{}-retry{}", src.stem, nudges[i]),
9073                )
9074            };
9075            batch.push(SeatJob {
9076                spec: specs[i].clone(),
9077                seat: seats[i].clone(),
9078                cwd: src.cwd.clone(),
9079                prompt,
9080                timeout,
9081                allow_write: src.allow_write,
9082                sessions: src.sessions,
9083                artifacts: src.artifacts.clone(),
9084                stem,
9085                handover: None,
9086            });
9087        }
9088
9089        if !renudged.is_empty() {
9090            state.event(
9091                ctx.node,
9092                format!("retry {round}: re-asking {}", renudged.join(", ")),
9093            );
9094        }
9095        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9096        let mut still = Vec::new();
9097        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9098            seats[i] = seat;
9099            // No agent was asked: nothing to hand over, nudge, remember or
9100            // charge. The caller sees `resource_block` and stops the node.
9101            if let AgentOutcome::ResourceBlocked(why) = &out {
9102                done[i] = Some(Err(anyhow::anyhow!("{why}")));
9103                continue;
9104            }
9105            let class = FailClass::of(&out);
9106            // A dropped stream is nudged first (the conversation is still
9107            // there to pick up); only a seat whose nudges are spent hands over.
9108            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9109                && nudges[i] < retries
9110                && !roster.is_empty();
9111            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9112                let next = should_hand_over(prev[i].as_ref(), &cur)
9113                    .then(|| next_agent(i, &tried[i], &specs))
9114                    .flatten();
9115                if carry {
9116                    let h = state
9117                        .seat_history
9118                        .entry(originals[i].seat.key.clone())
9119                        .or_default();
9120                    h.failed.insert(specs[i].id.clone());
9121                    h.last_fail = Some(cur.clone());
9122                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9123                        h.last_ok = None;
9124                    }
9125                    // Saved before the next agent is asked, so a restart in
9126                    // between does not forget who failed.
9127                    if let Err(e) = state.save() {
9128                        tracing::warn!("could not persist a seat's failure history: {e:#}");
9129                    }
9130                }
9131                if let Some(next) = next {
9132                    record_handover(
9133                        state,
9134                        ctx.node,
9135                        &originals[i].seat.key,
9136                        &specs[i].id,
9137                        &next.id,
9138                        &cur,
9139                        &fail_reason(&out),
9140                    );
9141                    tried[i].insert(next.id.clone());
9142                    prev[i] = Some(cur.clone());
9143                    seats[i] =
9144                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9145                    specs[i] = next;
9146                    fresh[i] = Some(cur.stem_word().to_owned());
9147                    nudges[i] = 0;
9148                    done[i] = Some(Err(anyhow::anyhow!(
9149                        "handed over after: {}",
9150                        fail_reason(&out)
9151                    )));
9152                    still.push(i);
9153                    continue;
9154                }
9155            }
9156            if carry
9157                && !nudge_first
9158                && let Some(cur) = class.clone()
9159            {
9160                // The chain ended here (no successor, or a repeated failure).
9161                let h = state
9162                    .seat_history
9163                    .entry(originals[i].seat.key.clone())
9164                    .or_default();
9165                h.failed.insert(specs[i].id.clone());
9166                h.last_fail = Some(cur);
9167            }
9168            let parsed = match out {
9169                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9170                AgentOutcome::Quota(o) => {
9171                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9172                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9173                }
9174                // Not a parseable answer: the nudge loop re-asks it, which is
9175                // exactly what a dropped stream needs. Just don't hand its raw
9176                // error JSON to `extract_json`.
9177                AgentOutcome::Dropped(o) => {
9178                    let why = o
9179                        .dropped
9180                        .as_ref()
9181                        .map(|d| d.why.as_str())
9182                        .unwrap_or("the CLI ended the stream without delivering its answer");
9183                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9184                }
9185                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9186                    Err(anyhow::anyhow!(e))
9187                }
9188            };
9189            let quota = class == Some(FailClass::Quota);
9190            let failed = parsed.is_err();
9191            done[i] = Some(parsed);
9192            if carry && !failed {
9193                let h = state
9194                    .seat_history
9195                    .entry(originals[i].seat.key.clone())
9196                    .or_default();
9197                h.failed.remove(&specs[i].id);
9198                h.last_ok = Some(specs[i].id.clone());
9199                h.last_fail = None;
9200            }
9201            // Do not re-ask a rate-limited seat (quota) — a retry is known to
9202            // fail the same way; and never re-ask a seat that already parsed.
9203            // A failed agent that still has a successor is not re-asked
9204            // either: the handover was its remedy and has just been refused
9205            // (the chain stops on a repeated failure class). A seat with no
9206            // successor left (a single-agent roster, the roster's tail, or an
9207            // empty roster) keeps the same-agent nudge, bounded by `retries`.
9208            let agent_failure = class.is_some()
9209                && !nudge_first
9210                && !roster.is_empty()
9211                && next_agent(i, &tried[i], &specs).is_some();
9212            if failed && !quota && !agent_failure && nudges[i] < retries {
9213                nudges[i] += 1;
9214                still.push(i);
9215            }
9216        }
9217        pending = still;
9218    }
9219
9220    // One loss per seat whose chain ended on a quota: the intermediate ones
9221    // were absorbed by a handover and are not losses.
9222    for (i, q) in last_quota.into_iter().enumerate() {
9223        if let Some(reset) = q {
9224            losses.push(QuotaLoss {
9225                seat: originals[i].seat.key.clone(),
9226                node: ctx.node.to_owned(),
9227                at: Timestamp::now(),
9228                reset,
9229            });
9230        }
9231    }
9232
9233    seats
9234        .into_iter()
9235        .zip(done)
9236        .zip(nudges)
9237        .map(|((seat, res), attempts)| {
9238            (
9239                seat,
9240                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9241                attempts,
9242            )
9243        })
9244        .collect()
9245}
9246
9247/// Did the wave end without asking any agent (see [`block_wave`])?
9248fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9249    results
9250        .iter()
9251        .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9252}
9253
9254/// Settle a wave that never spawned anything: every job answers
9255/// [`AgentOutcome::ResourceBlocked`], its seat leaves `active`, and nothing
9256/// else about the seat (session, history, quota) is touched.
9257fn block_wave(
9258    jobs: Vec<SeatJob>,
9259    state: &mut RunState,
9260    node: &str,
9261    attempt: usize,
9262    why: &str,
9263) -> Vec<(usize, SeatState, AgentOutcome)> {
9264    state.resource_wait = None;
9265    state
9266        .active
9267        .retain(|_, a| !(a.node == node && a.attempt == attempt));
9268    if let Err(e) = state.save() {
9269        tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9270    }
9271    jobs.into_iter()
9272        .enumerate()
9273        .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9274        .collect()
9275}
9276
9277/// Acquire the shared build cache's lease, waiting out contention within
9278/// `budget` (never past it — see AGENTS.md's build-cache section on why an
9279/// unbounded wait is never acceptable).
9280///
9281/// A first, non-blocking check happens before ever waiting; if it finds the
9282/// lease busy, that fact is logged as a `verify` event *and* flushed with
9283/// [`RunState::save`] immediately — not only once the wait finally succeeds
9284/// or gives up — so a `magi show` run by a different process while this one
9285/// is still waiting reads a `run.json` that says so, rather than whatever it
9286/// looked like before the wait started. The same applies to the terminal
9287/// failure: logged and saved before this returns `Err`, so a caller that
9288/// could not get the lease at all still leaves a legible record of why.
9289async fn acquire_cache_lease(
9290    state: &mut RunState,
9291    cache_dir: &Path,
9292    owner: &crate::cache::Owner,
9293    budget: Duration,
9294    context: &str,
9295    cancel: &(dyn Fn() -> bool + Sync),
9296) -> Result<Option<crate::cache::Guard>> {
9297    let home = crate::run::home();
9298    let started = Instant::now();
9299    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9300        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9301        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9302        Err(e) => {
9303            state.event(
9304                "verify",
9305                format!("{context}: could not check the shared build cache: {e:#}"),
9306            );
9307            if let Err(e2) = state.save() {
9308                tracing::warn!("could not persist a cache-check failure: {e2:#}");
9309            }
9310            return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9311        }
9312    };
9313    state.event(
9314        "verify",
9315        format!(
9316            "{context}: waiting for the shared build cache at {} ({})",
9317            cache_dir.display(),
9318            busy.describe()
9319        ),
9320    );
9321    // Seats of this node are waiting, not running: say so, so the countdown a
9322    // reader sees only ever measures an agent that has been spawned.
9323    state.resource_wait = Some(crate::run::ResourceWait {
9324        node: context.to_owned(),
9325        kind: "shared build cache".to_owned(),
9326        holder: busy.describe(),
9327        since: Timestamp::now(),
9328        limit_secs: budget.as_secs(),
9329    });
9330    if let Err(e) = state.save() {
9331        tracing::warn!("could not persist a cache wait: {e:#}");
9332    }
9333    let remaining = budget.saturating_sub(started.elapsed());
9334    let waited = crate::cache::wait_for_unless(
9335        &home,
9336        cache_dir,
9337        owner,
9338        remaining,
9339        Duration::from_secs(5),
9340        cancel,
9341    )
9342    .await;
9343    state.resource_wait = None;
9344    match waited {
9345        Ok(g) => Ok(g),
9346        Err(e) => {
9347            state.event("verify", format!("{context}: {e:#}"));
9348            if let Err(e2) = state.save() {
9349                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9350            }
9351            Err(e)
9352        }
9353    }
9354}
9355
9356/// The lease state could not be read at all (unreadable lease file, a
9357/// permission problem). Unlike a busy lease that outlasts its bound, nothing is
9358/// known to be contended, so a wave degrades to running without a shared cache
9359/// instead of stopping on a hold that [`crate::triage`] could never release.
9360#[derive(Debug)]
9361struct CacheCheckError(String);
9362
9363impl std::fmt::Display for CacheCheckError {
9364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9365        f.write_str(&self.0)
9366    }
9367}
9368
9369impl std::error::Error for CacheCheckError {}
9370
9371/// Run `body` — a verify command batch — while holding the shared build
9372/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
9373/// never interleave with another borrower's build against the same
9374/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
9375/// timeout, or a human's own `magi review`. See the `cache` module doc for
9376/// why this matters more than Cargo's own per-target locking covers — two
9377/// *different* worktrees building the same package name/version into one
9378/// cache directory is a staleness bug, not a lock contention one.
9379///
9380/// The wait for the lease is carved out of `budget`, never on top of it —
9381/// `body` is handed whatever is left, so a caller's own node timeout is the
9382/// only clock involved, exactly what AGENTS.md's build-cache section asks
9383/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
9384/// cache configured at all — this is a pass-through: `body` runs with the
9385/// full budget and nothing is leased.
9386///
9387/// A lease that cannot be acquired within `budget` is reported as a single
9388/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
9389/// verification — the same shape a spawn failure already takes in
9390/// [`run_commands`], so a caller need not special-case it.
9391#[allow(clippy::too_many_arguments)]
9392async fn with_cache_lease<'s, F, Fut>(
9393    state: &'s mut RunState,
9394    cache_dir: Option<&Path>,
9395    node: &str,
9396    seat: &str,
9397    worktree: &Path,
9398    head: &str,
9399    budget: Duration,
9400    context: &str,
9401    body: F,
9402) -> (Vec<CommandOutcome>, bool)
9403where
9404    F: FnOnce(&'s mut RunState, Duration) -> Fut,
9405    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9406{
9407    let Some(cache_dir) = cache_dir else {
9408        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9409        return (outcomes, retried);
9410    };
9411    let home = crate::run::home();
9412    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9413    let started = Instant::now();
9414    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9415        .await
9416        .and_then(|g| {
9417            g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9418        }) {
9419        Ok(g) => g,
9420        Err(e) => {
9421            return (
9422                vec![CommandOutcome {
9423                    command: "(waiting for the shared build cache)".to_owned(),
9424                    code: None,
9425                    output_tail: e.to_string(),
9426                    duration_ms: started.elapsed().as_millis() as u64,
9427                    resource_blocked: true,
9428                }],
9429                false,
9430            );
9431        }
9432    };
9433    let identity = crate::cache::Identity::new(worktree, head);
9434    if let Err(e) = crate::cache::ensure_fresh(
9435        &home,
9436        cache_dir,
9437        &identity,
9438        &state.config.verify.cargo_manifests(),
9439    ) {
9440        // A failed freshness check means this process cannot vouch for what
9441        // is sitting in the cache right now - on Windows this is exactly the
9442        // "a stale test executable is still locked, `cargo clean -p` cannot
9443        // remove it" case the evidence log records. Running verify anyway
9444        // and reporting whatever it says would let a result nobody can trust
9445        // stand for the tree it claims to have checked; fail the step
9446        // instead of the patch.
9447        state.event(
9448            "verify",
9449            format!(
9450                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9451                worktree.display(),
9452                short(head)
9453            ),
9454        );
9455        guard.release();
9456        return (
9457            vec![CommandOutcome {
9458                command: "(confirming the shared build cache is fresh)".to_owned(),
9459                code: None,
9460                output_tail: e.to_string(),
9461                duration_ms: started.elapsed().as_millis() as u64,
9462                resource_blocked: true,
9463            }],
9464            false,
9465        );
9466    }
9467    let remaining = budget.saturating_sub(started.elapsed());
9468    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9469    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
9470    // `start_kill`); confirm it actually has before handing the directory to
9471    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
9472    // for what this can and cannot see.
9473    if !timed_out_pids.is_empty() {
9474        wait_for_timed_out_children_to_die(&timed_out_pids).await;
9475    }
9476    guard.release();
9477    (outcomes, retried)
9478}
9479
9480/// Poll `pids` — commands [`run_commands`] reports as still running when its
9481/// own timeout elapsed — until every one is confirmed gone, or
9482/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
9483///
9484/// Real confirmation where confirmation is possible, not a substitute for
9485/// full process-tree observation: a grandchild the timed-out process spawned
9486/// and that survives independently of it is invisible to a pid check the
9487/// same way it always was, and continuing to observe and collect *that*
9488/// stays a different piece of work with its own owner. This only narrows a
9489/// fixed blind wait into an actual check of the pids this process does know
9490/// about.
9491async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9492    wait_for_pids_with(
9493        pids,
9494        crate::proc::pid_alive,
9495        LEASE_RELEASE_POLL,
9496        LEASE_RELEASE_MAX_WAIT,
9497    )
9498    .await;
9499}
9500
9501/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
9502/// interval and ceiling supplied by the caller, so the polling *logic* -
9503/// returns as soon as every pid reports dead, gives up at the ceiling
9504/// otherwise - is testable on millisecond durations without asking the real
9505/// OS about a pid at all.
9506async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9507    pids: &[u32],
9508    alive: F,
9509    poll: Duration,
9510    max_wait: Duration,
9511) {
9512    let deadline = Instant::now() + max_wait;
9513    loop {
9514        if pids.iter().all(|&pid| !alive(pid)) {
9515            return;
9516        }
9517        if Instant::now() >= deadline {
9518            return;
9519        }
9520        tokio::time::sleep(poll).await;
9521    }
9522}
9523
9524/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
9525/// admission that it could not even get a verify command to run, as opposed
9526/// to evidence the command actually produced? A caller that would otherwise
9527/// read a resource-blocked outcome as a red command must check this first:
9528/// see [`Runner::gate`], which retries rather than records `Blocked` when
9529/// this is true.
9530fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9531    outcomes.iter().any(|o| o.resource_blocked)
9532}
9533
9534/// What [`Runner::gate_fix_round`] decided.
9535enum GateFix {
9536    /// The tree changed and `verify.e2e` is still green: run the gate again.
9537    Retry,
9538    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
9539    /// last failure stands and the run ends blocked.
9540    Stop,
9541    /// `verify.e2e` could not run after the fix (magi's own contention):
9542    /// decide nothing now, a later reentry retries.
9543    Defer,
9544}
9545
9546/// Is every red command in `outcomes` an ordinary failure the code could
9547/// explain: it ran, exited non-zero, and said something?
9548///
9549/// A timeout, a spawn failure and a killed process all leave `code` `None`;
9550/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
9551/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
9552/// what the command is stays the gate's business.
9553fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9554    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9555    red.peek().is_some()
9556        && red.all(|o| {
9557            !o.resource_blocked
9558                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9559                && !o.output_tail.trim().is_empty()
9560        })
9561}
9562
9563/// Describe one verify command's outcome for the event log, distinguishing a
9564/// build/link failure — the toolchain never produced a binary to run — from
9565/// an actual test failure, since only the latter is a verdict on the patch.
9566fn e2e_outcome_label(o: &CommandOutcome) -> String {
9567    if o.ok() {
9568        return "pass".to_owned();
9569    }
9570    let reason = if o.build_failed() {
9571        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
9572    } else {
9573        format!("FAIL ({:?})", o.code)
9574    };
9575    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
9576}
9577
9578/// Run `verify.e2e`, retrying once if the first attempt could not build or
9579/// link — a build/link failure is frequently a race against a shared
9580/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
9581/// `verify` event per command, tagged with `context` (normally `"round N"`)
9582/// so the two call sites that need this — the ordinary per-round leg in
9583/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
9584/// it will ever call a round green — read identically in the event log.
9585async fn run_e2e_with_retry(
9586    state: &mut RunState,
9587    shell: &[String],
9588    commands: &[String],
9589    worktree: &Path,
9590    timeout: Duration,
9591    context: &str,
9592) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
9593    let (mut e2e, mut timed_out_pids) = run_commands(
9594        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
9595    )
9596    .await;
9597    for o in &e2e {
9598        state.event(
9599            "verify",
9600            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
9601        );
9602    }
9603    // A build/link failure is not a verdict on the patch — it is frequently a
9604    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
9605    // one retry before letting a red like that decide the round.
9606    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
9607    if verify_retried {
9608        state.event(
9609            "verify",
9610            format!(
9611                "{context}: verify could not build/link, not a test result — retrying once \
9612                 before concluding"
9613            ),
9614        );
9615        let retried = run_commands(
9616            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
9617        )
9618        .await;
9619        e2e = retried.0;
9620        // Both attempts' timeouts matter, not just the last one: the first
9621        // attempt's descendants may still be alive alongside the retry's.
9622        timed_out_pids.extend(retried.1);
9623        for o in &e2e {
9624            state.event(
9625                "verify",
9626                format!(
9627                    "{context}: retry `{}` -> {}",
9628                    o.command,
9629                    e2e_outcome_label(o)
9630                ),
9631            );
9632        }
9633    }
9634    (e2e, verify_retried, timed_out_pids)
9635}
9636
9637/// Run configured shell commands in `cwd`, in order. The second element is
9638/// the pid of every command that hit `timeout` and was still running when
9639/// this stopped waiting on it (best-effort: `None` when the platform did not
9640/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
9641/// that releases a shared resource afterward needs to know.
9642///
9643/// Records `task` into [`RunState::active`] at every command boundary
9644/// (`RunState::task_command`) and clears it once the whole list has run
9645/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
9646/// for minutes with no seat and no output of its own to show for it (see
9647/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
9648/// apart from "not yet run" without this), and this is the only place that
9649/// knows which command is running right now and how many are left. Three
9650/// saves per command — start, not per second — matching the same "only at a
9651/// boundary" rule [`wave`] already follows for seats.
9652#[allow(clippy::too_many_arguments)]
9653async fn run_commands(
9654    state: &mut RunState,
9655    node: &str,
9656    task: &str,
9657    attempt: usize,
9658    shell: &[String],
9659    commands: &[String],
9660    cwd: &Path,
9661    timeout: Duration,
9662) -> (Vec<CommandOutcome>, Vec<u32>) {
9663    if commands.is_empty() {
9664        // Nothing to mark as running and nothing to clear — an empty list
9665        // means "not configured", and touching `active` (or the disk) over
9666        // that would be a write for every round of a repo with no
9667        // `verify.e2e` / `verify.gate` commands at all.
9668        return (Vec::new(), Vec::new());
9669    }
9670    let mut out = Vec::new();
9671    let mut timed_out_pids = Vec::new();
9672    let total = commands.len();
9673    for (idx, command) in commands.iter().enumerate() {
9674        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
9675        if let Err(e) = state.save() {
9676            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
9677        }
9678        let started = Instant::now();
9679        let mut cmd = tokio::process::Command::new(&shell[0]);
9680        cmd.quiet();
9681        cmd.args(&shell[1..])
9682            .arg(command)
9683            .current_dir(cwd)
9684            .stdin(std::process::Stdio::null())
9685            .stdout(std::process::Stdio::piped())
9686            .stderr(std::process::Stdio::piped())
9687            .kill_on_drop(true);
9688        let spawned = cmd.spawn();
9689        let (code, body) = match spawned {
9690            Ok(child) => {
9691                // Captured before the child is consumed below: `kill_on_drop`
9692                // only *asks* the process to die when the timeout branch
9693                // drops it, and the pid is the only way anyone downstream can
9694                // later check whether that request actually took.
9695                let pid = child.id();
9696                match tokio::time::timeout(timeout, child.wait_with_output()).await {
9697                    Ok(Ok(o)) => {
9698                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
9699                        body.push_str(&String::from_utf8_lossy(&o.stderr));
9700                        (o.status.code(), body)
9701                    }
9702                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
9703                    Err(_) => {
9704                        if let Some(pid) = pid {
9705                            timed_out_pids.push(pid);
9706                        }
9707                        (None, format!("timed out after {}s", timeout.as_secs()))
9708                    }
9709                }
9710            }
9711            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
9712        };
9713        out.push(CommandOutcome {
9714            command: command.clone(),
9715            code,
9716            output_tail: tail(&body, OUTPUT_TAIL),
9717            duration_ms: started.elapsed().as_millis() as u64,
9718            resource_blocked: false,
9719        });
9720    }
9721    state.task_finished(task);
9722    if let Err(e) = state.save() {
9723        tracing::warn!("could not persist the end of {task}: {e:#}");
9724    }
9725    (out, timed_out_pids)
9726}
9727
9728/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
9729/// section (`report::run`) and in the `merge` event this node records — for
9730/// the operator to run by hand.
9731///
9732/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
9733/// branch whose ruleset forbids merge commits (GitHub's "must not contain
9734/// merge commits", or "require linear history") rejects the push a `--no-ff`
9735/// merge would produce, which is exactly the guidance this function replaces.
9736/// `message`'s first line becomes the squash commit's subject, matching the
9737/// note `report::run` prints alongside this command — see that function for
9738/// why an explicit subject is not optional there.
9739fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
9740    let repo = repo.display();
9741    match style {
9742        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
9743        MergeStyle::Squash => {
9744            // The subject sits inside double quotes, and a title an agent
9745            // wrote may carry the characters that break out of them.
9746            let subject = message
9747                .lines()
9748                .next()
9749                .unwrap_or(branch)
9750                .replace(['\\', '"', '$', '`'], "");
9751            format!(
9752                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
9753            )
9754        }
9755        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
9756    }
9757}
9758
9759/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
9760/// under the hood, rejects a `title` over 256 characters and the whole
9761/// command fails — no PR at all, for a run whose body was otherwise fine
9762/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
9763/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
9764/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
9765/// this bound. It is a margin, not a guarantee — a title packed
9766/// with multi-unit characters could still in principle land close to the
9767/// edge, but a real task title's occasional emoji or accented letter fits
9768/// comfortably inside it.
9769const PR_TITLE_MAX: usize = 240;
9770
9771/// A pull request title from the opening line of `text`, or `None` when that
9772/// line is not English (GitHub text is) or has no letters.
9773///
9774/// A line within `max` is kept as is. A longer one is cut at the end of its
9775/// first sentence when that falls inside `max`, else at a word boundary with a
9776/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
9777/// make every merely-truncated title look non-English). The language check
9778/// runs on the kept text before any mark is added, so only what GitHub will
9779/// show is judged: an English opening followed by non-ASCII far past the cut
9780/// still passes.
9781fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
9782    let line = queue::first_line(text)?;
9783    let chars: Vec<char> = line.chars().collect();
9784    let (kept, mark) = if chars.len() <= max {
9785        (line.to_owned(), "")
9786    } else if let Some(end) = sentence_end(&chars, max) {
9787        (chars[..end].iter().collect::<String>(), "")
9788    } else {
9789        let room = max.saturating_sub(3);
9790        // Cut at the last space inside the room; when the char just past the
9791        // room is a space the room already ends on a word.
9792        let cut = if chars[room].is_whitespace() {
9793            room
9794        } else {
9795            chars[..room]
9796                .iter()
9797                .rposition(|c| c.is_whitespace())
9798                .unwrap_or(room)
9799        };
9800        let head: String = chars[..cut].iter().collect();
9801        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
9802        (head.to_owned(), "...")
9803    };
9804    if kept.is_empty() {
9805        return None;
9806    }
9807    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
9808        return None;
9809    }
9810    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
9811        return None;
9812    }
9813    Some(format!("{kept}{mark}"))
9814}
9815
9816/// The char length of the first sentence of `chars` when it ends within `max`
9817/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
9818/// abbreviations so `e.g. foo` does not end a title early.
9819fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
9820    const MIN: usize = 20;
9821    for i in MIN..max.min(chars.len()) {
9822        if !matches!(chars[i], '.' | '!' | '?') {
9823            continue;
9824        }
9825        let Some(&next) = chars.get(i + 1) else {
9826            continue;
9827        };
9828        if !next.is_whitespace() {
9829            continue;
9830        }
9831        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
9832        if after.is_some_and(|c| c.is_ascii_lowercase()) {
9833            continue;
9834        }
9835        let word: String = chars[..i]
9836            .iter()
9837            .rev()
9838            .take_while(|c| !c.is_whitespace())
9839            .collect::<Vec<_>>()
9840            .into_iter()
9841            .rev()
9842            .collect();
9843        let word = word.to_ascii_lowercase();
9844        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
9845            continue;
9846        }
9847        let end = chars[..i]
9848            .iter()
9849            .rposition(|c| !c.is_whitespace())
9850            .map_or(i, |p| p + 1);
9851        return Some(end);
9852    }
9853    None
9854}
9855
9856/// What `merge = "pr"` (and the merge commit of the other modes) says about a
9857/// change: a title and a body describing what was *implemented*, not the task
9858/// that asked for it. A task reads as a request; a reader of the merged
9859/// history wants the change.
9860struct PrMessage {
9861    title: String,
9862    body: String,
9863}
9864
9865impl PrMessage {
9866    /// Title, blank line, body. The first line is the squash/merge commit
9867    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
9868    /// has to stay one sensible line.
9869    fn commit_message(&self) -> String {
9870        format!("{}\n\n{}", self.title, self.body)
9871    }
9872}
9873
9874/// The text after a leading `TITLE:` (any case) on `line`.
9875fn title_marker(line: &str) -> Option<&str> {
9876    let line = line.trim();
9877    let head = line.get(..6)?;
9878    head.eq_ignore_ascii_case("title:")
9879        .then(|| line[6..].trim())
9880}
9881
9882/// The implementer's own one-line title: the `TITLE:` line the implement
9883/// prompt asks for at the top of its SUMMARY. Candidate commits are all
9884/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
9885/// source, and a title that says as much is refused here too.
9886fn summary_title(summary: &str) -> Option<String> {
9887    let first = summary.lines().find(|l| !l.trim().is_empty())?;
9888    let raw = title_marker(first)?;
9889    if raw.is_empty() {
9890        return None;
9891    }
9892    let title = queue::title_from(raw, PR_TITLE_MAX);
9893    let lower = title.to_ascii_lowercase();
9894    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9895        return None;
9896    }
9897    Some(title)
9898}
9899
9900/// How `open_review`'s instruction begins; see [`landing_title`].
9901const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9902
9903/// Marker `open_review` gives a candidate that nothing in the roster wrote.
9904const EXISTING_BRANCH: &str = "(existing branch)";
9905
9906/// Does this run review work that already existed, rather than implement a
9907/// task? Runs recorded before `reviewed_commits` existed carry only the
9908/// candidate marker.
9909fn is_review_run(state: &RunState) -> bool {
9910    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9911}
9912
9913/// The title of a review-only run: the subject of the oldest commit under
9914/// review. Later commits are usually fixups, and `instruction` is the review
9915/// prompt, which says nothing about the change. GitHub text is English, so a
9916/// non-ASCII or blank subject yields `None` and the caller's neutral title.
9917fn review_title(state: &RunState) -> Option<String> {
9918    english_subject(
9919        state.reviewed_commits.as_ref()?.first()?,
9920        state.config.graph.github_english(),
9921    )
9922}
9923
9924/// `raw` as a pull request title, or `None` when it is blank, not English
9925/// (GitHub text is), or one of magi's own candidate commit subjects.
9926fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
9927    let raw = raw.trim();
9928    if raw.is_empty() {
9929        return None;
9930    }
9931    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
9932        return None;
9933    }
9934    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
9935        return None;
9936    }
9937    let title = queue::title_from(raw, PR_TITLE_MAX);
9938    let lower = title.to_ascii_lowercase();
9939    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9940        return None;
9941    }
9942    Some(title)
9943}
9944
9945/// What a review-only run's branch says about itself, read at the moment the
9946/// pull request is opened.
9947#[derive(Debug, Clone, PartialEq, Eq)]
9948struct BranchFacts {
9949    /// `(subject, body)` of each commit, oldest first.
9950    commits: Vec<(String, String)>,
9951    /// Trimmed `git diff --stat`.
9952    stat: String,
9953}
9954
9955/// Longest diff stat shown: this many file lines plus the summary line.
9956const STAT_FILE_LINES: usize = 25;
9957/// Cap on the commit list, well inside GitHub's 65536-character body limit.
9958const COMMITS_MAX_CHARS: usize = 20_000;
9959
9960/// Read the commits and diff stat of `base..branch`. `None` when git cannot
9961/// say or finds nothing, so the caller falls back to what the run recorded.
9962async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
9963    let commits = git::commit_log(repo, base, branch).await.ok()?;
9964    if commits.is_empty() {
9965        return None;
9966    }
9967    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
9968    let lines: Vec<&str> = stat.lines().collect();
9969    let stat = if lines.len() > STAT_FILE_LINES + 1 {
9970        let omitted = lines.len() - 1 - STAT_FILE_LINES;
9971        let more = format!(" ... {omitted} more file(s)");
9972        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
9973        kept.push(&more);
9974        kept.push(lines[lines.len() - 1]);
9975        kept.join("\n")
9976    } else {
9977        lines.join("\n")
9978    };
9979    Some(BranchFacts { commits, stat })
9980}
9981
9982/// Defang what would break the surrounding markdown: a closing `</details>`
9983/// and a code fence.
9984fn markdown_safe(text: &str) -> String {
9985    text.replace("</details>", "&lt;/details&gt;")
9986        .replace("\x60\x60\x60", "~~~")
9987}
9988
9989fn neutral_title(state: &RunState, winner: char) -> String {
9990    format!(
9991        "chore: land candidate {} of run {}",
9992        winner.to_ascii_uppercase(),
9993        state.id
9994    )
9995}
9996
9997/// The pull request title to hand to `land::merge_subject`. A review-only run
9998/// opened by an earlier build titled its pull request with the review prompt;
9999/// that title is dropped (empty, so the fallback applies) rather than landed.
10000/// Any other title, including an operator's rename, passes through untouched,
10001/// and so does every title of a run that implements a task.
10002pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10003    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10004        ""
10005    } else {
10006        pr_title
10007    }
10008}
10009
10010/// What the squash subject falls back to when the pull request title is empty
10011/// or candidate-shaped: for a review-only run the derived title, never the
10012/// review prompt held in `instruction`.
10013pub fn landing_subject_source(state: &RunState) -> String {
10014    if is_review_run(state) {
10015        let winner = state.candidates.first().map_or('A', |c| c.label);
10016        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10017    }
10018    state.instruction.clone()
10019}
10020
10021/// `summary` without its `TITLE:` line, which the pull request title already
10022/// carries.
10023fn summary_without_title(summary: &str) -> String {
10024    let mut lines = summary.trim().lines().peekable();
10025    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10026        lines.next();
10027    }
10028    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10029}
10030
10031/// The pull request title and body for the winning candidate.
10032///
10033/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10034/// the task's own opening line via [`queue::title_from`] when there is none.
10035/// `state.instruction` can open with blank lines (`task_text` only rejects a
10036/// body that is blank *entirely*), which `title_from` skips.
10037///
10038/// Body: the implementer's summary and the fixer's notes, then — when the
10039/// winning review round was not clean — the findings still open and whatever
10040/// the fixer declined, so `merge = "pr"` hands the reader the same material
10041/// `magi show` does. The task follows inside a collapsed block, and the
10042/// footer repeats the run and candidate as plain tags for a reader holding
10043/// only the merged commit or the PR body.
10044#[cfg(test)]
10045fn pr_message(state: &RunState, winner: char) -> PrMessage {
10046    pr_message_with(state, winner, None)
10047}
10048
10049/// [`pr_message`] with what the branch of a review-only run says about itself.
10050/// `facts` is ignored for a run that implements a task.
10051#[cfg(test)]
10052fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10053    let raw = pr_message_raw(
10054        state,
10055        winner,
10056        facts,
10057        crate::pr_outcome::FinalHead::Unreported,
10058        ChangeNote::NotAsked,
10059    );
10060    let id = crate::scrub::Identity::current();
10061    PrMessage {
10062        title: crate::scrub::scrub(&raw.title, &id),
10063        body: crate::scrub::scrub(&raw.body, &id),
10064    }
10065}
10066
10067/// What the description of a review-only run may say about the change itself.
10068#[derive(Debug, Clone, Copy)]
10069enum ChangeNote<'a> {
10070    /// No summary was asked for (a preview, a non-publishing merge mode, or an
10071    /// implementing run, whose implementer wrote its own): the description is
10072    /// built as it always was.
10073    NotAsked,
10074    /// A summary written from the final diff.
10075    Summary(&'a crate::run::ChangeSummary),
10076    /// A summary was asked for and could not be produced; the text says why.
10077    Unavailable(&'a str),
10078}
10079
10080fn pr_message_raw(
10081    state: &RunState,
10082    winner: char,
10083    facts: Option<&BranchFacts>,
10084    head: crate::pr_outcome::FinalHead<'_>,
10085    change: ChangeNote<'_>,
10086) -> PrMessage {
10087    let summary = state
10088        .candidates
10089        .iter()
10090        .find(|c| c.label == winner)
10091        .map(|c| c.summary.as_str())
10092        .unwrap_or_default();
10093    // The fallback is the operator's own words and may not be English; GitHub
10094    // text always is, so a non-English task gets a neutral title instead.
10095    let review = is_review_run(state);
10096    let generated_title = match change {
10097        ChangeNote::Summary(c) if review && !c.title.trim().is_empty() => Some(c.title.clone()),
10098        _ => None,
10099    };
10100    let title = if let Some(t) = generated_title {
10101        t
10102    } else if review {
10103        facts
10104            .and_then(|f| {
10105                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10106            })
10107            .or_else(|| review_title(state))
10108            .or_else(|| {
10109                state
10110                    .candidates
10111                    .iter()
10112                    .find(|c| c.label == winner)
10113                    .filter(|c| !c.branch.starts_with("magi/"))
10114                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10115            })
10116            .unwrap_or_else(|| neutral_title(state, winner))
10117    } else {
10118        summary_title(summary).unwrap_or_else(|| {
10119            english_title(
10120                &state.instruction,
10121                PR_TITLE_MAX,
10122                state.config.graph.github_english(),
10123            )
10124            .unwrap_or_else(|| neutral_title(state, winner))
10125        })
10126    };
10127
10128    let mut body = String::new();
10129    let what = summary_without_title(summary);
10130    if review {
10131        match change {
10132            ChangeNote::Summary(c) => {
10133                body.push_str("## Summary\n\n");
10134                body.push_str(c.body.trim());
10135                body.push_str("\n\n");
10136            }
10137            ChangeNote::Unavailable(why) => body.push_str(&format!(
10138                "## Summary\n\nNo description of what this change does could be generated ({why}). \
10139                 The commit list and diff stat below only list what the branch carries; they do not \
10140                 explain it.\n\n"
10141            )),
10142            ChangeNote::NotAsked => {}
10143        }
10144    }
10145    if !what.is_empty() {
10146        body.push_str("## Summary\n\n");
10147        body.push_str(&what);
10148        body.push_str("\n\n");
10149    }
10150
10151    // The last round is usually a clean verification pass with no fix of its
10152    // own, so every round's notes are read, not just the final one's. Sentences
10153    // the final state contradicts are left out (see `pr_outcome`).
10154    body.push_str(&crate::pr_outcome::review_fixes(state, head).0);
10155    body.push_str(&crate::pr_outcome::final_verification(state, head));
10156    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10157
10158    let open = state.open_findings();
10159    if !open.is_empty() {
10160        body.push_str("## Open review findings\n\n");
10161        for f in &open {
10162            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
10163        }
10164        body.push('\n');
10165    }
10166
10167    if let Some(fix) = fix
10168        && !fix.rejected.is_empty()
10169    {
10170        body.push_str("## Declined by the fixer\n\n");
10171        for r in &fix.rejected {
10172            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
10173        }
10174        body.push('\n');
10175    }
10176
10177    if review {
10178        // The review prompt is not the task; list what the branch carries.
10179        body.push_str("## Commits under review\n\n");
10180        if let Some(facts) = facts {
10181            let mut left = COMMITS_MAX_CHARS;
10182            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10183                let mut entry = format!("- {}\n", markdown_safe(subject));
10184                for l in markdown_safe(text).lines() {
10185                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10186                }
10187                if left == 0 {
10188                    body.push_str(&format!(
10189                        "- ... {} more commit(s)\n",
10190                        facts.commits.len() - i
10191                    ));
10192                    break;
10193                }
10194                if entry.len() > left {
10195                    // Even the first commit is cut: one huge body must not
10196                    // push the whole description past GitHub's limit.
10197                    let mut end = left;
10198                    while !entry.is_char_boundary(end) {
10199                        end -= 1;
10200                    }
10201                    entry.truncate(end);
10202                    entry.push_str("\n  ... (truncated)\n");
10203                    left = 0;
10204                } else {
10205                    left -= entry.len();
10206                }
10207                body.push_str(&entry);
10208            }
10209            if !facts.stat.trim().is_empty() {
10210                body.push_str(&format!(
10211                    "\n## Diff stat\n\n```\n{}\n```\n",
10212                    markdown_safe(facts.stat.trim())
10213                ));
10214            }
10215        } else {
10216            match &state.reviewed_commits {
10217                Some(subjects) => {
10218                    for s in subjects {
10219                        body.push_str(&format!("- {}\n", s.trim()));
10220                    }
10221                }
10222                None => {
10223                    // An older run kept only the prompt, with the commit list
10224                    // after its first paragraph.
10225                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10226                    body.push_str(rest.trim());
10227                    body.push('\n');
10228                }
10229            }
10230        }
10231    } else {
10232        let task = state.instruction.trim();
10233        let task = if task.is_empty() {
10234            "(empty task)"
10235        } else {
10236            task
10237        };
10238        body.push_str(&format!(
10239            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10240            task.replace("</details>", "&lt;/details&gt;")
10241        ));
10242    }
10243
10244    body.push_str(&format!(
10245        "\n---\nmagi:run/{} magi:candidate-{}\n",
10246        state.id,
10247        winner.to_ascii_lowercase()
10248    ));
10249
10250    PrMessage { title, body }
10251}
10252
10253/// The task with what the repository says about the existing work it names
10254/// appended, so an implementer knows what it started from and what it must
10255/// not redo. Unchanged when the task names nothing.
10256fn seeded_instruction(state: &RunState) -> String {
10257    match refs::describe(&state.seeds) {
10258        Some(facts) => format!(
10259            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10260             Candidates start from the unmerged branch named above, when there \
10261             is one, and carry any unmerged commit named by sha as a \
10262             cherry-pick. Check that this is what the task meant before \
10263             building on it.",
10264            state.instruction
10265        ),
10266        None => state.instruction.clone(),
10267    }
10268}
10269
10270/// Does the winner have no commits ahead of the base it would land on?
10271/// Any failure to find out reads as "not empty": the merge then behaves as it
10272/// always did rather than refusing on a guess.
10273async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10274    let base = &state.base_branch;
10275    let mut against = base.clone();
10276    if mode == MergeMode::Pr {
10277        // A pull request lands on the remote's base, never the local branch
10278        // of the same name: if that cannot be read, "not empty" is the safe
10279        // answer.
10280        let remote = &state.config.merge.remote;
10281        let tracking = format!("{remote}/{base}");
10282        let fetched = git::fetch(repo, remote, base).await;
10283        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10284            against = tracking;
10285        } else {
10286            return false;
10287        }
10288    }
10289    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10290}
10291
10292/// Why nothing was opened for an empty winner, with what the task's own
10293/// references resolved to.
10294fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10295    let mut detail = format!(
10296        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10297         nothing to open a pull request for"
10298    );
10299    match refs::describe(&state.seeds) {
10300        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10301        None => detail.push_str(
10302            "\nThe task names no existing branch or commit; if it means to land work \
10303             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10304        ),
10305    }
10306    detail
10307}
10308
10309/// What the `Pr` merge does once it knows whether the branch already has an
10310/// open pull request.
10311#[derive(Debug, PartialEq, Eq)]
10312enum PrPlan {
10313    Create,
10314    Adopt { url: String, title: String },
10315    Stop(String),
10316}
10317
10318/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
10319/// a failed lookup -> stop with the real reason. Never guesses.
10320fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10321    match found {
10322        Ok(land::OpenPr::None) => PrPlan::Create,
10323        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10324        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10325            "several open pull requests exist for this branch, not picking one: {}",
10326            urls.join(" ")
10327        )),
10328        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10329    }
10330}
10331
10332/// `gh pr create`, returning the PR url.
10333pub(crate) async fn gh_pr_create(
10334    cwd: &Path,
10335    base: &str,
10336    head: &str,
10337    title: &str,
10338    body: &str,
10339) -> Result<String> {
10340    let out = tokio::process::Command::new("gh")
10341        .args([
10342            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10343        ])
10344        .current_dir(cwd)
10345        .quiet()
10346        .stdin(std::process::Stdio::null())
10347        .output()
10348        .await
10349        .context("spawn gh")?;
10350    if out.status.success() {
10351        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10352    } else {
10353        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10354    }
10355}
10356
10357/// Tear a run's worktrees and branches down.
10358///
10359/// `home` is where the updated `run.json` is saved (via
10360/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
10361/// a housekeeping pass already has its own honest `home` handed to it, and
10362/// falling through to the global here would write back through whichever
10363/// directory some other process or test pinned into that `OnceLock` first,
10364/// not the one the caller actually resolved its `runs` and `state` from.
10365pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10366    let repo = state.repo.clone();
10367    let root = state.worktree_root();
10368    let winner = state.tally.as_ref().map(|t| t.winner);
10369    let mut removed = Vec::new();
10370
10371    for i in 0..state.candidates.len() {
10372        let c = state.candidates[i].clone();
10373        let is_winner = Some(c.label) == winner;
10374        if is_winner && !drop_winner {
10375            continue;
10376        }
10377        if c.worktree.exists() {
10378            git::worktree_remove(&repo, &c.worktree).await.ok();
10379            removed.push(c.worktree.to_string_lossy().into_owned());
10380        }
10381        // A branch handed to a later run (and its pull request) is not this
10382        // run's to delete.
10383        let handed_over = state.released_branches.contains(&c.branch);
10384        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10385            git::branch_delete(&repo, &c.branch).await.ok();
10386            removed.push(c.branch.clone());
10387        }
10388        state.candidates[i].folded = true;
10389    }
10390
10391    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10392        let path = name.path();
10393        let keep = !drop_winner
10394            && winner.is_some_and(|w| {
10395                path.file_name()
10396                    .is_some_and(|n| n == format!("cand-{w}").as_str())
10397            });
10398        if keep {
10399            continue;
10400        }
10401        git::worktree_remove(&repo, &path).await.ok();
10402        removed.push(path.to_string_lossy().into_owned());
10403    }
10404
10405    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
10406    // judge worktrees, so once the loop above has cleared all of them out,
10407    // the parent is a bare directory nobody else was ever going to remove -
10408    // git only ever managed what was inside it. Left alone, one of these
10409    // accumulates per fully-folded run; the operator's own machine had 74.
10410    // `remove_if_empty` re-checks rather than assuming: a run whose winner
10411    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
10412    // so does anything a run never claimed that happens to share the bay.
10413    remove_if_empty(&root);
10414
10415    if state.enabled_worktree_config && drop_winner {
10416        // A release, not a raw disable: some sibling run in this repository
10417        // may still hold its own reference (see `git::acquire_worktree_config`),
10418        // and only the last release actually turns the setting back off.
10419        git::release_worktree_config(&repo).await.ok();
10420        state.enabled_worktree_config = false;
10421    }
10422    state.save_under(home)?;
10423    Ok(removed)
10424}
10425
10426/// Remove `dir` if it exists and has nothing in it.
10427///
10428/// Best-effort and silent by design: a directory that is not empty (a run
10429/// whose winner is still parked there, a stray file some other process left)
10430/// is exactly the case this must refuse, and a directory that is already gone
10431/// is not a failure worth reporting either. `std::fs::remove_dir` itself
10432/// already refuses a non-empty directory, so the emptiness check below is
10433/// belt, not suspenders - it is what keeps this from ever attempting the
10434/// removal in the case that matters, rather than trusting `remove_dir`'s
10435/// error path to have no side effects if it ever changed.
10436fn remove_if_empty(dir: &Path) {
10437    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10438        std::fs::remove_dir(dir).ok();
10439    }
10440}
10441
10442/// Severity of the worst open finding in the last review round, for reporting.
10443pub fn worst_open(state: &RunState) -> Option<Severity> {
10444    state
10445        .reviews
10446        .last()?
10447        .reviews
10448        .iter()
10449        .flat_map(|r| r.findings.iter())
10450        .map(|f| f.severity)
10451        .max()
10452}
10453
10454/// `[worktree] setup` hides its products per worktree, which needs
10455/// `extensions.worktreeConfig`. Taken once per run and released with the run's
10456/// worktrees, like the hook's; every place that runs setup calls this first.
10457async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10458    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10459        git::acquire_worktree_config(repo).await?;
10460        state.enabled_worktree_config = true;
10461    }
10462    Ok(())
10463}
10464
10465#[cfg(test)]
10466mod tests {
10467    #[test]
10468    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10469        let leaked = vec!["chore(deps): update a crate".to_owned()];
10470        let own = "fix(daemon): apply a chosen action";
10471        assert!(should_retitle("", own, &leaked));
10472        assert!(should_retitle(
10473            &format!("{REVIEW_PROMPT_OPENING} `x`"),
10474            own,
10475            &leaked
10476        ));
10477        assert!(should_retitle(
10478            "chore: land candidate A of run 1",
10479            own,
10480            &leaked
10481        ));
10482        assert!(should_retitle(
10483            "magi: candidate A (uncommitted work)",
10484            own,
10485            &leaked
10486        ));
10487        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10488        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10489        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10490    }
10491
10492    #[test]
10493    fn pr_merge_plan_creates_adopts_or_stops() {
10494        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10495        assert_eq!(
10496            pr_merge_plan(Ok(land::OpenPr::One {
10497                url: "u".into(),
10498                title: "t".into()
10499            })),
10500            PrPlan::Adopt {
10501                url: "u".into(),
10502                title: "t".into()
10503            }
10504        );
10505        let PrPlan::Stop(many) =
10506            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10507        else {
10508            panic!("many must stop");
10509        };
10510        assert!(many.contains('a') && many.contains('b'));
10511        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10512            panic!("a failed lookup must stop");
10513        };
10514        assert!(err.contains("bad token"));
10515    }
10516
10517    use super::*;
10518    use crate::run::GateStatus;
10519    use std::collections::BTreeMap;
10520    use std::time::Duration;
10521
10522    fn conductor() -> AgentSpec {
10523        AgentSpec {
10524            id: "conductor".to_owned(),
10525            kind: crate::config::AgentKind::Command,
10526            model: None,
10527            command: vec!["true".to_owned()],
10528            extra_args: Vec::new(),
10529            env: BTreeMap::new(),
10530            prompt_delivery: None,
10531        }
10532    }
10533
10534    fn spec(id: &str) -> AgentSpec {
10535        AgentSpec {
10536            id: id.to_owned(),
10537            kind: crate::config::AgentKind::Command,
10538            model: None,
10539            command: vec!["true".to_owned()],
10540            extra_args: Vec::new(),
10541            env: BTreeMap::new(),
10542            prompt_delivery: None,
10543        }
10544    }
10545
10546    fn ids(xs: &[&str]) -> BTreeSet<String> {
10547        xs.iter().map(|s| (*s).to_owned()).collect()
10548    }
10549
10550    #[test]
10551    fn pick_successor_skips_an_agent_another_seat_holds() {
10552        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
10553        let roster = [spec("a"), spec("b"), spec("c")];
10554        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10555        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10556    }
10557
10558    #[test]
10559    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
10560        // The other seat started on c but was handed to d; c is free again.
10561        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
10562        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10563        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10564        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
10565        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
10566    }
10567
10568    #[test]
10569    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
10570        let roster = [spec("a"), spec("b")];
10571        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10572        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10573        let carried = ids(&["b"]);
10574        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
10575        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10576    }
10577
10578    #[test]
10579    fn pick_successor_returns_none_without_any_untried_successor() {
10580        let roster = [spec("a"), spec("b")];
10581        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
10582        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
10583        let carried = ids(&["a"]);
10584        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
10585    }
10586
10587    #[test]
10588    fn pick_successor_rescue_avoids_another_seats_occupant() {
10589        // b is a carried failure and free; a is held by the other seat.
10590        let roster = [spec("a"), spec("b"), spec("c")];
10591        let carried = ids(&["b", "c"]);
10592        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
10593        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10594    }
10595
10596    #[test]
10597    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
10598        let roster = [spec("a"), spec("b"), spec("c")];
10599        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
10600        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10601    }
10602
10603    #[test]
10604    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
10605        let roster = [spec("a"), spec("b"), spec("c")];
10606        let failed = ids(&["a", "b", "c"]);
10607        // Rescue looks at the whole roster, once per id, then runs out.
10608        let mut tried = ids(&["b"]);
10609        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10610        assert_eq!(first.id, "a");
10611        tried.insert(first.id.clone());
10612        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10613        assert_eq!(second.id, "c");
10614        tried.insert(second.id.clone());
10615        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
10616    }
10617
10618    #[test]
10619    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
10620        let roster = [spec("a"), spec("b")];
10621        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
10622        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10623    }
10624
10625    #[test]
10626    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
10627        let roster = [spec("a"), spec("b"), spec("c")];
10628        let h = SeatHistory {
10629            failed: ids(&["a"]),
10630            last_ok: Some("b".to_owned()),
10631            last_fail: None,
10632        };
10633        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
10634        // A last answerer that left the roster, or later failed, is ignored.
10635        let gone = SeatHistory {
10636            last_ok: Some("zzz".to_owned()),
10637            ..h.clone()
10638        };
10639        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
10640        let failed = SeatHistory {
10641            failed: ids(&["a", "b"]),
10642            last_ok: Some("b".to_owned()),
10643            last_fail: None,
10644        };
10645        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
10646    }
10647
10648    #[test]
10649    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
10650        let first = SeatState::new("review-1", "alpha", 7);
10651        let next = handover_seat("review-1", "gamma", 7);
10652        assert_ne!(first.claude_session, next.claude_session);
10653    }
10654
10655    #[test]
10656    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
10657        let mut state = state_with_summary("x", "y");
10658        let a = handover_seat("review-1", "beta", state.next_seat_seed());
10659        let b = handover_seat("review-1", "beta", state.next_seat_seed());
10660        assert_ne!(a.claude_session, b.claude_session);
10661    }
10662
10663    #[test]
10664    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
10665        let roster = [spec("a"), spec("b")];
10666        let h = SeatHistory {
10667            failed: ids(&["a", "b"]),
10668            ..SeatHistory::default()
10669        };
10670        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
10671        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
10672        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
10673    }
10674
10675    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
10676    // fallback loop depends on to terminate: it must walk forward from the
10677    // seat's own position, never restart at the front of the roster, and it
10678    // must never hand back an id already tried, however many times that id
10679    // happens to appear.
10680
10681    #[test]
10682    fn failure_signature_ignores_numbers_and_paths() {
10683        assert_eq!(
10684            failure_signature("exited with Some(2) and no usable output"),
10685            failure_signature("exited with Some(137) and no usable output")
10686        );
10687        assert_eq!(
10688            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
10689            failure_signature("cannot open /var/x.txt: denied")
10690        );
10691        assert_ne!(failure_signature("boom"), failure_signature("bang"));
10692    }
10693
10694    #[test]
10695    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
10696        let other = FailClass::Other("x".into());
10697        assert!(should_hand_over(None, &FailClass::Quota));
10698        assert!(should_hand_over(Some(&other), &FailClass::Quota));
10699        assert!(should_hand_over(
10700            Some(&FailClass::Timeout),
10701            &FailClass::Timeout
10702        ));
10703        assert!(should_hand_over(None, &other));
10704        assert!(!should_hand_over(Some(&other), &other));
10705        assert!(should_hand_over(
10706            Some(&other),
10707            &FailClass::Other("y".into())
10708        ));
10709        // A quota or timeout in between ends the run of identical failures.
10710        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
10711        assert!(should_hand_over(Some(&FailClass::Quota), &other));
10712    }
10713
10714    #[test]
10715    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
10716        let a = SeatState::new("judge-1", "alpha", 7);
10717        let b = handover_seat("judge-1", "beta", 7);
10718        assert_ne!(a.claude_session, b.claude_session);
10719        assert_eq!(b.turns, 0);
10720    }
10721
10722    #[test]
10723    fn a_timeout_is_classified_apart_from_other_failures() {
10724        assert_eq!(
10725            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
10726            Some(FailClass::Timeout)
10727        );
10728        assert!(matches!(
10729            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
10730            Some(FailClass::Other(_))
10731        ));
10732    }
10733
10734    #[test]
10735    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
10736        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10737        let tried = BTreeSet::from(["beta".to_owned()]);
10738        // beta sits at index 1; the next candidate is gamma, never alpha —
10739        // which is very likely a different candidate slot's own agent.
10740        let next = next_untried_in_roster(&roster, 1, &tried);
10741        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
10742    }
10743
10744    #[test]
10745    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
10746        let roster = vec![spec("alpha"), spec("beta")];
10747        let tried = BTreeSet::from(["beta".to_owned()]);
10748        // beta is the roster's last entry: nothing follows it, and alpha —
10749        // earlier in the roster, almost certainly a different candidate
10750        // slot's own agent — must not be reached by wrapping back to it.
10751        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10752    }
10753
10754    #[test]
10755    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
10756        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10757        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
10758        // beta (index 1) and gamma (index 2, the only entry after it) have
10759        // both been tried; alpha (index 0) never has, but it comes before
10760        // beta's own position, so there is nothing further for this seat.
10761        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10762    }
10763
10764    #[test]
10765    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
10766        let roster = vec![spec("a"), spec("a"), spec("b")];
10767        let tried = BTreeSet::from(["a".to_owned()]);
10768        let next = next_untried_in_roster(&roster, 0, &tried);
10769        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10770    }
10771
10772    #[test]
10773    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
10774        let roster = vec![spec("a"), spec("b")];
10775        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
10776        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
10777    }
10778
10779    #[test]
10780    fn remove_if_empty_only_ever_takes_a_bare_directory() {
10781        let dir = tempfile::tempdir().unwrap();
10782        let bay = dir.path().join("ffff");
10783
10784        // Not there yet: nothing to do, nothing to panic on.
10785        remove_if_empty(&bay);
10786        assert!(!bay.exists());
10787
10788        // Something still inside - the winner's worktree, or a stray file -
10789        // keeps the directory standing.
10790        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
10791        remove_if_empty(&bay);
10792        assert!(bay.exists(), "non-empty directory must survive");
10793
10794        // Once the last entry is gone, so is the directory itself.
10795        std::fs::remove_dir(bay.join("cand-A")).unwrap();
10796        remove_if_empty(&bay);
10797        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
10798    }
10799
10800    // `round_is_clean` is the exact decision this task fixed: a round with a
10801    // seat that never answered must not read the same as a round every seat
10802    // actually reviewed. These are deterministic and process-free by design —
10803    // the equivalent end-to-end check (a real reviewer timing out under a
10804    // live graph run) is a genuine race against wall-clock contention, and a
10805    // spawn slow enough to blow even a generous budget under a loaded test
10806    // run must not turn this specific regression check flaky.
10807
10808    #[test]
10809    fn a_full_panel_that_found_nothing_is_clean() {
10810        assert!(round_is_clean(
10811            0,
10812            true,
10813            2,
10814            2,
10815            0,
10816            IncompleteReviewPolicy::Block
10817        ));
10818    }
10819
10820    #[test]
10821    fn a_missing_seat_is_never_clean_under_the_default_policy() {
10822        assert!(!round_is_clean(
10823            0,
10824            true,
10825            1,
10826            2,
10827            0,
10828            IncompleteReviewPolicy::Block
10829        ));
10830    }
10831
10832    #[test]
10833    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
10834        assert!(!round_is_clean(
10835            1,
10836            true,
10837            1,
10838            2,
10839            0,
10840            IncompleteReviewPolicy::Warn
10841        ));
10842    }
10843
10844    #[test]
10845    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
10846        assert!(round_is_clean(
10847            0,
10848            true,
10849            1,
10850            2,
10851            0,
10852            IncompleteReviewPolicy::Warn
10853        ));
10854    }
10855
10856    #[test]
10857    fn a_full_panel_with_an_open_finding_is_not_clean() {
10858        assert!(!round_is_clean(
10859            1,
10860            true,
10861            2,
10862            2,
10863            0,
10864            IncompleteReviewPolicy::Block
10865        ));
10866    }
10867
10868    #[test]
10869    fn a_full_panel_with_a_red_e2e_is_not_clean() {
10870        assert!(!round_is_clean(
10871            0,
10872            false,
10873            2,
10874            2,
10875            0,
10876            IncompleteReviewPolicy::Block
10877        ));
10878    }
10879
10880    // The stall this task closes: under the default `block` policy, a seat
10881    // missing only because it was rate limited must not force a wait for a
10882    // session limit that will not lift by the next round. `round_is_clean`
10883    // is where that quorum carve-out lives; the review loop around it never
10884    // changes what a reviewer's vote or a finding's severity means.
10885
10886    #[test]
10887    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
10888        // 1 of 2 answered, and the one missing was quota'd — the exact
10889        // "review-2 rate limited (quota)" shape from the field report.
10890        assert!(round_is_clean(
10891            0,
10892            true,
10893            1,
10894            2,
10895            1,
10896            IncompleteReviewPolicy::Block
10897        ));
10898    }
10899
10900    #[test]
10901    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
10902        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
10903        // not a quota loss (`quota_missing` stays 0) — worth another try.
10904        assert!(!round_is_clean(
10905            0,
10906            true,
10907            1,
10908            2,
10909            0,
10910            IncompleteReviewPolicy::Block
10911        ));
10912    }
10913
10914    #[test]
10915    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
10916        assert!(!round_is_clean(
10917            1,
10918            true,
10919            1,
10920            2,
10921            1,
10922            IncompleteReviewPolicy::Block
10923        ));
10924        assert!(!round_is_clean(
10925            0,
10926            false,
10927            1,
10928            2,
10929            1,
10930            IncompleteReviewPolicy::Block
10931        ));
10932    }
10933
10934    #[test]
10935    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
10936        // Every seat quota'd, nobody answered: there is no panel to decide
10937        // on, so this must fall through to the existing block-and-retry
10938        // fallback rather than call an unreviewed patch clean.
10939        assert!(!round_is_clean(
10940            0,
10941            true,
10942            0,
10943            2,
10944            2,
10945            IncompleteReviewPolicy::Block
10946        ));
10947    }
10948
10949    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
10950        CommandOutcome {
10951            command: "test".to_owned(),
10952            code,
10953            output_tail: String::new(),
10954            duration_ms: 0,
10955            resource_blocked,
10956        }
10957    }
10958
10959    #[test]
10960    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
10961        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
10962        assert!(
10963            !verify_inconclusive(&[outcome(Some(1), false)]),
10964            "an ordinary failure is still evidence about the patch"
10965        );
10966        assert!(verify_inconclusive(&[outcome(None, true)]));
10967        assert!(
10968            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
10969            "one inconclusive outcome taints the whole batch"
10970        );
10971        assert!(!verify_inconclusive(&[]));
10972    }
10973
10974    #[tokio::test]
10975    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
10976        // Alive for the first two checks, then dead - confirms the loop
10977        // actually re-polls rather than deciding once and sleeping out the
10978        // ceiling regardless.
10979        let calls = std::sync::atomic::AtomicUsize::new(0);
10980        let started = Instant::now();
10981        wait_for_pids_with(
10982            &[123],
10983            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
10984            Duration::from_millis(5),
10985            Duration::from_secs(5),
10986        )
10987        .await;
10988        assert!(
10989            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
10990            "must keep checking rather than deciding on the first answer"
10991        );
10992        assert!(
10993            started.elapsed() < Duration::from_secs(1),
10994            "must return the moment it is confirmed dead, not wait out the ceiling"
10995        );
10996    }
10997
10998    #[tokio::test]
10999    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11000        let started = Instant::now();
11001        wait_for_pids_with(
11002            &[123],
11003            |_| true, // never reports dead
11004            Duration::from_millis(5),
11005            Duration::from_millis(30),
11006        )
11007        .await;
11008        let elapsed = started.elapsed();
11009        assert!(
11010            elapsed >= Duration::from_millis(30),
11011            "must not give up before its own ceiling: {elapsed:?}"
11012        );
11013        assert!(
11014            elapsed < Duration::from_secs(1),
11015            "must not wait past its own ceiling either: {elapsed:?}"
11016        );
11017    }
11018
11019    #[tokio::test]
11020    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11021        let started = Instant::now();
11022        wait_for_pids_with(
11023            &[],
11024            |_| true,
11025            Duration::from_secs(5),
11026            Duration::from_secs(5),
11027        )
11028        .await;
11029        assert!(
11030            started.elapsed() < Duration::from_millis(200),
11031            "an empty pid list has nothing to confirm"
11032        );
11033    }
11034
11035    // `review_conclusion` is the exact decision the review hand-off task
11036    // fixed: a round budget spent (or a tree that stopped moving) must not
11037    // collapse into `Blocked` regardless of what verification actually
11038    // said. Deterministic and process-free for the same reason the
11039    // `round_is_clean` family above is.
11040    fn review_round(
11041        clean: bool,
11042        blocking: usize,
11043        answered: usize,
11044        expected: usize,
11045        progressed: bool,
11046        e2e_ok: bool,
11047    ) -> ReviewRound {
11048        ReviewRound {
11049            round: 1,
11050            head: "h".to_owned(),
11051            verified_head: None,
11052            verified_at: None,
11053            reviews: Vec::new(),
11054            e2e: vec![CommandOutcome {
11055                command: "test".to_owned(),
11056                code: Some(if e2e_ok { 0 } else { 1 }),
11057                output_tail: String::new(),
11058                duration_ms: 0,
11059                resource_blocked: false,
11060            }],
11061            verify_retried: false,
11062            e2e_deferred: false,
11063            e2e_defer_reason: None,
11064            fix: None,
11065            blocking,
11066            answered,
11067            expected,
11068            clean,
11069            progressed,
11070            vote_split: false,
11071            reconsideration: Vec::new(),
11072            verdict: None,
11073        }
11074    }
11075
11076    #[test]
11077    fn review_conclusion_is_none_when_nothing_has_run() {
11078        assert_eq!(review_conclusion(&[], 3), None);
11079    }
11080
11081    #[test]
11082    fn review_conclusion_is_none_while_rounds_remain() {
11083        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11084        assert_eq!(review_conclusion(&rounds, 3), None);
11085    }
11086
11087    #[test]
11088    fn review_conclusion_is_gating_once_a_round_is_clean() {
11089        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11090        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11091    }
11092
11093    #[test]
11094    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11095        let rounds = vec![
11096            review_round(false, 1, 2, 2, true, true),
11097            review_round(false, 1, 2, 2, true, true),
11098        ];
11099        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11100    }
11101
11102    #[test]
11103    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11104        let rounds = vec![
11105            review_round(false, 1, 2, 2, true, true),
11106            review_round(false, 1, 2, 2, true, false),
11107        ];
11108        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11109    }
11110
11111    #[test]
11112    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11113        // Magi never got a command to run against this round's own head — a
11114        // resource-blocked attempt, not a red one — so this must never
11115        // settle on `Blocked` the way a genuine e2e failure would. `None`
11116        // here is what tells `Runner::review_loop` to retry the check
11117        // itself rather than trust this cheap recomputation with a verdict
11118        // it cannot actually produce.
11119        let mut blocked = review_round(false, 1, 2, 2, true, false);
11120        blocked.e2e[0].resource_blocked = true;
11121        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11122        assert_eq!(review_conclusion(&rounds, 2), None);
11123    }
11124
11125    #[test]
11126    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11127        // Missing input, not a verified tree — never a hand-off candidate.
11128        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11129        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11130    }
11131
11132    #[test]
11133    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11134        let rounds = vec![
11135            review_round(false, 1, 2, 2, false, true),
11136            review_round(false, 1, 2, 2, false, true),
11137        ];
11138        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11139    }
11140
11141    fn secs(n: u64) -> Duration {
11142        Duration::from_secs(n)
11143    }
11144
11145    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11146    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11147    fn init_repo(dir: &Path) {
11148        let run = |args: &[&str]| {
11149            let out = std::process::Command::new("git")
11150                .args(args)
11151                .current_dir(dir)
11152                .quiet()
11153                .output()
11154                .expect("spawn git");
11155            assert!(
11156                out.status.success(),
11157                "git {args:?} failed: {}",
11158                String::from_utf8_lossy(&out.stderr)
11159            );
11160        };
11161        run(&["init", "-b", "main"]);
11162        run(&["config", "user.name", "magi test"]);
11163        run(&["config", "user.email", "magi@example.com"]);
11164        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11165        run(&["add", "-A"]);
11166        run(&["commit", "-m", "init"]);
11167    }
11168
11169    // `settle_questions` is what closes the ghost the phone showed: a run's
11170    // seat asked something, the run then ended, and nothing was left to
11171    // abandon the question it left `open`. `HOME` is a process-wide
11172    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
11173    // first time it runs in the binary — every test below still reaches the
11174    // same directory whichever call won, and each gets its own run id from
11175    // `RunState::new`, so they never collide there.
11176    fn ask_test_home() {
11177        crate::run::pin_test_home();
11178    }
11179
11180    /// A minimal, git-free `Runner` at a given status — `settle_questions`
11181    /// reads nothing else off it.
11182    fn runner_at(status: RunStatus) -> Runner {
11183        let mut state = RunState::new(
11184            PathBuf::from("/nonexistent/repo"),
11185            "main".to_owned(),
11186            "deadbeef".to_owned(),
11187            "task".to_owned(),
11188            Config::default(),
11189        );
11190        state.status = status;
11191        Runner {
11192            state,
11193            roles: ResolvedRoles {
11194                implementers: Vec::new(),
11195                judges: Vec::new(),
11196                reviewers: Vec::new(),
11197                fixer: None,
11198                conductor: conductor(),
11199                implementer_roster: Vec::new(),
11200                judge_roster: Vec::new(),
11201                reviewer_roster: Vec::new(),
11202            },
11203            sem: Arc::new(Semaphore::new(1)),
11204            pause: Pause::new(),
11205            interrupt: Pause::new(),
11206        }
11207    }
11208
11209    /// `park_here` folding in the reason `Pause::park_because` recorded -
11210    /// this is what lets an operator reading a run's events tell an
11211    /// interrupt-driven park from an ordinary shutdown park.
11212    #[test]
11213    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
11214        crate::run::pin_test_home();
11215        let mut runner = runner_at(RunStatus::Implementing);
11216        let interrupt = Pause::new();
11217        runner.watch_interrupt(interrupt.clone());
11218
11219        interrupt.park_because("task a1b2 asked to run first");
11220
11221        assert!(runner.park_here().expect("park_here"));
11222        assert!(runner.state.parked);
11223        let last = runner.state.events.last().expect("a park event");
11224        assert_eq!(last.node, "park");
11225        assert!(
11226            last.message.contains("task a1b2 asked to run first"),
11227            "expected the interrupt reason in {:?}",
11228            last.message
11229        );
11230    }
11231
11232    /// The cause is stored on the pass when it parks, and the first cause a
11233    /// shared pause was given is the one that stays.
11234    #[test]
11235    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
11236        crate::run::pin_test_home();
11237        let mut runner = runner_at(RunStatus::Implementing);
11238        runner.state.begin_pass();
11239        let shutdown = Pause::new();
11240        runner.on_pause(shutdown.clone());
11241        let upgrade = ParkCause::Upgrade {
11242            from: "0.121.0".to_owned(),
11243            to: Some("0.121.1".to_owned()),
11244        };
11245        shutdown.park_for(upgrade.clone());
11246        shutdown.park_for(ParkCause::Operator);
11247
11248        assert!(runner.park_here().expect("park_here"));
11249        assert_eq!(
11250            runner.state.passes[0].end,
11251            Some(crate::run::PassEnd::Parked { cause: upgrade })
11252        );
11253        let last = runner.state.events.last().expect("a park event");
11254        assert!(last.message.contains("0.121.0"), "{}", last.message);
11255    }
11256
11257    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
11258    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
11259    /// cleared) must not make a *different* run - one only watching its own,
11260    /// unshared interrupt `Pause` - see itself as parked. If a future change
11261    /// ever collapsed these back into one handle, the interrupt scheduler
11262    /// would park every run for the rest of the daemon's life, not just the
11263    /// one it meant to interrupt.
11264    #[test]
11265    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
11266        crate::run::pin_test_home();
11267        let mut runner = runner_at(RunStatus::Implementing);
11268        let shutdown = Pause::new();
11269        runner.on_pause(shutdown.clone());
11270        let interrupt = Pause::new();
11271        runner.watch_interrupt(interrupt.clone());
11272
11273        // Nobody has asked for anything yet.
11274        assert!(!runner.park_here().expect("park_here"));
11275        assert!(!runner.state.parked);
11276
11277        // Only the interrupt handle fires; the shutdown handle stays clear.
11278        interrupt.park_because("test");
11279        assert!(!shutdown.parked());
11280        assert!(runner.park_here().expect("park_here"));
11281    }
11282
11283    /// The property every prior attempt at this feature failed to pin down:
11284    /// asking a run to park while one of its nodes has a real, in-flight
11285    /// async operation running (an agent call, in production) must not cut
11286    /// that operation short. `park_here` is only ever consulted *between*
11287    /// `execute`'s node calls - see its own doc - so nothing inside a node
11288    /// can observe a park request until the node itself returns. This proves
11289    /// that structurally, with real `tokio` concurrency and a channel
11290    /// handshake (never a sleep, which would only prove "usually", not
11291    /// "cannot"): the "node" below reports that it has genuinely started,
11292    /// and only then is the park requested; the node still has to be told to
11293    /// finish before `park_here` is ever called, exactly mirroring every
11294    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
11295    /// in `execute`.
11296    #[tokio::test]
11297    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
11298        crate::run::pin_test_home();
11299        let mut runner = runner_at(RunStatus::Implementing);
11300        let interrupt = Pause::new();
11301        runner.watch_interrupt(interrupt.clone());
11302
11303        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
11304        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
11305
11306        // Stands in for one node's in-flight agent call: it proves it has
11307        // genuinely started, then blocks - exactly as a spawned CLI process
11308        // does - until told to finish.
11309        let node = async move {
11310            started_tx.send(()).expect("send started");
11311            finish_rx.await.expect("recv finish");
11312            "node finished"
11313        };
11314
11315        let interrupter = async move {
11316            started_rx.await.expect("recv started");
11317            // The call is now genuinely in flight. Ask it to park.
11318            interrupt.park_because("higher-priority task waiting");
11319            // Nothing the node does can observe this yet - there is no
11320            // check inside it, by construction - so let the executor run
11321            // anything pending and then let the node finish on its own.
11322            tokio::task::yield_now().await;
11323            finish_tx.send(()).expect("send finish");
11324        };
11325
11326        let (node_result, ()) = tokio::join!(node, interrupter);
11327        assert_eq!(
11328            node_result, "node finished",
11329            "the in-flight call ran to completion"
11330        );
11331
11332        // Only now, at the boundary the real `execute` would check right
11333        // after this node, does the park take effect.
11334        assert!(runner.park_here().expect("park_here"));
11335        assert!(runner.state.parked);
11336    }
11337
11338    /// A run parked mid-competition carries every field it had accumulated
11339    /// through the exact same disk round-trip an ordinary resume uses -
11340    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
11341    /// Nothing about parking for an interrupt is a special case of that path;
11342    /// this is what proves it rather than assuming it.
11343    #[test]
11344    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
11345        crate::run::pin_test_home();
11346        let mut runner = runner_at(RunStatus::Judging);
11347        // `Runner::resume` re-resolves roles from the saved config, which
11348        // refuses an empty roster - give it the same minimal one `conductor`
11349        // itself uses.
11350        runner.state.config.agents = vec![conductor()];
11351        runner.state.candidates = vec![Candidate {
11352            index: 0,
11353            label: 'A',
11354            agent: "alpha".to_owned(),
11355            branch: "magi/x/A".to_owned(),
11356            worktree: PathBuf::from("/nonexistent/worktree"),
11357            summary: "did the thing".to_owned(),
11358            stat: "1 file changed".to_owned(),
11359            files: 1,
11360            commits: 1,
11361            empty: false,
11362            failed: None,
11363            verified_noop: None,
11364            duration_ms: 1234,
11365            folded: false,
11366        }];
11367        let run_id = runner.state.id.clone();
11368
11369        let interrupt = Pause::new();
11370        runner.watch_interrupt(interrupt.clone());
11371        interrupt.park_because("task c3d4 asked to run first");
11372        assert!(runner.park_here().expect("park_here"));
11373
11374        let resumed = Runner::resume(&run_id).expect("resume");
11375        assert_eq!(resumed.state.candidates.len(), 1);
11376        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
11377        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
11378        assert_eq!(resumed.state.status, runner.state.status);
11379        assert!(
11380            resumed.state.parked,
11381            "still parked until `execute` actually walks the graph again"
11382        );
11383        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
11384    }
11385
11386    /// A fresh open question on `run`, stored and handed back for assertions.
11387    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
11388        let mut q = ask::Question::new(
11389            run.to_owned(),
11390            "implement".to_owned(),
11391            "impl-A".to_owned(),
11392            "Which storage backend should the cache use?".to_owned(),
11393            String::new(),
11394            vec!["SQLite".to_owned(), "Redis".to_owned()],
11395        );
11396        store.put(&mut q).unwrap();
11397        q
11398    }
11399
11400    #[test]
11401    fn a_failed_runs_open_question_is_abandoned() {
11402        ask_test_home();
11403        let store = ask::Questions::open();
11404        let mut runner = runner_at(RunStatus::Failed);
11405        let run = runner.state.id.clone();
11406        let q = ask_open_question(&store, &run);
11407
11408        runner.settle_questions();
11409
11410        let back = store.get(&q.id).unwrap();
11411        assert!(
11412            !back.status.open(),
11413            "the seat that asked died with the run; nobody is left to read an answer"
11414        );
11415        assert!(
11416            back.detail.contains(&run) && back.detail.contains("failed"),
11417            "the reason names what the run became, not just that it is gone: {}",
11418            back.detail
11419        );
11420    }
11421
11422    #[test]
11423    fn a_merged_runs_open_question_is_abandoned_too() {
11424        ask_test_home();
11425        let store = ask::Questions::open();
11426        // A run that finishes cleanly still leaves nobody to read an answer -
11427        // this is not only a failure-path cleanup.
11428        for status in [RunStatus::Merged, RunStatus::Ready] {
11429            let mut runner = runner_at(status);
11430            let run = runner.state.id.clone();
11431            let q = ask_open_question(&store, &run);
11432
11433            runner.settle_questions();
11434
11435            let back = store.get(&q.id).unwrap();
11436            assert!(
11437                !back.status.open(),
11438                "{status:?} run's question must not outlive the run"
11439            );
11440        }
11441    }
11442
11443    #[test]
11444    fn a_still_resumable_runs_open_question_is_left_alone() {
11445        ask_test_home();
11446        let store = ask::Questions::open();
11447        // `Blocked` and `Stalled` can still be resumed — the candidates, the
11448        // review round and the seat sessions are all still on disk — so a
11449        // question asked mid-round may yet get a real answer from a real
11450        // resume. Sweeping it here would be exactly the failure mode this
11451        // whole feature exists to avoid on the other side.
11452        for status in [RunStatus::Blocked, RunStatus::Stalled] {
11453            let mut runner = runner_at(status);
11454            let run = runner.state.id.clone();
11455            let q = ask_open_question(&store, &run);
11456
11457            runner.settle_questions();
11458
11459            let back = store.get(&q.id).unwrap();
11460            assert!(
11461                back.status.open(),
11462                "{status:?} is still alive; the question must still be waiting"
11463            );
11464        }
11465    }
11466
11467    #[test]
11468    fn settle_questions_never_touches_an_already_answered_question() {
11469        ask_test_home();
11470        let store = ask::Questions::open();
11471        let mut runner = runner_at(RunStatus::Failed);
11472        let run = runner.state.id.clone();
11473        let mut q = ask_open_question(&store, &run);
11474        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
11475            .unwrap();
11476        store.put(&mut q).unwrap();
11477
11478        // Called twice, the way a crash-recovered daemon reclaim and the
11479        // graph's own cleanup both can for the same run — `abandon_for_run`
11480        // only ever touches what is still open, so this must be inert both
11481        // times, not merely the second.
11482        runner.settle_questions();
11483        runner.settle_questions();
11484
11485        let back = store.get(&q.id).unwrap();
11486        assert_eq!(
11487            back.status,
11488            ask::QuestionStatus::Answered,
11489            "a real answer is a decision on record, never overwritten by a sweep"
11490        );
11491    }
11492
11493    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
11494    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
11495    /// without merging, whose winner is still the operator's answer to read.
11496    /// Nothing previously called `fold_run` itself with a real `tally`, so
11497    /// this is the first test to pin down the one distinction the whole
11498    /// automatic-fold feature depends on: the winner's worktree and branch
11499    /// must survive, everything else sharing the run's worktree bay - a
11500    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
11501    /// second sweep treats every non-winner directory under the bay alike -
11502    /// must not.
11503    #[tokio::test]
11504    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
11505        crate::run::pin_test_home();
11506        let tmp = tempfile::tempdir().expect("tempdir");
11507        let repo = tmp.path().join("repo");
11508        std::fs::create_dir_all(&repo).unwrap();
11509        init_repo(&repo);
11510
11511        let mut config = Config::default();
11512        config.graph.worktree_root = Some(tmp.path().join("wt"));
11513
11514        let mut state = RunState::new(
11515            repo.clone(),
11516            "main".to_owned(),
11517            "deadbeef".to_owned(),
11518            "task".to_owned(),
11519            config,
11520        );
11521        let root = state.worktree_root();
11522        let wt_a = root.join("cand-A");
11523        let wt_b = root.join("cand-B");
11524        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
11525            .await
11526            .expect("worktree A");
11527        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
11528            .await
11529            .expect("worktree B");
11530
11531        state.candidates = vec![
11532            Candidate {
11533                index: 0,
11534                label: 'A',
11535                agent: "alpha".to_owned(),
11536                branch: "magi/x/A".to_owned(),
11537                worktree: wt_a.clone(),
11538                summary: String::new(),
11539                stat: String::new(),
11540                files: 0,
11541                commits: 0,
11542                empty: false,
11543                failed: None,
11544                verified_noop: None,
11545                duration_ms: 0,
11546                folded: false,
11547            },
11548            Candidate {
11549                index: 1,
11550                label: 'B',
11551                agent: "beta".to_owned(),
11552                branch: "magi/x/B".to_owned(),
11553                worktree: wt_b.clone(),
11554                summary: String::new(),
11555                stat: String::new(),
11556                files: 0,
11557                commits: 0,
11558                empty: false,
11559                failed: None,
11560                verified_noop: None,
11561                duration_ms: 0,
11562                folded: false,
11563            },
11564        ];
11565        state.tally = Some(Tally {
11566            first_choice: BTreeMap::from([('A', 1)]),
11567            borda: BTreeMap::new(),
11568            winner: 'A',
11569            rankings: 1,
11570            unanimous_initial: true,
11571            deliberated: false,
11572            changed_votes: 0,
11573            unanimous_final: true,
11574            tie_break: None,
11575            judges: 1,
11576            present: 1,
11577            quorum: 1,
11578            met_quorum: true,
11579            uncontested: None,
11580        });
11581        state.status = RunStatus::Ready;
11582
11583        fold_run(&mut state, false, &crate::run::home())
11584            .await
11585            .expect("fold_run");
11586
11587        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
11588        assert!(
11589            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11590            "the unmerged winner's branch survives"
11591        );
11592        assert!(
11593            !state.candidates[0].folded,
11594            "the winner is not marked folded"
11595        );
11596
11597        assert!(!wt_b.exists(), "the loser's worktree is removed");
11598        assert!(
11599            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
11600            "the loser's branch is removed"
11601        );
11602        assert!(state.candidates[1].folded, "the loser is marked folded");
11603    }
11604
11605    /// A branch handed to a later run is that run's (and its pull request's):
11606    /// folding the run that released it must not delete it.
11607    #[tokio::test]
11608    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
11609        let tmp = tempfile::tempdir().expect("tempdir");
11610        let repo = tmp.path().join("repo");
11611        std::fs::create_dir_all(&repo).unwrap();
11612        init_repo(&repo);
11613        let home = tmp.path().join("home");
11614
11615        let mut config = Config::default();
11616        config.graph.worktree_root = Some(tmp.path().join("wt"));
11617        let mut state = RunState::new(
11618            repo.clone(),
11619            "main".to_owned(),
11620            "deadbeef".to_owned(),
11621            "task".to_owned(),
11622            config,
11623        );
11624        // The worktree is already gone (released); the branch survives.
11625        git::git(&repo, &["branch", "magi/x/A", "main"])
11626            .await
11627            .expect("branch");
11628        state.candidates = vec![Candidate {
11629            index: 0,
11630            label: 'A',
11631            agent: "alpha".to_owned(),
11632            branch: "magi/x/A".to_owned(),
11633            worktree: state.worktree_root().join("cand-A"),
11634            summary: String::new(),
11635            stat: String::new(),
11636            files: 0,
11637            commits: 0,
11638            empty: false,
11639            failed: None,
11640            verified_noop: None,
11641            duration_ms: 0,
11642            folded: true,
11643        }];
11644        state.released_to = Some("20260901-000000-new1".to_owned());
11645        state.released_branches = vec!["magi/x/A".to_owned()];
11646
11647        fold_run(&mut state, true, &home).await.expect("fold_run");
11648
11649        assert!(
11650            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11651            "the handed-over branch survives a fold"
11652        );
11653    }
11654
11655    /// A winner with nothing ahead of the base is caught before `gh` is ever
11656    /// asked for a pull request, and the message carries what the task's
11657    /// references resolved to.
11658    #[tokio::test]
11659    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
11660        let tmp = tempfile::tempdir().expect("tempdir");
11661        let repo = tmp.path().join("repo");
11662        std::fs::create_dir_all(&repo).unwrap();
11663        init_repo(&repo);
11664        let run = |args: &[&str]| {
11665            let out = std::process::Command::new("git")
11666                .quiet()
11667                .args(args)
11668                .current_dir(&repo)
11669                .output()
11670                .expect("spawn git");
11671            assert!(out.status.success(), "git {args:?}");
11672        };
11673        run(&["branch", "magi/x/A"]);
11674        run(&["checkout", "-q", "-b", "magi/x/B"]);
11675        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
11676        run(&["add", "-A"]);
11677        run(&["commit", "-q", "-m", "work"]);
11678        run(&["checkout", "-q", "main"]);
11679
11680        // A pull request is compared against the remote's base, so the
11681        // fixture needs one. Before it exists nothing can be read, and the
11682        // answer must be "not empty".
11683        let probe = RunState::new(
11684            repo.clone(),
11685            "main".to_owned(),
11686            "deadbeef".to_owned(),
11687            "task".to_owned(),
11688            Config::default(),
11689        );
11690        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
11691        let bare = tmp.path().join("origin.git");
11692        let out = std::process::Command::new("git")
11693            .quiet()
11694            .args(["init", "-q", "--bare"])
11695            .arg(&bare)
11696            .output()
11697            .expect("spawn git");
11698        assert!(out.status.success(), "git init --bare");
11699        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
11700        run(&["push", "-q", "origin", "main"]);
11701
11702        let mut state = RunState::new(
11703            repo.clone(),
11704            "main".to_owned(),
11705            "deadbeef".to_owned(),
11706            "task".to_owned(),
11707            Config::default(),
11708        );
11709        state.seeds = vec![refs::Seed {
11710            token: "magi/27b2/A".to_owned(),
11711            kind: refs::SeedKind::Unresolved,
11712            sha: String::new(),
11713            branch: true,
11714            detail: "no branch or commit named magi/27b2/A".to_owned(),
11715        }];
11716
11717        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
11718        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
11719        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
11720        let detail = empty_candidate_detail(&state, "main");
11721        assert!(detail.starts_with("empty candidate"), "{detail}");
11722        assert!(detail.contains("magi/27b2/A"), "{detail}");
11723    }
11724
11725    /// `status == Ready` used to be read as "this is the harmless
11726    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
11727    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
11728    /// run's PR was closed without merging — and reentering `merge` with
11729    /// `mode` still `Pr` does not know the difference, so it pushed and
11730    /// opened a second pull request. `mode == Local` reproduces the same
11731    /// blind spot without a network call: reentry must not attempt another
11732    /// git merge once this node has already recorded an outcome.
11733    #[tokio::test]
11734    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
11735        let tmp = tempfile::tempdir().expect("tempdir");
11736        let repo = tmp.path().join("repo");
11737        std::fs::create_dir_all(&repo).unwrap();
11738        init_repo(&repo);
11739
11740        let mut config = Config::default();
11741        config.merge.mode = MergeMode::Local;
11742
11743        let mut state = RunState::new(
11744            repo.clone(),
11745            "main".to_owned(),
11746            "deadbeef".to_owned(),
11747            "task".to_owned(),
11748            config,
11749        );
11750        state.candidates = vec![Candidate {
11751            index: 0,
11752            label: 'A',
11753            agent: "alpha".to_owned(),
11754            branch: "does-not-exist".to_owned(),
11755            worktree: repo.clone(),
11756            summary: String::new(),
11757            stat: String::new(),
11758            files: 0,
11759            commits: 0,
11760            empty: false,
11761            failed: None,
11762            verified_noop: None,
11763            duration_ms: 0,
11764            folded: false,
11765        }];
11766        state.tally = Some(Tally {
11767            first_choice: BTreeMap::from([('A', 1)]),
11768            borda: BTreeMap::new(),
11769            winner: 'A',
11770            rankings: 1,
11771            unanimous_initial: true,
11772            deliberated: false,
11773            changed_votes: 0,
11774            unanimous_final: true,
11775            tie_break: None,
11776            judges: 0,
11777            present: 0,
11778            quorum: 0,
11779            met_quorum: true,
11780            uncontested: Some("only candidate A produced a change".to_owned()),
11781        });
11782        state.reviews = vec![ReviewRound {
11783            round: 1,
11784            head: "deadbeef".to_owned(),
11785            verified_head: None,
11786            verified_at: None,
11787            reviews: Vec::new(),
11788            e2e: Vec::new(),
11789            fix: None,
11790            blocking: 0,
11791            answered: 0,
11792            expected: 0,
11793            clean: true,
11794            verify_retried: false,
11795            e2e_deferred: false,
11796            e2e_defer_reason: None,
11797            progressed: false,
11798            vote_split: false,
11799            reconsideration: Vec::new(),
11800            verdict: None,
11801        }];
11802        state.gate = vec![CommandOutcome {
11803            command: "test".to_owned(),
11804            code: Some(0),
11805            output_tail: String::new(),
11806            duration_ms: 0,
11807            resource_blocked: false,
11808        }];
11809        state.gate_ran = true;
11810        // Reached its conclusion already — e.g. `land` closing the PR without
11811        // merging it, which (like the honest `MergeMode::None` path) leaves
11812        // `status` at `Ready`. The recorded outcome is what actually marks
11813        // this node done.
11814        state.status = RunStatus::Ready;
11815        state.merge = Some(MergeOutcome {
11816            mode: MergeMode::Local,
11817            ok: false,
11818            detail: "already concluded".to_owned(),
11819            empty: false,
11820        });
11821
11822        let mut runner = Runner {
11823            state,
11824            roles: ResolvedRoles {
11825                implementers: Vec::new(),
11826                judges: Vec::new(),
11827                reviewers: Vec::new(),
11828                fixer: None,
11829                conductor: conductor(),
11830                implementer_roster: Vec::new(),
11831                judge_roster: Vec::new(),
11832                reviewer_roster: Vec::new(),
11833            },
11834            sem: Arc::new(Semaphore::new(1)),
11835            pause: Pause::new(),
11836            interrupt: Pause::new(),
11837        };
11838
11839        runner.merge().await.expect("merge");
11840
11841        assert_eq!(
11842            runner.state.status,
11843            RunStatus::Ready,
11844            "a concluded run's status must not change on reentry"
11845        );
11846        assert_eq!(
11847            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11848            Some("already concluded"),
11849            "merge must not run again once the node already recorded an outcome"
11850        );
11851    }
11852
11853    /// `gate` leaves `state.gate_ran` false both before it has ever run and
11854    /// when its last attempt was resource-blocked (the shared build cache
11855    /// could not be acquired or confirmed fresh in time - see
11856    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
11857    /// `Vec` this also leaves behind used to read as "nothing failed" and let
11858    /// a run merge a tree the gate never actually checked - exactly the case
11859    /// a contended cache produces on every retry until it clears. `merge`
11860    /// must refuse until `gate` has actually recorded an attempt.
11861    #[tokio::test]
11862    async fn merge_refuses_a_gate_that_has_not_actually_run() {
11863        let tmp = tempfile::tempdir().expect("tempdir");
11864        let repo = tmp.path().join("repo");
11865        std::fs::create_dir_all(&repo).unwrap();
11866        init_repo(&repo);
11867
11868        let mut config = Config::default();
11869        config.merge.mode = MergeMode::Local;
11870
11871        let mut state = RunState::new(
11872            repo.clone(),
11873            "main".to_owned(),
11874            "deadbeef".to_owned(),
11875            "task".to_owned(),
11876            config,
11877        );
11878        state.candidates = vec![Candidate {
11879            index: 0,
11880            label: 'A',
11881            agent: "alpha".to_owned(),
11882            branch: "does-not-exist".to_owned(),
11883            worktree: repo.clone(),
11884            summary: String::new(),
11885            stat: String::new(),
11886            files: 0,
11887            commits: 0,
11888            empty: false,
11889            failed: None,
11890            verified_noop: None,
11891            duration_ms: 0,
11892            folded: false,
11893        }];
11894        state.tally = Some(Tally {
11895            first_choice: BTreeMap::from([('A', 1)]),
11896            borda: BTreeMap::new(),
11897            winner: 'A',
11898            rankings: 1,
11899            unanimous_initial: true,
11900            deliberated: false,
11901            changed_votes: 0,
11902            unanimous_final: true,
11903            tie_break: None,
11904            judges: 0,
11905            present: 0,
11906            quorum: 0,
11907            met_quorum: true,
11908            uncontested: Some("only candidate A produced a change".to_owned()),
11909        });
11910        state.reviews = vec![ReviewRound {
11911            round: 1,
11912            head: "deadbeef".to_owned(),
11913            verified_head: None,
11914            verified_at: None,
11915            reviews: Vec::new(),
11916            e2e: Vec::new(),
11917            fix: None,
11918            blocking: 0,
11919            answered: 0,
11920            expected: 0,
11921            clean: true,
11922            verify_retried: false,
11923            e2e_deferred: false,
11924            e2e_defer_reason: None,
11925            progressed: false,
11926            vote_split: false,
11927            reconsideration: Vec::new(),
11928            verdict: None,
11929        }];
11930        // The point: `gate` has not recorded anything yet.
11931        state.gate = Vec::new();
11932        state.gate_ran = false;
11933        state.status = RunStatus::Gating;
11934
11935        let mut runner = Runner {
11936            state,
11937            roles: ResolvedRoles {
11938                implementers: Vec::new(),
11939                judges: Vec::new(),
11940                reviewers: Vec::new(),
11941                fixer: None,
11942                conductor: conductor(),
11943                implementer_roster: Vec::new(),
11944                judge_roster: Vec::new(),
11945                reviewer_roster: Vec::new(),
11946            },
11947            sem: Arc::new(Semaphore::new(1)),
11948            pause: Pause::new(),
11949            interrupt: Pause::new(),
11950        };
11951
11952        runner.merge().await.expect("merge");
11953
11954        assert!(
11955            runner.state.merge.is_none(),
11956            "an empty gate must never be read as a passing one: {:?}",
11957            runner.state.merge
11958        );
11959    }
11960
11961    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
11962    /// commands configured and `merge.mode` is `none` (a review-only run).
11963    /// `gate` must still record a real attempt — zero commands, vacuously
11964    /// passed — rather than leaving `state.gate` empty in a way `merge`
11965    /// cannot tell apart from "never ran"; otherwise the run reaches
11966    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
11967    #[tokio::test]
11968    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
11969        let tmp = tempfile::tempdir().expect("tempdir");
11970        let repo = tmp.path().join("repo");
11971        std::fs::create_dir_all(&repo).unwrap();
11972        init_repo(&repo);
11973
11974        // Default config: `verify.gate` empty, `merge.mode` is `none`.
11975        let config = Config::default();
11976
11977        let mut state = RunState::new(
11978            repo.clone(),
11979            "main".to_owned(),
11980            "deadbeef".to_owned(),
11981            "task".to_owned(),
11982            config,
11983        );
11984        state.candidates = vec![Candidate {
11985            index: 0,
11986            label: 'A',
11987            agent: "alpha".to_owned(),
11988            branch: "does-not-exist".to_owned(),
11989            worktree: repo.clone(),
11990            summary: String::new(),
11991            stat: String::new(),
11992            files: 0,
11993            commits: 0,
11994            empty: false,
11995            failed: None,
11996            verified_noop: None,
11997            duration_ms: 0,
11998            folded: false,
11999        }];
12000        state.tally = Some(Tally {
12001            first_choice: BTreeMap::from([('A', 1)]),
12002            borda: BTreeMap::new(),
12003            winner: 'A',
12004            rankings: 1,
12005            unanimous_initial: true,
12006            deliberated: false,
12007            changed_votes: 0,
12008            unanimous_final: true,
12009            tie_break: None,
12010            judges: 0,
12011            present: 0,
12012            quorum: 0,
12013            met_quorum: true,
12014            uncontested: Some("only candidate A produced a change".to_owned()),
12015        });
12016        state.reviews = vec![ReviewRound {
12017            round: 1,
12018            head: "deadbeef".to_owned(),
12019            verified_head: None,
12020            verified_at: None,
12021            reviews: Vec::new(),
12022            e2e: Vec::new(),
12023            fix: None,
12024            blocking: 0,
12025            answered: 0,
12026            expected: 0,
12027            clean: true,
12028            verify_retried: false,
12029            e2e_deferred: false,
12030            e2e_defer_reason: None,
12031            progressed: false,
12032            vote_split: false,
12033            reconsideration: Vec::new(),
12034            verdict: None,
12035        }];
12036
12037        let mut runner = Runner {
12038            state,
12039            roles: ResolvedRoles {
12040                implementers: Vec::new(),
12041                judges: Vec::new(),
12042                reviewers: Vec::new(),
12043                fixer: None,
12044                conductor: conductor(),
12045                implementer_roster: Vec::new(),
12046                judge_roster: Vec::new(),
12047                reviewer_roster: Vec::new(),
12048            },
12049            sem: Arc::new(Semaphore::new(1)),
12050            pause: Pause::new(),
12051            interrupt: Pause::new(),
12052        };
12053
12054        runner.gate().await.expect("gate");
12055        assert!(
12056            runner.state.gate_ran,
12057            "zero configured commands is still a real attempt, not an unrun gate"
12058        );
12059        assert!(runner.state.gate.is_empty());
12060        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12061        assert_ne!(
12062            runner.state.status,
12063            RunStatus::Blocked,
12064            "a gate with nothing to check must not read as failed"
12065        );
12066
12067        runner.merge().await.expect("merge");
12068        assert_eq!(
12069            runner.state.status,
12070            RunStatus::Ready,
12071            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12072        );
12073    }
12074
12075    /// `Config::cache_dir` is derived from `verify.e2e` as well as
12076    /// `verify.gate` (so the e2e leg and the final gate never build against
12077    /// different directories). With zero `verify.gate` commands but a
12078    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
12079    /// that lease before discovering it had nothing to run - so a repo with
12080    /// no gate commands could come back `resource_blocked` (and therefore
12081    /// still `gate_ran == false`) on nothing but an unrelated run holding the
12082    /// cache, exactly the contention this run's own zero commands could
12083    /// never have touched. `gate` must recognise there is nothing to check
12084    /// before it ever asks for the lease.
12085    #[tokio::test]
12086    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12087        crate::run::pin_test_home();
12088        let home = crate::run::home();
12089
12090        let tmp = tempfile::tempdir().expect("tempdir");
12091        let repo = tmp.path().join("repo");
12092        std::fs::create_dir_all(&repo).unwrap();
12093        init_repo(&repo);
12094        // Unique to this test, so holding its lease cannot collide with
12095        // another test sharing the same process-wide `home`.
12096        let cache_dir = tmp.path().join("target");
12097
12098        let mut config = Config::default();
12099        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12100        // `verify.gate` stays empty (the default). Bounded so a regression
12101        // that does start waiting fails the test in seconds, not hangs it.
12102        config.graph.timeout_verify = Some(2);
12103
12104        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12105        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12106            .expect("no io error acquiring directly")
12107        {
12108            crate::cache::AcquireOutcome::Acquired(g) => g,
12109            crate::cache::AcquireOutcome::Busy(b) => {
12110                panic!("expected the direct acquire to win the lease first: {b:?}")
12111            }
12112        };
12113
12114        let mut state = RunState::new(
12115            repo.clone(),
12116            "main".to_owned(),
12117            "deadbeef".to_owned(),
12118            "task".to_owned(),
12119            config,
12120        );
12121        state.candidates = vec![Candidate {
12122            index: 0,
12123            label: 'A',
12124            agent: "alpha".to_owned(),
12125            branch: "does-not-exist".to_owned(),
12126            worktree: repo.clone(),
12127            summary: String::new(),
12128            stat: String::new(),
12129            files: 0,
12130            commits: 0,
12131            empty: false,
12132            failed: None,
12133            verified_noop: None,
12134            duration_ms: 0,
12135            folded: false,
12136        }];
12137        state.tally = Some(Tally {
12138            first_choice: BTreeMap::from([('A', 1)]),
12139            borda: BTreeMap::new(),
12140            winner: 'A',
12141            rankings: 1,
12142            unanimous_initial: true,
12143            deliberated: false,
12144            changed_votes: 0,
12145            unanimous_final: true,
12146            tie_break: None,
12147            judges: 0,
12148            present: 0,
12149            quorum: 0,
12150            met_quorum: true,
12151            uncontested: Some("only candidate A produced a change".to_owned()),
12152        });
12153        state.reviews = vec![ReviewRound {
12154            round: 1,
12155            head: "deadbeef".to_owned(),
12156            verified_head: None,
12157            verified_at: None,
12158            reviews: Vec::new(),
12159            e2e: Vec::new(),
12160            fix: None,
12161            blocking: 0,
12162            answered: 0,
12163            expected: 0,
12164            clean: true,
12165            verify_retried: false,
12166            e2e_deferred: false,
12167            e2e_defer_reason: None,
12168            progressed: false,
12169            vote_split: false,
12170            reconsideration: Vec::new(),
12171            verdict: None,
12172        }];
12173
12174        let mut runner = Runner {
12175            state,
12176            roles: ResolvedRoles {
12177                implementers: Vec::new(),
12178                judges: Vec::new(),
12179                reviewers: Vec::new(),
12180                fixer: None,
12181                conductor: conductor(),
12182                implementer_roster: Vec::new(),
12183                judge_roster: Vec::new(),
12184                reviewer_roster: Vec::new(),
12185            },
12186            sem: Arc::new(Semaphore::new(1)),
12187            pause: Pause::new(),
12188            interrupt: Pause::new(),
12189        };
12190
12191        let started = std::time::Instant::now();
12192        runner.gate().await.expect("gate");
12193        assert!(
12194            started.elapsed() < Duration::from_secs(1),
12195            "a gate with nothing to run must never wait on a lease it never needed"
12196        );
12197        assert!(
12198            runner.state.gate_ran,
12199            "zero commands is still a real, immediate attempt"
12200        );
12201        assert!(runner.state.gate.is_empty());
12202        assert_ne!(
12203            runner.state.status,
12204            RunStatus::Blocked,
12205            "must not read as resource-blocked on a lease it never asked for"
12206        );
12207    }
12208
12209    /// The addendum's second gap: a `verify.gate` command running for real
12210    /// wall-clock time had nothing at all to show for it in `active` before
12211    /// `run_commands` learned to record it — a run could sit in `Gating` for
12212    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
12213    /// what was actually happening. Proven with a genuinely still-running
12214    /// command, not just a before/after check on the final state: a poller
12215    /// task reads the same `run.json` `gate()` is writing, the same way the
12216    /// phone or `magi show` would, while the shell command is still blocked
12217    /// on its own release marker.
12218    #[tokio::test]
12219    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
12220        crate::run::pin_test_home();
12221
12222        let tmp = tempfile::tempdir().expect("tempdir");
12223        let repo = tmp.path().join("repo");
12224        std::fs::create_dir_all(&repo).unwrap();
12225        init_repo(&repo);
12226
12227        let mut config = Config::default();
12228        config.verify.gate = vec![
12229            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
12230             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
12231                .to_owned(),
12232        ];
12233
12234        let mut state = RunState::new(
12235            repo.clone(),
12236            "main".to_owned(),
12237            "deadbeef".to_owned(),
12238            "task".to_owned(),
12239            config,
12240        );
12241        let run_id = state.id.clone();
12242        state.candidates = vec![Candidate {
12243            index: 0,
12244            label: 'A',
12245            agent: "alpha".to_owned(),
12246            branch: "does-not-exist".to_owned(),
12247            worktree: repo.clone(),
12248            summary: String::new(),
12249            stat: String::new(),
12250            files: 0,
12251            commits: 0,
12252            empty: false,
12253            failed: None,
12254            verified_noop: None,
12255            duration_ms: 0,
12256            folded: false,
12257        }];
12258        state.tally = Some(Tally {
12259            first_choice: BTreeMap::from([('A', 1)]),
12260            borda: BTreeMap::new(),
12261            winner: 'A',
12262            rankings: 1,
12263            unanimous_initial: true,
12264            deliberated: false,
12265            changed_votes: 0,
12266            unanimous_final: true,
12267            tie_break: None,
12268            judges: 0,
12269            present: 0,
12270            quorum: 0,
12271            met_quorum: true,
12272            uncontested: Some("only candidate A produced a change".to_owned()),
12273        });
12274        state.reviews = vec![ReviewRound {
12275            round: 1,
12276            head: "deadbeef".to_owned(),
12277            verified_head: None,
12278            verified_at: None,
12279            reviews: Vec::new(),
12280            e2e: Vec::new(),
12281            fix: None,
12282            blocking: 0,
12283            answered: 0,
12284            expected: 0,
12285            clean: true,
12286            verify_retried: false,
12287            e2e_deferred: false,
12288            e2e_defer_reason: None,
12289            progressed: false,
12290            vote_split: false,
12291            reconsideration: Vec::new(),
12292            verdict: None,
12293        }];
12294
12295        let mut runner = Runner {
12296            state,
12297            roles: ResolvedRoles {
12298                implementers: Vec::new(),
12299                judges: Vec::new(),
12300                reviewers: Vec::new(),
12301                fixer: None,
12302                conductor: conductor(),
12303                implementer_roster: Vec::new(),
12304                judge_roster: Vec::new(),
12305                reviewer_roster: Vec::new(),
12306            },
12307            sem: Arc::new(Semaphore::new(1)),
12308            pause: Pause::new(),
12309            interrupt: Pause::new(),
12310        };
12311
12312        let started_marker = repo.join("started.marker");
12313        let release_marker = repo.join("release.marker");
12314        let poller = tokio::spawn(async move {
12315            // Bounded so a regression that never records the task entry
12316            // fails this test in seconds instead of hanging the suite —
12317            // the same shape `a_park_requested_while_a_seat_is_mid_call_
12318            // does_not_cut_it_short` uses for the same reason.
12319            for _ in 0..100 {
12320                if started_marker.exists()
12321                    && let Ok(s) = crate::run::RunState::load(&run_id)
12322                    && let Some(a) = s.active.get("gate")
12323                {
12324                    std::fs::write(&release_marker, b"go").expect("release marker");
12325                    return Some(a.clone());
12326                }
12327                tokio::time::sleep(Duration::from_millis(50)).await;
12328            }
12329            None
12330        });
12331
12332        runner.gate().await.expect("gate");
12333        let captured = poller.await.expect("poller task");
12334        let captured = captured.expect(
12335            "the poller never saw a `gate` task entry in run.json while the command was \
12336             still blocked on its own release marker",
12337        );
12338
12339        assert_eq!(captured.task.as_deref(), Some("gate"));
12340        assert_eq!(captured.node, "gate");
12341        assert_eq!(captured.index, Some(1));
12342        assert_eq!(captured.total, Some(1));
12343        assert!(
12344            captured
12345                .command
12346                .as_deref()
12347                .is_some_and(|c| c.contains("started.marker")),
12348            "{captured:?}"
12349        );
12350
12351        assert!(
12352            runner.state.active.is_empty(),
12353            "the entry must be cleared once the command actually finished: {:?}",
12354            runner.state.active
12355        );
12356        assert!(runner.state.gate_ran);
12357        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
12358    }
12359
12360    /// The hand-off over a blocking finding a reviewer rejected on leaves a
12361    /// record for `land`; one with only a Minor, or no reject, leaves none.
12362    #[tokio::test]
12363    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
12364        use crate::verdict::{Finding, ReviewVote, Severity};
12365        crate::run::pin_test_home();
12366        let tmp = tempfile::tempdir().expect("tempdir");
12367        let repo = tmp.path().join("repo");
12368        std::fs::create_dir_all(&repo).unwrap();
12369        init_repo(&repo);
12370
12371        for (severity, vote, expect) in [
12372            (Severity::Major, ReviewVote::Reject, true),
12373            (Severity::Minor, ReviewVote::Reject, false),
12374            (Severity::Major, ReviewVote::Approve, false),
12375        ] {
12376            let mut round = review_round(false, 1, 1, 1, false, true);
12377            round.reviews = vec![ReviewRecord {
12378                reviewer: 1,
12379                agent: "alpha".to_owned(),
12380                summary: String::new(),
12381                findings: vec![Finding {
12382                    id: "R1-1-1".to_owned(),
12383                    severity,
12384                    file: None,
12385                    line: None,
12386                    title: "t".to_owned(),
12387                    detail: String::new(),
12388                }],
12389                vote: Some(vote),
12390                failed: None,
12391                duration_ms: 0,
12392                attempts: 0,
12393            }];
12394            let mut state = RunState::new(
12395                repo.clone(),
12396                "main".to_owned(),
12397                "deadbeef".to_owned(),
12398                "task".to_owned(),
12399                Config::default(),
12400            );
12401            state.reviews = vec![round];
12402            let mut runner = Runner {
12403                state,
12404                roles: ResolvedRoles {
12405                    implementers: Vec::new(),
12406                    judges: Vec::new(),
12407                    reviewers: Vec::new(),
12408                    fixer: None,
12409                    conductor: conductor(),
12410                    implementer_roster: Vec::new(),
12411                    judge_roster: Vec::new(),
12412                    reviewer_roster: Vec::new(),
12413                },
12414                sem: Arc::new(Semaphore::new(1)),
12415                pause: Pause::new(),
12416                interrupt: Pause::new(),
12417            };
12418            let shell = runner.state.config.shell();
12419            runner
12420                .stop_reviewing("round budget spent", &shell, &repo)
12421                .await
12422                .expect("stop_reviewing");
12423            assert_eq!(runner.state.status, RunStatus::Gating);
12424            assert_eq!(
12425                runner.state.contested_handoff.is_some(),
12426                expect,
12427                "{severity:?} + {vote:?}"
12428            );
12429        }
12430    }
12431
12432    /// The shape the incident this whole fix responds to actually had: the
12433    /// round budget spent, the last round's own e2e blocked on the shared
12434    /// build cache (held here by a live pid — this test process — exactly
12435    /// `cache`'s own unit tests' pattern for "another owner, still alive"
12436    /// without forking a process). `stop_reviewing` must retry it — not
12437    /// silently leave the round looking untouched (the catch-up-only half of
12438    /// the bug), and not read the contention as a red `e2e` and block the
12439    /// run on it (the other half). Called directly, the same way
12440    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
12441    /// above exercises `gate`, so this never needs a real cargo build to
12442    /// reach: the lease is never released, so `with_cache_lease` never gets
12443    /// past acquiring it into anything that would need a real workspace.
12444    #[tokio::test]
12445    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
12446        crate::run::pin_test_home();
12447        let home = crate::run::home();
12448
12449        let tmp = tempfile::tempdir().expect("tempdir");
12450        let repo = tmp.path().join("repo");
12451        std::fs::create_dir_all(&repo).unwrap();
12452        init_repo(&repo);
12453        let head = crate::git::rev_parse(&repo, "HEAD")
12454            .await
12455            .expect("rev-parse");
12456        // Unique to this test, so holding its lease cannot collide with
12457        // another test sharing the same process-wide `home`.
12458        let cache_dir = tmp.path().join("target");
12459
12460        let mut config = Config::default();
12461        config.verify.e2e = vec![format!(
12462            "CARGO_TARGET_DIR='{}' test -f README.md",
12463            cache_dir.display()
12464        )];
12465        config.graph.review_rounds = 1;
12466        // Bounded so a regression that does start waiting fails the test in
12467        // seconds, not hangs it.
12468        config.graph.timeout_verify = Some(2);
12469
12470        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12471        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12472            .expect("no io error acquiring directly")
12473        {
12474            crate::cache::AcquireOutcome::Acquired(g) => g,
12475            crate::cache::AcquireOutcome::Busy(b) => {
12476                panic!("expected the direct acquire to win the lease first: {b:?}")
12477            }
12478        };
12479
12480        let mut state = RunState::new(
12481            repo.clone(),
12482            "main".to_owned(),
12483            head.clone(),
12484            "task".to_owned(),
12485            config,
12486        );
12487        state.candidates = vec![Candidate {
12488            index: 0,
12489            label: 'A',
12490            agent: "alpha".to_owned(),
12491            branch: "does-not-exist".to_owned(),
12492            worktree: repo.clone(),
12493            summary: String::new(),
12494            stat: String::new(),
12495            files: 0,
12496            commits: 0,
12497            empty: false,
12498            failed: None,
12499            verified_noop: None,
12500            duration_ms: 0,
12501            folded: false,
12502        }];
12503        state.tally = Some(Tally {
12504            first_choice: BTreeMap::from([('A', 1)]),
12505            borda: BTreeMap::new(),
12506            winner: 'A',
12507            rankings: 1,
12508            unanimous_initial: true,
12509            deliberated: false,
12510            changed_votes: 0,
12511            unanimous_final: true,
12512            tie_break: None,
12513            judges: 0,
12514            present: 0,
12515            quorum: 0,
12516            met_quorum: true,
12517            uncontested: Some("only candidate A produced a change".to_owned()),
12518        });
12519        // The round budget's last round, deferred: `needs_catchup_run`'s
12520        // other trigger. `stop_reviewing`'s retry machinery must treat this
12521        // exactly like a resource-blocked attempt once it actually runs.
12522        state.reviews = vec![ReviewRound {
12523            round: 1,
12524            head: head.clone(),
12525            verified_head: None,
12526            verified_at: None,
12527            reviews: Vec::new(),
12528            e2e: Vec::new(),
12529            fix: None,
12530            blocking: 1,
12531            answered: 1,
12532            expected: 1,
12533            clean: false,
12534            verify_retried: false,
12535            e2e_deferred: true,
12536            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
12537            progressed: false,
12538            vote_split: false,
12539            reconsideration: Vec::new(),
12540            verdict: None,
12541        }];
12542
12543        let mut runner = Runner {
12544            state,
12545            roles: ResolvedRoles {
12546                implementers: Vec::new(),
12547                judges: Vec::new(),
12548                reviewers: Vec::new(),
12549                fixer: None,
12550                conductor: conductor(),
12551                implementer_roster: Vec::new(),
12552                judge_roster: Vec::new(),
12553                reviewer_roster: Vec::new(),
12554            },
12555            sem: Arc::new(Semaphore::new(1)),
12556            pause: Pause::new(),
12557            interrupt: Pause::new(),
12558        };
12559
12560        let shell = runner.state.config.shell();
12561        runner
12562            .stop_reviewing("round budget spent", &shell, &repo)
12563            .await
12564            .expect("stop_reviewing");
12565
12566        let last = runner.state.reviews.last().expect("round record");
12567        assert_eq!(
12568            last.e2e_status(),
12569            E2eStatus::ResourceBlocked,
12570            "the shared cache is still held; the attempt must read as blocked, not deferred or \
12571             failed: {last:?}"
12572        );
12573        assert_eq!(
12574            last.verified_head.as_deref(),
12575            Some(head.as_str()),
12576            "which commit this attempt targeted is known even though nothing finished checking \
12577             it"
12578        );
12579        let first_attempt_at = last
12580            .verified_at
12581            .expect("when this attempt ran is known too");
12582        assert_ne!(
12583            runner.state.status,
12584            RunStatus::Blocked,
12585            "contention is evidence about the machine, not the patch — it must not settle the \
12586             run as blocked: {:?}",
12587            runner.state.status
12588        );
12589        assert!(
12590            !runner
12591                .state
12592                .events
12593                .iter()
12594                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
12595            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
12596            runner.state.events
12597        );
12598
12599        // The cache is still held: a later reentry must retry the same
12600        // round's verification again — not leave it looking exactly as
12601        // untouched as the first blocked attempt, which is indistinguishable
12602        // from never having tried again at all.
12603        runner
12604            .stop_reviewing("round budget spent", &shell, &repo)
12605            .await
12606            .expect("stop_reviewing retry");
12607        assert_eq!(
12608            runner.state.reviews.len(),
12609            1,
12610            "no new round was started: {:?}",
12611            runner.state.reviews
12612        );
12613        let last = runner.state.reviews.last().expect("round record");
12614        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
12615        assert!(
12616            last.verified_at.expect("still known") > first_attempt_at,
12617            "a second reentry must be a fresh attempt, not a stale copy of the first"
12618        );
12619        assert_ne!(runner.state.status, RunStatus::Blocked);
12620
12621        held.release();
12622    }
12623
12624    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
12625    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
12626    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
12627    /// own top-of-function fast path (`review_conclusion`) correctly reads
12628    /// this shape as `None` rather than guessing `Blocked`, and the loop's
12629    /// own `for` range is empty once the round budget is spent, so
12630    /// `review_loop` must retry the check itself rather than silently doing
12631    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
12632    /// above exercises directly, but through `review_loop`'s own entry point
12633    /// this time, proving the wiring between the two rather than just the
12634    /// retry logic in isolation.
12635    #[tokio::test]
12636    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
12637        crate::run::pin_test_home();
12638        let home = crate::run::home();
12639
12640        let tmp = tempfile::tempdir().expect("tempdir");
12641        let repo = tmp.path().join("repo");
12642        std::fs::create_dir_all(&repo).unwrap();
12643        init_repo(&repo);
12644        let head = crate::git::rev_parse(&repo, "HEAD")
12645            .await
12646            .expect("rev-parse");
12647        let cache_dir = tmp.path().join("target");
12648
12649        let mut config = Config::default();
12650        config.verify.e2e = vec![format!(
12651            "CARGO_TARGET_DIR='{}' test -f README.md",
12652            cache_dir.display()
12653        )];
12654        config.graph.review_rounds = 1;
12655        config.graph.timeout_verify = Some(2);
12656
12657        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12658        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12659            .expect("no io error acquiring directly")
12660        {
12661            crate::cache::AcquireOutcome::Acquired(g) => g,
12662            crate::cache::AcquireOutcome::Busy(b) => {
12663                panic!("expected the direct acquire to win the lease first: {b:?}")
12664            }
12665        };
12666
12667        let mut state = RunState::new(
12668            repo.clone(),
12669            "main".to_owned(),
12670            head.clone(),
12671            "task".to_owned(),
12672            config,
12673        );
12674        state.candidates = vec![Candidate {
12675            index: 0,
12676            label: 'A',
12677            agent: "alpha".to_owned(),
12678            branch: "does-not-exist".to_owned(),
12679            worktree: repo.clone(),
12680            summary: String::new(),
12681            stat: String::new(),
12682            files: 0,
12683            commits: 0,
12684            empty: false,
12685            failed: None,
12686            verified_noop: None,
12687            duration_ms: 0,
12688            folded: false,
12689        }];
12690        state.tally = Some(Tally {
12691            first_choice: BTreeMap::from([('A', 1)]),
12692            borda: BTreeMap::new(),
12693            winner: 'A',
12694            rankings: 1,
12695            unanimous_initial: true,
12696            deliberated: false,
12697            changed_votes: 0,
12698            unanimous_final: true,
12699            tie_break: None,
12700            judges: 0,
12701            present: 0,
12702            quorum: 0,
12703            met_quorum: true,
12704            uncontested: Some("only candidate A produced a change".to_owned()),
12705        });
12706        // The exact shape a prior process's `stop_reviewing` would have left
12707        // on disk: the round budget's last round, a real attempt already
12708        // made and already resource-blocked.
12709        state.reviews = vec![ReviewRound {
12710            round: 1,
12711            head: head.clone(),
12712            verified_head: Some(head.clone()),
12713            verified_at: Some(jiff::Timestamp::now()),
12714            reviews: Vec::new(),
12715            e2e: vec![CommandOutcome {
12716                command: format!(
12717                    "CARGO_TARGET_DIR='{}' test -f README.md",
12718                    cache_dir.display()
12719                ),
12720                code: None,
12721                output_tail: "waiting for the shared build cache".to_owned(),
12722                duration_ms: 0,
12723                resource_blocked: true,
12724            }],
12725            fix: None,
12726            blocking: 1,
12727            answered: 1,
12728            expected: 1,
12729            clean: false,
12730            verify_retried: false,
12731            e2e_deferred: false,
12732            e2e_defer_reason: None,
12733            progressed: false,
12734            vote_split: false,
12735            reconsideration: Vec::new(),
12736            verdict: None,
12737        }];
12738
12739        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
12740        let mut runner = Runner {
12741            state,
12742            roles: ResolvedRoles {
12743                implementers: Vec::new(),
12744                judges: Vec::new(),
12745                reviewers: Vec::new(),
12746                fixer: None,
12747                conductor: conductor(),
12748                implementer_roster: Vec::new(),
12749                judge_roster: Vec::new(),
12750                reviewer_roster: Vec::new(),
12751            },
12752            sem: Arc::new(Semaphore::new(1)),
12753            pause: Pause::new(),
12754            interrupt: Pause::new(),
12755        };
12756
12757        // The lease is still held throughout, so this reentry's own retry is
12758        // also contended — proving `review_loop` actually tried again (not
12759        // that it happened to succeed) is what the timestamp comparison
12760        // below is for.
12761        runner.review_loop().await.expect("review_loop");
12762
12763        assert_eq!(
12764            runner.state.reviews.len(),
12765            1,
12766            "no new round was started on top of the unresolved one: {:?}",
12767            runner.state.reviews
12768        );
12769        let last = &runner.state.reviews[0];
12770        assert_eq!(
12771            last.e2e_status(),
12772            E2eStatus::ResourceBlocked,
12773            "still contended: {last:?}"
12774        );
12775        assert!(
12776            last.verified_at.expect("still known") > first_attempt_at,
12777            "review_loop must have actually retried the check, not left it exactly as found"
12778        );
12779        assert_ne!(
12780            runner.state.status,
12781            RunStatus::Blocked,
12782            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
12783            runner.state.status
12784        );
12785
12786        held.release();
12787    }
12788
12789    #[tokio::test]
12790    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
12791        crate::run::pin_test_home();
12792        let tmp = tempfile::tempdir().expect("tempdir");
12793        let repo = tmp.path().join("repo");
12794        std::fs::create_dir_all(&repo).unwrap();
12795        init_repo(&repo);
12796
12797        let mut config = Config::default();
12798        config.merge.mode = MergeMode::Pr;
12799        config.graph.land = true;
12800        config.graph.land_approval = false;
12801
12802        let mut state = RunState::new(
12803            repo.clone(),
12804            "main".to_owned(),
12805            "deadbeef".to_owned(),
12806            "task".to_owned(),
12807            config,
12808        );
12809        state.candidates = vec![Candidate {
12810            index: 0,
12811            label: 'A',
12812            agent: "alpha".to_owned(),
12813            branch: "does-not-exist".to_owned(),
12814            worktree: repo.clone(),
12815            summary: String::new(),
12816            stat: String::new(),
12817            files: 0,
12818            commits: 0,
12819            empty: false,
12820            failed: None,
12821            verified_noop: None,
12822            duration_ms: 0,
12823            folded: false,
12824        }];
12825        state.tally = Some(Tally {
12826            first_choice: BTreeMap::from([('A', 1)]),
12827            borda: BTreeMap::new(),
12828            winner: 'A',
12829            rankings: 1,
12830            unanimous_initial: true,
12831            deliberated: false,
12832            changed_votes: 0,
12833            unanimous_final: true,
12834            tie_break: None,
12835            judges: 0,
12836            present: 0,
12837            quorum: 0,
12838            met_quorum: true,
12839            uncontested: Some("only candidate A produced a change".to_owned()),
12840        });
12841        state.reviews = vec![ReviewRound {
12842            round: 1,
12843            head: "deadbeef".to_owned(),
12844            verified_head: None,
12845            verified_at: None,
12846            reviews: Vec::new(),
12847            e2e: Vec::new(),
12848            fix: None,
12849            blocking: 0,
12850            answered: 0,
12851            expected: 0,
12852            clean: true,
12853            verify_retried: false,
12854            e2e_deferred: false,
12855            e2e_defer_reason: None,
12856            progressed: false,
12857            vote_split: false,
12858            reconsideration: Vec::new(),
12859            verdict: None,
12860        }];
12861        state.gate = vec![CommandOutcome {
12862            command: "test".to_owned(),
12863            code: Some(0),
12864            output_tail: String::new(),
12865            duration_ms: 0,
12866            resource_blocked: false,
12867        }];
12868        state.gate_ran = true;
12869        // A first pass through `merge` already pushed and opened this pull
12870        // request; `status` is `Landing` because a previous call into `land`
12871        // parked or was interrupted before it reached a terminal outcome.
12872        state.status = RunStatus::Landing;
12873        state.merge = Some(MergeOutcome {
12874            mode: MergeMode::Pr,
12875            ok: true,
12876            detail: "https://example.invalid/x/y/pull/1".to_owned(),
12877            empty: false,
12878        });
12879
12880        // The Landing-resume shortcut calls `run_land` directly rather than
12881        // through `merge`, which is exactly the call site that used to skip
12882        // `settle_questions` - see the fixture below.
12883        ask_test_home();
12884        // A real resume loads a persisted run; driver ownership is checked
12885        // against that record before the Landing shortcut is entered.
12886        state.save().expect("save landing run");
12887        let store = ask::Questions::open();
12888        let q = ask_open_question(&store, &state.id);
12889
12890        let mut runner = Runner {
12891            state,
12892            roles: ResolvedRoles {
12893                implementers: Vec::new(),
12894                judges: Vec::new(),
12895                reviewers: Vec::new(),
12896                fixer: None,
12897                conductor: conductor(),
12898                implementer_roster: Vec::new(),
12899                judge_roster: Vec::new(),
12900                reviewer_roster: Vec::new(),
12901            },
12902            sem: Arc::new(Semaphore::new(1)),
12903            pause: Pause::new(),
12904            interrupt: Pause::new(),
12905        };
12906
12907        // `execute`, not `merge` directly: the Landing-resume shortcut lives
12908        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
12909        // exactly because `review_loop` would otherwise clobber the marker
12910        // first.
12911        runner.execute().await.expect("execute");
12912
12913        assert_eq!(
12914            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12915            Some("https://example.invalid/x/y/pull/1"),
12916            "reentry must not push again or open a second pull request over the \
12917             one `land` is already watching"
12918        );
12919        assert_ne!(
12920            runner.state.status,
12921            RunStatus::Landing,
12922            "land could not actually reach the fake pull request, so it must \
12923             have given up rather than left the run silently parked forever"
12924        );
12925        // `land` could not reach the fake pull request, so it gave up into
12926        // `Blocked` - still resumable, so the question must not have been
12927        // swept just because this branch now also calls `settle_questions`.
12928        assert_eq!(runner.state.status, RunStatus::Blocked);
12929        assert!(
12930            store.get(&q.id).unwrap().status.open(),
12931            "Blocked is still alive; settle_questions must have been a no-op here"
12932        );
12933    }
12934
12935    fn state_with_round(round: ReviewRound) -> RunState {
12936        let mut s = RunState::new(
12937            PathBuf::from("/repo"),
12938            "main".to_owned(),
12939            "abc1234".to_owned(),
12940            "add retries".to_owned(),
12941            Config::default(),
12942        );
12943        s.reviews = vec![round];
12944        s
12945    }
12946
12947    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
12948        crate::verdict::Finding {
12949            id: id.to_owned(),
12950            severity,
12951            file: None,
12952            line: None,
12953            title: title.to_owned(),
12954            detail: String::new(),
12955        }
12956    }
12957
12958    #[test]
12959    fn pr_body_names_open_findings_and_declined_ones() {
12960        let round = ReviewRound {
12961            round: 2,
12962            head: "deadbee".to_owned(),
12963            verified_head: None,
12964            verified_at: None,
12965            reviews: vec![ReviewRecord {
12966                attempts: 0,
12967                reviewer: 1,
12968                agent: "alpha".to_owned(),
12969                summary: String::new(),
12970                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
12971                vote: None,
12972                failed: None,
12973                duration_ms: 0,
12974            }],
12975            e2e: vec![CommandOutcome {
12976                command: "cargo test".to_owned(),
12977                code: Some(0),
12978                output_tail: String::new(),
12979                duration_ms: 0,
12980                resource_blocked: false,
12981            }],
12982            verify_retried: false,
12983            e2e_deferred: false,
12984            e2e_defer_reason: None,
12985            fix: Some(FixRecord {
12986                agent: "alpha".to_owned(),
12987                addressed: Vec::new(),
12988                rejected: vec![crate::verdict::Rejection {
12989                    id: "R1-1-1".to_owned(),
12990                    why: "not reachable from any caller".to_owned(),
12991                }],
12992                notes: String::new(),
12993                committed: true,
12994                failed: None,
12995                duration_ms: 0,
12996                continuation: None,
12997            }),
12998            blocking: 0,
12999            answered: 1,
13000            expected: 1,
13001            clean: false,
13002            progressed: true,
13003            vote_split: false,
13004            reconsideration: Vec::new(),
13005            verdict: None,
13006        };
13007        let state = state_with_round(round);
13008        let body = pr_message(&state, 'A').body;
13009
13010        assert!(body.contains("add retries"), "the task must still be there");
13011        assert!(body.contains("R2-1-1"), "{body}");
13012        assert!(body.contains("unused import"), "{body}");
13013        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13014        assert!(
13015            body.contains("not reachable from any caller"),
13016            "the reason it was declined: {body}"
13017        );
13018    }
13019
13020    #[test]
13021    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13022        let round = ReviewRound {
13023            round: 1,
13024            head: "deadbee".to_owned(),
13025            verified_head: None,
13026            verified_at: None,
13027            reviews: vec![ReviewRecord {
13028                attempts: 0,
13029                reviewer: 1,
13030                agent: "alpha".to_owned(),
13031                summary: String::new(),
13032                findings: Vec::new(),
13033                vote: None,
13034                failed: None,
13035                duration_ms: 0,
13036            }],
13037            e2e: Vec::new(),
13038            verify_retried: false,
13039            e2e_deferred: false,
13040            e2e_defer_reason: None,
13041            fix: None,
13042            blocking: 0,
13043            answered: 1,
13044            expected: 1,
13045            clean: true,
13046            progressed: false,
13047            vote_split: false,
13048            reconsideration: Vec::new(),
13049            verdict: None,
13050        };
13051        let state = state_with_round(round);
13052        let body = pr_message(&state, 'A').body;
13053        assert!(!body.contains("Open review findings"), "{body}");
13054        assert!(!body.contains("Declined"), "{body}");
13055    }
13056
13057    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13058        let mut state = RunState::new(
13059            PathBuf::from("/repo"),
13060            "main".to_owned(),
13061            "abc1234".to_owned(),
13062            instruction.to_owned(),
13063            Config::default(),
13064        );
13065        state.candidates.push(Candidate {
13066            index: 0,
13067            label: 'A',
13068            agent: "alpha".to_owned(),
13069            branch: "magi/x/A".to_owned(),
13070            worktree: PathBuf::from("/wt"),
13071            summary: summary.to_owned(),
13072            stat: String::new(),
13073            files: 1,
13074            commits: 1,
13075            empty: false,
13076            failed: None,
13077            verified_noop: None,
13078            folded: false,
13079            duration_ms: 0,
13080        });
13081        state
13082    }
13083
13084    fn review_state(subjects: &[&str]) -> RunState {
13085        let mut state = state_with_summary(
13086            "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",
13087            "",
13088        );
13089        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13090        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13091        state
13092    }
13093
13094    /// A branch rebased onto a main that moved past the recorded
13095    /// `base_commit` is titled from its own first commit, never main's.
13096    #[tokio::test]
13097    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13098        ask_test_home();
13099        let tmp = tempfile::tempdir().unwrap();
13100        let repo = tmp.path().join("repo");
13101        std::fs::create_dir_all(&repo).unwrap();
13102        init_repo(&repo);
13103        let origin = tmp.path().join("origin.git");
13104        let g = |dir: &Path, args: &[&str]| {
13105            let out = std::process::Command::new("git")
13106                .args(args)
13107                .current_dir(dir)
13108                .quiet()
13109                .output()
13110                .expect("spawn git");
13111            assert!(
13112                out.status.success(),
13113                "git {args:?}: {}",
13114                String::from_utf8_lossy(&out.stderr)
13115            );
13116        };
13117        g(
13118            tmp.path(),
13119            &[
13120                "clone",
13121                "--bare",
13122                "-q",
13123                repo.to_str().unwrap(),
13124                origin.to_str().unwrap(),
13125            ],
13126        );
13127        g(
13128            &repo,
13129            &["remote", "add", "origin", origin.to_str().unwrap()],
13130        );
13131        let c1 = git::rev_parse(&repo, "main").await.unwrap();
13132
13133        // Main moves on; the branch is built on top of the new main.
13134        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
13135        g(&repo, &["add", "-A"]);
13136        g(
13137            &repo,
13138            &["commit", "-q", "-m", "chore(deps): update a crate"],
13139        );
13140        g(&repo, &["push", "-q", "origin", "main"]);
13141        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
13142        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13143        g(&repo, &["add", "-A"]);
13144        g(
13145            &repo,
13146            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
13147        );
13148        g(&repo, &["checkout", "-q", "main"]);
13149
13150        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
13151        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
13152        // Without a readable tracking ref the recorded base's merge base is used.
13153        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
13154        assert_eq!(fallback, c1);
13155
13156        let mut state = review_state(&[
13157            "chore(deps): update a crate",
13158            "fix(daemon): apply a chosen action",
13159        ]);
13160        state.repo = repo.clone();
13161        state.base_branch = "main".to_owned();
13162        state.base_commit = c1;
13163        refresh_reviewed_commits(&mut state, "feat/own").await;
13164        assert_eq!(
13165            state.reviewed_commits,
13166            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
13167        );
13168        assert_eq!(
13169            review_title(&state).as_deref(),
13170            Some("fix(daemon): apply a chosen action")
13171        );
13172        assert_eq!(
13173            leaked_subjects(&state, "feat/own").await,
13174            Some(vec!["chore(deps): update a crate".to_owned()])
13175        );
13176        // A stale tracking ref is still used when the fetch fails, but the
13177        // leak list is withheld.
13178        state.config.merge.remote = "nowhere".to_owned();
13179        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
13180    }
13181
13182    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
13183    /// with `origin/main` one commit behind it, and a summarizing agent that
13184    /// prints whatever `reply` holds.
13185    struct SummaryFixture {
13186        _tmp: tempfile::TempDir,
13187        repo: PathBuf,
13188        reply: PathBuf,
13189        runner: Runner,
13190    }
13191
13192    fn summary_fixture() -> SummaryFixture {
13193        ask_test_home();
13194        let tmp = tempfile::tempdir().unwrap();
13195        let repo = tmp.path().join("repo");
13196        std::fs::create_dir_all(&repo).unwrap();
13197        init_repo(&repo);
13198        let origin = tmp.path().join("origin.git");
13199        let g = |args: &[&str]| {
13200            let out = std::process::Command::new("git")
13201                .args(args)
13202                .current_dir(&repo)
13203                .quiet()
13204                .output()
13205                .expect("spawn git");
13206            assert!(
13207                out.status.success(),
13208                "git {args:?}: {}",
13209                String::from_utf8_lossy(&out.stderr)
13210            );
13211        };
13212        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
13213        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
13214        g(&["push", "-q", "origin", "main"]);
13215        g(&["checkout", "-q", "-b", "feat/own"]);
13216        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13217        g(&["add", "-A"]);
13218        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
13219        let reply = tmp.path().join("reply.txt");
13220        std::fs::write(
13221            &reply,
13222            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
13223        )
13224        .unwrap();
13225        let mut runner = runner_at(RunStatus::Gating);
13226        let mut state = review_state(&["feat(own): add the own file"]);
13227        state.repo = repo.clone();
13228        state.base_branch = "main".to_owned();
13229        state.candidates[0].branch = "feat/own".to_owned();
13230        state.candidates[0].worktree = repo.clone();
13231        let mut author = spec("alpha");
13232        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
13233        author
13234            .env
13235            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
13236        state.config.agents = vec![author];
13237        runner.state = state;
13238        SummaryFixture {
13239            _tmp: tmp,
13240            repo,
13241            reply,
13242            runner,
13243        }
13244    }
13245
13246    async fn posted(f: &mut SummaryFixture) -> PrMessage {
13247        let winner = f.runner.state.candidates[0].clone();
13248        f.runner
13249            .guarded_pr_message(&winner, None, true)
13250            .await
13251            .unwrap()
13252            .expect("an English description never parks")
13253    }
13254
13255    #[tokio::test]
13256    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
13257        let mut f = summary_fixture();
13258        let m = posted(&mut f).await;
13259        assert_eq!(m.title, "feat(own): add the own file");
13260        assert!(
13261            m.body
13262                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
13263            "{}",
13264            m.body
13265        );
13266        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
13267        assert!(m.body.contains("## Commits under review"), "{}", m.body);
13268        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13269        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13270        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
13271        let kept = f.runner.state.change_summary.as_ref().unwrap();
13272        assert_eq!(
13273            (kept.head.as_str(), kept.base.as_str()),
13274            (head.as_str(), base.as_str())
13275        );
13276        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
13277    }
13278
13279    #[tokio::test]
13280    async fn review_only_summary_receives_the_configured_github_language() {
13281        let mut f = summary_fixture();
13282        f.runner.state.config.graph.github_language = "ja".into();
13283        let captured = f.repo.join("summary-prompt.txt");
13284        let author = &mut f.runner.state.config.agents[0];
13285        author.command = vec![
13286            "sh".into(),
13287            "-c".into(),
13288            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
13289        ];
13290        author.env.insert(
13291            "PROMPT_FILE".into(),
13292            captured.to_string_lossy().into_owned(),
13293        );
13294        std::fs::write(
13295            &f.reply,
13296            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
13297        )
13298        .unwrap();
13299        let m = posted(&mut f).await;
13300        let prompt = std::fs::read_to_string(captured).unwrap();
13301        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
13302        assert!(!prompt.contains("in English"));
13303        assert_eq!(m.title, "feat(own): ファイルを追加");
13304        assert!(m.body.contains("own.txt を追加します。"));
13305    }
13306
13307    #[tokio::test]
13308    async fn review_fixes_come_after_the_whole_change_summary() {
13309        let mut f = summary_fixture();
13310        f.runner.state.reviews = vec![
13311            round_with_notes(1, Some("handled the PATH case")),
13312            round_with_notes(2, None),
13313        ];
13314        let m = posted(&mut f).await;
13315        let summary = m.body.find("## Summary").expect("summary");
13316        let fixes = m.body.find("## Review fixes").expect("review fixes");
13317        assert!(summary < fixes, "{}", m.body);
13318        assert!(m.body.contains("handled the PATH case"), "{}", m.body);
13319        assert!(m.body.contains("## Final verification"), "{}", m.body);
13320    }
13321
13322    #[tokio::test]
13323    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
13324        let mut f = summary_fixture();
13325        let first = posted(&mut f).await;
13326        let first_head = f.runner.state.change_summary.clone().unwrap().head;
13327        // The same head reuses the record: a different reply is not read.
13328        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
13329        let again = posted(&mut f).await;
13330        assert_eq!((again.title, again.body), (first.title, first.body));
13331
13332        let g = |args: &[&str]| {
13333            let out = std::process::Command::new("git")
13334                .args(args)
13335                .current_dir(&f.repo)
13336                .quiet()
13337                .output()
13338                .expect("spawn git");
13339            assert!(out.status.success(), "git {args:?}");
13340        };
13341        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
13342        g(&["add", "-A"]);
13343        g(&["commit", "-q", "-m", "fix(own): handle more"]);
13344        let second = posted(&mut f).await;
13345        let kept = f.runner.state.change_summary.clone().unwrap();
13346        assert_ne!(kept.head, first_head);
13347        assert!(second.body.contains("Not read yet."), "{}", second.body);
13348        assert!(!second.body.contains("so the first summary is read"));
13349        assert_eq!(second.title, "feat(own): other");
13350    }
13351
13352    #[tokio::test]
13353    async fn an_unavailable_summary_is_stated_and_never_invented() {
13354        let mut f = summary_fixture();
13355        std::fs::write(&f.reply, "no title line here").unwrap();
13356        let m = posted(&mut f).await;
13357        assert!(
13358            m.body
13359                .contains("No description of what this change does could be generated"),
13360            "{}",
13361            m.body
13362        );
13363        assert!(m.body.contains("do not \nexplain it") || m.body.contains("do not explain it"));
13364        assert!(f.runner.state.change_summary.is_none());
13365        assert!(m.body.contains("## Commits under review"), "{}", m.body);
13366        // A preview asks nobody and claims nothing.
13367        let winner = f.runner.state.candidates[0].clone();
13368        let preview = f
13369            .runner
13370            .guarded_pr_message(&winner, None, false)
13371            .await
13372            .unwrap()
13373            .unwrap();
13374        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
13375    }
13376
13377    #[test]
13378    fn pr_message_review_single_commit_uses_its_subject() {
13379        let state = review_state(&["feat(nats): per-role user"]);
13380        let m = pr_message(&state, 'A');
13381        assert_eq!(m.title, "feat(nats): per-role user");
13382        assert!(!m.body.contains("Review the work already"), "{}", m.body);
13383        assert!(m.body.contains("## Commits under review"), "{}", m.body);
13384    }
13385
13386    #[test]
13387    fn pr_message_review_multi_commit_takes_the_oldest() {
13388        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
13389        let m = pr_message(&state, 'A');
13390        assert_eq!(m.title, "feat: the change");
13391        for s in ["feat: the change", "fix: typo", "fix: again"] {
13392            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
13393        }
13394    }
13395
13396    #[test]
13397    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
13398        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
13399            let mut state = review_state(&[first, "fix: later fixup"]);
13400            state.candidates[0].branch = "機能/ログイン".to_owned();
13401            let m = pr_message(&state, 'A');
13402            assert!(
13403                m.title.starts_with("chore: land candidate A of run"),
13404                "{}",
13405                m.title
13406            );
13407        }
13408    }
13409
13410    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
13411        BranchFacts {
13412            commits: commits
13413                .iter()
13414                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
13415                .collect(),
13416            stat: stat.to_owned(),
13417        }
13418    }
13419
13420    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
13421        let mut r = review_round(true, 0, 1, 1, true, true);
13422        r.round = round;
13423        r.fix = notes.map(|n| FixRecord {
13424            agent: "fixer".to_owned(),
13425            addressed: Vec::new(),
13426            rejected: Vec::new(),
13427            notes: n.to_owned(),
13428            committed: true,
13429            failed: None,
13430            duration_ms: 0,
13431            continuation: None,
13432        });
13433        r
13434    }
13435
13436    #[test]
13437    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
13438        let state = review_state(&["ignored"]);
13439        let f = facts(
13440            &[
13441                (
13442                    "fix(login): resolve PATH on macOS",
13443                    "Login shells skip rc files.",
13444                ),
13445                ("fix: address review", ""),
13446            ],
13447            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
13448        );
13449        let m = pr_message_with(&state, 'A', Some(&f));
13450        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
13451        assert!(
13452            m.body
13453                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
13454            "{}",
13455            m.body
13456        );
13457        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
13458        assert!(m.body.contains("## Diff stat"), "{}", m.body);
13459        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
13460        for banned in [
13461            "Review the work already",
13462            "no task statement",
13463            "Original task",
13464        ] {
13465            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
13466        }
13467        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
13468    }
13469
13470    #[test]
13471    fn pr_message_review_truncates_a_huge_first_commit_body() {
13472        let state = review_state(&["ignored"]);
13473        let f = facts(
13474            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
13475            "s",
13476        );
13477        let m = pr_message_with(&state, 'A', Some(&f));
13478        assert!(m.body.len() < 30_000, "{}", m.body.len());
13479        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
13480        assert!(
13481            m.body.contains("- ... 1 more commit(s)"),
13482            "{}",
13483            m.body.len()
13484        );
13485        assert!(m.body.ends_with("magi:candidate-a\n"));
13486    }
13487
13488    #[test]
13489    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
13490        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
13491        assert_eq!(m.title, "feat: x");
13492        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
13493        assert!(!m.body.contains("Diff stat"), "{}", m.body);
13494        assert!(!m.body.contains("no task statement"), "{}", m.body);
13495    }
13496
13497    #[test]
13498    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
13499        let mut state = review_state(&["日本語の件名"]);
13500        state.candidates[0].branch = "fix/macos-login-path".to_owned();
13501        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
13502        state.candidates[0].branch = "機能/ログイン".to_owned();
13503        assert!(
13504            pr_message(&state, 'A')
13505                .title
13506                .starts_with("chore: land candidate A")
13507        );
13508    }
13509
13510    #[test]
13511    fn pr_message_review_fixes_survive_a_clean_final_round() {
13512        let mut state = review_state(&["feat: x"]);
13513        state.reviews = vec![
13514            round_with_notes(1, Some("handled the PATH case")),
13515            round_with_notes(2, None),
13516        ];
13517        let body = pr_message(&state, 'A').body;
13518        assert!(
13519            body.contains("## Review fixes\n\nhandled the PATH case\n"),
13520            "{body}"
13521        );
13522        assert!(!body.contains("### Round"), "{body}");
13523
13524        state.reviews = vec![
13525            round_with_notes(1, Some("first fix")),
13526            round_with_notes(2, Some("")),
13527            round_with_notes(3, Some("second fix")),
13528            round_with_notes(4, None),
13529        ];
13530        let body = pr_message(&state, 'A').body;
13531        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
13532        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
13533        assert!(!body.contains("### Round 2"), "{body}");
13534    }
13535
13536    const FINAL: &str = "abcdef1234567890";
13537
13538    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
13539        let mut r = round_with_notes(round, None);
13540        r.verified_head = verified.map(str::to_owned);
13541        r
13542    }
13543
13544    fn ok_gate(state: &mut RunState, head: Option<&str>) {
13545        state.gate = vec![CommandOutcome {
13546            command: "gate".to_owned(),
13547            code: Some(0),
13548            output_tail: String::new(),
13549            duration_ms: 0,
13550            resource_blocked: false,
13551        }];
13552        state.gate_ran = true;
13553        state.gate_head = head.map(str::to_owned);
13554    }
13555
13556    fn body_at(state: &RunState) -> String {
13557        pr_message_raw(
13558            state,
13559            'A',
13560            None,
13561            crate::pr_outcome::FinalHead::At(FINAL),
13562            ChangeNote::NotAsked,
13563        )
13564        .body
13565    }
13566
13567    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
13568
13569    #[test]
13570    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
13571        let mut state = review_state(&["feat: x"]);
13572        state.reviews = vec![
13573            round_with_notes(1, Some(SANDBOXED)),
13574            passing_round(2, Some(FINAL)),
13575        ];
13576        ok_gate(&mut state, Some(FINAL));
13577        let body = body_at(&state);
13578        assert!(body.contains("Handled the PATH case."), "{body}");
13579        assert!(!body.contains("could not commit"), "{body}");
13580        assert!(!body.contains("could not run"), "{body}");
13581        assert!(
13582            body.contains("The fix is committed on the branch"),
13583            "{body}"
13584        );
13585        assert!(body.contains("2 statement(s)"), "{body}");
13586        assert!(
13587            body.contains("Tests (`verify.e2e`): passed on the final commit"),
13588            "{body}"
13589        );
13590        assert!(
13591            body.contains("Gate (`verify.gate`): passed on the final commit"),
13592            "{body}"
13593        );
13594        // The raw report is untouched in the record.
13595        assert!(
13596            state.reviews[0]
13597                .fix
13598                .as_ref()
13599                .unwrap()
13600                .notes
13601                .contains("could not commit")
13602        );
13603    }
13604
13605    #[test]
13606    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
13607        let mut state = review_state(&["feat: x"]);
13608        let mut r1 = round_with_notes(1, Some(SANDBOXED));
13609        r1.fix.as_mut().unwrap().committed = false;
13610        let mut r2 = passing_round(2, Some(FINAL));
13611        r2.e2e[0].code = Some(1);
13612        state.reviews = vec![r1, r2];
13613        let body = body_at(&state);
13614        assert!(body.contains("could not commit"), "{body}");
13615        assert!(body.contains("could not run"), "{body}");
13616        assert!(body.contains("This round produced no commit"), "{body}");
13617        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
13618        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
13619        assert!(!body.contains("passed"), "{body}");
13620    }
13621
13622    #[test]
13623    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
13624        let mut state = review_state(&["feat: x"]);
13625        state.reviews = vec![
13626            round_with_notes(1, Some(SANDBOXED)),
13627            passing_round(2, Some("0123456789abcdef")),
13628        ];
13629        ok_gate(&mut state, Some("0123456789abcdef"));
13630        let body = body_at(&state);
13631        assert!(body.contains("HTTP tests could not run"), "{body}");
13632        // The commit claim is still settled by the structured record.
13633        assert!(!body.contains("could not commit"), "{body}");
13634        assert!(
13635            body.contains("passed on an earlier commit (`0123456`)"),
13636            "{body}"
13637        );
13638        assert!(!body.contains("passed on the final commit"), "{body}");
13639
13640        // A gate recorded before `gate_head` existed is "not recorded".
13641        ok_gate(&mut state, None);
13642        let body = body_at(&state);
13643        assert!(
13644            body.contains("passed on a commit that was not recorded"),
13645            "{body}"
13646        );
13647    }
13648
13649    #[test]
13650    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
13651        let state = state_with_summary(
13652            "add retries to the client",
13653            "TITLE: feat: retries\n\nDid it.",
13654        );
13655        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
13656        assert_eq!(m.title, "feat: retries");
13657        assert!(
13658            m.body.contains("<summary>Original task</summary>"),
13659            "{}",
13660            m.body
13661        );
13662        assert!(!m.body.contains("Commits under review"), "{}", m.body);
13663        assert!(
13664            m.body
13665                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
13666            "{}",
13667            m.body
13668        );
13669    }
13670
13671    #[test]
13672    fn pr_message_review_bounds_a_long_english_subject() {
13673        let long = format!("feat: {}", "word ".repeat(100));
13674        let m = pr_message(&review_state(&[&long]), 'A');
13675        assert!(m.title.starts_with("feat: word"), "{}", m.title);
13676        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13677    }
13678
13679    #[test]
13680    fn pr_message_implementation_run_is_unchanged_by_review_support() {
13681        let state = state_with_summary("add retries\n\ndetails", "- did some things");
13682        let m = pr_message(&state, 'A');
13683        assert_eq!(m.title, "add retries");
13684        assert!(m.body.contains("<summary>Original task</summary>"));
13685        assert!(!m.body.contains("Commits under review"));
13686        assert_eq!(landing_subject_source(&state), state.instruction);
13687    }
13688
13689    #[test]
13690    fn review_run_squash_subject_is_the_change_not_the_prompt() {
13691        let state = review_state(&["feat: the change", "fix: typo"]);
13692        let source = landing_subject_source(&state);
13693        assert_eq!(land::merge_subject("", &source), "feat: the change");
13694        assert_eq!(
13695            land::merge_subject("magi: candidate A (uncommitted work)", &source),
13696            "feat: the change"
13697        );
13698        // An operator's rename still wins.
13699        assert_eq!(
13700            land::merge_subject("feat: renamed by hand", &source),
13701            "feat: renamed by hand"
13702        );
13703        let blank = review_state(&["日本語"]);
13704        assert!(
13705            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
13706        );
13707    }
13708
13709    #[test]
13710    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
13711        let state = review_state(&["feat: the change"]);
13712        let source = landing_subject_source(&state);
13713        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
13714        assert_eq!(
13715            land::merge_subject(landing_title(&state, old), &source),
13716            "feat: the change"
13717        );
13718        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
13719        let task = state_with_summary("add retries", "");
13720        assert_eq!(landing_title(&task, old), old);
13721    }
13722
13723    #[test]
13724    fn pr_message_describes_the_change_not_the_task() {
13725        let state = state_with_summary(
13726            "今回やってほしいこと: results projector を直す",
13727            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
13728        );
13729        let m = pr_message(&state, 'A');
13730        assert_eq!(m.title, "fix(web): batch the runs list reads");
13731        assert!(
13732            m.body.starts_with("## Summary\n\n- reads run.json once"),
13733            "{}",
13734            m.body
13735        );
13736        assert!(!m.body.contains("TITLE:"), "{}", m.body);
13737        let task_at = m.body.find("今回やってほしいこと").unwrap();
13738        let details_at = m.body.find("<details>").unwrap();
13739        assert!(
13740            details_at < task_at,
13741            "the task lives inside <details>: {}",
13742            m.body
13743        );
13744        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
13745        assert!(m.body.contains("magi:candidate-a"));
13746    }
13747
13748    #[test]
13749    fn pr_message_falls_back_to_the_task_without_a_title_line() {
13750        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
13751        let m = pr_message(&state, 'A');
13752        assert_eq!(m.title, "add retries");
13753        assert!(
13754            m.body.contains("## Summary\n\n- did some things"),
13755            "{}",
13756            m.body
13757        );
13758
13759        let none = RunState::new(
13760            PathBuf::from("/repo"),
13761            "main".to_owned(),
13762            "abc1234".to_owned(),
13763            "add retries".to_owned(),
13764            Config::default(),
13765        );
13766        let m = pr_message(&none, 'A');
13767        assert_eq!(m.title, "add retries");
13768        assert!(!m.body.contains("## Summary"), "{}", m.body);
13769    }
13770
13771    #[test]
13772    fn pr_message_refuses_the_candidate_commit_subject() {
13773        for bad in [
13774            "TITLE: magi: candidate A (uncommitted work)",
13775            "TITLE: chore: stuff (uncommitted work)",
13776            "TITLE:   ",
13777        ] {
13778            let state = state_with_summary("add retries", bad);
13779            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
13780        }
13781    }
13782
13783    #[test]
13784    fn pr_message_bounds_a_very_long_task_and_title() {
13785        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
13786        let state = state_with_summary(&long, "- nothing");
13787        let m = pr_message(&state, 'A');
13788        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13789        assert!(!m.title.contains('\n'));
13790
13791        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
13792        let m = pr_message(&state, 'A');
13793        assert!(m.title.starts_with("feat: "));
13794        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
13795        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
13796    }
13797
13798    fn long_title_of(instruction: &str) -> String {
13799        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
13800    }
13801
13802    #[test]
13803    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
13804        let first = "Make the landing path keep a readable title for long tasks";
13805        let line = format!(
13806            "{first}. {}",
13807            "And then keep going with more words ".repeat(20)
13808        );
13809        let t = long_title_of(&line);
13810        assert_eq!(t, first);
13811        assert!(!t.starts_with("chore: land"));
13812    }
13813
13814    #[test]
13815    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
13816        let line = "word ".repeat(200);
13817        let t = long_title_of(&line);
13818        assert!(t.ends_with("word..."), "{t}");
13819        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
13820    }
13821
13822    #[test]
13823    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
13824        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
13825            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
13826        }
13827    }
13828
13829    #[test]
13830    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
13831        let mut state = state_with_summary("再試行を追加する", "- nothing");
13832        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
13833        state.config.graph.github_language = "ja".into();
13834        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
13835    }
13836
13837    #[test]
13838    fn pr_message_title_limit_is_exact() {
13839        let at = "a".repeat(PR_TITLE_MAX);
13840        assert_eq!(long_title_of(&at), at);
13841        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
13842        assert!(over.ends_with("..."), "{over}");
13843        assert_eq!(over.chars().count(), PR_TITLE_MAX);
13844    }
13845
13846    #[test]
13847    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
13848        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
13849        let t = long_title_of(&line);
13850        assert!(t.ends_with("..."), "{t}");
13851        assert!(t.is_ascii(), "{t}");
13852    }
13853
13854    #[test]
13855    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
13856        let line = format!(
13857            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
13858            "plus more filler words ".repeat(20)
13859        );
13860        let t = long_title_of(&line);
13861        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
13862    }
13863
13864    #[test]
13865    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
13866        let line = format!("\n\n# {}", "title words ".repeat(40));
13867        let state = state_with_summary(&line, "- nothing");
13868        let m = pr_message(&state, 'A');
13869        assert!(m.title.starts_with("title words"), "{}", m.title);
13870        assert_eq!(
13871            land::merge_subject(&m.title, &landing_subject_source(&state)),
13872            m.title
13873        );
13874        // An operator's rename wins untouched.
13875        assert_eq!(
13876            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
13877            "feat: renamed by hand"
13878        );
13879    }
13880
13881    #[tokio::test]
13882    async fn github_text_rewrite_is_bounded_and_falls_back() {
13883        crate::run::pin_test_home();
13884        for (reply, accepted) in [
13885            (
13886                "TITLE: fix: retries\nAdd retries for failed requests.",
13887                true,
13888            ),
13889            (
13890                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
13891                false,
13892            ),
13893            (
13894                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
13895                true,
13896            ),
13897            (
13898                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
13899                true,
13900            ),
13901            (
13902                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
13903                true,
13904            ),
13905            (
13906                "TITLE: fix: retries\n日本語の説明です。 token=secret",
13907                false,
13908            ),
13909            ("TITLE: fix: retries\n", false),
13910        ] {
13911            let dir = tempfile::tempdir().unwrap();
13912            let mut runner = runner_at(RunStatus::Gating);
13913            runner.state = state_with_summary(
13914                "add retries",
13915                "TITLE: fix: retries\n日本語の説明を書きます。",
13916            );
13917            runner.state.repo = dir.path().to_owned();
13918            runner.state.candidates[0].worktree = dir.path().to_owned();
13919            let mut author = spec("alpha");
13920            author.command = vec![
13921                "sh".into(),
13922                "-c".into(),
13923                "printf '%s' \"$REWRITE_REPLY\"".into(),
13924            ];
13925            author.env.insert("REWRITE_REPLY".into(), reply.into());
13926            runner.state.config.agents = vec![author];
13927            let winner = runner.state.candidates[0].clone();
13928            let first = runner
13929                .guarded_pr_message(&winner, None, true)
13930                .await
13931                .unwrap();
13932            let message = if accepted {
13933                first.expect("a passing rewrite never asks")
13934            } else {
13935                // Withheld: the run parks on a question and posts nothing yet.
13936                assert!(first.is_none());
13937                assert!(runner.state.parked);
13938                let store = ask::Questions::open();
13939                let asked: Vec<_> = store
13940                    .list()
13941                    .into_iter()
13942                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13943                    .collect();
13944                assert_eq!(asked.len(), 1);
13945                assert!(!asked[0].detail.contains("secret"));
13946                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
13947                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
13948                assert_eq!(
13949                    asked[0]
13950                        .choices
13951                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
13952                    rewrite.is_some()
13953                );
13954                // Silence: the question lapses and the neutral text is used,
13955                // without a second rewrite call or a second question.
13956                store
13957                    .update(&asked[0].id, |q| {
13958                        q.abandon("test");
13959                        Ok(())
13960                    })
13961                    .unwrap();
13962                let again = runner
13963                    .guarded_pr_message(&winner, None, true)
13964                    .await
13965                    .unwrap()
13966                    .expect("an abandoned question falls back");
13967                let n = store
13968                    .list()
13969                    .into_iter()
13970                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
13971                    .count();
13972                assert_eq!(n, 1, "asked once per rejected text");
13973                again
13974            };
13975            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
13976            assert!(!message.body.contains("secret"));
13977            assert!(!message.body.contains("prefix"));
13978            assert!(!message.title.contains("secret"));
13979            assert_eq!(
13980                message.body.contains("Add retries for failed requests."),
13981                accepted
13982            );
13983            assert_eq!(
13984                runner.state.seats["github-text-rewrite"].turns, 1,
13985                "only one rewrite invocation"
13986            );
13987            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
13988            if accepted {
13989                assert!(runner.state.github_text.is_none());
13990                assert!(!runner.state.parked);
13991                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
13992                assert!(
13993                    message
13994                        .body
13995                        .contains(&format!("magi:run/{}", runner.state.id))
13996                );
13997            }
13998            if !accepted {
13999                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14000                assert!(
14001                    message
14002                        .body
14003                        .contains(&format!("magi:run/{}", runner.state.id))
14004                );
14005            }
14006        }
14007    }
14008
14009    fn scripted(id: &str, reply: &str) -> AgentSpec {
14010        let mut a = spec(id);
14011        a.command = vec![
14012            "sh".into(),
14013            "-c".into(),
14014            "printf '%s' \"$REWRITE_REPLY\"".into(),
14015        ];
14016        a.env.insert("REWRITE_REPLY".into(), reply.into());
14017        a
14018    }
14019
14020    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
14021    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
14022
14023    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
14024        let mut runner = runner_at(RunStatus::Gating);
14025        runner.state = state_with_summary("add retries", JA_SUMMARY);
14026        if review_only {
14027            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14028        }
14029        runner.state.repo = dir.to_owned();
14030        runner.state.candidates[0].worktree = dir.to_owned();
14031        runner.state.config.agents = agents;
14032        runner
14033    }
14034
14035    fn gate_questions(runner: &Runner) -> usize {
14036        ask::Questions::open()
14037            .list()
14038            .into_iter()
14039            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14040            .count()
14041    }
14042
14043    #[tokio::test]
14044    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
14045        crate::run::pin_test_home();
14046        let dir = tempfile::tempdir().unwrap();
14047        let mut runner = rewrite_runner(
14048            dir.path(),
14049            true,
14050            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14051        );
14052        runner.state.config.roles.language_judge =
14053            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
14054        let winner = runner.state.candidates[0].clone();
14055        let msg = runner
14056            .guarded_pr_message(&winner, None, true)
14057            .await
14058            .unwrap()
14059            .expect("an English rewrite posts without asking");
14060        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
14061        assert!(msg.body.contains("<details>kept</details>"));
14062        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
14063        assert_eq!(gate_questions(&runner), 0);
14064        assert!(runner.state.github_text.is_none());
14065        assert!(!runner.state.seats.contains_key("impl-A"));
14066        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14067    }
14068
14069    #[tokio::test]
14070    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
14071        crate::run::pin_test_home();
14072        let dir = tempfile::tempdir().unwrap();
14073        let mut runner = rewrite_runner(
14074            dir.path(),
14075            false,
14076            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14077        );
14078        runner.state.config.roles.language_judge =
14079            Some(crate::config::AgentChoice::One("beta".into()));
14080        runner.state.seats.insert(
14081            "impl-A".into(),
14082            SeatState::new("impl-A", "alpha", runner.state.seed),
14083        );
14084        let winner = runner.state.candidates[0].clone();
14085        let msg = runner
14086            .guarded_pr_message(&winner, None, true)
14087            .await
14088            .unwrap()
14089            .expect("rewritten");
14090        assert!(msg.body.contains("<details>kept</details>"));
14091        assert_eq!(runner.state.seats["impl-A"].turns, 0);
14092        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14093        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
14094    }
14095
14096    #[tokio::test]
14097    async fn rewrite_reuses_a_resumable_implementer_session() {
14098        crate::run::pin_test_home();
14099        let dir = tempfile::tempdir().unwrap();
14100        let mut runner = rewrite_runner(
14101            dir.path(),
14102            false,
14103            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
14104        );
14105        runner.state.config.roles.language_judge =
14106            Some(crate::config::AgentChoice::One("beta".into()));
14107        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
14108        seat.turns = 1;
14109        runner.state.seats.insert("impl-A".into(), seat);
14110        let winner = runner.state.candidates[0].clone();
14111        runner
14112            .guarded_pr_message(&winner, None, true)
14113            .await
14114            .unwrap()
14115            .expect("rewritten");
14116        assert_eq!(runner.state.seats["impl-A"].turns, 2);
14117        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
14118    }
14119
14120    #[tokio::test]
14121    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
14122        crate::run::pin_test_home();
14123        let dir = tempfile::tempdir().unwrap();
14124        let mut broken = spec("bad");
14125        broken.command = vec!["false".into()];
14126        let mut runner = rewrite_runner(
14127            dir.path(),
14128            true,
14129            vec![
14130                broken,
14131                scripted("noformat", "Add retries."),
14132                scripted("good", EN_REWRITE),
14133            ],
14134        );
14135        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
14136            "bad".into(),
14137            "noformat".into(),
14138            "good".into(),
14139        ]));
14140        let winner = runner.state.candidates[0].clone();
14141        let msg = runner
14142            .guarded_pr_message(&winner, None, true)
14143            .await
14144            .unwrap()
14145            .expect("the third agent rewrites");
14146        assert!(msg.body.contains("`src/net.rs`"));
14147        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
14148        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
14149    }
14150
14151    #[tokio::test]
14152    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
14153        crate::run::pin_test_home();
14154        // (agents, role): still Japanese; no TITLE; nothing resolvable.
14155        for (agents, role, saved) in [
14156            (
14157                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
14158                Some("beta"),
14159                true,
14160            ),
14161            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
14162            (Vec::new(), None, false),
14163        ] {
14164            let dir = tempfile::tempdir().unwrap();
14165            let mut runner = rewrite_runner(dir.path(), true, agents);
14166            runner.state.config.roles.language_judge =
14167                role.map(|r| crate::config::AgentChoice::One(r.into()));
14168            let winner = runner.state.candidates[0].clone();
14169            let first = runner
14170                .guarded_pr_message(&winner, None, true)
14171                .await
14172                .unwrap();
14173            assert!(first.is_none(), "withheld text parks the run");
14174            assert!(runner.state.parked);
14175            assert_eq!(gate_questions(&runner), 1);
14176            let g = runner.state.github_text.as_ref().unwrap();
14177            assert_eq!(g.rewrite.is_some(), saved);
14178            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
14179        }
14180    }
14181
14182    #[tokio::test]
14183    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
14184        crate::run::pin_test_home();
14185        let dir = tempfile::tempdir().unwrap();
14186        let mut runner = runner_at(RunStatus::Gating);
14187        runner.state =
14188            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
14189        runner.state.repo = dir.path().to_owned();
14190        runner.state.candidates[0].worktree = dir.path().to_owned();
14191        let mut author = spec("alpha");
14192        author.command = vec![
14193            "sh".into(),
14194            "-c".into(),
14195            "printf '%s' \"$REWRITE_REPLY\"".into(),
14196        ];
14197        author.env.insert(
14198            "REWRITE_REPLY".into(),
14199            format!(
14200                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
14201                "English sentence. ".repeat(600)
14202            ),
14203        );
14204        runner.state.config.agents = vec![author];
14205        let winner = runner.state.candidates[0].clone();
14206        assert!(
14207            runner
14208                .guarded_pr_message(&winner, None, true)
14209                .await
14210                .unwrap()
14211                .is_none()
14212        );
14213        let record = runner.state.github_text.clone().unwrap();
14214        let rewrite = record.rewrite.clone().unwrap();
14215        assert_eq!(rewrite.failed, ["body-language"]);
14216        assert!(
14217            rewrite
14218                .body
14219                .ends_with(&format!("magi:run/{}", runner.state.id))
14220        );
14221        let store = ask::Questions::open();
14222        let q = store.get(record.question.as_ref().unwrap()).unwrap();
14223        assert!(
14224            q.choices
14225                .contains(&crate::github_text::USE_REWRITE.to_owned())
14226        );
14227        for label in [
14228            "ORIGINAL",
14229            "FALLBACK",
14230            "REWRITE",
14231            "Rules still failed: body-language",
14232            "truncated",
14233        ] {
14234            assert!(q.detail.contains(label), "{label}");
14235        }
14236        let artifact = std::fs::read_to_string(crate::run::artifact_path(
14237            &runner.state,
14238            &crate::github_text::artifact_name(&record.fingerprint),
14239        ))
14240        .unwrap();
14241        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
14242        // Restart from serialized state, then retry after a bad owner title.
14243        runner.state =
14244            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
14245        store
14246            .update(&q.id, |q| {
14247                q.say("まだ日本語")?;
14248                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
14249            })
14250            .unwrap();
14251        assert!(
14252            runner
14253                .guarded_pr_message(&winner, None, true)
14254                .await
14255                .unwrap()
14256                .is_none()
14257        );
14258        let retry = runner.state.github_text.as_ref().unwrap();
14259        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
14260        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
14261        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
14262        assert!(
14263            q.posting_comparison
14264                .as_ref()
14265                .unwrap()
14266                .matches_detail(&q.detail)
14267        );
14268        store
14269            .update(&q.id, |q| {
14270                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14271            })
14272            .unwrap();
14273        let message = runner
14274            .guarded_pr_message(&winner, None, true)
14275            .await
14276            .unwrap()
14277            .unwrap();
14278        assert_eq!(message.title, rewrite.title);
14279        assert_eq!(message.body, rewrite.body);
14280        assert_eq!(
14281            runner.state.github_text.as_ref().unwrap().choice,
14282            Some(GithubTextChoice::Rewrite)
14283        );
14284        let again = runner
14285            .guarded_pr_message(&winner, None, true)
14286            .await
14287            .unwrap()
14288            .unwrap();
14289        assert_eq!(again.title, message.title);
14290        assert_eq!(again.body, message.body);
14291        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14292        assert!(
14293            !runner
14294                .state
14295                .events
14296                .iter()
14297                .any(|e| e.message.contains(&rewrite.title)
14298                    || e.message.contains("English sentence."))
14299        );
14300    }
14301
14302    #[tokio::test]
14303    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
14304        crate::run::pin_test_home();
14305        let dir = tempfile::tempdir().unwrap();
14306        let mut runner = runner_at(RunStatus::Gating);
14307        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
14308        runner.state.repo = dir.path().to_owned();
14309        runner.state.candidates[0].worktree = dir.path().to_owned();
14310        let winner = runner.state.candidates[0].clone();
14311        assert!(
14312            runner
14313                .guarded_pr_message(&winner, None, true)
14314                .await
14315                .unwrap()
14316                .is_none()
14317        );
14318        let record = runner.state.github_text.as_ref().unwrap();
14319        assert!(record.rewrite.is_none());
14320        // Records written before the additive field deserialize with no rewrite.
14321        let mut old = serde_json::to_value(record).unwrap();
14322        old.as_object_mut().unwrap().remove("rewrite");
14323        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
14324        assert!(old.rewrite.is_none());
14325        let id = record.question.clone().unwrap();
14326        ask::Questions::open()
14327            .update(&id, |q| {
14328                q.choices.push(crate::github_text::USE_REWRITE.into());
14329                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14330            })
14331            .unwrap();
14332        let message = runner
14333            .guarded_pr_message(&winner, None, true)
14334            .await
14335            .unwrap()
14336            .unwrap();
14337        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14338        assert_eq!(
14339            runner.state.github_text.as_ref().unwrap().choice,
14340            Some(GithubTextChoice::Fallback)
14341        );
14342    }
14343
14344    #[tokio::test]
14345    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
14346        crate::run::pin_test_home();
14347        let dir = tempfile::tempdir().unwrap();
14348        let mut runner = runner_at(RunStatus::Gating);
14349        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
14350        runner.state.repo = dir.path().to_owned();
14351        runner.state.candidates[0].worktree = dir.path().to_owned();
14352        let winner = runner.state.candidates[0].clone();
14353        assert!(
14354            runner
14355                .guarded_pr_message(&winner, None, true)
14356                .await
14357                .unwrap()
14358                .is_none()
14359        );
14360        let store = ask::Questions::open();
14361        let find = |run: &str| {
14362            store
14363                .list()
14364                .into_iter()
14365                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
14366                .unwrap()
14367        };
14368        let run = runner.state.id.clone();
14369        let q = find(&run);
14370        let fp = runner
14371            .state
14372            .github_text
14373            .as_ref()
14374            .unwrap()
14375            .fingerprint
14376            .clone();
14377        let name = crate::github_text::artifact_name(&fp);
14378        let path = crate::run::artifact_path(&runner.state, &name);
14379        assert!(q.detail.contains(&path.display().to_string()));
14380        assert!(q.detail.contains("title-language: title"));
14381        let saved = std::fs::read_to_string(&path).expect("artifact written");
14382        assert!(saved.contains("日本語のタイトル"));
14383        assert!(q.cwd.is_none());
14384        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
14385        // A bad replacement is refused and asked about once more.
14386        let say = |id: &str, text: &str, choice: &str| {
14387            store
14388                .update(id, |q| {
14389                    q.thread.push(ask::Turn {
14390                        who: ask::Who::Operator,
14391                        body: text.to_owned(),
14392                        at: jiff::Timestamp::now(),
14393                        note: None,
14394                    });
14395                    q.answer(ask::Answer::Choice(choice.to_owned()))
14396                })
14397                .unwrap();
14398        };
14399        say(&q.id, "まだ日本語", "use my text");
14400        assert!(
14401            runner
14402                .guarded_pr_message(&winner, None, true)
14403                .await
14404                .unwrap()
14405                .is_none()
14406        );
14407        let second = find(&run);
14408        assert_ne!(second.id, q.id);
14409        assert!(second.detail.contains("did not pass"));
14410        // The retry still compares the original text; the rejected
14411        // replacement is named by category only.
14412        assert!(second.detail.contains("日本語のタイトル"));
14413        assert!(!second.detail.contains("まだ日本語"));
14414        assert!(second.detail.contains(&path.display().to_string()));
14415        say(&second.id, "fix: retry failed requests", "use my text");
14416        let message = runner
14417            .guarded_pr_message(&winner, None, true)
14418            .await
14419            .unwrap()
14420            .expect("a vetted replacement is posted");
14421        assert_eq!(message.title, "fix: retry failed requests");
14422        assert_eq!(
14423            runner
14424                .state
14425                .github_text
14426                .as_ref()
14427                .unwrap()
14428                .chosen_title
14429                .as_deref(),
14430            Some("fix: retry failed requests")
14431        );
14432        assert!(runner.state.github_text.as_ref().unwrap().resolved);
14433        // Resumed again: the saved decision is applied, nothing is re-asked.
14434        let again = runner
14435            .guarded_pr_message(&winner, None, true)
14436            .await
14437            .unwrap()
14438            .unwrap();
14439        assert_eq!(again.title, message.title);
14440        assert_eq!(
14441            store
14442                .list()
14443                .into_iter()
14444                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
14445                .count(),
14446            2
14447        );
14448    }
14449
14450    #[test]
14451    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
14452        // What magi itself writes stays English under any configured language,
14453        // so a future localisation of these headings fails here. (The agents'
14454        // own text is also checked by the posting gate.)
14455        let mut state = state_with_summary(
14456            "add retries",
14457            "TITLE: fix(web): batch reads\n- reads run.json once",
14458        );
14459        state.config.graph.language = "ja".to_owned();
14460        let m = pr_message(&state, 'A');
14461        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
14462        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
14463
14464        // The task is the operator's own text: it goes in untouched, and the
14465        // fallback title (no summary) may be in its language too.
14466        let task = "今回やってほしいこと: results projector を直す";
14467        let mut state = state_with_summary(task, "- no title line");
14468        state.config.graph.language = "ja".to_owned();
14469        let m = pr_message(&state, 'A');
14470        assert_eq!(
14471            m.title,
14472            format!("chore: land candidate A of run {}", state.id)
14473        );
14474        assert!(
14475            m.body.contains(&format!(
14476                "<summary>Original task</summary>\n\n{task}\n\n</details>"
14477            )),
14478            "{}",
14479            m.body
14480        );
14481    }
14482
14483    #[test]
14484    fn pr_message_scrubs_home_paths_and_addresses() {
14485        let state = state_with_summary(
14486            "fix it in /Users/someone/src/x",
14487            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
14488        );
14489        let m = pr_message(&state, 'A');
14490        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
14491            assert!(!m.body.contains(leak), "{}", m.body);
14492        }
14493        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
14494    }
14495
14496    #[test]
14497    fn pr_message_survives_a_task_that_closes_details() {
14498        let state = state_with_summary("a </details> b", "TITLE: fix: x");
14499        let m = pr_message(&state, 'A');
14500        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
14501    }
14502
14503    #[test]
14504    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
14505        let cmd = manual_merge_command(
14506            MergeStyle::Squash,
14507            Path::new("/repo"),
14508            "b",
14509            "fix: \"quoted\" $(x) `y`\n\nbody",
14510        );
14511        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
14512    }
14513
14514    #[test]
14515    fn manual_merge_command_matches_the_configured_style() {
14516        let repo = Path::new("/repo");
14517        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
14518
14519        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
14520        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
14521
14522        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
14523        assert_eq!(
14524            squash,
14525            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
14526             \"Merge magi run 0832 (candidate A)\""
14527        );
14528
14529        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
14530        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
14531    }
14532
14533    #[test]
14534    fn a_nudge_gets_a_quarter_of_the_budget() {
14535        // The judge and implement budgets magi ships with.
14536        assert_eq!(retry_budget(secs(1200), true), secs(300));
14537        assert_eq!(retry_budget(secs(3600), true), secs(900));
14538    }
14539
14540    #[test]
14541    fn a_resent_prompt_keeps_the_whole_budget() {
14542        // The seat kept no context, so the retry is the original job again and
14543        // shortening it would only guarantee a second failure.
14544        assert_eq!(retry_budget(secs(1200), false), secs(1200));
14545        assert_eq!(retry_budget(secs(60), false), secs(60));
14546    }
14547
14548    #[test]
14549    fn the_floor_never_exceeds_the_original_budget() {
14550        // A short configured timeout must not be *raised* by the floor: the
14551        // operator asked for a bound, and a retry may not outlast the attempt
14552        // it is retrying.
14553        assert_eq!(retry_budget(secs(60), true), secs(60));
14554        assert_eq!(retry_budget(secs(480), true), secs(120));
14555        assert_eq!(retry_budget(secs(0), true), secs(0));
14556    }
14557
14558    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
14559        agent::CommandEvidence {
14560            id: "item1".to_owned(),
14561            description: "cargo test".to_owned(),
14562            exit_code,
14563            result_summary: String::new(),
14564            source: "codex".to_owned(),
14565        }
14566    }
14567
14568    #[test]
14569    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
14570        // No evidence is not the same fact as unconfirmed evidence: a
14571        // backend with no adapter, or a reply that ran no commands at all,
14572        // must not be misread as carrying a dangling job.
14573        assert!(!has_unconfirmed_command(&[]));
14574    }
14575
14576    #[test]
14577    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
14578        // Deliberately not a check on the exit code's *value*: a fixer
14579        // legitimately runs something that fails mid-iteration before it
14580        // succeeds, and that must never by itself reopen a valid report.
14581        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
14582        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
14583        assert!(!has_unconfirmed_command(&[
14584            evidence(Some(0)),
14585            evidence(Some(101))
14586        ]));
14587    }
14588
14589    #[test]
14590    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
14591        assert!(has_unconfirmed_command(&[
14592            evidence(Some(0)),
14593            evidence(None)
14594        ]));
14595    }
14596
14597    #[test]
14598    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
14599        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14600        assert_eq!(
14601            verified_noop_claim(true, &[], text).as_deref(),
14602            Some("already fixed by b32cfc4, on main.")
14603        );
14604    }
14605
14606    #[test]
14607    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
14608        // A timeout or a bad exit code reads as the ordinary loss it is,
14609        // whatever the reply's own prose claims.
14610        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14611        assert!(verified_noop_claim(false, &[], text).is_none());
14612    }
14613
14614    #[test]
14615    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
14616        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
14617        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
14618        // A confirmed command alongside the marker is fine.
14619        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
14620    }
14621
14622    #[test]
14623    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
14624        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
14625    }
14626
14627    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
14628    /// pair, in order, labelled A, B, C, ...
14629    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
14630        runner.state.candidates = shape
14631            .iter()
14632            .enumerate()
14633            .map(|(i, &(empty, verified))| Candidate {
14634                index: i,
14635                label: (b'A' + i as u8) as char,
14636                agent: "sonnet".to_owned(),
14637                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
14638                worktree: PathBuf::from(format!("/wt/{i}")),
14639                summary: String::new(),
14640                stat: String::new(),
14641                files: 0,
14642                commits: 0,
14643                empty,
14644                failed: None,
14645                verified_noop: verified.map(str::to_owned),
14646                duration_ms: 0,
14647                folded: false,
14648            })
14649            .collect();
14650    }
14651
14652    #[test]
14653    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
14654        ask_test_home();
14655        let mut runner = runner_at(RunStatus::Implementing);
14656        set_candidates(
14657            &mut runner,
14658            &[
14659                (true, Some("already on main at b32cfc4")),
14660                (true, Some("same fix, see the existing test")),
14661            ],
14662        );
14663
14664        runner
14665            .after_implement()
14666            .expect("a verified no-op is not an error");
14667
14668        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
14669    }
14670
14671    #[test]
14672    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
14673        ask_test_home();
14674        let mut runner = runner_at(RunStatus::Implementing);
14675        // Candidate A declares a verified no-op; candidate B simply wrote
14676        // nothing and said nothing about why. One candidate's claim is not
14677        // the whole run's agreement.
14678        set_candidates(
14679            &mut runner,
14680            &[(true, Some("already on main at b32cfc4")), (true, None)],
14681        );
14682
14683        let err = runner
14684            .after_implement()
14685            .expect_err("an unverified empty candidate must still fail the run");
14686
14687        assert!(
14688            err.to_string().contains("no candidate produced a change"),
14689            "{err}"
14690        );
14691        assert_eq!(runner.state.status, RunStatus::Failed);
14692    }
14693
14694    #[test]
14695    fn after_implement_still_fails_an_ordinary_all_empty_run() {
14696        ask_test_home();
14697        let mut runner = runner_at(RunStatus::Implementing);
14698        set_candidates(&mut runner, &[(true, None), (true, None)]);
14699
14700        let err = runner
14701            .after_implement()
14702            .expect_err("no candidate declared anything; this is an ordinary failure");
14703
14704        assert!(
14705            err.to_string().contains("no candidate produced a change"),
14706            "{err}"
14707        );
14708        assert_eq!(runner.state.status, RunStatus::Failed);
14709    }
14710}
14711
14712/// A graft rollback whose restore could not be confirmed (see
14713/// [`Runner::graft`]).
14714#[derive(Debug)]
14715struct GraftRestoreFailed(String);
14716
14717impl std::fmt::Display for GraftRestoreFailed {
14718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14719        write!(
14720            f,
14721            "could not restore the winner to its pre-graft commit: {}",
14722            self.0
14723        )
14724    }
14725}
14726
14727impl std::error::Error for GraftRestoreFailed {}
14728
14729/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
14730/// not the agent: they never draw on an agent's own timeout, and running out of
14731/// them never reads as an agent failure.
14732#[cfg(test)]
14733mod resource_wait_tests {
14734    use super::*;
14735    use std::collections::BTreeMap;
14736
14737    fn sh(id: &str, script: &str) -> AgentSpec {
14738        AgentSpec {
14739            id: id.to_owned(),
14740            kind: crate::config::AgentKind::Command,
14741            model: None,
14742            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
14743            extra_args: Vec::new(),
14744            env: BTreeMap::new(),
14745            prompt_delivery: None,
14746        }
14747    }
14748
14749    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
14750        SeatJob {
14751            seat: SeatState::new("impl-A", &spec.id, 1),
14752            spec,
14753            cwd: dir.to_path_buf(),
14754            prompt: "do it".to_owned(),
14755            timeout,
14756            allow_write: true,
14757            sessions: false,
14758            artifacts: dir.join("art"),
14759            stem: "impl-A".to_owned(),
14760            handover: None,
14761        }
14762    }
14763
14764    fn state(resource_wait_secs: u64) -> RunState {
14765        let mut config = Config::default();
14766        config.graph.timeout_resource_wait = resource_wait_secs;
14767        RunState::new(
14768            PathBuf::from("/repo"),
14769            "main".to_owned(),
14770            "abc1234".to_owned(),
14771            "task".to_owned(),
14772            config,
14773        )
14774    }
14775
14776    fn hold(cache: &Path) -> crate::cache::Guard {
14777        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
14778        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
14779            crate::cache::AcquireOutcome::Acquired(g) => g,
14780            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
14781        }
14782    }
14783
14784    async fn run(
14785        jobs: Vec<SeatJob>,
14786        sem: Arc<Semaphore>,
14787        cache: Option<&Path>,
14788        st: &mut RunState,
14789    ) -> Vec<(usize, SeatState, AgentOutcome)> {
14790        let prompts = Prompts::default();
14791        let ctx = WaveCtx {
14792            run: "r",
14793            node: "implement",
14794            prompts: &prompts,
14795            cache,
14796            round: None,
14797            carry_seats: false,
14798            pauses: None,
14799        };
14800        wave(jobs, sem, &ctx, st, 0).await
14801    }
14802
14803    #[tokio::test]
14804    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
14805        crate::run::pin_test_home();
14806        let tmp = tempfile::tempdir().unwrap();
14807        let cache = tmp.path().join("target-a");
14808        let guard = hold(&cache);
14809        // Held for longer than the agent's whole 1s budget.
14810        tokio::spawn(async move {
14811            tokio::time::sleep(Duration::from_millis(1500)).await;
14812            guard.release();
14813        });
14814        let mut st = state(60);
14815        let results = run(
14816            vec![job(
14817                sh("alpha", "echo ok"),
14818                tmp.path(),
14819                Duration::from_secs(1),
14820            )],
14821            Arc::new(Semaphore::new(1)),
14822            Some(&cache),
14823            &mut st,
14824        )
14825        .await;
14826        assert!(
14827            matches!(results[0].2, AgentOutcome::Ok(_)),
14828            "the wait must not be charged to the agent"
14829        );
14830        assert!(st.resource_block.is_none());
14831        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
14832        assert!(st.resource_wait.is_none() && st.active.is_empty());
14833    }
14834
14835    #[tokio::test]
14836    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
14837        crate::run::pin_test_home();
14838        let tmp = tempfile::tempdir().unwrap();
14839        let cache = tmp.path().join("target-b");
14840        let _held = hold(&cache);
14841        let mut st = state(1);
14842        let results = run(
14843            vec![job(
14844                sh("alpha", "echo ok"),
14845                tmp.path(),
14846                Duration::from_secs(1),
14847            )],
14848            Arc::new(Semaphore::new(1)),
14849            Some(&cache),
14850            &mut st,
14851        )
14852        .await;
14853        let (_, seat, out) = &results[0];
14854        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
14855        assert!(wave_blocked(&results));
14856        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
14857        assert_eq!(seat.turns, 0, "nothing was asked");
14858        assert!(
14859            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
14860            "no agent was spawned"
14861        );
14862        assert!(FailClass::of(out).is_none(), "not a failure class");
14863        let block = st.resource_block.as_ref().expect("recorded for the resume");
14864        assert_eq!(block.kind, "shared build cache");
14865        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
14866        assert!(st.active.is_empty());
14867    }
14868
14869    #[tokio::test]
14870    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
14871        crate::run::pin_test_home();
14872        let tmp = tempfile::tempdir().unwrap();
14873        let mut st = state(1);
14874        let results = run(
14875            vec![job(
14876                sh("alpha", "echo ok"),
14877                tmp.path(),
14878                Duration::from_secs(1),
14879            )],
14880            Arc::new(Semaphore::new(0)),
14881            None,
14882            &mut st,
14883        )
14884        .await;
14885        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
14886        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
14887        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
14888    }
14889
14890    #[tokio::test]
14891    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
14892        crate::run::pin_test_home();
14893        let tmp = tempfile::tempdir().unwrap();
14894        let mut st = state(60);
14895        let results = run(
14896            vec![job(
14897                sh("alpha", "sleep 30"),
14898                tmp.path(),
14899                Duration::from_secs(1),
14900            )],
14901            Arc::new(Semaphore::new(1)),
14902            None,
14903            &mut st,
14904        )
14905        .await;
14906        let out = &results[0].2;
14907        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
14908        let class = FailClass::of(out).expect("a failure");
14909        assert_eq!(class, FailClass::Timeout);
14910        assert!(should_hand_over(None, &class), "existing handover policy");
14911        assert!(st.resource_block.is_none());
14912    }
14913}