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 repair or `magi fix`, 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 repair or `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
581/// Holds the mutable runner as well as its durable lease: cancellation must
582/// clear both copies of ownership, including state a caller may save later.
583struct DriverExecution<'a> {
584    runner: &'a mut Runner,
585    driver: crate::driver::DriverGuard,
586}
587
588impl Drop for DriverExecution<'_> {
589    fn drop(&mut self) {
590        self.driver.release_local(&mut self.runner.state);
591    }
592}
593
594impl Runner {
595    /// Start a fresh run against `repo`.
596    pub async fn start(
597        repo: &Path,
598        instruction: String,
599        config: Config,
600        origin: Origin,
601    ) -> Result<Self> {
602        Self::start_naming(repo, instruction, "", config, origin).await
603    }
604
605    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
606    /// searched for branch and commit references along with the instruction,
607    /// since a task may name the work it is about only in its title.
608    pub async fn start_naming(
609        repo: &Path,
610        instruction: String,
611        also_scan: &str,
612        config: Config,
613        origin: Origin,
614    ) -> Result<Self> {
615        let repo = git::toplevel(repo).await?;
616        let missing = agent::missing_programs(&config.agents);
617        if !missing.is_empty() {
618            bail!(
619                "these agent programs are not on PATH: {}. Fix the roster in \
620                 magi.toml or install them.",
621                missing.join(", ")
622            );
623        }
624        let base_branch =
625            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
626                .await?;
627        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
628        let roles = config.resolve_roles()?;
629        let max_parallel = config.graph.max_parallel.max(1);
630        // A task that points at work already in the repository starts from
631        // it; what the repository says about each reference is recorded.
632        let seeds = refs::resolve(
633            &repo,
634            &base_commit,
635            &config.merge.remote,
636            &format!("{also_scan}\n{instruction}"),
637        )
638        .await;
639        refs::plan(&repo, &seeds).await?;
640        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
641        // Recorded before the first save, so a crash right after minting
642        // cannot leave a run with no origin. Legibility only: nothing reads it
643        // to decide anything.
644        state.origin = Some(origin);
645        for seed in &seeds {
646            state.event(
647                "seed",
648                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
649            );
650        }
651        state.seeds = seeds;
652        state.event("start", format!("run {} created", state.id));
653        state.save()?;
654        Ok(Self {
655            state,
656            roles,
657            sem: Arc::new(Semaphore::new(max_parallel)),
658            pause: Pause::new(),
659            interrupt: Pause::new(),
660        })
661    }
662
663    /// Open a review-only run against work that already exists on `branch`.
664    ///
665    /// The expensive half of the graph is the implement wave — measured at
666    /// 111 and 134 internal tool-loop turns on this repository, against a
667    /// handful for a judge or a reviewer. The cheap half is worth running on
668    /// hand-written work too, and there was no way to reach it.
669    ///
670    /// No new state and no schema change are needed: a run with **one** viable
671    /// candidate and a tally already decided degrades `execute` to exactly
672    /// review → gate → merge, because `judge` skips a single-candidate field,
673    /// `deliberate` has fewer than two first choices to reconcile, `vote`
674    /// returns early, `tally` is already present and `fold_losers` has no
675    /// losers. Resuming such a run therefore does the right thing as well.
676    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
677        Self::review_taking_over(repo, branch, config, None, origin).await
678    }
679
680    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
681    /// at the same task still has `branch` checked out, its worktree is
682    /// released first if that is safe (see [`crate::handover`]), and the
683    /// review refuses with the reason if it is not. `None` is a hand-run
684    /// review: it has no earlier attempts, so only a worktree of a dead run
685    /// magi recorded itself can be released.
686    pub async fn review_taking_over(
687        repo: &Path,
688        branch: &str,
689        config: Config,
690        takeover: Option<crate::handover::Takeover>,
691        origin: Origin,
692    ) -> Result<Self> {
693        let repo = git::toplevel(repo).await?;
694        let missing = agent::missing_programs(&config.agents);
695        if !missing.is_empty() {
696            bail!(
697                "these agent programs are not on PATH: {}. Fix the roster in \
698                 magi.toml or install them.",
699                missing.join(", ")
700            );
701        }
702        let base_branch =
703            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
704                .await?;
705        if base_branch == branch {
706            bail!("`{branch}` is the base branch; there is nothing to review against");
707        }
708        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
709
710        let roles = config.resolve_roles()?;
711        let max_parallel = config.graph.max_parallel.max(1);
712        let mut state = RunState::new(
713            repo.clone(),
714            base_branch,
715            base_commit.clone(),
716            String::new(),
717            config,
718        );
719        state.origin = Some(origin);
720
721        // Released before anything else touches the branch: a stale local
722        // branch is moved with `git branch -f`, which git refuses while an
723        // earlier attempt's worktree still has it checked out. Everything
724        // after this point that can fail puts the old run back.
725        // A hand-run review has no task, hence no earlier attempts, but a
726        // worktree of a dead run magi made may still be released.
727        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
728            earlier: Vec::new(),
729            home: crate::run::home(),
730            choice: None,
731            task_instruction: None,
732        });
733        let wait = repository_wait(&state, None);
734        let coordination_config = state.config.coordination.clone();
735        let coordination =
736            crate::repo_lock::SeatLease::operation_with(&repo, coordination_config.as_ref(), &wait)
737                .await?;
738        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
739        if let Some(released) = &released {
740            state.event(
741                "release",
742                format!(
743                    "took `{branch}` over from run {}: its worktree was released: {}",
744                    crate::run::short_of(&released.old_id),
745                    released.audit
746                ),
747            );
748        }
749        // The owner's answer to an earlier divergence question is applied
750        // here: after the release (git will not move a checked-out branch)
751        // and before the sync that would otherwise ask again.
752        if let Some(choice) = takeover.choice.as_ref()
753            && let Err(e) =
754                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
755                    .await
756        {
757            if let Some(released) = &released {
758                released.restore(&repo, branch).await;
759            }
760            return Err(e.context("applying the owner's answer about the diverged branch"));
761        }
762        // An earlier attempt of this task that published the task head, and
763        // whose work is the branch under review, hands that head over: the
764        // review run updates the same pull request. Without one (a legacy
765        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
766        // own head and its own open pull request is adopted as before.
767        for id in takeover.earlier.iter().rev() {
768            if let Ok(old) = RunState::load_under(id, &takeover.home)
769                && let Some(head) = old.pr_head.clone()
770                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
771            {
772                state.pr_head = Some(head);
773                break;
774            }
775        }
776        drop(coordination);
777        let opened = Self::open_review(
778            &repo,
779            branch,
780            state,
781            roles,
782            max_parallel,
783            base_commit,
784            &takeover,
785        )
786        .await;
787        if opened.is_err()
788            && let Some(released) = &released
789        {
790            let _coordination = crate::repo_lock::SeatLease::operation_with(
791                &repo,
792                coordination_config.as_ref(),
793                &wait,
794            )
795            .await?;
796            released.restore(&repo, branch).await;
797        }
798        opened
799    }
800
801    /// Recover exactly the operator-selected public implementation, without touching old sources.
802    pub async fn recover_pr(
803        repo: &Path,
804        task: &str,
805        selection: crate::pr_recovery::Selection,
806        config: Config,
807    ) -> Result<Self> {
808        selection.check_task(task, &selection.instruction)?;
809        let base_branch =
810            git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
811                .await?;
812        let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
813        selection
814            .verify(repo, &config.merge.remote, &base_branch, &selection.head)
815            .await?;
816        let roles = config.resolve_roles()?;
817        let max_parallel = config.graph.max_parallel.max(1);
818        let mut state = RunState::new(
819            repo.to_path_buf(),
820            base_branch,
821            base_commit.clone(),
822            selection.instruction.clone(),
823            config,
824        );
825        state.origin = Some(crate::run::Origin::queue(task));
826        state.pr_head = Some(selection.branch.clone());
827        state.pr_recovery = Some(selection.clone());
828        crate::pr_head::check_predecessors(&state, &selection.branch).await?;
829        Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
830    }
831
832    async fn open_selected(
833        repo: &Path,
834        state: RunState,
835        roles: ResolvedRoles,
836        max_parallel: usize,
837        base_commit: String,
838        selection: crate::pr_recovery::Selection,
839    ) -> Result<Self> {
840        let mut state = state;
841        let branch = state.branch_for('A');
842        let wait = repository_wait(&state, None);
843        let coordination = coordinate_operation(&mut state, repo, &wait).await?;
844        git::git(repo, &["branch", &branch, &selection.head]).await?;
845        drop(coordination);
846        state.event(
847            "recovery",
848            format!(
849                "selected public implementation {} from {}",
850                selection.head, selection.url
851            ),
852        );
853        let takeover = crate::handover::Takeover {
854            earlier: Vec::new(),
855            home: crate::run::home(),
856            choice: None,
857            task_instruction: Some(selection.instruction.clone()),
858        };
859        state.save()?;
860        let id = state.id.clone();
861        let opened = Self::open_review(
862            repo,
863            &branch,
864            state,
865            roles,
866            max_parallel,
867            base_commit,
868            &takeover,
869        )
870        .await;
871        match opened {
872            Ok(runner) => Ok(runner),
873            Err(e) => {
874                if let Ok(mut partial) = RunState::load(&id) {
875                    partial.status = RunStatus::Blocked;
876                    partial.driver_exited = true;
877                    partial.event(
878                        "recovery",
879                        format!("startup failed; retained source and candidate {branch}: {e:#}"),
880                    );
881                    partial.save()?;
882                }
883                Err(crate::pr_recovery::StartupFailed {
884                    run: id,
885                    cause: format!("{e:#}"),
886                }
887                .into())
888            }
889        }
890    }
891
892    /// What the newest earlier attempt that reviewed `branch` leaves for this
893    /// run (see [`crate::recovery`]). `None` for a hand-run review, which has
894    /// no earlier attempts. Reads the earlier record only: ownership of its
895    /// worktree and branch is [`crate::handover`]'s business.
896    async fn inherit(
897        repo: &Path,
898        branch: &str,
899        base_commit: &str,
900        takeover: &crate::handover::Takeover,
901    ) -> Option<crate::recovery::RecoveryRecord> {
902        use crate::recovery::{Relation, assess};
903        let newest = takeover.earlier.last()?;
904        let mut chosen = None;
905        for id in takeover.earlier.iter().rev() {
906            if let Ok(old) = RunState::load_under(id, &takeover.home)
907                && !old.reviews.is_empty()
908                && (old.winner().is_some_and(|w| w.branch == branch)
909                    || old.pr_head.as_deref() == Some(branch))
910            {
911                chosen = Some(old);
912                break;
913            }
914        }
915        let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
916            .await
917            .unwrap_or_default();
918        let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
919            Some(last) if last.head == tip => Relation::Same,
920            Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
921            _ => Relation::Diverged,
922        };
923        let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
924        Some(assess(from, chosen.as_ref(), base_commit, relation))
925    }
926
927    /// The half of [`Runner::review_taking_over`] that can fail after an
928    /// earlier attempt's worktree was released.
929    async fn open_review(
930        repo: &Path,
931        branch: &str,
932        mut state: RunState,
933        roles: ResolvedRoles,
934        max_parallel: usize,
935        base_commit: String,
936        takeover: &crate::handover::Takeover,
937    ) -> Result<Self> {
938        let wait = repository_wait(&state, None);
939        let _coordination = coordinate_operation(&mut state, repo, &wait).await?;
940        if state.pr_recovery.is_none() {
941            sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
942        }
943        // The commit subjects are the closest thing to a task statement that
944        // existing work carries, and the reviewers are told as much.
945        let start = review_base(
946            repo,
947            &state.config.merge.remote,
948            &state.base_branch,
949            &base_commit,
950            branch,
951        )
952        .await;
953        let log = git::log_oneline(repo, &start, branch)
954            .await
955            .unwrap_or_default();
956        let log_text = if log.trim().is_empty() {
957            "(no commit messages)"
958        } else {
959            log.trim()
960        };
961        let mut instruction = format!(
962            "Review the work already on branch `{branch}`. There is no task \
963             statement: what the change claims to do is whatever its commits \
964             say.\n\n{log_text}"
965        );
966        // A retry of a task whose earlier attempt left reviews behind carries
967        // that attempt's evidence, and the task's own statement with it: the
968        // completion criteria are the task's, not whatever the commits claim.
969        if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
970            if let Some(task) = takeover
971                .task_instruction
972                .as_deref()
973                .filter(|t| !t.trim().is_empty())
974                && rec.reviewed_head.is_some()
975            {
976                instruction = format!(
977                    "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
978                    task.trim()
979                );
980                rec.criteria_inherited = true;
981            }
982            state.event("recovery", rec.summary());
983            state.recovery = Some(rec);
984        }
985        if let Some(selected) = &state.pr_recovery {
986            instruction = selected.instruction.clone();
987        }
988        state.task_instruction = takeover.task_instruction.clone();
989        state.instruction = instruction;
990        state.reviewed_commits = Some(
991            git::subjects(repo, &start, branch)
992                .await
993                .unwrap_or_default(),
994        );
995
996        // An attached worktree, so the fixer's commits land on the branch under
997        // review rather than on a detached head nobody will look at again.
998        let worktree = state.worktree_root().join("under-review");
999        if let Some(parent) = worktree.parent() {
1000            tokio::fs::create_dir_all(parent).await.ok();
1001        }
1002        let path = worktree.to_string_lossy().to_string();
1003        git::git(repo, &["worktree", "add", &path, branch])
1004            .await
1005            .with_context(|| {
1006                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
1007            })?;
1008
1009        let setup = match ensure_setup_config(&mut state, repo).await {
1010            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
1011            Err(e) => Err(e),
1012        };
1013        if let Err(e) = setup {
1014            git::worktree_remove_clean(repo, &worktree).await.ok();
1015            return Err(e);
1016        }
1017        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
1018            .await
1019            .unwrap_or(0);
1020        if commits == 0 {
1021            git::worktree_remove_clean(repo, &worktree).await.ok();
1022            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
1023        }
1024        let files = git::changed_files(&worktree, &base_commit, "HEAD")
1025            .await
1026            .map(|f| f.len())
1027            .unwrap_or(0);
1028        if files == 0
1029            && let (Ok(head_tree), Ok(base_tree)) = (
1030                git::tree_of(&worktree, "HEAD").await,
1031                git::tree_of(&worktree, &base_commit).await,
1032            )
1033            && head_tree == base_tree
1034        {
1035            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1036            git::worktree_remove_clean(repo, &worktree).await.ok();
1037            bail!(
1038                "`{branch}` at {} has a tree identical to base {}; this usually means \
1039                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1040                 against `{}/{branch}`) rather than an empty change",
1041                short(&head),
1042                short(&base_commit),
1043                state.config.merge.remote
1044            );
1045        }
1046        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1047            .await
1048            .unwrap_or_default();
1049
1050        state.candidates.push(Candidate {
1051            index: 0,
1052            label: 'A',
1053            // Not an agent id on purpose: nothing in the roster wrote this, and
1054            // the stats tables must not credit anyone with a win for it.
1055            agent: EXISTING_BRANCH.to_owned(),
1056            branch: branch.to_owned(),
1057            worktree,
1058            summary: String::new(),
1059            stat,
1060            files,
1061            commits,
1062            empty: false,
1063            failed: None,
1064            verified_noop: None,
1065            duration_ms: 0,
1066            folded: false,
1067        });
1068        state.tally = Some(Tally {
1069            first_choice: BTreeMap::from([('A', 0)]),
1070            borda: BTreeMap::new(),
1071            winner: 'A',
1072            rankings: 0,
1073            unanimous_initial: false,
1074            deliberated: false,
1075            changed_votes: 0,
1076            unanimous_final: false,
1077            tie_break: None,
1078            // No panel sat, so no quorum applies. Zero judges is the correct
1079            // number for work that never competed, and must not be reported as
1080            // a collapsed panel.
1081            judges: 0,
1082            present: 0,
1083            quorum: 0,
1084            met_quorum: true,
1085            uncontested: Some("review-only run: nothing competed".to_owned()),
1086        });
1087        state.status = RunStatus::Reviewing;
1088        state.prep_complete = Some(true);
1089        state.event(
1090            "start",
1091            format!(
1092                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1093                state.id
1094            ),
1095        );
1096        state.save()?;
1097        Ok(Self {
1098            state,
1099            roles,
1100            sem: Arc::new(Semaphore::new(max_parallel)),
1101            pause: Pause::new(),
1102            interrupt: Pause::new(),
1103        })
1104    }
1105
1106    /// Reopen an existing run.
1107    pub fn resume(id: &str) -> Result<Self> {
1108        let state = RunState::load(id)?;
1109        crate::pr_head::ensure_owner(&state)?;
1110        if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1111            bail!(
1112                "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1113            );
1114        }
1115        if let Some(to) = &state.released_to {
1116            bail!(
1117                "run {} cannot be resumed: its worktree was released to run {}",
1118                state.short(),
1119                crate::run::short_of(to)
1120            );
1121        }
1122        let roles = state.config.resolve_roles()?;
1123        if let Some(id) = &state.config.graph.selected_implementer
1124            && state.candidates.iter().any(|c| c.agent != *id)
1125        {
1126            bail!(
1127                "replacement implementer pin does not match the saved candidates; task must stay held"
1128            );
1129        }
1130        let max_parallel = state.config.graph.max_parallel.max(1);
1131        Ok(Self {
1132            state,
1133            roles,
1134            sem: Arc::new(Semaphore::new(max_parallel)),
1135            pause: Pause::new(),
1136            interrupt: Pause::new(),
1137        })
1138    }
1139
1140    /// Walk the graph to a terminal state, skipping nodes already recorded.
1141    ///
1142    /// Every way a run is driven - the queue loop, `magi run`, a resume from
1143    /// the phone - ends here, so this is the one place a run that ended
1144    /// Blocked / Stalled / Failed, or died with an error, is announced to the
1145    /// notification centre. Best-effort: see [`crate::notices::raise`].
1146    pub async fn execute(&mut self) -> Result<()> {
1147        // Queue claims cover daemon work; this also serializes manual drivers
1148        // consuming a repair answer, including the new review and decision.
1149        let _repair_claim = if self.state.config.graph.selected_implementer.is_some()
1150            || self.state.land_approval.is_some()
1151            || self.state.merge_decision.is_some()
1152            || !self.state.merge_repairs.is_empty()
1153            || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1154        {
1155            let claim = FixClaim::acquire(&self.state.dir())?;
1156            let disk = RunState::load(&self.state.id)?;
1157            let checkpoint = |s: &RunState| {
1158                (
1159                    s.merge_repairs.len(),
1160                    s.merge_repairs
1161                        .last()
1162                        .map(|r| (r.question.clone(), r.phase)),
1163                    s.land_approval
1164                        .as_ref()
1165                        .map(|a| (a.question.clone(), a.head.clone())),
1166                )
1167            };
1168            if checkpoint(&disk) != checkpoint(&self.state) {
1169                bail!(
1170                    "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1171                );
1172            }
1173            Some(claim)
1174        } else {
1175            None
1176        };
1177        let mut execution = self.claim_driver()?;
1178        let runner = &mut *execution.runner;
1179        let wait = runner.repository_wait();
1180        // Keep the graph's large future off the callers' stack, including
1181        // standalone task attempts running on a default-sized test thread.
1182        let result = wait.scope(Box::pin(runner.execute_graph())).await;
1183        let result = match result {
1184            Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
1185                record_repository_block(&mut runner.state, &e);
1186                runner.park_here()?;
1187                Ok(())
1188            }
1189            other => other,
1190        };
1191        execution.driver.finish(&mut runner.state)?;
1192        let ended = if result.is_err() {
1193            Some(crate::notices::run_stopped(&runner.state.id, &runner.state))
1194        } else {
1195            crate::notices::run_ended(&runner.state)
1196        };
1197        if let Some(notice) = ended {
1198            crate::notices::raise(notice);
1199        }
1200        result
1201    }
1202
1203    fn repository_wait(&self) -> crate::repo_lock::WaitPolicy {
1204        repository_wait(&self.state, Some((&self.pause, &self.interrupt)))
1205    }
1206
1207    async fn coordinate(&mut self, cwd: &Path) -> Result<Option<crate::repo_lock::SeatLease>> {
1208        let wait = self.repository_wait();
1209        coordinate_operation(&mut self.state, cwd, &wait).await
1210    }
1211
1212    /// Validate ownership and persist the driver claim in one critical section.
1213    fn claim_driver(&mut self) -> Result<DriverExecution<'_>> {
1214        let owner = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1215        let disk = RunState::load(&self.state.id)?;
1216        crate::pr_head::ensure_owner(&disk)?;
1217        if disk.liveness(false) == crate::run::Liveness::Live {
1218            bail!("run already has a live driver; wait for its execution to end");
1219        }
1220        if let Some(to) = &disk.released_to {
1221            bail!(
1222                "run {} cannot continue: its worktree was released to run {}",
1223                self.state.short(),
1224                crate::run::short_of(to)
1225            );
1226        }
1227        let driver = crate::driver::DriverGuard::acquire(&crate::run::home(), &disk)?;
1228        let mut execution = DriverExecution {
1229            runner: self,
1230            driver,
1231        };
1232        // Declare after the execution guard: on any claim error this short
1233        // lock must drop before that guard reloads and releases ownership.
1234        let _owner = owner;
1235        let state = &mut execution.runner.state;
1236        // Moving again, so it is no longer parked. Set before the walk rather
1237        // than in `resume`, so every way of re-entering the graph clears it
1238        // and a card cannot claim a run is waiting to be resumed while the
1239        // agents are already working.
1240        state.parked = false;
1241        state.resource_wait = None;
1242        state.resource_block = None;
1243        // Any seat this state still lists as answering belongs to whatever
1244        // process last drove this run — this one included, if it crashed
1245        // mid-wave. Cleared and flushed immediately, before anything else
1246        // runs, so a resume can never show a seat as live when nothing is
1247        // asking it anything yet; the node that actually dispatches the next
1248        // wave repopulates it.
1249        state.clear_active();
1250        // Recorded in the same spot, and flushed together with the clear
1251        // above: this is the pid a reader checks (`RunState::liveness`) when
1252        // no daemon claim exists to answer "is a process still driving this
1253        // run" — a plain `magi run` / `magi review` typed into a terminal
1254        // claims nothing there. Always overwritten, never only-if-absent, so
1255        // a resumed run's stale pid from a previous, possibly-dead process
1256        // can never survive into this one's own report. Unlike
1257        // `clear_active`, this changes on every single `execute()` call, so
1258        // the save below is now unconditional rather than only-if-cleared.
1259        //
1260        // `driver_started_at` is recorded in the same breath, from this same
1261        // pid, so `liveness` can tell a live pid that is genuinely still us
1262        // apart from one the OS has since handed to an unrelated process —
1263        // see that field's own doc for why the pid alone is not enough.
1264        let pid = std::process::id();
1265        state.driver_pid = Some(pid);
1266        state.driver_started_at = crate::proc::process_started_at(pid);
1267        state.driver_exited = false;
1268        state.begin_pass();
1269        execution.driver.bind(state);
1270        state.save()?;
1271        execution.driver.record(state)?;
1272        Ok(execution)
1273    }
1274
1275    async fn execute_graph(&mut self) -> Result<()> {
1276        // Preservation may invalidate an old empty classification. Resolve it
1277        // before any resume shortcut can reuse a verdict or publish a branch.
1278        self.persist_candidates().await?;
1279        if self
1280            .state
1281            .merge_decision
1282            .as_ref()
1283            .is_some_and(|decision| !decision.closed)
1284            && self.state.status != RunStatus::Landing
1285        {
1286            if crate::merge_decision::resumable_hold(&self.state) {
1287                self.state.merge = Some(MergeOutcome {
1288                    mode: MergeMode::Pr,
1289                    ok: false,
1290                    detail: self
1291                        .state
1292                        .merge_decision
1293                        .as_ref()
1294                        .unwrap()
1295                        .target
1296                        .pr
1297                        .clone(),
1298                    empty: false,
1299                });
1300                self.run_land().await?;
1301                self.settle_questions();
1302                return Ok(());
1303            }
1304            bail!(
1305                "owner stop decision is held; inspect the existing PR and request an explicit fresh review/decision before further work"
1306            );
1307        }
1308        if self.state.config.graph.selected_implementer.is_some()
1309            && self.state.status == RunStatus::Blocked
1310            && self.state.tally.is_none()
1311            && self.state.candidate_preservation.is_empty()
1312        {
1313            bail!(
1314                "selected implementation is held; explicitly release the owning task to retry it"
1315            );
1316        }
1317
1318        if self.state.merge_repairs.last().is_some_and(|r| {
1319            matches!(
1320                r.phase,
1321                crate::run::MergeRepairPhase::Requested
1322                    | crate::run::MergeRepairPhase::Fixing
1323                    | crate::run::MergeRepairPhase::Reviewing
1324            )
1325        }) {
1326            if self.repair_merge_review().await? {
1327                self.run_land().await?;
1328            }
1329            self.settle_questions();
1330            return Ok(());
1331        }
1332        // Publication already passed review and gate. Resume only that node:
1333        // syncing to a newer base here could change the verified commit.
1334        if self.state.pr_publication_pending {
1335            self.merge().await?;
1336            if self.resource_halt()? {
1337                return Ok(());
1338            }
1339            self.state.save()?;
1340            self.settle_questions();
1341            return Ok(());
1342        }
1343        // A run that already lost its quorum never resumes into the verdict
1344        // machinery: `deliberate` and `vote` would otherwise clobber the
1345        // stalled marker back to Voting and the run would keep going past a
1346        // verdict that is no longer trustworthy. Everything already recorded is
1347        // kept, so the run stays resumable (or foldable) for a human to pick up.
1348        //
1349        // On --resume the run gets one chance to repair itself: the seats a
1350        // rate limit took out are re-asked. If their quota has since reset and
1351        // the quorum is restored, the run picks up and finishes; otherwise it
1352        // stays stale and still-resumable for a later retry. If it does not
1353        // recover, the returned status stays `Stalled` and nothing was
1354        // clobbered (the recovery only mutates entries for the lost seats).
1355        if self.state.status == RunStatus::Stalled {
1356            if self.recover_stall().await? {
1357                self.finish_after_tally().await?;
1358            } else {
1359                // Still below quorum: persist the marker and stay resumable.
1360                self.state.save()?;
1361            }
1362            return Ok(());
1363        }
1364        // A run parked inside `land` - watching CI, mid fix-round, or
1365        // waiting on the owner's merge approval - resumes directly into it,
1366        // never back through `prep`. Everything before `merge` already
1367        // concluded; that is the only way `status` reaches `Landing` in the
1368        // first place. Re-walking `review_loop` first would also be actively
1369        // wrong: its own status recomputation (see its doc) treats any
1370        // clean round as reason to set `status` to `Gating`, which would
1371        // clobber this marker before `merge` ever ran, and this run would
1372        // never find its way back into `land` at all.
1373        if self.state.status == RunStatus::Landing {
1374            self.run_land().await?;
1375            // `run_land` may have settled the run right here - CI came back
1376            // green and the PR merged, say - without ever passing back
1377            // through `merge`'s own trailing call. Whatever it left `status`
1378            // as is what this has to read.
1379            self.settle_questions();
1380            return Ok(());
1381        }
1382        // A run parked at `merge` on the owner's word about a withheld PR text
1383        // resumes straight into `merge`. Re-walking the tail would sync to the
1384        // base again (possibly rebasing, possibly spending the fixer) after the
1385        // gate had passed, and then push a tree nobody reviewed or gated.
1386        if self.state.github_text.is_some()
1387            && self.state.merge.is_none()
1388            && self.state.status == RunStatus::Gating
1389        {
1390            self.merge().await?;
1391            if self.resource_halt()? {
1392                return Ok(());
1393            }
1394            self.state.save()?;
1395            return Ok(());
1396        }
1397        if let Err(e) = self.prep().await {
1398            if e.is::<crate::repo_lock::ResourceBlocked>() {
1399                return Err(e);
1400            }
1401            self.state.status = RunStatus::Blocked;
1402            self.state
1403                .prep_failure
1404                .get_or_insert_with(|| format!("{e:#}"));
1405            self.state
1406                .event("prep", format!("preparation stopped: {e:#}"));
1407            self.state.save()?;
1408            return Err(e);
1409        }
1410        if self.park_here()? {
1411            return Ok(());
1412        }
1413        self.advise().await?;
1414        if self.park_here()? {
1415            return Ok(());
1416        }
1417        self.implement().await?;
1418        if self.park_here()? {
1419            return Ok(());
1420        }
1421        // `after_implement` already saved the state and settled any open
1422        // questions when it set this; nothing later in the graph has
1423        // anything to judge.
1424        if self.state.status == RunStatus::VerifiedNoop {
1425            return Ok(());
1426        }
1427        self.judge().await?;
1428        if self.park_here()? {
1429            return Ok(());
1430        }
1431        self.deliberate().await?;
1432        if self.park_here()? {
1433            return Ok(());
1434        }
1435        self.vote().await?;
1436        if self.park_here()? {
1437            return Ok(());
1438        }
1439        self.tally()?;
1440        // A verdict that lost its quorum is not trustworthy: do not review,
1441        // gate, or merge on it. Everything already done is kept, so the run
1442        // stays resumable (or foldable); the human can replace the agent that
1443        // ran out of quota and pick it up.
1444        if self.state.status == RunStatus::Stalled {
1445            // Persist the stalled marker now — the normal end-of-execute save
1446            // below is below this early return, and without it a resumed run
1447            // would reload a pre-tally status and keep going.
1448            self.state.save()?;
1449            return Ok(());
1450        }
1451        self.finish_after_tally().await?;
1452        Ok(())
1453    }
1454
1455    /// A wave could not start its agents because a shared resource stayed busy
1456    /// (see [`RunState::resource_block`]): leave the run `Blocked` and
1457    /// resumable. Unstarted seats have no result; completed peers retain
1458    /// their saved candidate state.
1459    fn stop_for_resource(&mut self) -> Result<()> {
1460        // A wait abandoned because the run is parking stays a park.
1461        if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1462        {
1463            self.state.status = RunStatus::Blocked;
1464        }
1465        self.state.save()
1466    }
1467
1468    /// Stop walking the graph if a node ended on a resource block.
1469    fn resource_halt(&mut self) -> Result<bool> {
1470        if self.state.resource_block.is_none() {
1471            return Ok(false);
1472        }
1473        self.stop_for_resource()?;
1474        Ok(true)
1475    }
1476
1477    /// Park here if asked to, recording it in the run's own timeline.
1478    ///
1479    /// Returns whether the caller should stop walking the graph. The state is
1480    /// saved either way by the node that just finished; this adds the event so
1481    /// the operator's card says why a run that is neither finished nor moving
1482    /// is sitting where it is.
1483    fn park_here(&mut self) -> Result<bool> {
1484        let parking = self.pause.parked() || self.interrupt.parked();
1485        if !parking && self.state.resource_block.is_some() {
1486            self.stop_for_resource()?;
1487            return Ok(true);
1488        }
1489        // Either handle asking is enough - see `Pause`'s own doc for why
1490        // they are never the same one. `interrupt` is checked second so a
1491        // reason it carries is preferred in the message below over a plain
1492        // shutdown park racing it at the same boundary.
1493        if !self.pause.parked() && !self.interrupt.parked() {
1494            return Ok(false);
1495        }
1496        let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1497        let why = match cause.as_ref().map(ParkCause::label) {
1498            Some(reason) => format!(
1499                "parked after `{}` ({reason}) — resume to carry on from here",
1500                self.state.status.as_str()
1501            ),
1502            None => format!(
1503                "parked after `{}` — resume to carry on from here",
1504                self.state.status.as_str()
1505            ),
1506        };
1507        self.state.event("park", why);
1508        self.state
1509            .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1510        self.state.parked = true;
1511        self.state.save()?;
1512        Ok(true)
1513    }
1514
1515    /// Only a new explicit operator approval may retry failed preparation.
1516    pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1517        if self.state.prep_failure.is_none() {
1518            return Ok(());
1519        }
1520        let failed_at = self
1521            .state
1522            .events
1523            .iter()
1524            .rev()
1525            .find(|event| event.node == "prep")
1526            .map_or(self.state.updated_at, |event| event.at);
1527        anyhow::ensure!(
1528            approval_at > failed_at,
1529            "preparation retry requires a new explicit release after the failure"
1530        );
1531        self.state.prep_failure = None;
1532        self.state.prep_complete = Some(false);
1533        self.state
1534            .event("prep-retry", "operator approved retry of preparation");
1535        Ok(())
1536    }
1537
1538    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1539    pub fn on_pause(&mut self, pause: Pause) {
1540        self.pause = pause;
1541    }
1542
1543    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1544    /// scheduler asking this one run - and no other - to park so a task
1545    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1546    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1547    /// handle.
1548    pub fn watch_interrupt(&mut self, pause: Pause) {
1549        self.interrupt = pause;
1550    }
1551
1552    /// Abandon this run's own open questions, once `status` has actually
1553    /// settled rather than merely paused.
1554    ///
1555    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1556    /// either back up with the candidates, the review round and the seat
1557    /// sessions already on disk, so a question an implementer asked mid-round
1558    /// may still get a real answer read by a real resume. Only the statuses
1559    /// `resumable` excludes are actually final: the run merged, it reached
1560    /// `Ready` with nothing left to do, it failed outright with no
1561    /// established point to continue from, or every candidate agreed, with
1562    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1563    /// every one of those the seat that asked is gone for good, exactly like
1564    /// the run being deleted under `magi run rm` - so the same cleanup
1565    /// applies, worded for what actually happened instead of "the run was
1566    /// deleted".
1567    ///
1568    /// Best-effort and silent on success: called from every place `status`
1569    /// can land on one of those three, including ones a resumed run revisits,
1570    /// so it must cost nothing when there was nothing open to begin with.
1571    fn settle_questions(&mut self) {
1572        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1573            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1574        }
1575    }
1576
1577    /// The tail of the graph after a trustworthy tally: fold losers, review,
1578    /// gate, merge, and persist.
1579    async fn finish_after_tally(&mut self) -> Result<()> {
1580        // Before the losers are folded: their branches are what the graft
1581        // reads from.
1582        self.graft().await?;
1583        if self.resource_halt()? {
1584            return Ok(());
1585        }
1586        self.fold_losers().await?;
1587        // Before review starts, and again right before the gate: a run's
1588        // review rounds can themselves take long enough for the base to move
1589        // a second time, and the gate is the one node whose "green" gets
1590        // acted on.
1591        self.sync_to_base().await?;
1592        if self.state.status == RunStatus::AlreadyInBase {
1593            return Ok(());
1594        }
1595        self.review_loop().await?;
1596        if self.resource_halt()? {
1597            return Ok(());
1598        }
1599        self.sync_to_base().await?;
1600        if self.state.status == RunStatus::AlreadyInBase {
1601            return Ok(());
1602        }
1603        self.gate().await?;
1604        self.merge().await?;
1605        if self.resource_halt()? {
1606            return Ok(());
1607        }
1608        self.state.save()?;
1609        Ok(())
1610    }
1611
1612    // ---------------------------------------------------------------- prep
1613
1614    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1615    /// run with the step and its output in the event; it is never swallowed.
1616    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1617    /// resume starts over instead of finding candidates and missing seats.
1618    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1619        let result = match ensure_setup_config(&mut self.state, repo).await {
1620            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1621            Err(e) => Err(e),
1622        };
1623        if let Err(e) = result {
1624            git::worktree_remove_clean(repo, wt).await.ok();
1625            if in_prep {
1626                self.rollback_prep(repo).await;
1627            }
1628            self.state.status = RunStatus::Blocked;
1629            self.state.event(
1630                "setup",
1631                format!("worktree setup failed in {}: {e:#}", wt.display()),
1632            );
1633            self.state.save()?;
1634            return Err(e);
1635        }
1636        Ok(())
1637    }
1638
1639    /// Undo everything `prep` created, so a resume re-runs it whole.
1640    async fn rollback_prep(&mut self, repo: &Path) {
1641        let root = self.state.worktree_root();
1642        for c in std::mem::take(&mut self.state.candidates) {
1643            if matches!(
1644                git::worktree_remove_clean(repo, &c.worktree).await,
1645                Ok(true)
1646            ) {
1647                git::branch_delete(repo, &c.branch).await.ok();
1648            } else {
1649                self.state.candidates.push(c);
1650            }
1651        }
1652        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1653        for j in 1..=seats {
1654            git::worktree_remove_clean(repo, &root.join(format!("judge-{j}")))
1655                .await
1656                .ok();
1657        }
1658        for k in 1..=self.state.config.graph.advisors {
1659            git::worktree_remove_clean(repo, &root.join(format!("advisor-{k}")))
1660                .await
1661                .ok();
1662        }
1663    }
1664
1665    async fn prep(&mut self) -> Result<()> {
1666        if let Some(reason) = &self.state.prep_failure {
1667            bail!(
1668                "candidate preparation previously failed: {reason}; correct the source before retrying"
1669            );
1670        }
1671        if self
1672            .state
1673            .prep_complete
1674            .unwrap_or(!self.state.candidates.is_empty())
1675        {
1676            return Ok(());
1677        }
1678        let repo = self.state.repo.clone();
1679        // Includes rollback, config, refs, checkout creation and setup. No
1680        // repository mutation may precede the real shared lock acquisition.
1681        let _coordination = self.coordinate(&repo).await?;
1682        self.rollback_prep(&repo).await;
1683        if !self.state.candidates.is_empty() {
1684            bail!(
1685                "preparation rollback retained worktrees; recover their uncommitted work before retrying"
1686            );
1687        }
1688        self.state.prep_complete = Some(false);
1689        self.state.status = RunStatus::Prep;
1690        self.state.save()?;
1691        let repo = self.state.repo.clone();
1692        let base = self.state.base_commit.clone();
1693        let plan = refs::plan(&repo, &self.state.seeds).await?;
1694        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1695        let root = self.state.worktree_root();
1696        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1697
1698        // The hook is the write-time half of the blindness contract; the
1699        // presentation filter in `blind` is the half that cannot be bypassed.
1700        let hooks_dir = self.state.dir().join("hooks");
1701        if self.state.config.blind.commit_msg_hook {
1702            std::fs::create_dir_all(&hooks_dir)
1703                .with_context(|| format!("create {}", hooks_dir.display()))?;
1704            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1705            let path = hooks_dir.join("commit-msg");
1706            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1707            make_executable(&path)?;
1708            // Ref-counted rather than a plain idempotent set: with more than
1709            // one run able to be in flight in the same repository at once
1710            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1711            // true?" check cannot tell "another run of mine still needs
1712            // this" from "nobody does", and the run that happens to finish
1713            // first would disable the hook out from under a sibling still
1714            // relying on it.
1715            git::acquire_worktree_config(&repo).await?;
1716            self.state.enabled_worktree_config = true;
1717        }
1718
1719        // `[worktree] setup` hides its products per worktree, which needs the
1720        // same per-worktree config the hook does. Held until fold, like it.
1721        ensure_setup_config(&mut self.state, &repo).await?;
1722
1723        for (index, (spec, label)) in self
1724            .roles
1725            .implementers
1726            .clone()
1727            .into_iter()
1728            .zip(labels)
1729            .enumerate()
1730        {
1731            let branch = self.state.branch_for(label);
1732            let worktree = root.join(format!("cand-{label}"));
1733            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1734            if self.state.config.blind.commit_msg_hook {
1735                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1736            }
1737            git::local_exclude(&worktree, "/.magi/").await?;
1738            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1739                // Nothing of this prep may survive: a resume must start over,
1740                // not find half the candidates and skip the rest.
1741                git::worktree_remove_clean(&repo, &worktree).await.ok();
1742                git::branch_delete(&repo, &branch).await.ok();
1743                self.rollback_prep(&repo).await;
1744                self.state.status = RunStatus::Blocked;
1745                self.state
1746                    .event("prep", format!("worktree setup failed: {e:#}"));
1747                self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1748                self.state.save()?;
1749                return Err(e);
1750            }
1751            self.state.candidates.push(Candidate {
1752                index,
1753                label,
1754                agent: spec.id.clone(),
1755                branch,
1756                worktree,
1757                summary: String::new(),
1758                stat: String::new(),
1759                files: 0,
1760                commits: 0,
1761                empty: false,
1762                failed: None,
1763                verified_noop: None,
1764                duration_ms: 0,
1765                folded: false,
1766            });
1767            self.state.save()?;
1768            for pick in &plan.picks {
1769                if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1770                {
1771                    self.state.status = RunStatus::Blocked;
1772                    self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1773                    self.state
1774                        .event("prep", format!("cannot apply referenced commit: {e}"));
1775                    self.state.save()?;
1776                    return Err(e);
1777                }
1778            }
1779        }
1780
1781        for j in 1..=self.roles.judges.len() {
1782            let wt = root.join(format!("judge-{j}"));
1783            if !wt.exists() {
1784                git::worktree_add_detached(&repo, &wt, &base).await?;
1785                self.setup_seat_worktree(&repo, &wt, true).await?;
1786            }
1787        }
1788
1789        // Disposable, detached checkouts for the design-deliberation stage's
1790        // advisor seats — the same shape as the judges' above, at the same
1791        // base commit, since advisors also only ever read. Sized off the
1792        // configured count directly rather than a resolved roster: unlike
1793        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1794        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1795        // `prep` has no `ResolvedRoles` field to read a count from here.
1796        if self.state.config.graph.advise {
1797            for k in 1..=self.state.config.graph.advisors {
1798                let wt = root.join(format!("advisor-{k}"));
1799                if !wt.exists() {
1800                    git::worktree_add_detached(&repo, &wt, &base).await?;
1801                    self.setup_seat_worktree(&repo, &wt, true).await?;
1802                }
1803            }
1804        }
1805
1806        // A judge cannot tell it is looking at its own patch — the seats keep
1807        // separate conversations — but a panel that shares agents with the
1808        // field is less independent than it looks, and that is worth saying out
1809        // loud once per run rather than leaving it in the config.
1810        let authors: Vec<&str> = self
1811            .roles
1812            .implementers
1813            .iter()
1814            .map(|a| a.id.as_str())
1815            .collect();
1816        let overlap: Vec<String> = self
1817            .roles
1818            .judges
1819            .iter()
1820            .enumerate()
1821            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1822            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1823            .collect();
1824        if !overlap.is_empty() {
1825            let note = format!(
1826                "{} also authored a candidate; blind, but the panel is less \
1827                 independent than {} distinct agents would be",
1828                overlap.join(", "),
1829                self.roles.judges.len()
1830            );
1831            self.state.event("prep", note);
1832        }
1833
1834        self.state.event(
1835            "prep",
1836            format!(
1837                "{} candidates, {} judges, base {} ({})",
1838                self.state.candidates.len(),
1839                self.roles.judges.len(),
1840                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1841                self.state.base_branch
1842            ),
1843        );
1844        self.state.prep_complete = Some(true);
1845        self.state.status = RunStatus::Implementing;
1846        self.state.save()?;
1847        Ok(())
1848    }
1849
1850    // -------------------------------------------------------------- advise
1851
1852    /// The design-deliberation stage: independent, read-only advisor seats
1853    /// each sketch a design before any implementer touches the repository,
1854    /// and (when at least one produced a usable proposal) a synthesis seat
1855    /// blends them into a brief `implement` carries in every candidate's
1856    /// prompt.
1857    ///
1858    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1859    /// advisors` is the proposal count. Everything here is best-effort and
1860    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1861    /// that cannot reach quota, or a synthesis seat that produced nothing
1862    /// usable all leave `implement` exactly as it was before this stage
1863    /// existed — the task instruction alone — rather than failing the whole
1864    /// competition over an enrichment stage. Every outcome is still recorded
1865    /// as an event, so a run that got nothing from this stage says why.
1866    ///
1867    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1868    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1869    /// resumed run whose stage failed would re-run it, and re-spend the
1870    /// agent calls, on every reentry before `implement`.
1871    ///
1872    /// Also skipped once any candidate shows implementation progress — the
1873    /// exact predicate `implement` itself uses to decide a candidate is no
1874    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1875    /// is not enough: a run created by an older binary that predates this
1876    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1877    /// an already-`Implementing`-or-later run under this build would
1878    /// otherwise walk straight back through `prep` (a no-op once candidates
1879    /// exist) into this node and spawn every advisor seat against worktrees
1880    /// `prep` never recreated — after implementation has already started,
1881    /// which is exactly the invariant this stage exists to guarantee.
1882    async fn advise(&mut self) -> Result<()> {
1883        let implement_untouched = self
1884            .state
1885            .candidates
1886            .iter()
1887            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1888        if !self.state.config.graph.advise || self.state.advise_attempted {
1889            return Ok(());
1890        }
1891        if !implement_untouched {
1892            self.state.event(
1893                "advise",
1894                "skipping the design-deliberation stage: at least one \
1895                 candidate already shows implementation progress, so this \
1896                 run is past the point the stage exists to run before"
1897                    .to_owned(),
1898            );
1899            self.state.advise_attempted = true;
1900            self.state.save()?;
1901            return Ok(());
1902        }
1903        let run_id = self.state.id.clone();
1904        let prompts = self.state.config.prompts.clone();
1905        let instruction = self.state.instruction.clone();
1906        let language = self.state.config.graph.language.clone();
1907        let root = self.state.worktree_root();
1908        let n = self.state.config.graph.advisors;
1909        let where_recorded = self.state.dir().join("run.json");
1910
1911        let seats = match self.state.config.advisors() {
1912            Ok(seats) if !seats.is_empty() => seats,
1913            Ok(_) => {
1914                self.state.event(
1915                    "advise",
1916                    format!(
1917                        "[graph] advisors is 0; skipping the design-deliberation \
1918                         stage and continuing without a synthesis brief (see {})",
1919                        where_recorded.display()
1920                    ),
1921                );
1922                self.state.advise_attempted = true;
1923                self.state.save()?;
1924                return Ok(());
1925            }
1926            Err(e) => {
1927                self.state.event(
1928                    "advise",
1929                    format!(
1930                        "could not resolve advisor seats ({e:#}); continuing \
1931                         without a design-deliberation brief (see {})",
1932                        where_recorded.display()
1933                    ),
1934                );
1935                self.state.advise_attempted = true;
1936                self.state.save()?;
1937                return Ok(());
1938            }
1939        };
1940
1941        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1942        let artifacts = agent::artifacts_dir(&self.state.dir());
1943        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1944
1945        let mut jobs = Vec::new();
1946        for (i, spec) in seats.iter().cloned().enumerate() {
1947            let seat_key = format!("advisor-{}", i + 1);
1948            let seat = self.seat(&seat_key, &spec.id);
1949            jobs.push(SeatJob {
1950                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1951                spec,
1952                seat,
1953                cwd: worktrees[i % worktrees.len()].clone(),
1954                timeout,
1955                allow_write: false,
1956                sessions: false,
1957                artifacts: artifacts.clone(),
1958                stem: seat_key,
1959                handover: None,
1960            });
1961        }
1962
1963        self.state.event(
1964            "advise",
1965            format!(
1966                "{} advisor seat(s) sketching a design in parallel",
1967                jobs.len()
1968            ),
1969        );
1970        let mut quota_losses = Vec::new();
1971        let cache = self.state.config.cache_dir();
1972        let ctx = WaveCtx {
1973            carry_seats: false,
1974            pauses: Some((&self.pause, &self.interrupt)),
1975            run: &run_id,
1976            node: "advise",
1977            prompts: &prompts,
1978            cache: cache.as_deref(),
1979            round: None,
1980        };
1981        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1982        let results = ask_json_wave::<Proposal>(
1983            jobs,
1984            Arc::clone(&self.sem),
1985            self.state.config.graph.retries,
1986            &advisor_roster,
1987            &ctx,
1988            &mut quota_losses,
1989            &mut self.state,
1990            &|p: &Proposal| p.validate(),
1991        )
1992        .await;
1993        if self.state.resource_block.is_some() {
1994            return self.stop_for_resource();
1995        }
1996        self.state.quota.extend(quota_losses);
1997
1998        let mut records = Vec::with_capacity(results.len());
1999        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
2000            let agent_id = seat.agent.clone();
2001            self.state.seats.insert(seat.key.clone(), seat);
2002            match res {
2003                Ok((proposal, out)) => {
2004                    self.state
2005                        .event("advise", format!("advisor-{} proposed a design", i + 1));
2006                    records.push(advise::AdvisorRecord::proposed(
2007                        i + 1,
2008                        agent_id,
2009                        proposal,
2010                        out.duration_ms,
2011                    ));
2012                }
2013                Err(e) => {
2014                    self.state.event(
2015                        "advise",
2016                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
2017                    );
2018                    records.push(advise::AdvisorRecord::failed(
2019                        i + 1,
2020                        agent_id,
2021                        e.to_string(),
2022                    ));
2023                }
2024            }
2025        }
2026
2027        let mut advice = advise::Advice {
2028            records,
2029            synthesis: None,
2030        };
2031        if advice.proposals().is_empty() {
2032            self.state.event(
2033                "advise",
2034                "no advisor produced a usable proposal; continuing without a \
2035                 synthesis brief"
2036                    .to_owned(),
2037            );
2038        } else {
2039            match self
2040                .synthesize_brief(
2041                    &advice,
2042                    &instruction,
2043                    &language,
2044                    &worktrees[0],
2045                    &artifacts,
2046                    &run_id,
2047                    &prompts,
2048                    cache.as_deref(),
2049                )
2050                .await
2051            {
2052                Ok(Some(text)) => {
2053                    self.state.event(
2054                        "advise",
2055                        "synthesized a design brief for the implementer".to_owned(),
2056                    );
2057                    advice.synthesis = Some(text);
2058                }
2059                Ok(None) => {
2060                    self.state.event(
2061                        "advise",
2062                        "the synthesis seat produced nothing usable; continuing \
2063                         without a design brief"
2064                            .to_owned(),
2065                    );
2066                }
2067                Err(e) => {
2068                    if e.is::<crate::repo_lock::ResourceBlocked>() {
2069                        return Err(e);
2070                    }
2071                    self.state.event(
2072                        "advise",
2073                        format!("could not synthesize a design brief: {e:#}"),
2074                    );
2075                }
2076            }
2077        }
2078        advise::apply_reflection(&mut advice);
2079
2080        self.state.advice = Some(advice);
2081        self.state.advise_attempted = true;
2082        self.state.save()?;
2083        Ok(())
2084    }
2085
2086    /// The synthesis seat: reads every advisor's proposal and blends them
2087    /// into the design brief `advise` stores on [`RunState::advice`]. Split
2088    /// out of [`Runner::advise`] only for readability — it is not called
2089    /// anywhere else.
2090    ///
2091    /// Picked the same way [`crate::talk`]'s standing conversation and
2092    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
2093    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
2094    /// order (a claude seat, else the first runnable agent in roster order)
2095    /// used when that field is unset — see `[roles] synthesizer`'s own doc
2096    /// in [`crate::config`] for why a dedicated field exists here at all.
2097    #[allow(clippy::too_many_arguments)]
2098    async fn synthesize_brief(
2099        &mut self,
2100        advice: &advise::Advice,
2101        instruction: &str,
2102        language: &str,
2103        cwd: &Path,
2104        artifacts: &Path,
2105        run_id: &str,
2106        prompts: &Prompts,
2107        cache: Option<&Path>,
2108    ) -> Result<Option<String>> {
2109        let chain = agent::pick_chain(
2110            &self.state.config.agents,
2111            self.state.config.roles.synthesizer.as_ref(),
2112            &agent::installed,
2113            "synthesizer",
2114        )?;
2115        let proposals = advice.proposals();
2116        let mut prompt = prompt::with_overlay(
2117            prompt::synthesize_brief(instruction, &proposals, language),
2118            prompts.overlay("advise"),
2119        );
2120        if cache.is_some() {
2121            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
2122            // below — see `prompt::build_cache_note`'s doc for why telling a
2123            // read-only seat to build through the shared cache is exactly how
2124            // a sandbox's write refusal gets misread as a defect.
2125            prompt.push('\n');
2126            prompt.push_str(&prompt::build_cache_note("advise", false));
2127        }
2128        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2129        // Each id is tried once, in order; a quota hit, error or unusable
2130        // answer moves to the next. The seat is single-turn (`sessions:
2131        // false`) and the prompt is the whole context, so a fallback agent
2132        // needs nothing carried over.
2133        let mut last = None;
2134        for (n, spec) in chain.iter().enumerate() {
2135            if n > 0 {
2136                self.state
2137                    .event("advise", format!("synthesis falling back to {}", spec.id));
2138            }
2139            let mut seat = self.seat("advise-synthesis", &spec.id);
2140            let wait = self.repository_wait();
2141            let outcome = wait
2142                .scope(agent::invoke(
2143                    spec,
2144                    &mut seat,
2145                    &Invocation {
2146                        coordination: self.state.config.coordination.as_ref(),
2147                        cwd,
2148                        prompt: &prompt,
2149                        timeout,
2150                        allow_write: false,
2151                        unsandboxed: false,
2152                        sessions: false,
2153                        artifacts,
2154                        stem: &if n == 0 {
2155                            "advise-synthesis".to_owned()
2156                        } else {
2157                            format!("advise-synthesis-{}", spec.id)
2158                        },
2159                        run: run_id,
2160                        node: "advise",
2161                        cache_dir: None,
2162                        attachments: &[],
2163                        writable: &[],
2164                    },
2165                ))
2166                .await;
2167            if outcome.is_ok() {
2168                self.state.seats.insert(seat.key.clone(), seat);
2169            }
2170            let advance = agent::chain_advances(&outcome);
2171            last = Some(outcome);
2172            if !advance {
2173                break;
2174            }
2175        }
2176        // Exhausted: the last attempt's result is what a single failed seat
2177        // would have produced.
2178        let out = last.expect("a chain holds at least one agent")?;
2179        if !out.usable() {
2180            return Ok(None);
2181        }
2182        let text =
2183            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2184        Ok((!text.trim().is_empty()).then_some(text))
2185    }
2186
2187    // ----------------------------------------------------------- implement
2188
2189    async fn implement(&mut self) -> Result<()> {
2190        self.persist_candidates().await?;
2191        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2192        // agent files with `magi task add` name the run that paid for it. The
2193        // prompt overlay is cloned alongside it because the waves borrow it
2194        // while `self` is mutably borrowed by the node's own bookkeeping.
2195        let run_id = self.state.id.clone();
2196        let prompts = self.state.config.prompts.clone();
2197        let todo: Vec<usize> = self
2198            .state
2199            .candidates
2200            .iter()
2201            .enumerate()
2202            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2203            .map(|(i, _)| i)
2204            .collect();
2205        if todo.is_empty() {
2206            return self.after_implement();
2207        }
2208        self.state.status = RunStatus::Implementing;
2209
2210        let language = self.state.config.graph.language.clone();
2211        let github_language = self.state.config.graph.github_language.clone();
2212        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2213        let sessions = self.state.config.graph.sessions;
2214        let artifacts = agent::artifacts_dir(&self.state.dir());
2215        // The design-deliberation stage's blended brief, when `advise` found
2216        // one — carried into every implementer's prompt the same way
2217        // regardless of which candidate it is.
2218        let brief = self
2219            .state
2220            .advice
2221            .as_ref()
2222            .and_then(|a| a.synthesis.as_deref())
2223            .map(str::to_owned);
2224        let attachments = self.state.attachments.clone();
2225
2226        let mut jobs = Vec::new();
2227        for &i in &todo {
2228            let (index, label, worktree) = {
2229                let c = &self.state.candidates[i];
2230                (c.index, c.label, c.worktree.clone())
2231            };
2232            let spec = self.roles.implementers[index].clone();
2233            let seat_key = format!("impl-{label}");
2234            let seat = self.seat(&seat_key, &spec.id);
2235            let instruction = seeded_instruction(&self.state);
2236            jobs.push(SeatJob {
2237                spec,
2238                seat,
2239                prompt: prompt::implement_in(
2240                    &instruction,
2241                    &worktree.to_string_lossy(),
2242                    &language,
2243                    &github_language,
2244                    brief.as_deref(),
2245                    &attachments,
2246                ),
2247                cwd: worktree,
2248                timeout,
2249                allow_write: true,
2250                sessions,
2251                artifacts: artifacts.clone(),
2252                stem: format!("impl-{label}"),
2253                handover: None,
2254            });
2255        }
2256
2257        self.state.event(
2258            "implement",
2259            format!("{} candidates in parallel", jobs.len()),
2260        );
2261        // Kept so a seat whose CLI hung up can be asked again from the same
2262        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
2263        // can update a seat's own entry once a fallback agent takes it over —
2264        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
2265        // whichever agent actually answered, not the one that quota'd out.
2266        let mut sent = jobs.clone();
2267        let cache = self.state.config.cache_dir();
2268        let ctx = WaveCtx {
2269            carry_seats: false,
2270            pauses: Some((&self.pause, &self.interrupt)),
2271            run: &run_id,
2272            node: "implement",
2273            prompts: &prompts,
2274            cache: cache.as_deref(),
2275            round: None,
2276        };
2277        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2278        if !wave_blocked(&results) {
2279            self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2280                .await;
2281            self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2282                .await?;
2283            self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2284                .await;
2285        }
2286        let resource_blocked = self.state.resource_block.is_some() || wave_blocked(&results);
2287
2288        let selected_failed = self.state.config.graph.selected_implementer.is_some()
2289            && results
2290                .iter()
2291                .any(|(_, _, out)| FailClass::of(out).is_some());
2292        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2293            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
2294                continue;
2295            }
2296            let seat_key = seat.key.clone();
2297            // A quota fallback (`resume_seat_handovers`) may have handed this
2298            // seat to a different agent than the one `prep` recorded on the
2299            // candidate; the stats tables and any later fixer-defaults-to-
2300            // winner's-author lookup must credit whoever actually answered —
2301            // unless every fallback also quota'd out, in which case nobody
2302            // actually answered and crediting the last agent tried would
2303            // erase every earlier agent's own quota loss from the stats
2304            // tables instead of just this one seat's.
2305            let agent = seat.agent.clone();
2306            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2307            self.state.seats.insert(seat.key.clone(), seat);
2308            let label = self.state.candidates[i].label;
2309
2310            let (summary, duration, failed, verified_claim) = match out {
2311                AgentOutcome::Ok(o) => {
2312                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2313                    let failed = (!o.usable()).then(|| {
2314                        if o.timed_out {
2315                            "agent timed out".to_owned()
2316                        } else {
2317                            format!(
2318                                "agent exited with {}",
2319                                crate::notices::exit_status(o.exit_code)
2320                            )
2321                        }
2322                    });
2323                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2324                    (text, o.duration_ms, failed, verified_claim)
2325                }
2326                // Left un-resumed by `resume_undelivered` (a dirty tree
2327                // already rescues the work, or there was no session left to
2328                // resume into) — reported like the ordinary failure it is,
2329                // never as if `o.text` (the CLI's raw error JSON) were an
2330                // answer.
2331                AgentOutcome::Dropped(o) => {
2332                    let why = o
2333                        .dropped
2334                        .as_ref()
2335                        .map(|d| d.why.as_str())
2336                        .unwrap_or("the CLI ended the stream without delivering its answer");
2337                    (
2338                        String::new(),
2339                        o.duration_ms,
2340                        Some(format!("the CLI dropped the stream ({why})")),
2341                        None,
2342                    )
2343                }
2344                AgentOutcome::Quota(o) => {
2345                    self.state.quota.push(QuotaLoss {
2346                        seat: seat_key,
2347                        node: "implement".to_owned(),
2348                        at: Timestamp::now(),
2349                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2350                    });
2351                    (
2352                        String::new(),
2353                        o.duration_ms,
2354                        Some("rate limited (quota); produced no change".to_owned()),
2355                        None,
2356                    )
2357                }
2358                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2359                    (String::new(), 0, Some(e), None)
2360                }
2361            };
2362
2363            // Save every completed turn before any fallible Git operation. A failed
2364            // rescue must not erase the reply or prevent collection of other seats.
2365            let c = &mut self.state.candidates[i];
2366            if !exhausted_the_fallback_chain {
2367                c.agent = agent;
2368            }
2369            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2370            c.duration_ms = duration;
2371            c.failed = failed;
2372            c.verified_noop = verified_claim;
2373            self.state
2374                .candidate_preservation
2375                .insert(label, "awaiting persistence".to_owned());
2376        }
2377        self.state.save()?;
2378        self.persist_candidates().await?;
2379
2380        if resource_blocked {
2381            return self.stop_for_resource();
2382        }
2383        if selected_failed {
2384            self.state.status = RunStatus::Blocked;
2385            self.state.event("implement", "selected implementer failed or hit quota; holding without fallback, review or publication");
2386            self.state.save()?;
2387            bail!(
2388                "selected implementer unavailable or quota-limited; restore availability and explicitly release the task"
2389            );
2390        }
2391        self.after_implement()
2392    }
2393
2394    /// Retry only persistence after a completed implementation turn.
2395    async fn persist_candidates(&mut self) -> Result<()> {
2396        let labels: Vec<_> = self.state.candidate_preservation.keys().copied().collect();
2397        for label in labels {
2398            let i = self
2399                .state
2400                .candidates
2401                .iter()
2402                .position(|c| c.label == label)
2403                .context("pending candidate is missing")?;
2404            let c = self.state.candidates[i].clone();
2405            let result = async {
2406                let wait = crate::repo_lock::WaitPolicy::new(
2407                    Duration::from_secs(self.state.config.graph.timeout_resource_wait),
2408                    || false,
2409                );
2410                let _coordination =
2411                    coordinate_operation(&mut self.state, &c.worktree, &wait).await?;
2412                // Best effort evidence requires no index writes. A successful snapshot
2413                // never authorizes removal of a tree whose rescue failed.
2414                if let Err(e) = git::snapshot_uncommitted(
2415                    &c.worktree,
2416                    &self
2417                        .state
2418                        .dir()
2419                        .join("artifacts")
2420                        .join(format!("candidate-{label}-recovery")),
2421                )
2422                .await
2423                {
2424                    self.state.event(
2425                        "preserve",
2426                        format!("candidate {label} snapshot incomplete: {e:#}"),
2427                    );
2428                }
2429                let rescued = git::rescue_commit(
2430                    &c.worktree,
2431                    &format!("magi: candidate {label} (uncommitted work)"),
2432                )
2433                .await?;
2434                self.state.note_withheld("implement", &rescued.withheld);
2435                let base = &self.state.base_commit;
2436                let commits = git::commits_ahead(&c.worktree, base, "HEAD").await?;
2437                let patch = git::diff(&c.worktree, base, "HEAD").await?;
2438                let stat = git::diff_stat(&c.worktree, base, "HEAD").await?;
2439                let files = git::changed_files(&c.worktree, base, "HEAD").await?.len();
2440                write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2441                if c.empty && commits > 0 && !patch.trim().is_empty()
2442                    && (self.state.judge_skipped || !self.state.judgements.is_empty()
2443                        || !self.state.deliberation.is_empty() || !self.state.votes.is_empty()
2444                        || self.state.tally.is_some())
2445                {
2446                    // Keep the old classification and pending marker so every
2447                    // retry stops too. Retain decisions as recovery evidence;
2448                    // only a supported recovery operation may replace them.
2449                    bail!("candidate {label} changes the judged candidate set; supported recovery and fresh judging are required before continuing");
2450                }
2451                let c = &mut self.state.candidates[i];
2452                c.commits = commits;
2453                c.stat = stat;
2454                c.files = files;
2455                c.empty = commits == 0 || patch.trim().is_empty();
2456                if !c.empty {
2457                    c.failed = None;
2458                    c.verified_noop = None;
2459                }
2460                self.state.event(
2461                    "preserve",
2462                    format!("candidate {label}: {files} files, {commits} commits"),
2463                );
2464                Ok::<_, anyhow::Error>(())
2465            }
2466            .await;
2467            match result {
2468                Ok(()) => {
2469                    self.state.candidate_preservation.remove(&label);
2470                }
2471                Err(e) => {
2472                    let reason = format!("candidate {label} preservation failed: {e:#}");
2473                    self.state.event("preserve", &reason);
2474                    self.state.candidate_preservation.insert(label, reason);
2475                }
2476            }
2477            if self.state.candidate_preservation.is_empty()
2478                && self.state.status == RunStatus::Blocked
2479                && self.state.candidates.iter().all(|c| c.failed.is_none())
2480            {
2481                // Persist the recovered phase with the cleared pending marker;
2482                // a crash between saves must not turn this into a selected-seat
2483                // failure hold on the next resume.
2484                self.state.status = RunStatus::Implementing;
2485                self.state.event(
2486                    "preserve",
2487                    "candidate persistence recovered; implementation can continue",
2488                );
2489            }
2490            self.state.save()?;
2491        }
2492        if !self.state.candidate_preservation.is_empty() {
2493            self.state.status = RunStatus::Blocked;
2494            self.state.save()?;
2495            bail!(
2496                "candidate preservation incomplete; retain worktrees and artifacts, repair persistence and resume this run"
2497            );
2498        }
2499        Ok(())
2500    }
2501
2502    /// Ask again, once, for work a CLI did and then failed to hand over.
2503    ///
2504    /// [`agent::dropped_stream`] recognises the one shape observed: an error
2505    /// status with an empty response and a usage report showing output tokens,
2506    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
2507    /// seven minutes and 14,267 output tokens that arrived as an empty
2508    /// candidate, because `agy`'s own subscriber fell behind and hung up.
2509    ///
2510    /// Two conditions, and both matter:
2511    ///
2512    /// - **Only when the tree is untouched.** Often the agent has already
2513    ///   written its files and only the closing message was lost; the rescue
2514    ///   commit below picks that up and there is nothing to ask for. Re-asking
2515    ///   then would pay for a second implementation of work already on disk.
2516    /// - **Once.** A CLI that drops one stream can drop the next, and this
2517    ///   node is the most expensive in the graph.
2518    ///
2519    /// The re-ask is a resume, not a re-run: `has_context` is true because the
2520    /// dropped reply still carried its `conversation_id`, so the seat is asked
2521    /// to finish what it was doing rather than sent the whole task again. It
2522    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2523    /// node's - for the same reason a re-ranked judge does: restating finished
2524    /// work is not the work.
2525    ///
2526    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2527    /// same way until it resets, while an abandoned conversation is still
2528    /// there to be picked up.
2529    async fn resume_undelivered(
2530        &mut self,
2531        results: &mut [(usize, SeatState, AgentOutcome)],
2532        sent: &[SeatJob],
2533        prompts: &Prompts,
2534        run_id: &str,
2535    ) {
2536        for (wi, seat, out) in results.iter_mut() {
2537            let Some(dropped) = (match &*out {
2538                AgentOutcome::Dropped(o) => o.dropped.clone(),
2539                _ => None,
2540            }) else {
2541                continue;
2542            };
2543            let Some(job) = sent.get(*wi) else { continue };
2544            // Already on disk? Then only the closing message was lost.
2545            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2546                self.state.event(
2547                    "implement",
2548                    format!(
2549                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2550                         work is in the tree",
2551                        seat.key, dropped.output_tokens, dropped.why
2552                    ),
2553                );
2554                continue;
2555            }
2556            // The re-ask only makes sense as a resume: `resume_after_drop`
2557            // says nothing about the task, trusting the seat to still hold it.
2558            // Without a session to resume — sessions disabled, or this CLI's
2559            // drop shape happened not to carry a session id — that prompt
2560            // would open a brand-new conversation with no context at all,
2561            // which is worse than leaving this as the ordinary failure it
2562            // already is.
2563            if !has_context(&job.spec, seat, job.sessions) {
2564                self.state.event(
2565                    "implement",
2566                    format!(
2567                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2568                         is no session left to resume",
2569                        seat.key, dropped.output_tokens, dropped.why
2570                    ),
2571                );
2572                continue;
2573            }
2574            self.state.event(
2575                "implement",
2576                format!(
2577                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2578                     conversation",
2579                    seat.key, dropped.output_tokens, dropped.why
2580                ),
2581            );
2582            let mut retry = job.clone();
2583            retry.seat = seat.clone();
2584            retry.prompt = prompt::resume_after_drop(&dropped.why);
2585            retry.timeout = retry_budget(job.timeout, true);
2586            retry.stem = format!("{}-resume", job.stem);
2587            let cache = self.state.config.cache_dir();
2588            let ctx = WaveCtx {
2589                carry_seats: false,
2590                pauses: Some((&self.pause, &self.interrupt)),
2591                run: run_id,
2592                node: "implement",
2593                prompts,
2594                cache: cache.as_deref(),
2595                round: None,
2596            };
2597            let (resumed_seat, resumed) =
2598                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2599            *seat = resumed_seat;
2600            *out = resumed;
2601        }
2602    }
2603
2604    /// Fall an implement seat through to the next untried agent in the
2605    /// implementer roster when it lost to quota — or, since the handover was
2606    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2607    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2608    /// quota path itself being unchanged — instead of leaving the
2609    /// seat's loss final the moment one agent's account runs dry.
2610    ///
2611    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2612    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2613    /// truncates to the single slot rotation picked, so a solo task whose one
2614    /// implementer hits quota mid-run used to have nothing else to try. This
2615    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2616    /// unrotated roster — which is the only place the *other* candidates in
2617    /// the machine's roster still exist once `implementers` has been cut down
2618    /// to size.
2619    ///
2620    /// Walks forward from just past the seat's own original position in the
2621    /// roster, never wrapping back to the front: a later candidate slot (say
2622    /// `beta`, the roster's second entry) must fall through to the *next*
2623    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2624    /// almost certainly a different candidate's own agent already — and once
2625    /// the roster's tail is exhausted there is nothing left to fall through
2626    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2627    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2628    /// let this retry that id forever. The loop keeps falling through until
2629    /// an attempt lands something other than `Quota` or the roster's tail
2630    /// runs out of untried ids, at which point the seat is left exactly as
2631    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2632    /// `QuotaLoss` recorded, the candidate failed/empty.
2633    ///
2634    /// `sent` is taken mutably and updated with the fallback agent's spec:
2635    /// `resume_unconfirmed_commands`, which runs after this and also reads
2636    /// `sent`, must see whichever agent actually ended up answering the seat
2637    /// — reading the stale, original spec there would check session
2638    /// eligibility against the wrong CLI and could hand a fallback agent's
2639    /// session id to the agent that just lost the seat to quota.
2640    ///
2641    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2642    /// — `self.seat` only reuses state when the agent id is unchanged, so
2643    /// handing it a different id already gets this for free. Reusing the old
2644    /// seat would resume a different CLI's session as if it were a
2645    /// continuation of this one.
2646    ///
2647    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2648    /// a quota loss cuts an agent off mid-turn, so anything already in the
2649    /// tree is unfinished work, not a completed candidate a re-ask would pay
2650    /// for twice. A dirty tree is rescued into a commit first (the same
2651    /// neutral-identity rescue `implement`'s own outcome loop gives every
2652    /// candidate) so the next agent starts clean.
2653    ///
2654    /// The new agent gets the implementer's full prompt and full
2655    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2656    /// it has no session and no context, and is implementing the task from
2657    /// nothing, unlike a resumed drop which is only restating work already
2658    /// done.
2659    ///
2660    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2661    /// `implement` event, never into `self.state.quota` — that is what
2662    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2663    /// go unspent, and a seat that ultimately recovered on its second or
2664    /// third agent is not the stalled panel that check exists to catch. Only
2665    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2666    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2667    /// outcome loop already has — this helper never pushes to it itself.
2668    async fn resume_seat_handovers(
2669        &mut self,
2670        results: &mut [(usize, SeatState, AgentOutcome)],
2671        sent: &mut [SeatJob],
2672        prompts: &Prompts,
2673        run_id: &str,
2674    ) -> Result<()> {
2675        let instruction = seeded_instruction(&self.state);
2676        let language = self.state.config.graph.language.clone();
2677        let github_language = self.state.config.graph.github_language.clone();
2678        let brief = self
2679            .state
2680            .advice
2681            .as_ref()
2682            .and_then(|a| a.synthesis.as_deref())
2683            .map(str::to_owned);
2684        let attachments = self.state.attachments.clone();
2685        for (wi, seat, out) in results.iter_mut() {
2686            // Who holds the other candidate seats of this wave right now
2687            // (earlier handovers already written back to `sent`).
2688            let others: BTreeSet<String> = sent
2689                .iter()
2690                .enumerate()
2691                .filter(|(j, _)| j != wi)
2692                .map(|(_, j)| j.spec.id.clone())
2693                .collect();
2694            let Some(job) = sent.get_mut(*wi) else {
2695                continue;
2696            };
2697            // Where the seat's own original agent sits in the roster — the
2698            // fallback walk starts just past here, never at the front, so a
2699            // later candidate slot's quota loss does not fall back onto an
2700            // earlier slot's own agent.
2701            let start = self
2702                .roles
2703                .implementer_roster
2704                .iter()
2705                .position(|s| s.id == job.spec.id)
2706                .unwrap_or(0);
2707            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2708            let mut fallback_attempt = 0usize;
2709            let mut prev: Option<FailClass> = None;
2710            while let Some(cur) = FailClass::of(&*out) {
2711                if !should_hand_over(prev.as_ref(), &cur) {
2712                    break;
2713                }
2714                let Some(next) =
2715                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2716                        .cloned()
2717                else {
2718                    break;
2719                };
2720                tried.insert(next.id.clone());
2721                fallback_attempt += 1;
2722
2723                let coordination = self.coordinate(&job.cwd).await?;
2724                if let Ok(r) = git::rescue_commit(
2725                    &job.cwd,
2726                    &format!(
2727                        "magi: candidate {} (uncommitted work before {} fallback)",
2728                        seat.key,
2729                        if cur == FailClass::Quota {
2730                            "quota"
2731                        } else {
2732                            "handover"
2733                        }
2734                    ),
2735                )
2736                .await
2737                {
2738                    self.state.note_withheld("implement", &r.withheld);
2739                }
2740                drop(coordination);
2741
2742                record_handover(
2743                    &mut self.state,
2744                    "implement",
2745                    &seat.key,
2746                    &seat.agent,
2747                    &next.id,
2748                    &cur,
2749                    &fail_reason(&*out),
2750                );
2751                prev = Some(cur.clone());
2752
2753                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2754                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2755                // Kept in sync on `sent` itself, not just the local retry: a
2756                // later helper (`resume_unconfirmed_commands`) reads `sent`
2757                // after this one returns and must see whichever agent is now
2758                // occupying the seat, not the one that just quota'd out —
2759                // otherwise it would judge session/continuation eligibility
2760                // by the wrong CLI and could resend a fallback's session id
2761                // to the agent that lost it the seat in the first place.
2762                job.spec = next.clone();
2763                let mut retry = job.clone();
2764                retry.seat = new_seat;
2765                retry.prompt = prompt::implement_in(
2766                    &instruction,
2767                    &job.cwd.to_string_lossy(),
2768                    &language,
2769                    &github_language,
2770                    brief.as_deref(),
2771                    &attachments,
2772                );
2773                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2774                let cache = self.state.config.cache_dir();
2775                let ctx = WaveCtx {
2776                    carry_seats: false,
2777                    pauses: Some((&self.pause, &self.interrupt)),
2778                    run: run_id,
2779                    node: "implement",
2780                    prompts,
2781                    cache: cache.as_deref(),
2782                    round: None,
2783                };
2784                let (fallback_seat, fallback_out) = run_one(
2785                    retry,
2786                    Arc::clone(&self.sem),
2787                    &ctx,
2788                    &mut self.state,
2789                    fallback_attempt,
2790                )
2791                .await;
2792                *seat = fallback_seat;
2793                *out = fallback_out;
2794            }
2795        }
2796        Ok(())
2797    }
2798
2799    /// Ask an implement seat's own CLI to confirm what it started, once, when
2800    /// its reply reported a command whose completion status it never
2801    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2802    /// that does and does not mean.
2803    ///
2804    /// The completion contract this task asks for, extended to `implement`
2805    /// with the same signal `continue_fix_report` reads for the fixer,
2806    /// rather than a keyword search over the reply or a hard requirement on
2807    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2808    /// a candidate's CLI turn ended cleanly while a test run it had started
2809    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2810    /// named in it at all is untouched by this: `commands` is empty, so
2811    /// there is nothing to be unconfirmed.
2812    ///
2813    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2814    /// this is not about recovering edits that might already be on disk, it
2815    /// is about a result the seat itself never vouched for, which resuming
2816    /// asks for regardless of what the tree already holds. Bounded to one
2817    /// attempt for the same reason `resume_undelivered` is — this is the
2818    /// most expensive node in the graph — and a seat that still cannot
2819    /// confirm on that attempt is left as whatever its (possibly still
2820    /// unconfirmed) reply says; this does not invent a new "failed" reason
2821    /// for a candidate that otherwise produced a real, committed change.
2822    async fn resume_unconfirmed_commands(
2823        &mut self,
2824        results: &mut [(usize, SeatState, AgentOutcome)],
2825        sent: &[SeatJob],
2826        prompts: &Prompts,
2827        run_id: &str,
2828    ) {
2829        for (wi, seat, out) in results.iter_mut() {
2830            let AgentOutcome::Ok(o) = &*out else {
2831                continue;
2832            };
2833            if !has_unconfirmed_command(&o.commands) {
2834                continue;
2835            }
2836            let Some(job) = sent.get(*wi) else { continue };
2837            if !has_context(&job.spec, seat, job.sessions) {
2838                self.state.event(
2839                    "implement",
2840                    format!(
2841                        "{}: the reply named a command whose own CLI never confirmed the exit \
2842                         status of, but there is no session left to resume",
2843                        seat.key
2844                    ),
2845                );
2846                continue;
2847            }
2848            self.state.event(
2849                "implement",
2850                format!(
2851                    "{}: the reply named a command whose own CLI never confirmed the exit \
2852                     status of; resuming the conversation",
2853                    seat.key
2854                ),
2855            );
2856            let mut retry = job.clone();
2857            retry.seat = seat.clone();
2858            retry.prompt = prompt::resume_incomplete(
2859                "a command in your last reply had no confirmed exit status",
2860            );
2861            retry.timeout = retry_budget(job.timeout, true);
2862            retry.stem = format!("{}-confirm", job.stem);
2863            let cache = self.state.config.cache_dir();
2864            let ctx = WaveCtx {
2865                carry_seats: false,
2866                pauses: Some((&self.pause, &self.interrupt)),
2867                run: run_id,
2868                node: "implement",
2869                prompts,
2870                cache: cache.as_deref(),
2871                round: None,
2872            };
2873            let (resumed_seat, resumed) =
2874                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2875            *seat = resumed_seat;
2876            *out = resumed;
2877        }
2878    }
2879
2880    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2881    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2882    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2883    /// that motivated this.
2884    ///
2885    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2886    /// own nudge loop already covers for judge/review/vote seats, and not a
2887    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2888    /// implement seats: here the CLI turn genuinely finished while the node's
2889    /// own work — the fixer's account of what it did — had not. Gated purely
2890    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2891    /// reply, never on any wording in it, so a fixer whose valid, first-try
2892    /// `FixReport` happens to mention having waited on a background test is
2893    /// never resumed — the `Ok(report)` branch at the call site returns
2894    /// before this is ever invoked.
2895    ///
2896    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2897    /// attempt ([`retry_budget`]), nothing attempted once the session is
2898    /// gone, and a quota hit ends the loop immediately rather than retrying a
2899    /// rate limit that fails the same way again.
2900    async fn continue_fix_report(
2901        &mut self,
2902        mut seat: SeatState,
2903        parse_err: String,
2904        job: &SeatJob,
2905        prompts: &Prompts,
2906        run_id: &str,
2907        round: usize,
2908    ) -> (
2909        SeatState,
2910        Option<FixReport>,
2911        Option<String>,
2912        ContinuationRecord,
2913    ) {
2914        let mut last_err = parse_err;
2915        let mut cumulative_wait_ms = 0u64;
2916        let mut attempts = 0usize;
2917        loop {
2918            if !has_context(&job.spec, &seat, job.sessions) {
2919                self.state.event(
2920                    "fix",
2921                    format!(
2922                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2923                         session left to resume into"
2924                    ),
2925                );
2926                let outcome = if attempts == 0 {
2927                    ContinuationOutcome::NoSession
2928                } else {
2929                    ContinuationOutcome::Exhausted
2930                };
2931                return (
2932                    seat,
2933                    None,
2934                    Some(format!("unparsable fix report: {last_err}")),
2935                    ContinuationRecord {
2936                        attempts,
2937                        cumulative_wait_ms,
2938                        outcome,
2939                    },
2940                );
2941            }
2942            if attempts >= MAX_FIX_CONTINUATIONS {
2943                self.state.event(
2944                    "fix",
2945                    format!(
2946                        "round {round}: fixer's reply still had no adoption report after \
2947                         {attempts} continuation(s) ({last_err}); giving up"
2948                    ),
2949                );
2950                return (
2951                    seat,
2952                    None,
2953                    Some(format!(
2954                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2955                    )),
2956                    ContinuationRecord {
2957                        attempts,
2958                        cumulative_wait_ms,
2959                        outcome: ContinuationOutcome::Exhausted,
2960                    },
2961                );
2962            }
2963            attempts += 1;
2964            self.state.event(
2965                "fix",
2966                format!(
2967                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2968                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2969                ),
2970            );
2971            let mut retry = job.clone();
2972            retry.seat = seat.clone();
2973            retry.prompt = prompt::resume_incomplete(&last_err);
2974            retry.timeout = retry_budget(job.timeout, true);
2975            retry.stem = format!("{}-continue{attempts}", job.stem);
2976            let cache = self.state.config.cache_dir();
2977            let ctx = WaveCtx {
2978                carry_seats: false,
2979                pauses: Some((&self.pause, &self.interrupt)),
2980                run: run_id,
2981                node: "fix",
2982                prompts,
2983                cache: cache.as_deref(),
2984                round: Some(round),
2985            };
2986            let (resumed_seat, resumed_out) = run_one(
2987                retry,
2988                Arc::clone(&self.sem),
2989                &ctx,
2990                &mut self.state,
2991                attempts,
2992            )
2993            .await;
2994            seat = resumed_seat;
2995            match resumed_out {
2996                AgentOutcome::Ok(o) => {
2997                    cumulative_wait_ms += o.duration_ms;
2998                    match verdict::extract_json::<FixReport>(&o.text) {
2999                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
3000                            self.state.event(
3001                                "fix",
3002                                format!(
3003                                    "round {round}: fixer's adoption report recovered after \
3004                                     {attempts} continuation(s)"
3005                                ),
3006                            );
3007                            return (
3008                                seat,
3009                                Some(report),
3010                                None,
3011                                ContinuationRecord {
3012                                    attempts,
3013                                    cumulative_wait_ms,
3014                                    outcome: ContinuationOutcome::Resumed,
3015                                },
3016                            );
3017                        }
3018                        // The report parsed, but this same reply's own
3019                        // CommandEvidence — the identical record `state.jobs`
3020                        // renders — names a command whose CLI never
3021                        // confirmed an exit status. Read together, that is
3022                        // not a resolved answer: keep nudging rather than
3023                        // accept a report standing next to a command the
3024                        // seat's own CLI cannot vouch for.
3025                        Ok(_) => {
3026                            last_err = "the reply parsed, but it reported a command whose own CLI \
3027                                 never confirmed an exit status"
3028                                .to_owned();
3029                        }
3030                        Err(e) => last_err = e.to_string(),
3031                    }
3032                }
3033                AgentOutcome::Quota(o) => {
3034                    cumulative_wait_ms += o.duration_ms;
3035                    self.state.quota.push(QuotaLoss {
3036                        seat: seat.key.clone(),
3037                        node: "fix".to_owned(),
3038                        at: Timestamp::now(),
3039                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3040                    });
3041                    self.state.event(
3042                        "fix",
3043                        format!(
3044                            "round {round}: continuation rate limited (quota); not retrying now"
3045                        ),
3046                    );
3047                    return (
3048                        seat,
3049                        None,
3050                        Some("rate limited (quota) while recovering the fix report".to_owned()),
3051                        ContinuationRecord {
3052                            attempts,
3053                            cumulative_wait_ms,
3054                            outcome: ContinuationOutcome::QuotaLost,
3055                        },
3056                    );
3057                }
3058                AgentOutcome::Dropped(o) => {
3059                    cumulative_wait_ms += o.duration_ms;
3060                    let why = o
3061                        .dropped
3062                        .as_ref()
3063                        .map(|d| d.why.as_str())
3064                        .unwrap_or("the CLI ended the stream without delivering its answer");
3065                    last_err = format!("the CLI dropped the stream ({why})");
3066                }
3067                AgentOutcome::Failed(e) => last_err = e,
3068                // The continuation was never started; the report stays lost
3069                // and the caller records that, without any handover.
3070                AgentOutcome::ResourceBlocked(e) => {
3071                    return (
3072                        seat,
3073                        None,
3074                        Some(format!("the continuation was not started: {e}")),
3075                        ContinuationRecord {
3076                            attempts: attempts.saturating_sub(1),
3077                            cumulative_wait_ms,
3078                            outcome: ContinuationOutcome::NoSession,
3079                        },
3080                    );
3081                }
3082            }
3083        }
3084    }
3085
3086    fn after_implement(&mut self) -> Result<()> {
3087        if !self.state.candidate_preservation.is_empty() {
3088            bail!("candidate preservation incomplete; judging is unsafe");
3089        }
3090        // Scan every candidate patch once the set is complete.
3091        if self.state.leaks.is_empty() {
3092            let cfg = self.state.config.blind.clone();
3093            let mut leaks = Vec::new();
3094            for c in &self.state.candidates {
3095                let Some(patch) =
3096                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
3097                else {
3098                    continue;
3099                };
3100                leaks.extend(blind::scan(
3101                    &format!("candidate {} patch", c.label),
3102                    &patch,
3103                    &cfg.vendor_tokens,
3104                ));
3105            }
3106            if !leaks.is_empty() {
3107                let summary = leaks
3108                    .iter()
3109                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
3110                    .collect::<Vec<_>>()
3111                    .join(", ");
3112                match cfg.on_leak {
3113                    LeakPolicy::Fail => {
3114                        self.state.status = RunStatus::Failed;
3115                        self.state
3116                            .event("blind", format!("vendor text in a patch: {summary}"));
3117                        self.state.leaks = leaks;
3118                        self.state.save()?;
3119                        self.settle_questions();
3120                        bail!(
3121                            "blind.on_leak = \"fail\" and vendor text reached a \
3122                             judged patch: {summary}"
3123                        );
3124                    }
3125                    LeakPolicy::Redact => self.state.event(
3126                        "blind",
3127                        format!("redacting vendor text for judging: {summary}"),
3128                    ),
3129                    LeakPolicy::Warn => self.state.event(
3130                        "blind",
3131                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
3132                    ),
3133                }
3134                self.state.leaks = leaks;
3135            }
3136        }
3137
3138        if self.state.viable().is_empty() {
3139            if self.state.all_candidates_verified_noop() {
3140                // Every candidate agreed, with evidence the adoption guard
3141                // accepted, that nothing belongs in this worktree. That is
3142                // not the same fact as a candidate that simply failed to
3143                // write anything, and settling it as an ordinary `Failed`
3144                // (see `SCHEMA`'s doc for schema 10) is what let two of
3145                // task 391f's attempts burn a retry each re-discovering the
3146                // same already-landed fix. Terminal either way, so `judge`
3147                // must never run over an empty candidate set — unlike the
3148                // `Failed` branch below this returns `Ok`, not an error:
3149                // nothing here failed.
3150                self.state.status = RunStatus::VerifiedNoop;
3151                self.state.save()?;
3152                self.settle_questions();
3153                return Ok(());
3154            }
3155            self.state.status = RunStatus::Failed;
3156            self.state.save()?;
3157            self.settle_questions();
3158            bail!("no candidate produced a change; nothing to judge");
3159        }
3160        self.state.status = RunStatus::Judging;
3161        self.state.save()?;
3162        Ok(())
3163    }
3164
3165    // --------------------------------------------------------------- judge
3166
3167    async fn judge(&mut self) -> Result<()> {
3168        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3169        // agent files with `magi task add` name the run that paid for it. The
3170        // prompt overlay is cloned alongside it because the waves borrow it
3171        // while `self` is mutably borrowed by the node's own bookkeeping.
3172        let run_id = self.state.id.clone();
3173        let prompts = self.state.config.prompts.clone();
3174        if !self.state.judgements.is_empty() || self.state.judge_skipped {
3175            return Ok(());
3176        }
3177        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3178        if viable.len() == 1 {
3179            // Recorded so this is a one-time event: `judgements` stays empty
3180            // either way, which without this flag is indistinguishable from
3181            // "not yet judged" on the next reentry — and status is left
3182            // untouched, so a later node's conclusion (e.g. `Blocked` after
3183            // the review budget ran out) survives a resume instead of being
3184            // clobbered back to `Judging` by this node running again.
3185            self.state.judge_skipped = true;
3186            self.state.event(
3187                "judge",
3188                format!(
3189                    "only candidate {} produced a change; judging skipped",
3190                    viable[0].label
3191                ),
3192            );
3193            self.state.save()?;
3194            return Ok(());
3195        }
3196        self.state.status = RunStatus::Judging;
3197
3198        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3199        let language = self.state.config.graph.language.clone();
3200        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3201        let sessions = self.state.config.graph.sessions;
3202        let artifacts = agent::artifacts_dir(&self.state.dir());
3203        let root = self.state.worktree_root();
3204        let base_short = short(&self.state.base_commit);
3205
3206        let mut jobs = Vec::new();
3207        let mut orders = Vec::new();
3208        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3209            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3210            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3211            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3212            let seat_key = format!("judge-{}", j + 1);
3213            let seat = self.seat(&seat_key, &spec.id);
3214            jobs.push(SeatJob {
3215                prompt: prompt::judge(
3216                    &self.state.instruction,
3217                    &views,
3218                    self.roles.judges.len(),
3219                    &base_short,
3220                    &language,
3221                ),
3222                spec,
3223                seat,
3224                cwd: root.join(format!("judge-{}", j + 1)),
3225                timeout,
3226                allow_write: false,
3227                sessions,
3228                artifacts: artifacts.clone(),
3229                stem: format!("judge-{}", j + 1),
3230                handover: None,
3231            });
3232        }
3233
3234        self.state.event(
3235            "judge",
3236            format!(
3237                "{} judges ranking {} candidates blind",
3238                jobs.len(),
3239                viable.len()
3240            ),
3241        );
3242        let labels_for_check = labels.clone();
3243        let mut quota_losses = Vec::new();
3244        let cache = self.state.config.cache_dir();
3245        let ctx = WaveCtx {
3246            carry_seats: false,
3247            pauses: Some((&self.pause, &self.interrupt)),
3248            run: &run_id,
3249            node: "judge",
3250            prompts: &prompts,
3251            cache: cache.as_deref(),
3252            round: None,
3253        };
3254        let results = ask_json_wave::<Ranking>(
3255            jobs,
3256            Arc::clone(&self.sem),
3257            self.state.config.graph.retries,
3258            &self.roles.judge_roster,
3259            &ctx,
3260            &mut quota_losses,
3261            &mut self.state,
3262            &move |r: &Ranking| r.validate(&labels_for_check),
3263        )
3264        .await;
3265        if self.state.resource_block.is_some() {
3266            return self.stop_for_resource();
3267        }
3268        self.state.quota.extend(quota_losses);
3269
3270        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3271            let agent_id = seat.agent.clone();
3272            self.state.seats.insert(seat.key.clone(), seat);
3273            let mut record = Judgement {
3274                judge: j + 1,
3275                seat: format!("judge-{}", j + 1),
3276                agent: agent_id,
3277                ranking: Vec::new(),
3278                reasons: BTreeMap::new(),
3279                confidence: None,
3280                strengths: BTreeMap::new(),
3281                order: orders[j].clone(),
3282                failed: None,
3283                duration_ms: 0,
3284            };
3285            match res {
3286                Ok((ranking, out)) => {
3287                    record.ranking = ranking.normalized();
3288                    record.reasons = ranking.reasons;
3289                    record.strengths = ranking.strengths;
3290                    record.confidence = ranking.confidence;
3291                    record.duration_ms = out.duration_ms;
3292                    self.state.event(
3293                        "judge",
3294                        format!(
3295                            "judge {} ranked {}",
3296                            j + 1,
3297                            record.ranking.iter().collect::<String>()
3298                        ),
3299                    );
3300                }
3301                Err(e) => {
3302                    record.failed = Some(e.to_string());
3303                    self.state
3304                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3305                }
3306            }
3307            self.state.judgements.push(record);
3308            self.state.save()?;
3309        }
3310        Ok(())
3311    }
3312
3313    // ---------------------------------------------------------- deliberate
3314
3315    async fn deliberate(&mut self) -> Result<()> {
3316        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3317        // agent files with `magi task add` name the run that paid for it. The
3318        // prompt overlay is cloned alongside it because the waves borrow it
3319        // while `self` is mutably borrowed by the node's own bookkeeping.
3320        let run_id = self.state.id.clone();
3321        let prompts = self.state.config.prompts.clone();
3322        if !self.state.deliberation.is_empty() {
3323            return Ok(());
3324        }
3325        let tops: Vec<char> = self
3326            .state
3327            .judgements
3328            .iter()
3329            .filter_map(|j| j.ranking.first().copied())
3330            .collect();
3331        let rounds = self.state.config.graph.deliberate_rounds;
3332        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3333            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3334                self.state.event(
3335                    "deliberate",
3336                    format!("judges agreed on {} outright; no deliberation", tops[0]),
3337                );
3338            }
3339            self.state.status = RunStatus::Voting;
3340            self.state.save()?;
3341            return Ok(());
3342        }
3343
3344        self.state.status = RunStatus::Deliberating;
3345        self.state.event(
3346            "deliberate",
3347            format!(
3348                "split: first choices were {} — opening {rounds} round(s)",
3349                tops.iter().collect::<String>()
3350            ),
3351        );
3352
3353        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3354        let language = self.state.config.graph.language.clone();
3355        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3356        let sessions = self.state.config.graph.sessions;
3357        let artifacts = agent::artifacts_dir(&self.state.dir());
3358        let root = self.state.worktree_root();
3359        let base_short = short(&self.state.base_commit);
3360
3361        // Judges argue in sequence so that a turn can answer the one before it;
3362        // that is the difference between deliberation and three parallel
3363        // monologues.
3364        for round in 1..=rounds {
3365            let mut turns: Vec<DeliberationTurn> = Vec::new();
3366            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3367                if self.state.judgements[j].failed.is_some() {
3368                    continue;
3369                }
3370                let seat_key = format!("judge-{}", j + 1);
3371                let spec = self.occupant(&seat_key, spec);
3372                let mut seat = self.seat(&seat_key, &spec.id);
3373                let transcript = self.transcript(&turns, j);
3374                let build = |context: Option<&str>| {
3375                    prompt::deliberate(
3376                        &self.state.instruction,
3377                        context,
3378                        &transcript,
3379                        round,
3380                        rounds,
3381                        &language,
3382                    )
3383                };
3384                let block = self.candidate_block(&viable, &base_short);
3385                let full = build(Some(&block));
3386                let text = if has_context(&spec, &seat, sessions) {
3387                    build(None)
3388                } else {
3389                    full.clone()
3390                };
3391                let job = SeatJob {
3392                    spec,
3393                    seat: seat.clone(),
3394                    prompt: text,
3395                    cwd: root.join(format!("judge-{}", j + 1)),
3396                    timeout,
3397                    allow_write: false,
3398                    sessions,
3399                    artifacts: artifacts.clone(),
3400                    stem: format!("delib-{round}-judge-{}", j + 1),
3401                    handover: Some(full),
3402                };
3403                let cache = self.state.config.cache_dir();
3404                let ctx = WaveCtx {
3405                    carry_seats: false,
3406                    pauses: Some((&self.pause, &self.interrupt)),
3407                    run: &run_id,
3408                    node: "deliberate",
3409                    prompts: &prompts,
3410                    cache: cache.as_deref(),
3411                    round: None,
3412                };
3413                // A turn is never nudged (`retries` 0): a failed seat is
3414                // handed to the next roster agent, which gets the full
3415                // context. An empty answer is a turn, not a failure.
3416                let mut losses = Vec::new();
3417                let mut results = ask_wave_with::<String>(
3418                    vec![job],
3419                    Arc::clone(&self.sem),
3420                    0,
3421                    &self.roles.judge_roster,
3422                    &ctx,
3423                    &mut losses,
3424                    &mut self.state,
3425                    &|text: &str| {
3426                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3427                    },
3428                )
3429                .await;
3430                self.state.quota.extend(losses);
3431                let (updated, res, _) = results.pop().expect("one job in, one result out");
3432                seat = updated;
3433                let agent_id = seat.agent.clone();
3434                self.state.seats.insert(seat.key.clone(), seat);
3435                let body = match res {
3436                    Ok((body, _)) => body,
3437                    // Skip the seat; a CLI's raw error JSON is never read as
3438                    // this judge's position.
3439                    Err(e) => {
3440                        self.state
3441                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3442                        continue;
3443                    }
3444                };
3445                let tentative = verdict::extract_json::<Position>(&body)
3446                    .ok()
3447                    .and_then(|p| p.tentative)
3448                    .and_then(|s| s.trim().chars().next())
3449                    .map(|c| c.to_ascii_uppercase());
3450                self.state.event(
3451                    "deliberate",
3452                    format!(
3453                        "round {round}: judge {} now favours {}",
3454                        j + 1,
3455                        tentative.map_or("—".to_owned(), |c| c.to_string())
3456                    ),
3457                );
3458                turns.push(DeliberationTurn {
3459                    judge: j + 1,
3460                    agent: agent_id,
3461                    body: blind::sanitize_prose(&body, &self.state.config.blind),
3462                    tentative,
3463                });
3464            }
3465            self.state
3466                .deliberation
3467                .push(DeliberationRound { round, turns });
3468            self.state.save()?;
3469        }
3470
3471        self.state.status = RunStatus::Voting;
3472        self.state.save()?;
3473        Ok(())
3474    }
3475
3476    // ---------------------------------------------------------------- vote
3477
3478    async fn vote(&mut self) -> Result<()> {
3479        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3480        // agent files with `magi task add` name the run that paid for it. The
3481        // prompt overlay is cloned alongside it because the waves borrow it
3482        // while `self` is mutably borrowed by the node's own bookkeeping.
3483        let run_id = self.state.id.clone();
3484        let prompts = self.state.config.prompts.clone();
3485        if !self.state.votes.is_empty() {
3486            return Ok(());
3487        }
3488        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3489        if viable.len() == 1 {
3490            return Ok(());
3491        }
3492        self.state.status = RunStatus::Voting;
3493
3494        let language = self.state.config.graph.language.clone();
3495        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3496        let sessions = self.state.config.graph.sessions;
3497        let artifacts = agent::artifacts_dir(&self.state.dir());
3498        let root = self.state.worktree_root();
3499        let base_short = short(&self.state.base_commit);
3500        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3501
3502        let mut jobs = Vec::new();
3503        let mut seats_at = Vec::new();
3504        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3505            if self
3506                .state
3507                .judgements
3508                .get(j)
3509                .is_some_and(|r| r.failed.is_some())
3510            {
3511                continue;
3512            }
3513            let seat_key = format!("judge-{}", j + 1);
3514            let spec = self.occupant(&seat_key, spec);
3515            let seat = self.seat(&seat_key, &spec.id);
3516            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3517            let text = if has_context(&spec, &seat, sessions) {
3518                prompt::final_vote(&viable, &language)
3519            } else {
3520                full.clone()
3521            };
3522            jobs.push(SeatJob {
3523                spec,
3524                seat,
3525                prompt: text,
3526                cwd: root.join(format!("judge-{}", j + 1)),
3527                timeout,
3528                allow_write: false,
3529                sessions,
3530                artifacts: artifacts.clone(),
3531                stem: format!("vote-judge-{}", j + 1),
3532                handover: Some(full),
3533            });
3534            seats_at.push(j);
3535        }
3536
3537        self.state.event(
3538            "vote",
3539            format!(
3540                "collecting {} final votes one by one, privately",
3541                jobs.len()
3542            ),
3543        );
3544        let allowed = viable.clone();
3545        let mut quota_losses = Vec::new();
3546        let cache = self.state.config.cache_dir();
3547        let ctx = WaveCtx {
3548            carry_seats: false,
3549            pauses: Some((&self.pause, &self.interrupt)),
3550            run: &run_id,
3551            node: "vote",
3552            prompts: &prompts,
3553            cache: cache.as_deref(),
3554            round: None,
3555        };
3556        let results = ask_json_wave::<FinalVote>(
3557            jobs,
3558            Arc::clone(&self.sem),
3559            self.state.config.graph.retries,
3560            &self.roles.judge_roster,
3561            &ctx,
3562            &mut quota_losses,
3563            &mut self.state,
3564            &move |v: &FinalVote| match v.label() {
3565                Some(c) if allowed.contains(&c) => Ok(()),
3566                other => bail!("vote {other:?} is not one of {allowed:?}"),
3567            },
3568        )
3569        .await;
3570        if self.state.resource_block.is_some() {
3571            return self.stop_for_resource();
3572        }
3573        self.state.quota.extend(quota_losses);
3574
3575        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3576            let agent_id = seat.agent.clone();
3577            self.state.seats.insert(seat.key.clone(), seat);
3578            let initial = self
3579                .state
3580                .judgements
3581                .get(j)
3582                .and_then(|r| r.ranking.first().copied());
3583            let mut record = VoteRecord {
3584                judge: j + 1,
3585                agent: agent_id,
3586                vote: None,
3587                reason: String::new(),
3588                changed: false,
3589            };
3590            match res {
3591                Ok((v, _)) => {
3592                    record.vote = v.label();
3593                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3594                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3595                    self.state.event(
3596                        "vote",
3597                        format!(
3598                            "judge {} voted {}{}",
3599                            j + 1,
3600                            record.vote.unwrap_or('?'),
3601                            if record.changed { " (changed)" } else { "" }
3602                        ),
3603                    );
3604                }
3605                Err(e) => {
3606                    self.state
3607                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3608                }
3609            }
3610            self.state.votes.push(record);
3611            self.state.save()?;
3612        }
3613        Ok(())
3614    }
3615
3616    // --------------------------------------------------------------- tally
3617
3618    fn tally(&mut self) -> Result<()> {
3619        if self.state.tally.is_some() {
3620            return Ok(());
3621        }
3622        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3623        let tops: Vec<char> = self
3624            .state
3625            .judgements
3626            .iter()
3627            .filter_map(|j| j.ranking.first().copied())
3628            .collect();
3629        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3630
3631        // A judge whose private vote failed still counted once, in the initial
3632        // ranking; using it beats discarding a whole seat.
3633        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3634        let mut cast: Vec<char> = Vec::new();
3635        for (i, j) in self.state.judgements.iter().enumerate() {
3636            let vote = self
3637                .state
3638                .votes
3639                .iter()
3640                .find(|v| v.judge == i + 1)
3641                .and_then(|v| v.vote)
3642                .or_else(|| j.ranking.first().copied());
3643            if let Some(v) = vote {
3644                *first_choice.entry(v).or_insert(0) += 1;
3645                cast.push(v);
3646            }
3647        }
3648
3649        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3650        for j in &self.state.judgements {
3651            let n = j.ranking.len();
3652            for (pos, label) in j.ranking.iter().enumerate() {
3653                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3654            }
3655        }
3656
3657        let best = first_choice.values().copied().max().unwrap_or(0);
3658        let mut leaders: Vec<char> = first_choice
3659            .iter()
3660            .filter(|(_, v)| **v == best)
3661            .map(|(k, _)| *k)
3662            .collect();
3663        let mut tie_break = None;
3664        if leaders.len() > 1 {
3665            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3666            let borda_leaders: Vec<char> = leaders
3667                .iter()
3668                .copied()
3669                .filter(|l| borda[l] == top_borda)
3670                .collect();
3671            tie_break = Some(if borda_leaders.len() == 1 {
3672                format!(
3673                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3674                    leaders.len()
3675                )
3676            } else {
3677                format!(
3678                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3679                    leaders.len()
3680                )
3681            });
3682            leaders = borda_leaders;
3683            leaders.sort_unstable();
3684        }
3685        let winner = *leaders
3686            .first()
3687            .or(viable.first())
3688            .context("no candidate to declare a winner from")?;
3689
3690        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3691        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3692        let deliberated = !self.state.deliberation.is_empty();
3693
3694        // Whose verdict is this? A rate-limited seat is absent even if it
3695        // ranked before the limit hit, so presence is measured against the
3696        // recorded losses, not just "did a ranking ever appear".
3697        let quota_seats: std::collections::BTreeSet<&str> =
3698            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3699        let mut present = 0usize;
3700        for (i, j) in self.state.judgements.iter().enumerate() {
3701            if quota_seats.contains(j.seat.as_str()) {
3702                continue;
3703            }
3704            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3705            let voted = self
3706                .state
3707                .votes
3708                .iter()
3709                .any(|v| v.judge == i + 1 && v.vote.is_some());
3710            if ranked || voted {
3711                present += 1;
3712            }
3713        }
3714        // Strict majority of the configured panel. A bare majority is real
3715        // signal we can act on, while a minority verdict must never stand in
3716        // for a healthy one. A one-candidate run needs no panel at all, and
3717        // `judges` stays `0` rather than the roster size a panel that never
3718        // sat would otherwise be credited with.
3719        let needs_quorum = viable.len() > 1;
3720        let judges_total = if needs_quorum {
3721            self.roles.judges.len()
3722        } else {
3723            0
3724        };
3725        let quorum = if needs_quorum {
3726            judges_total / 2 + 1
3727        } else {
3728            0
3729        };
3730        let met_quorum = !needs_quorum || present >= quorum;
3731        let uncontested = (!needs_quorum).then(|| {
3732            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3733        });
3734
3735        self.state.event(
3736            "tally",
3737            match &uncontested {
3738                Some(reason) => format!("winner {winner} — {reason}"),
3739                None => format!(
3740                    "winner {winner} — votes {} | initial {} | {} changed | \
3741                     {present}/{judges_total} judges{}",
3742                    first_choice
3743                        .iter()
3744                        .map(|(k, v)| format!("{k}:{v}"))
3745                        .collect::<Vec<_>>()
3746                        .join(" "),
3747                    if unanimous_initial {
3748                        "unanimous"
3749                    } else {
3750                        "split"
3751                    },
3752                    changed_votes,
3753                    if met_quorum {
3754                        String::new()
3755                    } else {
3756                        format!(" — below quorum ({quorum} required)")
3757                    },
3758                ),
3759            },
3760        );
3761        if !met_quorum {
3762            self.state.event(
3763                "stall",
3764                format!(
3765                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3766                     the run stops here, resumable"
3767                ),
3768            );
3769        }
3770        self.state.tally = Some(Tally {
3771            first_choice,
3772            borda,
3773            winner,
3774            rankings: tops.len(),
3775            unanimous_initial,
3776            deliberated,
3777            changed_votes,
3778            unanimous_final,
3779            tie_break,
3780            judges: judges_total,
3781            present,
3782            quorum,
3783            met_quorum,
3784            uncontested,
3785        });
3786        self.state.status = if met_quorum {
3787            RunStatus::Reviewing
3788        } else {
3789            RunStatus::Stalled
3790        };
3791        self.state.save()?;
3792        Ok(())
3793    }
3794
3795    // ------------------------------------------------------------- recover
3796
3797    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3798    /// resumed toward completion once the transient cause clears.
3799    ///
3800    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3801    /// it toward the quorum, which is exactly the set of seats whose absence
3802    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3803    /// not depend on which node happened to hit the limit), or an ordinary
3804    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3805    /// seat is never disturbed.
3806    ///
3807    /// A seat that now answers with a usable ranking is "recovered": its
3808    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3809    /// `tally` counts it present again), and its vote re-collected. A seat that
3810    /// still fails keeps its loss and stays absent.
3811    ///
3812    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3813    /// to review/gate/merge), `false` when it is still below quorum (the run
3814    /// stays `Stalled`, still resumable for a later retry).
3815    #[allow(clippy::too_many_lines)]
3816    async fn recover_stall(&mut self) -> Result<bool> {
3817        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3818        // agent files with `magi task add` name the run that paid for it. The
3819        // prompt overlay is cloned alongside it because the waves borrow it
3820        // while `self` is mutably borrowed by the node's own bookkeeping.
3821        let run_id = self.state.id.clone();
3822        let prompts = self.state.config.prompts.clone();
3823        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3824        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3825        // is what keeps a non-quota collapse — or a quota loss recorded at the
3826        // deliberate node — from being a permanent dead-end on `--resume`.
3827        let quota_seats: BTreeSet<&str> =
3828            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3829        let absent: Vec<String> = self
3830            .state
3831            .judgements
3832            .iter()
3833            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3834            .map(|j| j.seat.clone())
3835            .collect();
3836        if absent.is_empty() {
3837            return Ok(false);
3838        }
3839        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3840        if viable.len() <= 1 {
3841            return Ok(false);
3842        }
3843        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3844        let language = self.state.config.graph.language.clone();
3845        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3846        let sessions = self.state.config.graph.sessions;
3847        let artifacts = agent::artifacts_dir(&self.state.dir());
3848        let root = self.state.worktree_root();
3849        let base_short = short(&self.state.base_commit);
3850        let candidates: Vec<Candidate> = viable.clone();
3851
3852        // Map each absent seat key to its 0-based position in `roles.judges`.
3853        let mut positions: Vec<usize> = absent
3854            .iter()
3855            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3856            .collect();
3857        if positions.is_empty() {
3858            return Ok(false);
3859        }
3860        positions.sort_unstable();
3861        positions.dedup();
3862
3863        // Re-rank the lost seats, one blind prompt each.
3864        let mut judge_jobs = Vec::new();
3865        for &j in &positions {
3866            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3867            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3868            let seat_key = format!("judge-{}", j + 1);
3869            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3870            let seat = self.seat(&seat_key, &spec.id);
3871            judge_jobs.push(SeatJob {
3872                spec,
3873                seat,
3874                prompt: prompt::judge(
3875                    &self.state.instruction,
3876                    &views,
3877                    self.roles.judges.len(),
3878                    &base_short,
3879                    &language,
3880                ),
3881                cwd: root.join(seat_key),
3882                timeout,
3883                allow_write: false,
3884                sessions,
3885                artifacts: artifacts.clone(),
3886                stem: format!("judge-{}-recover", j + 1),
3887                handover: None,
3888            });
3889        }
3890
3891        let labels_for_check = labels.clone();
3892        let mut judge_losses = Vec::new();
3893        let retries = self.state.config.graph.retries;
3894        let cache = self.state.config.cache_dir();
3895        let ctx = WaveCtx {
3896            carry_seats: false,
3897            pauses: Some((&self.pause, &self.interrupt)),
3898            run: &run_id,
3899            node: "judge",
3900            prompts: &prompts,
3901            cache: cache.as_deref(),
3902            round: None,
3903        };
3904        let results = ask_json_wave::<Ranking>(
3905            judge_jobs,
3906            Arc::clone(&self.sem),
3907            retries,
3908            &self.roles.judge_roster,
3909            &ctx,
3910            &mut judge_losses,
3911            &mut self.state,
3912            &move |r: &Ranking| r.validate(&labels_for_check),
3913        )
3914        .await;
3915        if self.state.resource_block.is_some() {
3916            self.stop_for_resource()?;
3917            return Ok(false);
3918        }
3919
3920        // Refresh the judgement of every seat that ranked again.
3921        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3922        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3923            let agent_id = seat.agent.clone();
3924            self.state.seats.insert(seat.key.clone(), seat);
3925            let record = &mut self.state.judgements[j];
3926            match res {
3927                Ok((ranking, out)) => {
3928                    record.agent = agent_id;
3929                    record.ranking = ranking.normalized();
3930                    record.reasons = ranking.reasons;
3931                    record.strengths = ranking.strengths;
3932                    record.confidence = ranking.confidence;
3933                    record.failed = None;
3934                    record.duration_ms = out.duration_ms;
3935                    recovered.insert(j);
3936                    self.state.event(
3937                        "recover",
3938                        format!("judge {} ranked again after the limit", j + 1),
3939                    );
3940                }
3941                Err(e) => {
3942                    self.state
3943                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3944                }
3945            }
3946        }
3947
3948        // Re-ask the votes of the seats that recovered a ranking.
3949        let mut vote_jobs = Vec::new();
3950        let mut vote_pos: Vec<usize> = Vec::new();
3951        for &j in &recovered {
3952            let seat_key = format!("judge-{}", j + 1);
3953            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3954            let seat = self.seat(&seat_key, &spec.id);
3955            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3956            let text = if has_context(&spec, &seat, sessions) {
3957                prompt::final_vote(&labels, &language)
3958            } else {
3959                full.clone()
3960            };
3961            vote_jobs.push(SeatJob {
3962                spec,
3963                seat,
3964                prompt: text,
3965                cwd: root.join(seat_key),
3966                timeout,
3967                allow_write: false,
3968                sessions,
3969                artifacts: artifacts.clone(),
3970                stem: format!("vote-judge-{}-recover", j + 1),
3971                handover: Some(full),
3972            });
3973            vote_pos.push(j);
3974        }
3975        let allowed = labels.clone();
3976        let mut vote_losses = Vec::new();
3977        let vote_retries = self.state.config.graph.retries;
3978        let vote_cache = self.state.config.cache_dir();
3979        let ctx = WaveCtx {
3980            carry_seats: false,
3981            pauses: Some((&self.pause, &self.interrupt)),
3982            run: &run_id,
3983            node: "vote",
3984            prompts: &prompts,
3985            cache: vote_cache.as_deref(),
3986            round: None,
3987        };
3988        let votes = ask_json_wave::<FinalVote>(
3989            vote_jobs,
3990            Arc::clone(&self.sem),
3991            vote_retries,
3992            &self.roles.judge_roster,
3993            &ctx,
3994            &mut vote_losses,
3995            &mut self.state,
3996            &move |v: &FinalVote| match v.label() {
3997                Some(c) if allowed.contains(&c) => Ok(()),
3998                other => bail!("vote {other:?} is not one of {allowed:?}"),
3999            },
4000        )
4001        .await;
4002        if self.state.resource_block.is_some() {
4003            self.stop_for_resource()?;
4004            return Ok(false);
4005        }
4006        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
4007            let agent_id = seat.agent.clone();
4008            self.state.seats.insert(seat.key.clone(), seat);
4009            match res {
4010                Ok((v, _)) => {
4011                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
4012                        rec.vote = v.label();
4013                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
4014                    } else {
4015                        self.state.votes.push(VoteRecord {
4016                            judge: j + 1,
4017                            agent: agent_id,
4018                            vote: v.label(),
4019                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
4020                            changed: false,
4021                        });
4022                    }
4023                    self.state.event(
4024                        "recover",
4025                        format!("judge {} voted again after the limit", j + 1),
4026                    );
4027                }
4028                Err(e) => {
4029                    self.state
4030                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
4031                }
4032            }
4033        }
4034
4035        // A seat that ranked again is present even if its re-vote failed —
4036        // `tally` falls back to the initial ranking's first choice — so clear
4037        // its quota loss. Seats that still fail keep theirs and stay absent.
4038        let recovered_keys: BTreeSet<String> = recovered
4039            .iter()
4040            .map(|&j| format!("judge-{}", j + 1))
4041            .collect();
4042        self.state
4043            .quota
4044            .retain(|q| !recovered_keys.contains(&q.seat));
4045        // A seat that hit the limit again is a fresh loss, not the old one:
4046        // replace the stale entry so the history stays one-per-seat and the
4047        // daemon can tell this attempt's loss from a previous session's.
4048        for loss in judge_losses.into_iter().chain(vote_losses) {
4049            if recovered_keys.contains(&loss.seat) {
4050                continue;
4051            }
4052            self.state.quota.retain(|q| q.seat != loss.seat);
4053            self.state.quota.push(loss);
4054        }
4055
4056        // Recompute the verdict from the refreshed panel.
4057        self.state.tally = None;
4058        self.tally()?;
4059        Ok(self
4060            .state
4061            .tally
4062            .as_ref()
4063            .map(|t| t.met_quorum)
4064            .unwrap_or(false))
4065    }
4066
4067    // ---------------------------------------------------------------- graft
4068
4069    /// Port the strengths the judges attributed to the losing candidates onto
4070    /// the winner, once, between the tally and the review loop.
4071    ///
4072    /// Best-effort by construction: it returns nothing and fails nothing. A
4073    /// skip, a fixer that failed or hit quota, a round that changed nothing
4074    /// and a result over the size limit ([`graft::limit`]) all leave the
4075    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
4076    /// run carries on as if the phase were off. See [`graft`] for the
4077    /// decisions and `AGENTS.md` for the invariants.
4078    ///
4079    /// The one exception is a rollback that could not be confirmed: the
4080    /// rejected commit may still be the winner's tip, so the run is blocked
4081    /// (resumable; the record stays `Running`, which makes a resume retry the
4082    /// restore) instead of reviewing, gating and merging it.
4083    async fn graft(&mut self) -> Result<()> {
4084        let Err(e) = self.graft_inner().await else {
4085            return Ok(());
4086        };
4087        if e.is::<crate::repo_lock::ResourceBlocked>() {
4088            return Err(e);
4089        }
4090        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
4091            self.state.status = RunStatus::Blocked;
4092            self.state.event("graft", format!("blocked: {e:#}"));
4093            self.state.save()?;
4094            return Err(e);
4095        }
4096        // An error after the `Running` record was saved may have left the
4097        // fixer's work in the tree: the record must not stay unsettled
4098        // (a later resume would reset past the review fixes), so confirm the
4099        // restore now, or block.
4100        if let Some(rec) = self.state.graft.clone()
4101            && !rec.outcome.settled()
4102        {
4103            if let Some(winner) = self.state.winner().cloned() {
4104                if let Err(re) = self.graft_restore(&winner.worktree, &rec.pre).await {
4105                    if re.is::<crate::repo_lock::ResourceBlocked>() {
4106                        return Err(re);
4107                    }
4108                    self.state.status = RunStatus::Blocked;
4109                    self.state.event("graft", format!("blocked: {re:#}"));
4110                    self.state.save()?;
4111                    return Err(re);
4112                }
4113            }
4114            if let Some(g) = self.state.graft.as_mut() {
4115                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
4116            }
4117        }
4118        self.state.event("graft", format!("skipped: {e:#}"));
4119        if let Err(e) = self.state.save() {
4120            tracing::warn!("save after graft: {e:#}");
4121        }
4122        Ok(())
4123    }
4124
4125    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
4126    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
4127    async fn graft_restore(&mut self, wt: &Path, pre: &str) -> Result<()> {
4128        let _coordination = self.coordinate(wt).await?;
4129        crate::recovery::restore_repair(wt, pre)
4130            .await
4131            .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
4132    }
4133
4134    /// A skip is a run event, recorded once however often the node is re-entered.
4135    fn graft_skipped(&mut self, why: &str) {
4136        let message = format!("skipped: {why}");
4137        if !self
4138            .state
4139            .events
4140            .iter()
4141            .any(|e| e.node == "graft" && e.message == message)
4142        {
4143            self.state.event("graft", message);
4144        }
4145    }
4146
4147    async fn graft_inner(&mut self) -> Result<()> {
4148        // A round that was started and never finished (crash, kill): the
4149        // fixer may have left anything in the tree, so put the winner back.
4150        if let Some(rec) = self.state.graft.clone()
4151            && !rec.outcome.settled()
4152        {
4153            // Reviews exist only if the graft ended without its record being
4154            // settled (a swallowed error): the tree has moved on since, and
4155            // a reset to `pre` would discard the review fixes.
4156            if self.state.reviews.is_empty()
4157                && let Some(winner) = self.state.winner().cloned()
4158            {
4159                self.graft_restore(&winner.worktree, &rec.pre).await?;
4160            }
4161            if rec.outcome == GraftOutcome::WaitingForRestore {
4162                self.state.graft = None;
4163                self.state.save()?;
4164            } else {
4165                if let Some(g) = self.state.graft.as_mut() {
4166                    g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
4167                }
4168                self.state.event(
4169                    "graft",
4170                    "rolled back: the previous round was interrupted; the winner is untouched",
4171                );
4172                self.state.save()?;
4173                return Ok(());
4174            }
4175        }
4176        let plan = match graft::plan(&self.state) {
4177            Ok(plan) => plan,
4178            Err(skip) => {
4179                if skip != graft::Skip::AlreadyDone {
4180                    self.graft_skipped(skip.reason());
4181                    self.state.save()?;
4182                }
4183                return Ok(());
4184            }
4185        };
4186        let Some(winner) = self.state.winner().cloned() else {
4187            return Ok(());
4188        };
4189        let wt = winner.worktree.clone();
4190        if !git::is_clean(&wt).await? {
4191            self.graft_skipped("the winner's worktree has uncommitted changes");
4192            self.state.save()?;
4193            return Ok(());
4194        }
4195        let pre = git::rev_parse(&wt, "HEAD").await?;
4196        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
4197        else {
4198            self.graft_skipped("the winner's diff could not be measured");
4199            self.state.save()?;
4200            return Ok(());
4201        };
4202        let limit = graft::limit(winner_lines);
4203        // Saved before the fixer is called: a stop from here on is repaired
4204        // by the branch above on the next entry.
4205        self.state.graft = Some(GraftRecord {
4206            pre: pre.clone(),
4207            post: None,
4208            lines: None,
4209            limit: Some(limit),
4210            agent: None,
4211            outcome: GraftOutcome::Running,
4212            requested: graft::requested(&plan),
4213            adoption: None,
4214            adoption_error: None,
4215        });
4216        self.state.event(
4217            "graft",
4218            format!(
4219                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4220                plan.strengths
4221                    .iter()
4222                    .map(|(l, _, _)| l.to_string())
4223                    .collect::<Vec<_>>()
4224                    .join(", "),
4225                plan.winner
4226            ),
4227        );
4228        self.state.save()?;
4229
4230        let fix_prompt = prompt::graft(
4231            &self.state.instruction,
4232            plan.winner,
4233            &plan.strengths,
4234            limit,
4235            &self.state.config.graph.language,
4236        );
4237        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4238        let sessions = self.state.config.graph.sessions;
4239        let artifacts = agent::artifacts_dir(&self.state.dir());
4240        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4241        let (_, seat, out) = self
4242            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4243                SeatJob {
4244                    prompt: fix_prompt.clone(),
4245                    spec,
4246                    seat,
4247                    cwd: wt.clone(),
4248                    timeout,
4249                    allow_write: true,
4250                    sessions,
4251                    artifacts: artifacts.clone(),
4252                    stem: "graft".to_owned(),
4253                    handover: None,
4254                }
4255            })
4256            .await;
4257        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4258            // Preserve the need to restore earlier fallback edits without
4259            // confusing an unstarted seat with an interrupted invocation.
4260            self.state.graft.as_mut().expect("graft record").outcome =
4261                GraftOutcome::WaitingForRestore;
4262            return self.stop_for_resource();
4263        }
4264        let mut why: Option<String> = None;
4265        match &out {
4266            AgentOutcome::Ok(o) if agent::output_advances(o) => {
4267                why = Some("the fixer produced nothing usable".to_owned());
4268            }
4269            AgentOutcome::Ok(_) => {}
4270            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4271            AgentOutcome::Quota(o) => {
4272                self.state.quota.push(QuotaLoss {
4273                    seat: seat.key.clone(),
4274                    node: "graft".to_owned(),
4275                    at: Timestamp::now(),
4276                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4277                });
4278                why = Some("rate limited (quota)".to_owned());
4279            }
4280            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4281                why = Some(format!(
4282                    "the fixer failed: {}",
4283                    e.lines()
4284                        .next()
4285                        .unwrap_or("")
4286                        .chars()
4287                        .take(160)
4288                        .collect::<String>()
4289                ));
4290            }
4291        }
4292        let agent_id = seat.agent.clone();
4293        self.state.seats.insert(seat.key.clone(), seat);
4294
4295        let mut post = None;
4296        let mut lines = None;
4297        let _coordination = self.coordinate(&wt).await?;
4298        if why.is_none() {
4299            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4300            if let Ok(r) = &rescued {
4301                self.state.note_withheld("graft", &r.withheld);
4302            }
4303            let head = git::rev_parse(&wt, "HEAD").await?;
4304            // What is measured must be what gets reviewed and gated: a failed
4305            // rescue, or anything still uncommitted (withheld lockfiles
4306            // aside, which are never part of the tree), rejects the graft.
4307            let dirty = match &rescued {
4308                Ok(r) => git::status_porcelain(&wt)
4309                    .await
4310                    .map(|st| {
4311                        st.lines().any(|l| {
4312                            !r.withheld
4313                                .iter()
4314                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
4315                        })
4316                    })
4317                    .unwrap_or(true),
4318                Err(_) => true,
4319            };
4320            if dirty {
4321                why = Some("the fixer left changes that could not be committed".to_owned());
4322            } else if head == pre {
4323                why = Some("the fixer changed nothing".to_owned());
4324            } else {
4325                lines = git::changed_lines(&wt, &pre, &head).await;
4326                if graft::within_budget(lines, limit) {
4327                    post = Some(head);
4328                } else {
4329                    why = Some(format!(
4330                        "{} changed lines against a limit of {limit}",
4331                        lines.map_or_else(
4332                            || "an unmeasurable number of".to_owned(),
4333                            |l| l.to_string()
4334                        )
4335                    ));
4336                }
4337            }
4338        }
4339        let (adoption, adoption_error) = match &out {
4340            AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4341            _ => (None, None),
4342        };
4343        let requested = graft::requested(&plan);
4344        let outcome = match why {
4345            None => {
4346                self.state.event(
4347                    "graft",
4348                    format!(
4349                        "applied: {} changed lines (limit {limit})",
4350                        lines.unwrap_or(0)
4351                    ),
4352                );
4353                GraftOutcome::Applied
4354            }
4355            Some(why) => {
4356                // Not settled until the restore is confirmed: the record
4357                // stays `Running`, so a resume retries it.
4358                crate::recovery::restore_repair(&wt, &pre)
4359                    .await
4360                    .map_err(|e| GraftRestoreFailed(format!("{e:#}")))?;
4361                self.state.event(
4362                    "graft",
4363                    format!("rolled back: {why}; the winner is untouched"),
4364                );
4365                GraftOutcome::RolledBack(why)
4366            }
4367        };
4368        self.state.graft = Some(GraftRecord {
4369            pre,
4370            post,
4371            lines,
4372            limit: Some(limit),
4373            agent: Some(agent_id),
4374            outcome,
4375            requested,
4376            adoption,
4377            adoption_error,
4378        });
4379        self.state.save()?;
4380        Ok(())
4381    }
4382
4383    // ----------------------------------------------------------------- fold
4384
4385    async fn fold_losers(&mut self) -> Result<()> {
4386        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4387            return Ok(());
4388        };
4389        if !self.state.candidate_preservation.is_empty() {
4390            bail!("candidate preservation incomplete; refusing cleanup");
4391        }
4392        let repo = self.state.repo.clone();
4393        let _coordination = self.coordinate(&repo).await?;
4394        let mut folded = Vec::new();
4395        for i in 0..self.state.candidates.len() {
4396            let c = &self.state.candidates[i];
4397            if c.label == winner || c.folded {
4398                continue;
4399            }
4400            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4401            remove_preserved_worktree(&repo, &wt).await?;
4402            if git::branch_exists(&repo, &branch).await?
4403                && !git::branch_delete(&repo, &branch).await?
4404            {
4405                bail!("candidate branch removal refused");
4406            }
4407            self.state.candidates[i].folded = true;
4408            self.state.save()?;
4409            folded.push(label.to_string());
4410        }
4411        // The judges are finished; their checkouts are pure cost from here.
4412        let root = self.state.worktree_root();
4413        for j in 1..=self.roles.judges.len() {
4414            let wt = root.join(format!("judge-{j}"));
4415            if wt.exists() {
4416                remove_preserved_worktree(&repo, &wt).await?;
4417            }
4418        }
4419        // The design-deliberation stage is finished by the time a tally
4420        // exists — same reasoning as the judges above.
4421        if self.state.config.graph.advise {
4422            for k in 1..=self.state.config.graph.advisors {
4423                let wt = root.join(format!("advisor-{k}"));
4424                if wt.exists() {
4425                    remove_preserved_worktree(&repo, &wt).await?;
4426                }
4427            }
4428        }
4429        if !folded.is_empty() {
4430            self.state
4431                .event("fold", format!("folded candidates {}", folded.join(", ")));
4432            self.state.save()?;
4433        }
4434        Ok(())
4435    }
4436
4437    // ------------------------------------------------------------ base sync
4438
4439    /// Land the winner's tree on the current tip of `<remote>/<base>` before
4440    /// anything verifies it.
4441    ///
4442    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
4443    /// read whatever is checked out in the winner's worktree. Left alone that
4444    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
4445    /// it when the run *branched* - and a run takes long enough that the base
4446    /// has usually moved by the time it gets here. A gate that ran there
4447    /// answers "green on the commit this run started from", not "green on
4448    /// what is about to land", and the difference showed up three times in
4449    /// one day as a green run whose merge would have reverted a file another
4450    /// pull request had already landed.
4451    ///
4452    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
4453    /// `land::Step::Rebase` calls, rather than a second implementation of the
4454    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
4455    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
4456    /// doc warns about ("two answers to a question nobody notices until a
4457    /// diff is wrong").
4458    ///
4459    /// A conflict is not the end of the road: the standing rebase is handed
4460    /// to the fixer seat, at most `graph.review_rounds` times, counted in
4461    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
4462    /// land). Once it finishes, review and the gate run as usual on the
4463    /// rebased tree, which is where a breakage the new base caused is caught
4464    /// by the ordinary gate-fix round. magi resolves nothing itself.
4465    ///
4466    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
4467    /// `state.base_sync.attempts`, is how many times the base is *rebased
4468    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
4469    /// *conflict* was given to a fixer. When the fixer cannot finish the
4470    /// rebase the branch is restored, `state.base_sync.conflict` is set with
4471    /// what was tried (rounds spent, paths still conflicted) and the branch
4472    /// and worktree stay exactly as they were - untouched, for a person to
4473    /// look at - which is also what makes re-entering this function
4474    /// afterwards a no-op instead of a second attempt at the same wall. A
4475    /// push failure ends the same way.
4476    async fn sync_to_base(&mut self) -> Result<()> {
4477        if self.state.status == RunStatus::AlreadyInBase {
4478            return Ok(());
4479        }
4480        let conflicted = self
4481            .state
4482            .base_sync
4483            .as_ref()
4484            .is_some_and(|s| s.conflict.is_some());
4485        let Some(winner) = self.state.winner().cloned() else {
4486            return Ok(());
4487        };
4488
4489        let repo = self.state.repo.clone();
4490        let remote = self.state.config.merge.remote.clone();
4491        let base_branch = self.state.base_branch.clone();
4492        let tracking = format!("{remote}/{base_branch}");
4493
4494        git::fetch(&repo, &remote, &base_branch).await.ok();
4495        // No network, or the remote never had this branch: the run already
4496        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
4497        // otherwise), and one that got this far is not blocked by a fetch
4498        // that fails now. It just has no newer tip to compare against.
4499        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4500            return Ok(());
4501        };
4502
4503        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4504        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4505        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4506
4507        // Before any rebase, and before a recorded conflict is honoured: a
4508        // branch whose change reached the base under other commit ids has
4509        // nothing to rebase and nothing to conflict with, and a run that
4510        // already stopped on that phantom conflict recovers here on resume.
4511        // `behind == 0` with head == tip is a branch the base has since taken
4512        // in whole, whether or not a conflict was ever recorded: the ancestry
4513        // proof must still run (`classify` ignores a head still on the start
4514        // commit).
4515        if (behind > 0 || conflicted || head == tip)
4516            && self
4517                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4518                .await?
4519        {
4520            return Ok(());
4521        }
4522        if conflicted {
4523            return Ok(());
4524        }
4525
4526        if behind == 0 {
4527            // A fixer-finished rebase moves the branch ref before the
4528            // winner's worktree is told (`sync_to_head` below). A run that
4529            // died in between resumes here with `behind == 0` and a tree still
4530            // holding the pre-rebase files, which review and the gate would
4531            // then read. That state is exactly: HEAD moved off the tip the
4532            // rebase started from, yet the tree is still identical to that
4533            // tip. A tree with edits of its own differs from it, so nothing
4534            // is thrown away.
4535            if let Some(from) = self
4536                .state
4537                .rebase_fixes
4538                .iter()
4539                .rev()
4540                .find_map(|r| r.from.clone())
4541                && from != head
4542                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4543                    .await
4544                    .is_ok_and(|o| o.ok())
4545            {
4546                git::sync_to_head(&winner.worktree).await?;
4547            }
4548            // An earlier attempt may have rebased the branch locally and died
4549            // before pushing it (only the fresh-rebase arm below pushes).
4550            // Publish it now, so the plain push at PR time is not refused as
4551            // a non-fast-forward. A run already holding a recorded conflict
4552            // never reaches here; that case is out of scope.
4553            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4554            if let Some(why) = &conflict {
4555                self.state.status = RunStatus::Blocked;
4556                self.state.event("land", why.clone());
4557            }
4558            self.state.base_sync = Some(BaseSync {
4559                tip,
4560                behind: 0,
4561                attempts,
4562                conflict,
4563                already_in: None,
4564            });
4565            self.state.save()?;
4566            return Ok(());
4567        }
4568
4569        if attempts >= BASE_SYNC_ROUNDS {
4570            let why = format!(
4571                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4572                 rebase(s); rebasing again would only race it",
4573                winner.branch
4574            );
4575            self.state.status = RunStatus::Blocked;
4576            self.state.base_sync = Some(BaseSync {
4577                tip,
4578                behind,
4579                attempts,
4580                conflict: Some(why.clone()),
4581                already_in: None,
4582            });
4583            self.state.event("land", why);
4584            self.state.save()?;
4585            return Ok(());
4586        }
4587
4588        self.state.event(
4589            "land",
4590            format!(
4591                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4592                winner.branch
4593            ),
4594        );
4595        self.state.save()?;
4596
4597        // The remote's copy of the branch, read now and only if the fetch
4598        // really succeeded (a stale tracking ref must never pin a lease). It is
4599        // pushed over after a rebase only when it is a commit this branch
4600        // already contains, by ancestry or by patch (an earlier rebase of ours
4601        // that never reached the remote): anything else is somebody else's work.
4602        let branch_tracking = format!("{remote}/{}", winner.branch);
4603        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4604        let remote_tip = if self.state.task_pr_branch().is_none()
4605            && matches!(&fetched_branch, Ok(o) if o.ok())
4606        {
4607            git::rev_parse(&repo, &branch_tracking).await.ok()
4608        } else {
4609            None
4610        };
4611        // A remote tip this branch does not contain is somebody else's work:
4612        // rebasing would leave a local tip that can never be pushed. Stop
4613        // before touching anything and say so.
4614        if let Some(theirs) = &remote_tip
4615            && !git::is_ancestor(&repo, theirs, &head).await
4616            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4617                .await
4618                .is_ok_and(|missing| missing.is_empty())
4619        {
4620            let why = format!(
4621                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4622                 them",
4623                short(theirs),
4624                winner.branch
4625            );
4626            self.state.status = RunStatus::Blocked;
4627            self.state.base_sync = Some(BaseSync {
4628                tip,
4629                behind,
4630                attempts,
4631                conflict: Some(why.clone()),
4632                already_in: None,
4633            });
4634            self.state.event("land", why);
4635            self.state.save()?;
4636            return Ok(());
4637        }
4638
4639        let scratch = self.state.dir().join("base-sync");
4640        let rebased = match crate::rebase::rebase_with_fixer(
4641            &mut self.state,
4642            &scratch,
4643            &winner.branch,
4644            &tracking,
4645        )
4646        .await
4647        {
4648            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4649            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4650            Err(e) => Err(e),
4651        };
4652        let attempts = attempts + 1;
4653        match rebased {
4654            Ok(None) => {
4655                // The branch ref moved, but a worktree that already had it
4656                // checked out (the winner's) was not told; sync its index and
4657                // files before anything reads them.
4658                git::sync_to_head(&winner.worktree).await?;
4659                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4660                let mut conflict = None;
4661                if let Some(pinned) = &remote_tip {
4662                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4663                    match pushed {
4664                        Ok(o) if o.ok() => self.state.event(
4665                            "land",
4666                            format!("pushed rebased {} to {remote}", winner.branch),
4667                        ),
4668                        Ok(o) => {
4669                            conflict = Some(format!(
4670                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4671                                winner.branch,
4672                                short(pinned),
4673                                o.stderr.chars().take(600).collect::<String>()
4674                            ));
4675                        }
4676                        Err(e) => {
4677                            conflict = Some(format!(
4678                                "rebased {} locally but could not push it: {e:#}",
4679                                winner.branch
4680                            ));
4681                        }
4682                    }
4683                }
4684                if let Some(why) = &conflict {
4685                    self.state.status = RunStatus::Blocked;
4686                    self.state.event("land", why.clone());
4687                }
4688                self.state.base_sync = Some(BaseSync {
4689                    tip: tip.clone(),
4690                    behind: 0,
4691                    attempts,
4692                    conflict,
4693                    already_in: None,
4694                });
4695                self.state
4696                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4697            }
4698            Ok(Some(conflict)) => {
4699                let why = format!(
4700                    "{} conflicts with {tracking} and did not rebase: {}",
4701                    winner.branch,
4702                    conflict.chars().take(600).collect::<String>()
4703                );
4704                self.state.status = RunStatus::Blocked;
4705                self.state.base_sync = Some(BaseSync {
4706                    tip,
4707                    behind,
4708                    attempts,
4709                    conflict: Some(why.clone()),
4710                    already_in: None,
4711                });
4712                self.state.event("land", why);
4713            }
4714            Err(e) => {
4715                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4716                self.state.status = RunStatus::Blocked;
4717                self.state.base_sync = Some(BaseSync {
4718                    tip,
4719                    behind,
4720                    attempts,
4721                    conflict: Some(why.clone()),
4722                    already_in: None,
4723                });
4724                self.state.event("land", why);
4725            }
4726        }
4727        self.state.save()?;
4728        Ok(())
4729    }
4730
4731    /// Push a branch an earlier attempt rebased locally but never published,
4732    /// pinned to the remote tip read right after a successful fetch. Returns
4733    /// the reason when the run must stop; `None` when there was nothing to do
4734    /// (no remote copy, the same tip, or a remote copy this branch already
4735    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4736    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4737        // Task competitions publish only after the PR text gate, to their
4738        // public head; never resurrect a candidate branch on the remote.
4739        if self.state.task_pr_branch().is_some() {
4740            return None;
4741        }
4742        let repo = self.state.repo.clone();
4743        let remote = self.state.config.merge.remote.clone();
4744        let fetched = git::fetch(&repo, &remote, branch).await;
4745        if !matches!(&fetched, Ok(o) if o.ok()) {
4746            return None;
4747        }
4748        let branch_tracking = format!("{remote}/{branch}");
4749        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4750        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4751            return None;
4752        }
4753        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4754            .await
4755            .is_ok_and(|missing| missing.is_empty())
4756        {
4757            return Some(format!(
4758                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4759                 them",
4760                short(&theirs)
4761            ));
4762        }
4763        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4764            Ok(o) if o.ok() => {
4765                self.state
4766                    .event("land", format!("pushed rebased {branch} to {remote}"));
4767                None
4768            }
4769            Ok(o) => Some(format!(
4770                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4771                 someone may have pushed): {}",
4772                short(&theirs),
4773                o.stderr.chars().take(600).collect::<String>()
4774            )),
4775            Err(e) => Some(format!(
4776                "{branch} is rebased locally but could not be pushed: {e:#}"
4777            )),
4778        }
4779    }
4780
4781    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4782    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4783    /// whether it did.
4784    ///
4785    /// Checked only when the base is ahead of the branch. A failing check is
4786    /// "not proven" - the ordinary rebase path then decides - never a reason to
4787    /// stop the run.
4788    ///
4789    /// The remote copy of the branch is held to the same standard as the local
4790    /// one: if it carries a tip this worktree does not, that tip must itself be
4791    /// proven in the base, or nothing is settled (a pull request would
4792    /// otherwise be closed over commits nobody checked). The pull request is
4793    /// closed *before* the terminal status is saved; if that fails for a
4794    /// reason other than a refusal (no network, a `gh` error) the run is left
4795    /// `Blocked` with the reason as its conflict, which a resume retries -
4796    /// the same recovery a phantom conflict gets.
4797    async fn settle_already_in(
4798        &mut self,
4799        branch: &str,
4800        tip: &str,
4801        head: &str,
4802        attempts: usize,
4803        behind: usize,
4804    ) -> Result<bool> {
4805        let repo = self.state.repo.clone();
4806        let remote = self.state.config.merge.remote.clone();
4807        let start = self.state.base_commit.clone();
4808        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4809            Ok(Some(e)) => e,
4810            Ok(None) => return Ok(false),
4811            Err(e) => {
4812                tracing::warn!("already-in-base check for {branch}: {e:#}");
4813                return Ok(false);
4814            }
4815        };
4816        let mut verified = vec![head.to_owned()];
4817        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4818        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4819        if matches!(&fetched, Ok(o) if o.ok())
4820            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4821            && theirs != head
4822        {
4823            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4824                Ok(Some(_)) => verified.push(theirs),
4825                _ => return Ok(false),
4826            }
4827        }
4828        let base_branch = self.state.base_branch.clone();
4829        let message = format!(
4830            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4831             land",
4832            evidence.names(),
4833            evidence.proof.as_str()
4834        );
4835        let closed =
4836            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4837                .await;
4838        match closed {
4839            Ok(Ok(url)) => self
4840                .state
4841                .event("land", format!("closed {url}: superseded on {base_branch}")),
4842            Ok(Err(why)) => self
4843                .state
4844                .event("land", format!("did not close a pull request: {why}")),
4845            Err(e) => {
4846                let why = format!(
4847                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4848                     not be closed ({e:#}); resume to retry"
4849                );
4850                self.state.status = RunStatus::Blocked;
4851                self.state.base_sync = Some(BaseSync {
4852                    tip: tip.to_owned(),
4853                    behind,
4854                    attempts,
4855                    conflict: Some(why.clone()),
4856                    already_in: None,
4857                });
4858                self.state.event("land", why);
4859                self.state.save()?;
4860                return Ok(true);
4861            }
4862        }
4863        self.state.status = RunStatus::AlreadyInBase;
4864        self.state.base_sync = Some(BaseSync {
4865            tip: tip.to_owned(),
4866            behind,
4867            attempts,
4868            conflict: None,
4869            already_in: Some(evidence),
4870        });
4871        self.state.event("land", message);
4872        self.state.save()?;
4873        self.settle_questions();
4874        Ok(true)
4875    }
4876
4877    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4878    /// last landed the winner on, once it has run, else the commit the run
4879    /// branched from.
4880    ///
4881    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4882    /// and `vote` all happen before there is a winner to rebase, so they
4883    /// compare every candidate against the branch point on purpose, and a
4884    /// base that moves after they are already done cannot change an answer
4885    /// they already gave.
4886    fn landing_base(&self) -> String {
4887        self.state
4888            .base_sync
4889            .as_ref()
4890            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4891    }
4892
4893    // ------------------------------------------------------- operator fix
4894
4895    /// Route specific, already-recorded review findings to a fixer for a
4896    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4897    /// entry point.
4898    ///
4899    /// Distinct from `review_loop`'s own fix step in three ways: it never
4900    /// runs a reviewer wave, it never spends review-round budget, and what
4901    /// happened is recorded as an [`OperatorFixRequest`] appended to
4902    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4903    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4904    /// and vote must never be rewritten to look like a manufactured blocking
4905    /// verdict.
4906    ///
4907    /// Only meaningful once review has actually concluded: `Ready` (handed
4908    /// off with findings still open, or simply concluded clean while minor
4909    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4910    /// gate failed). Everything else is refused: a run still in progress
4911    /// should simply be resumed, and a `Merged` run's branch has already
4912    /// landed — reopening *this* run's own record cannot change that, so the
4913    /// answer there is a fresh `magi review <branch>`.
4914    ///
4915    /// A real commit here re-verifies through a fresh, ordinary review-only
4916    /// run on the same branch ([`Self::review`]) rather than reopening this
4917    /// run's own `review_loop`: once any round in this run's history went
4918    /// clean, `review_conclusion` treats that as permanent by design (the
4919    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4920    /// way to force one more genuine reviewer wave out of *this* run without
4921    /// either rewriting history or weakening that guarantee for every other
4922    /// caller. A review-only run costs nothing extra — no implementation, no
4923    /// judging, no vote — and exercises the exact same review → verify →
4924    /// gate → (human) merge path, unmodified.
4925    pub async fn fix_selected(
4926        &mut self,
4927        ids: &[String],
4928        reason: &str,
4929        allow_stale: bool,
4930    ) -> Result<()> {
4931        let reason = reason.trim();
4932        if reason.is_empty() {
4933            bail!("a fix request needs a reason — that is the operator's own record of why");
4934        }
4935        if ids.is_empty() {
4936            bail!("no finding id given");
4937        }
4938        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4939            bail!(
4940                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4941                 has already concluded — can be given a targeted fix. A run still \
4942                 in progress should simply be resumed; a `merged` run's branch has \
4943                 already landed, so its answer is a fresh `magi review <branch>`, \
4944                 not reopening this run's own record",
4945                self.state.id,
4946                self.state.status.as_str()
4947            );
4948        }
4949        let Some(winner) = self.state.winner().cloned() else {
4950            bail!("run {} has no winning candidate to fix", self.state.id);
4951        };
4952        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4953            bail!(
4954                "branch `{}` no longer exists; this run cannot be extended",
4955                winner.branch
4956            );
4957        }
4958        let home = crate::run::home();
4959        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4960            bail!(
4961                "run {} is currently being worked on by another magi process",
4962                self.state.id
4963            );
4964        }
4965        // Held for the rest of this call, including the follow-up review
4966        // below: two `magi fix` invocations against the same run must not
4967        // both reach the worktree manipulation further down, which would
4968        // otherwise race to remove and recreate the same directory — see
4969        // [`FixClaim`]'s own doc.
4970        let _claim = FixClaim::acquire(&self.state.dir())?;
4971
4972        // Resolve every id before spending anything — an unknown id refuses
4973        // the whole request rather than silently dropping it — and dedup
4974        // while keeping the operator's own order.
4975        let mut seen = BTreeSet::new();
4976        let mut findings = Vec::new();
4977        let mut missing = Vec::new();
4978        for id in ids {
4979            if !seen.insert(id.clone()) {
4980                continue;
4981            }
4982            match self.state.finding(id) {
4983                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4984                    id: f.id.clone(),
4985                    severity: f.severity,
4986                    reviewer_vote: rec.vote,
4987                    round: round.round,
4988                    round_head: round.head.clone(),
4989                    reviewer: rec.reviewer,
4990                    agent: rec.agent.clone(),
4991                    file: f.file.clone(),
4992                    line: f.line,
4993                    title: f.title.clone(),
4994                    detail: f.detail.clone(),
4995                    outcome: OperatorFixOutcome::Pending,
4996                }),
4997                None => missing.push(id.clone()),
4998            }
4999        }
5000        if !missing.is_empty() {
5001            bail!(
5002                "unknown finding id(s): {}; nothing was changed",
5003                missing.join(", ")
5004            );
5005        }
5006
5007        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
5008        let stale_details: Vec<(String, String)> = findings
5009            .iter()
5010            .filter(|f| f.round_head != head_at_request)
5011            .map(|f| (f.id.clone(), f.round_head.clone()))
5012            .collect();
5013        let stale = !stale_details.is_empty();
5014        if stale && !allow_stale {
5015            bail!(
5016                "the branch has moved since some finding(s) were raised — {} — now \
5017                 at {}; pass --allow-stale to fix anyway, or re-run review first",
5018                stale_details
5019                    .iter()
5020                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
5021                    .collect::<Vec<_>>()
5022                    .join(", "),
5023                short(&head_at_request)
5024            );
5025        }
5026
5027        let request = OperatorFixRequest {
5028            requested_at: Timestamp::now(),
5029            reason: reason.to_owned(),
5030            findings,
5031            head_at_request: head_at_request.clone(),
5032            allow_stale,
5033            stale,
5034            fix: None,
5035            result_head: None,
5036            follow_up_review_run: None,
5037        };
5038        self.state.event(
5039            "fix",
5040            format!(
5041                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
5042                request.findings.len(),
5043                request
5044                    .findings
5045                    .iter()
5046                    .map(|f| f.id.as_str())
5047                    .collect::<Vec<_>>()
5048                    .join(", "),
5049            ),
5050        );
5051        // Recorded now, before any worktree work or the fixer call itself —
5052        // and re-saved at each checkpoint below: a crash at any point after
5053        // this (mid fixer call, mid follow-up review) must not lose the fact
5054        // that this was requested, for which findings, and why. Everything
5055        // past this point reads and writes through `request_index` rather
5056        // than a local variable, since `request` itself is moved here.
5057        self.state.operator_fixes.push(request);
5058        self.state.save()?;
5059        let request_index = self.state.operator_fixes.len() - 1;
5060
5061        // A fresh, dedicated worktree for this one call, never the winner's
5062        // own worktree in place: that one may already be gone (folded away),
5063        // and reusing it in place would leave the branch checked out there
5064        // when the follow-up review below tries to check it out again. Freed
5065        // immediately after, either way — but only once confirmed clean:
5066        // `worktree_remove` is a `git worktree remove --force`, which would
5067        // otherwise discard uncommitted work left there by the operator or
5068        // another process before this had a chance to even look at it.
5069        let repo = self.state.repo.clone();
5070        let coordination = self.coordinate(&repo).await?;
5071        if winner.worktree.exists() {
5072            // Lockfiles a rescue commit withheld stay untracked on purpose and
5073            // are already recorded; they are not the operator's work to protect.
5074            let dirty = git::git(
5075                &winner.worktree,
5076                &["status", "--porcelain", "--untracked-files=all"],
5077            )
5078            .await?;
5079            let only_withheld = dirty.lines().all(|l| {
5080                l.strip_prefix("?? ")
5081                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
5082            });
5083            if !only_withheld {
5084                bail!(
5085                    "`{}` has uncommitted changes; refusing to touch it — commit or \
5086                     discard them first",
5087                    winner.worktree.display()
5088                );
5089            }
5090            git::worktree_remove(&self.state.repo, &winner.worktree)
5091                .await
5092                .ok();
5093        }
5094        let fix_worktree = self.state.worktree_root().join("operator-fix");
5095        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
5096        git::git(
5097            &self.state.repo,
5098            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
5099        )
5100        .await
5101        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
5102        if !git::is_clean(&fix_worktree).await? {
5103            git::worktree_remove(&self.state.repo, &fix_worktree)
5104                .await
5105                .ok();
5106            bail!(
5107                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
5108                winner.branch
5109            );
5110        }
5111        let repo_for_setup = self.state.repo.clone();
5112        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
5113            Ok(()) => {
5114                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
5115            }
5116            Err(e) => Err(e),
5117        };
5118        if let Err(e) = setup {
5119            git::worktree_remove(&self.state.repo, &fix_worktree)
5120                .await
5121                .ok();
5122            return Err(e);
5123        }
5124
5125        let run_id = self.state.id.clone();
5126        let prompts = self.state.config.prompts.clone();
5127        let language = self.state.config.graph.language.clone();
5128        let sessions = self.state.config.graph.sessions;
5129        let artifacts = agent::artifacts_dir(&self.state.dir());
5130        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
5131            .findings
5132            .iter()
5133            .map(|f| Finding {
5134                id: f.id.clone(),
5135                severity: f.severity,
5136                file: f.file.clone(),
5137                line: f.line,
5138                title: f.title.clone(),
5139                detail: f.detail.clone(),
5140            })
5141            .collect();
5142        let fix_prompt = prompt::operator_fix_in(
5143            &self.state.instruction,
5144            &finding_list,
5145            reason,
5146            &stale_details,
5147            &head_at_request,
5148            &language,
5149            &self.state.config.graph.github_language,
5150        );
5151        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5152        drop(coordination);
5153        let (job, seat, out) = self
5154            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
5155                prompt: fix_prompt.clone(),
5156                spec,
5157                seat,
5158                cwd: fix_worktree.clone(),
5159                timeout,
5160                allow_write: true,
5161                sessions,
5162                artifacts: artifacts.clone(),
5163                stem: "operator-fix".to_owned(),
5164                handover: None,
5165            })
5166            .await;
5167        let agent_id = seat.agent.clone();
5168
5169        let mut fix = FixRecord {
5170            agent: agent_id,
5171            addressed: Vec::new(),
5172            rejected: Vec::new(),
5173            notes: String::new(),
5174            committed: false,
5175            failed: None,
5176            duration_ms: 0,
5177            continuation: None,
5178        };
5179        let mut final_seat = seat.clone();
5180        match out {
5181            AgentOutcome::Ok(o) => {
5182                fix.duration_ms = o.duration_ms;
5183                let parsed = verdict::extract_json::<FixReport>(&o.text);
5184                let incomplete_reason = match &parsed {
5185                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5186                        "the reply parsed, but it reported a command whose own CLI \
5187                         never confirmed an exit status"
5188                            .to_owned(),
5189                    ),
5190                    Ok(_) => None,
5191                    Err(e) => Some(e.to_string()),
5192                };
5193                match incomplete_reason {
5194                    None => {
5195                        let report = parsed.expect("checked Ok above");
5196                        fix.addressed = report.addressed;
5197                        fix.rejected = report.rejected;
5198                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5199                    }
5200                    Some(reason) => {
5201                        let (resumed_seat, resolved, failure, cont) = self
5202                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
5203                            .await;
5204                        fix.duration_ms += cont.cumulative_wait_ms;
5205                        fix.continuation = Some(cont);
5206                        final_seat = resumed_seat;
5207                        match resolved {
5208                            Some(report) => {
5209                                fix.addressed = report.addressed;
5210                                fix.rejected = report.rejected;
5211                                fix.notes =
5212                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
5213                            }
5214                            None => fix.failed = failure,
5215                        }
5216                    }
5217                }
5218            }
5219            AgentOutcome::Dropped(o) => {
5220                fix.duration_ms = o.duration_ms;
5221                let why = o
5222                    .dropped
5223                    .as_ref()
5224                    .map(|d| d.why.as_str())
5225                    .unwrap_or("the CLI ended the stream without delivering its answer");
5226                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5227            }
5228            AgentOutcome::Quota(o) => {
5229                self.state.quota.push(QuotaLoss {
5230                    seat: final_seat.key.clone(),
5231                    node: "fix".to_owned(),
5232                    at: Timestamp::now(),
5233                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5234                });
5235                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5236            }
5237            AgentOutcome::Failed(e) => fix.failed = Some(e),
5238            AgentOutcome::ResourceBlocked(e) => {
5239                fix.failed = Some(format!("the fixer was not started: {e}"));
5240            }
5241        }
5242        if fix.continuation.is_none() {
5243            fix.continuation = Some(ContinuationRecord::not_needed());
5244        }
5245        self.state.seats.insert(final_seat.key.clone(), final_seat);
5246
5247        let rescue_message = format!(
5248            "magi: operator-selected fix ({}) (uncommitted work)",
5249            self.state.operator_fixes[request_index]
5250                .findings
5251                .iter()
5252                .map(|f| f.id.as_str())
5253                .collect::<Vec<_>>()
5254                .join(", ")
5255        );
5256        let repo = self.state.repo.clone();
5257        let coordination = self.coordinate(&repo).await?;
5258        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5259            self.state.note_withheld("fix", &r.withheld);
5260        }
5261        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5262        fix.committed = after != head_at_request;
5263        git::worktree_remove(&self.state.repo, &fix_worktree)
5264            .await
5265            .ok();
5266        drop(coordination);
5267
5268        self.state.event(
5269            "fix",
5270            match &fix.failed {
5271                Some(reason) => format!(
5272                    "operator fix: adoption report was lost ({reason}); {}",
5273                    if fix.committed {
5274                        "committed"
5275                    } else {
5276                        "NO new commit"
5277                    }
5278                ),
5279                None => format!(
5280                    "operator fix: {} addressed, {} rejected, {}",
5281                    fix.addressed.len(),
5282                    fix.rejected.len(),
5283                    if fix.committed {
5284                        "committed"
5285                    } else {
5286                        "NO new commit"
5287                    }
5288                ),
5289            },
5290        );
5291
5292        // Every selected finding gets an outcome — never left `Pending` once
5293        // the fixer's own turn is over. A report that never came back at all
5294        // marks every one of them `Unreported`, not silently "not addressed":
5295        // quota, a dropped stream, or an exhausted continuation are gaps in
5296        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
5297        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
5298        for f in &mut self.state.operator_fixes[request_index].findings {
5299            f.outcome = if fix.failed.is_some() {
5300                OperatorFixOutcome::Unreported
5301            } else if fix.addressed.contains(&f.id) {
5302                OperatorFixOutcome::Addressed
5303            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5304                OperatorFixOutcome::Rejected { why: r.why.clone() }
5305            } else {
5306                OperatorFixOutcome::Unreported
5307            };
5308        }
5309
5310        let committed = fix.committed;
5311        if committed {
5312            self.state.operator_fixes[request_index].result_head = Some(after.clone());
5313        }
5314        self.state.operator_fixes[request_index].fix = Some(fix);
5315        // Saved again now that the fixer's own outcome is final, on top of
5316        // the save right after the request was first pushed above.
5317        self.state.save()?;
5318
5319        if committed {
5320            self.state.event(
5321                "fix",
5322                format!(
5323                    "operator fix committed {}; opening a follow-up review-only run",
5324                    short(&after)
5325                ),
5326            );
5327            // The operator asked for the fix, and the follow-up serves whatever
5328            // task the run it follows served.
5329            let origin =
5330                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5331            match Self::review(
5332                &self.state.repo,
5333                &winner.branch,
5334                self.state.config.clone(),
5335                origin,
5336            )
5337            .await
5338            {
5339                Ok(mut follow_up) => {
5340                    follow_up.state.event(
5341                        "start",
5342                        format!(
5343                            "requested by an operator fix on run {} for finding(s) {}",
5344                            self.state.id,
5345                            self.state.operator_fixes[request_index]
5346                                .findings
5347                                .iter()
5348                                .map(|f| f.id.as_str())
5349                                .collect::<Vec<_>>()
5350                                .join(", "),
5351                        ),
5352                    );
5353                    follow_up.state.save()?;
5354                    let follow_up_id = follow_up.state.id.clone();
5355                    // The follow-up is a run like any other: it belongs to the
5356                    // task of the run it follows, or to one filed for it.
5357                    let adopted = match crate::direct::adopt(
5358                        &follow_up.state,
5359                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5360                    ) {
5361                        Ok(a) => a,
5362                        Err(e) => {
5363                            // An ownerless run must not spend agent calls; it
5364                            // stays saved, and `magi run --resume` adopts it.
5365                            self.state.event(
5366                                "fix",
5367                                format!(
5368                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5369                                ),
5370                            );
5371                            self.state.save()?;
5372                            return Ok(());
5373                        }
5374                    };
5375                    let executed = follow_up.execute().await;
5376                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5377                    if let Some(a) = adopted {
5378                        a.finish(&follow_up.state, executed);
5379                    }
5380                    if let Some(e) = failure {
5381                        self.state.event(
5382                            "fix",
5383                            format!(
5384                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5385                            ),
5386                        );
5387                    }
5388                    self.state.operator_fixes[request_index].follow_up_review_run =
5389                        Some(follow_up_id);
5390                }
5391                Err(e) => {
5392                    self.state.event(
5393                        "fix",
5394                        format!("committed the fix but could not open a follow-up review: {e:#}"),
5395                    );
5396                }
5397            }
5398            self.state.save()?;
5399        }
5400
5401        Ok(())
5402    }
5403
5404    // --------------------------------------------------------------- review
5405
5406    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
5407    /// names, in order, each at most once, moving on only when the call
5408    /// advances (an error, a quota hit or nothing usable - the same decision
5409    /// point `agent::chain_advances` is for the other chained roles).
5410    ///
5411    /// Each agent gets its own seat from `Runner::seat`: the same agent
5412    /// continues its conversation (the winner's own implementer seat when it
5413    /// is the winner's author, now that the competition is over), another
5414    /// takes a fresh one so the full prompt is sent again. A handover is
5415    /// recorded under `node`, which is what makes the fallback stick for the
5416    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
5417    /// is returned, so an exhausted chain reads exactly like a single failed
5418    /// fixer: one quota loss, the same wording, the same refund. An earlier
5419    /// attempt's edits are left in the tree and judged, with the final
5420    /// attempt's, by what git says afterwards.
5421    ///
5422    /// Returns the job that produced the outcome, for `continue_fix_report`.
5423    async fn ask_fixer(
5424        &mut self,
5425        winner: &Candidate,
5426        node: &'static str,
5427        round: Option<usize>,
5428        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5429    ) -> (SeatJob, SeatState, AgentOutcome) {
5430        let attempts = fixer::attempts(&self.state, &self.roles, winner);
5431        self.ask_fixer_among(attempts, node, round, None, build)
5432            .await
5433    }
5434
5435    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
5436    /// worktree and a commit: before the next agent is asked after one that
5437    /// did not satisfy the call, the worktree is put back on that commit, so
5438    /// a failed agent's partial edits are not carried into the next one.
5439    async fn ask_fixer_among(
5440        &mut self,
5441        attempts: Vec<(AgentSpec, String)>,
5442        node: &'static str,
5443        round: Option<usize>,
5444        restore: Option<(&Path, &str)>,
5445        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5446    ) -> (SeatJob, SeatState, AgentOutcome) {
5447        let run_id = self.state.id.clone();
5448        let prompts = self.state.config.prompts.clone();
5449        let cache = self.state.config.cache_dir();
5450        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5451        let mut last = None;
5452        for (i, (spec, key)) in attempts.into_iter().enumerate() {
5453            let seat = self.seat(&key, &spec.id);
5454            let mut job = build(spec.clone(), seat);
5455            if i > 0 {
5456                job.stem = format!("{}-{}", job.stem, spec.id);
5457            }
5458            let ctx = WaveCtx {
5459                carry_seats: false,
5460                pauses: Some((&self.pause, &self.interrupt)),
5461                run: &run_id,
5462                node,
5463                prompts: &prompts,
5464                cache: cache.as_deref(),
5465                round,
5466            };
5467            let (seat, out) =
5468                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5469            // A resource block never advances the chain: the chosen agent
5470            // was not asked, so it must be the one asked again on resume.
5471            let advances = match &out {
5472                AgentOutcome::Ok(o) => agent::output_advances(o),
5473                AgentOutcome::ResourceBlocked(_) => false,
5474                _ => true,
5475            };
5476            if let Some(next) = ids.get(i + 1)
5477                && advances
5478            {
5479                self.state.seats.insert(seat.key.clone(), seat.clone());
5480                let class =
5481                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5482                let reason = match &out {
5483                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5484                    other => fail_reason(other),
5485                };
5486                // A failed agent's edits must not reach the next one, and a
5487                // restore that did not work ends the chain: the call fails
5488                // and the caller's own (confirmed) restore decides.
5489                if let Some((wt, rev)) = restore
5490                    && let Err(e) = self.graft_restore(wt, rev).await
5491                {
5492                    let why =
5493                        format!("could not restore the worktree before the next fixer: {e:#}");
5494                    tracing::warn!("{why}");
5495                    let out = if e.is::<crate::repo_lock::ResourceBlocked>() {
5496                        AgentOutcome::ResourceBlocked(why)
5497                    } else {
5498                        AgentOutcome::Failed(why)
5499                    };
5500                    last = Some((job, seat, out));
5501                    break;
5502                }
5503                record_handover(
5504                    &mut self.state,
5505                    node,
5506                    &seat.key,
5507                    &spec.id,
5508                    next,
5509                    &class,
5510                    &reason,
5511                );
5512                continue;
5513            }
5514            last = Some((job, seat, out));
5515            break;
5516        }
5517        last.expect("the fixer chain always has an entry")
5518    }
5519
5520    /// The one repair a verification-only failure earns (see
5521    /// [`crate::recovery`]), before the first review round of a run that took
5522    /// a branch over.
5523    ///
5524    /// Saved as started *before* the fixer is called, so a resume never
5525    /// repairs twice; a start found on re-entry is rolled back to the commit
5526    /// it began from. A failed or unusable fixer leaves the tree as it was and
5527    /// the review goes on over it, so the first round's own verification
5528    /// reports the failure again. The failure is never carried into a round as
5529    /// a result: verification is re-run on whatever commit this leaves.
5530    async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5531        use crate::recovery::{Mode, Repair};
5532        if !self.state.reviews.is_empty() {
5533            return Ok(());
5534        }
5535        let Some(mut rec) = self.state.recovery.clone() else {
5536            return Ok(());
5537        };
5538        if rec.mode != Mode::VerifyRepair {
5539            return Ok(());
5540        }
5541        let wt = winner.worktree.clone();
5542        let mut head = git::rev_parse(&wt, "HEAD").await?;
5543        match rec.repair.clone() {
5544            Repair::Done { .. } => return Ok(()),
5545            Repair::WaitingForRestore { before } => {
5546                self.graft_restore(&wt, &before).await?;
5547                head = before;
5548                rec.repair = Repair::NotStarted;
5549                self.state.recovery = Some(rec.clone());
5550                self.state.save()?;
5551            }
5552            Repair::Started { before } => {
5553                // Interrupted: the unfinished edits are not trusted.
5554                // An unconfirmed rollback blocks the run with `Started` kept,
5555                // so a resume retries it; the tree is never reviewed as is.
5556                if let Err(e) = self.graft_restore(&wt, &before).await {
5557                    if e.is::<crate::repo_lock::ResourceBlocked>() {
5558                        return Err(e);
5559                    }
5560                    return self
5561                        .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5562                        .await;
5563                }
5564                rec.repair = Repair::Done {
5565                    before: before.clone(),
5566                    after: before,
5567                    committed: false,
5568                    failed: Some("interrupted; rolled back".to_owned()),
5569                };
5570                self.state
5571                    .event("recovery", "the repair was interrupted and rolled back");
5572                self.state.recovery = Some(rec);
5573                self.state.save()?;
5574                return Ok(());
5575            }
5576            Repair::NotStarted => {}
5577        }
5578        if crate::recovery::recheck(&mut rec, &head) {
5579            self.state.event("recovery", rec.summary());
5580            self.state.recovery = Some(rec);
5581            self.state.save()?;
5582            return Ok(());
5583        }
5584        rec.repair = Repair::Started {
5585            before: head.clone(),
5586        };
5587        self.state.recovery = Some(rec.clone());
5588        self.state.save()?;
5589
5590        let language = self.state.config.graph.language.clone();
5591        let sessions = self.state.config.graph.sessions;
5592        let artifacts = agent::artifacts_dir(&self.state.dir());
5593        let fix_prompt = prompt::recovery_fix(
5594            &self.state.instruction,
5595            &rec,
5596            self.state.config.graph.review_rounds,
5597            &language,
5598        );
5599        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5600        self.state.event(
5601            "recovery",
5602            format!(
5603                "repair: the fixer starts from the failed verification on {}",
5604                short(&head)
5605            ),
5606        );
5607        let (_job, seat, out) = self
5608            .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5609                prompt: fix_prompt.clone(),
5610                spec,
5611                seat,
5612                cwd: wt.clone(),
5613                timeout,
5614                allow_write: true,
5615                sessions,
5616                artifacts: artifacts.clone(),
5617                stem: "fix-recovery".to_owned(),
5618                handover: None,
5619            })
5620            .await;
5621        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5622            rec.repair = Repair::WaitingForRestore { before: head };
5623            self.state.recovery = Some(rec);
5624            return self.stop_for_resource();
5625        }
5626        let failed = match &out {
5627            AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5628            AgentOutcome::Quota(o) => {
5629                self.state.quota.push(QuotaLoss {
5630                    seat: seat.key.clone(),
5631                    node: "fix".to_owned(),
5632                    at: Timestamp::now(),
5633                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5634                });
5635                Some("rate limited (quota); the fixer could not run".to_owned())
5636            }
5637            AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5638            AgentOutcome::Failed(e) => Some(e.clone()),
5639        };
5640        self.state.seats.insert(seat.key.clone(), seat);
5641        let coordination = self.coordinate(&wt).await?;
5642        let failed = match failed {
5643            // The agent's partial edits are not kept.
5644            Some(why) => {
5645                if let Err(e) = git::restore_to(&wt, &head).await {
5646                    return self
5647                        .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5648                        .await;
5649                }
5650                Some(why)
5651            }
5652            None => {
5653                if let Ok(r) = git::rescue_commit(
5654                    &wt,
5655                    "magi: recovery repair of the failed verification (uncommitted work)",
5656                )
5657                .await
5658                {
5659                    self.state.note_withheld("fix", &r.withheld);
5660                }
5661                None
5662            }
5663        };
5664        let after = git::rev_parse(&wt, "HEAD").await?;
5665        drop(coordination);
5666        self.state.event(
5667            "recovery",
5668            match &failed {
5669                Some(why) => format!("repair: nothing kept ({why})"),
5670                None if after != head => {
5671                    format!("repair: committed {}", short(&after))
5672                }
5673                None => "repair: the fixer made no commit".to_owned(),
5674            },
5675        );
5676        rec.repair = Repair::Done {
5677            before: head.clone(),
5678            committed: after != head,
5679            after,
5680            failed,
5681        };
5682        self.state.recovery = Some(rec);
5683        self.state.save()?;
5684        Ok(())
5685    }
5686
5687    /// A repair whose rollback could not be confirmed: the run is blocked and
5688    /// resumable, and the unconfirmed state is never reviewed.
5689    async fn stop_recovery(
5690        &mut self,
5691        mut rec: crate::recovery::RecoveryRecord,
5692        head: &str,
5693        why: String,
5694    ) -> Result<()> {
5695        // `Started` stays recorded: a resume retries the restore.
5696        rec.repair = crate::recovery::Repair::Started {
5697            before: head.to_owned(),
5698        };
5699        self.state.recovery = Some(rec);
5700        self.state
5701            .event("recovery", format!("repair could not be undone: {why}"));
5702        self.state.status = RunStatus::Blocked;
5703        self.state.save()?;
5704        bail!("recovery repair could not be rolled back: {why}")
5705    }
5706
5707    async fn review_loop(&mut self) -> Result<()> {
5708        // A base that would not rebase is a person's decision, not a review
5709        // round: nothing here would change the answer, and reviewers and a
5710        // fixer would be spending real budget on a tree that cannot land
5711        // regardless of what they find.
5712        if self
5713            .state
5714            .base_sync
5715            .as_ref()
5716            .is_some_and(|s| s.conflict.is_some())
5717        {
5718            return Ok(());
5719        }
5720        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
5721        // agent files with `magi task add` name the run that paid for it. The
5722        // prompt overlay is cloned alongside it because the waves borrow it
5723        // while `self` is mutably borrowed by the node's own bookkeeping.
5724        let run_id = self.state.id.clone();
5725        let prompts = self.state.config.prompts.clone();
5726        let Some(winner) = self.state.winner().cloned() else {
5727            return Ok(());
5728        };
5729        let max_rounds = self.state.config.graph.review_rounds;
5730        // A clean round, an exhausted round budget, or a stalled tree (see
5731        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
5732        // they are recorded — recomputed here, not read off `status`, so a
5733        // reentry into a run that already stopped restates the identical
5734        // verdict instead of silently handing back whatever an earlier node
5735        // in this same walk clobbered `status` to (a solo-candidate
5736        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
5737        // below runs an empty range once the budget is spent, and would
5738        // otherwise fall through without touching `status` at all.
5739        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5740            // A reentry after a crash between the last round's save and
5741            // `stop_reviewing` reaches the hand-off here, not there.
5742            if status == RunStatus::Gating {
5743                self.record_contested_handoff();
5744            }
5745            self.state.status = status;
5746            self.state.save()?;
5747            return Ok(());
5748        }
5749        self.state.status = RunStatus::Reviewing;
5750        // A last recorded round whose own verification never resolved
5751        // (`ResourceBlocked` — the shared build cache, not the patch) is
5752        // never a concluded round, whatever the round budget says: starting
5753        // a fresh round on top of it would spend a whole new reviewer wave
5754        // re-reading an unchanged patch instead of just retrying the one
5755        // check that actually needs it, and once the budget is spent the
5756        // loop below has nothing left to do at all (its range is empty).
5757        // Retry that check directly instead, exactly the same retry
5758        // `stop_reviewing` already does for its own catch-up case.
5759        if self
5760            .state
5761            .reviews
5762            .last()
5763            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5764        {
5765            let shell = self.state.config.shell();
5766            return self
5767                .stop_reviewing(
5768                    "the last round's own verification never resolved",
5769                    &shell,
5770                    &winner.worktree,
5771                )
5772                .await;
5773        }
5774
5775        self.recovery_repair(&winner).await?;
5776        if self.resource_halt()? {
5777            return Ok(());
5778        }
5779
5780        let repo = self.state.repo.clone();
5781        let root = self.state.worktree_root();
5782        let language = self.state.config.graph.language.clone();
5783        let sessions = self.state.config.graph.sessions;
5784        let artifacts = self.merge_repair_artifacts();
5785        let base = self.landing_base();
5786        let base_short = short(&base);
5787        let reviewers = self.roles.reviewers.clone();
5788        let shell = self.state.config.shell();
5789
5790        for round in (self.state.reviews.len() + 1)..=max_rounds {
5791            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5792            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5793            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5794            // The prior round's own record, already persisted — never a
5795            // hand-carried variable of just its failing output: that is
5796            // exactly what let a round's e2e result drift out of sync with
5797            // which commit it was actually about (see `SCHEMA`'s doc for
5798            // schema 8). Judged against `head`, the commit reviewers are
5799            // about to look at now, so the summary always reads as "an
5800            // earlier head" here — this round's own patch has not been
5801            // checked yet.
5802            let prev_verification = self
5803                .state
5804                .reviews
5805                .last()
5806                .and_then(|r| r.verification_summary(&head));
5807            // Only the first round of a run that took a branch over: later
5808            // rounds have this run's own history.
5809            let prior_section = match (&self.state.recovery, round) {
5810                (Some(rec), 1) if self.state.reviews.is_empty() => {
5811                    let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5812                        (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5813                            Some(crate::recovery::head_of(
5814                                &git::diff(&winner.worktree, reviewed, "HEAD")
5815                                    .await
5816                                    .unwrap_or_default(),
5817                                crate::recovery::SINCE_DIFF_MAX,
5818                            ))
5819                        }
5820                        _ => None,
5821                    };
5822                    Some(prompt::recovery_review(rec, since.as_deref()))
5823                }
5824                _ => None,
5825            };
5826
5827            // Each reviewer gets its own detached checkout of exactly this
5828            // commit: nobody can perturb the winner's tree, and the fixer can
5829            // keep working without racing a reviewer.
5830            let mut jobs = Vec::new();
5831            let coordination = self.coordinate(&repo).await?;
5832            for (r, spec) in reviewers.iter().cloned().enumerate() {
5833                let wt = root.join(format!("review-{}", r + 1));
5834                // `clean -fdx` in a reset wipes setup's products, so every
5835                // round sets the seat up again.
5836                if wt.exists() {
5837                    git::reset_detached(&wt, &head).await?;
5838                } else {
5839                    git::worktree_add_detached(&repo, &wt, &head).await?;
5840                }
5841                self.setup_seat_worktree(&repo, &wt, false).await?;
5842                let seat_key = format!("review-{}", r + 1);
5843                // The seat starts the round on whoever answered it last, not
5844                // on the agent the spec names, so a failure is not re-paid.
5845                let spec = pick_start_spec(
5846                    &self.roles.reviewer_roster,
5847                    spec,
5848                    self.state.seat_history.get(&seat_key),
5849                );
5850                let seat = self.seat(&seat_key, &spec.id);
5851                jobs.push(SeatJob {
5852                    prompt: prompt::review(&prompt::ReviewCtx {
5853                        instruction: &self.state.instruction,
5854                        branch: &winner.branch,
5855                        base_short: &base_short,
5856                        stat: &stat,
5857                        patch: &patch,
5858                        verification: prev_verification.as_ref(),
5859                        prior: prior_section.as_deref(),
5860                        reviewers: reviewers.len(),
5861                        round,
5862                        rounds: max_rounds,
5863                        // A review-only run has no rankings, so nothing
5864                        // competed for this patch and the reviewer is told so.
5865                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5866                        lens: Lens::for_seat(r),
5867                        language: &language,
5868                        github_language: &self.state.config.graph.github_language,
5869                    }),
5870                    spec,
5871                    seat,
5872                    cwd: wt,
5873                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5874                    allow_write: false,
5875                    sessions,
5876                    artifacts: artifacts.clone(),
5877                    stem: format!("review-{round}-{}", r + 1),
5878                    handover: None,
5879                });
5880            }
5881
5882            drop(coordination);
5883            self.state.event(
5884                "review",
5885                format!(
5886                    "round {round}: {} reviewers on {}",
5887                    jobs.len(),
5888                    short(&head)
5889                ),
5890            );
5891            let mut quota_losses = Vec::new();
5892            let review_retries = self.state.config.graph.retries;
5893            let review_cache = self.state.config.cache_dir();
5894            let ctx = WaveCtx {
5895                carry_seats: true,
5896                pauses: Some((&self.pause, &self.interrupt)),
5897                run: &run_id,
5898                node: "review",
5899                prompts: &prompts,
5900                cache: review_cache.as_deref(),
5901                round: Some(round),
5902            };
5903            let results = ask_json_wave::<Review>(
5904                jobs,
5905                Arc::clone(&self.sem),
5906                review_retries,
5907                &self.roles.reviewer_roster,
5908                &ctx,
5909                &mut quota_losses,
5910                &mut self.state,
5911                &|_: &Review| Ok(()),
5912            )
5913            .await;
5914            if self.state.resource_block.is_some() {
5915                return self.stop_for_resource();
5916            }
5917            // Counted before the move below: how many of *this* round's
5918            // reviewer seats were lost to their own rate limit, as opposed to
5919            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5920            let round_quota_missing = quota_losses.len();
5921            self.state.quota.extend(quota_losses);
5922
5923            let mut records = Vec::new();
5924            let mut all_findings = Vec::new();
5925            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5926                let agent_id = seat.agent.clone();
5927                self.state.seats.insert(seat.key.clone(), seat);
5928                let mut record = ReviewRecord {
5929                    reviewer: r + 1,
5930                    agent: agent_id,
5931                    summary: String::new(),
5932                    findings: Vec::new(),
5933                    vote: None,
5934                    failed: None,
5935                    duration_ms: 0,
5936                    // Set for both outcomes: `failed: Some(_)` with
5937                    // `attempts > 0` is a seat every retry still lost, not a
5938                    // recovered one — only `failed: None` with `attempts > 0`
5939                    // reads as "answered after a nudge" (see this field's own
5940                    // doc).
5941                    attempts,
5942                };
5943                match res {
5944                    Ok((review, out)) => {
5945                        // Sanitized here, at the point every other piece of
5946                        // agent prose in this file is (candidate summaries,
5947                        // deliberation turns, vote reasons): a reviewer's own
5948                        // words are the one thing about it that could name
5949                        // it, and reconsideration below broadcasts this same
5950                        // summary and these same findings to every other
5951                        // seat on the panel.
5952                        record.summary =
5953                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5954                        record.vote = Some(review.vote);
5955                        record.duration_ms = out.duration_ms;
5956                        for (n, mut f) in review.findings.into_iter().enumerate() {
5957                            // ids are magi's, never the agent's: the fixer's
5958                            // adoption report is keyed by them.
5959                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5960                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5961                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5962                            // `file` is agent-supplied prose too, never
5963                            // checked against the real tree — the same
5964                            // exposure `title`/`detail` above have, just in
5965                            // a field easy to forget because it looks like a
5966                            // path rather than free text.
5967                            f.file = f
5968                                .file
5969                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5970                            all_findings.push(f.clone());
5971                            record.findings.push(f);
5972                        }
5973                        self.state.event(
5974                            "review",
5975                            format!(
5976                                "round {round}: reviewer {} voted {} with {} finding(s)",
5977                                r + 1,
5978                                review.vote.label(),
5979                                record.findings.len()
5980                            ),
5981                        );
5982                    }
5983                    Err(e) => {
5984                        record.failed = Some(e.to_string());
5985                        self.state.event(
5986                            "review",
5987                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5988                        );
5989                    }
5990                }
5991                records.push(record);
5992            }
5993
5994            // Tally the round's votes and, if they split, spend the one
5995            // round of reconsideration the split -> deliberate -> revote
5996            // shape `judge`/`vote` use for the panel, sized down to what a
5997            // read-only review round can afford: one round, and a revote
5998            // rather than an argument, because the panel already wrote its
5999            // reasoning down as findings the first time around.
6000            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
6001            let vote_split =
6002                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
6003            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
6004            if vote_split {
6005                self.state.event(
6006                    "review",
6007                    format!(
6008                        "round {round}: votes split ({}) — one round of reconsideration",
6009                        initial_votes
6010                            .iter()
6011                            .map(|v| v.label())
6012                            .collect::<Vec<_>>()
6013                            .join(", ")
6014                    ),
6015                );
6016                // Seats read every seat's findings and votes, still numbered
6017                // and never named — the same anonymity `review` itself keeps.
6018                let panel: Vec<ReviewSeatReport<'_>> = records
6019                    .iter()
6020                    .filter_map(|r| {
6021                        r.vote.map(|vote| ReviewSeatReport {
6022                            reviewer: r.reviewer,
6023                            vote,
6024                            summary: &r.summary,
6025                            findings: &r.findings,
6026                        })
6027                    })
6028                    .collect();
6029
6030                let mut jobs = Vec::new();
6031                let mut seats_at = Vec::new();
6032                for (r, spec) in reviewers.iter().cloned().enumerate() {
6033                    // A seat with no initial vote has nothing to reconsider
6034                    // from and stays absent, the same as it stayed absent
6035                    // from `panel` above.
6036                    if records[r].vote.is_none() {
6037                        continue;
6038                    }
6039                    let wt = root.join(format!("review-{}", r + 1));
6040                    let seat_key = format!("review-{}", r + 1);
6041                    let spec = self.occupant(&seat_key, spec);
6042                    let seat = self.seat(&seat_key, &spec.id);
6043                    // A seat with no live session has already forgotten the
6044                    // initial review's prompt — restate the patch it is
6045                    // voting on, the same as `deliberate`/`vote` do for a
6046                    // judge in the same position.
6047                    // The panel already carries this seat's own review and
6048                    // vote, so restating the patch makes the prompt whole for
6049                    // a seat handed to another agent.
6050                    let build = |with_patch: bool| {
6051                        prompt::review_reconsider(&ReviewReconsiderCtx {
6052                            instruction: &self.state.instruction,
6053                            reviewer: r + 1,
6054                            lens: Lens::for_seat(r),
6055                            panel: &panel,
6056                            patch: with_patch.then_some(ReviewPatch {
6057                                branch: &winner.branch,
6058                                base_short: &base_short,
6059                                stat: &stat,
6060                                patch: &patch,
6061                            }),
6062                            round,
6063                            rounds: max_rounds,
6064                            language: &language,
6065                        })
6066                    };
6067                    let full = build(true);
6068                    let prompt = if has_context(&spec, &seat, sessions) {
6069                        build(false)
6070                    } else {
6071                        full.clone()
6072                    };
6073                    jobs.push(SeatJob {
6074                        prompt,
6075                        spec,
6076                        seat,
6077                        cwd: wt,
6078                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
6079                        allow_write: false,
6080                        sessions,
6081                        artifacts: artifacts.clone(),
6082                        stem: format!("review-{round}-reconsider-{}", r + 1),
6083                        handover: Some(full),
6084                    });
6085                    seats_at.push(r);
6086                }
6087
6088                let mut recon_quota_losses = Vec::new();
6089                let recon_cache = self.state.config.cache_dir();
6090                let recon_ctx = WaveCtx {
6091                    carry_seats: true,
6092                    pauses: Some((&self.pause, &self.interrupt)),
6093                    run: &run_id,
6094                    node: "review",
6095                    prompts: &prompts,
6096                    cache: recon_cache.as_deref(),
6097                    round: Some(round),
6098                };
6099                let recon_results = ask_json_wave::<ReviewRevote>(
6100                    jobs,
6101                    Arc::clone(&self.sem),
6102                    review_retries,
6103                    &self.roles.reviewer_roster,
6104                    &recon_ctx,
6105                    &mut recon_quota_losses,
6106                    &mut self.state,
6107                    &|_: &ReviewRevote| Ok(()),
6108                )
6109                .await;
6110                if self.state.resource_block.is_some() {
6111                    return self.stop_for_resource();
6112                }
6113                self.state.quota.extend(recon_quota_losses);
6114
6115                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
6116                    let agent_id = seat.agent.clone();
6117                    self.state.seats.insert(seat.key.clone(), seat);
6118                    let mut rec = ReviewRevoteRecord {
6119                        reviewer: r + 1,
6120                        agent: agent_id,
6121                        vote: None,
6122                        reason: String::new(),
6123                        failed: None,
6124                    };
6125                    match res {
6126                        Ok((rv, _)) => {
6127                            rec.vote = Some(rv.vote);
6128                            rec.reason =
6129                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
6130                            self.state.event(
6131                                "review",
6132                                format!(
6133                                    "round {round}: reviewer {} revoted {}",
6134                                    r + 1,
6135                                    rv.vote.label()
6136                                ),
6137                            );
6138                        }
6139                        Err(e) => {
6140                            rec.failed = Some(e.to_string());
6141                            self.state.event(
6142                                "review",
6143                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
6144                            );
6145                        }
6146                    }
6147                    reconsideration.push(rec);
6148                }
6149            } else if initial_votes.len() > 1 {
6150                self.state.event(
6151                    "review",
6152                    format!(
6153                        "round {round}: votes agreed ({}) — no reconsideration",
6154                        initial_votes[0].label()
6155                    ),
6156                );
6157            }
6158
6159            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
6160            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6161            // A round that already has a blocking finding and a round left to
6162            // try is going back to the fixer no matter what `verify.e2e`
6163            // says, so running it first only spends the loop's slowest step
6164            // (minutes, for a Rust repo's full test suite) on a head about
6165            // to be rewritten. Deferred, never skipped: `verify.e2e` still
6166            // runs once a round has no blocking findings left (see
6167            // `round_is_clean`, which a deferred — empty — `e2e` can never
6168            // satisfy since `blocking` is nonzero whenever this branch is
6169            // taken), and `stop_reviewing` forces a real run before it will
6170            // ever read a deferred round as green.
6171            let defer_e2e =
6172                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
6173            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
6174                let reason =
6175                    format!("{blocking} blocking finding(s) already required a fix this round");
6176                self.state.event(
6177                    "verify",
6178                    format!(
6179                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
6180                         {}); it will run once a round has none left",
6181                        short(&head)
6182                    ),
6183                );
6184                (Vec::new(), false, true, Some(reason))
6185            } else {
6186                let e2e_commands = self.state.config.verify.e2e.clone();
6187                let cache_dir = self.state.config.cache_dir();
6188                let context = format!("round {round}");
6189                let wait = self.repository_wait();
6190                let (e2e, verify_retried) = with_cache_lease(
6191                    &mut self.state,
6192                    cache_dir.as_deref(),
6193                    "e2e",
6194                    "e2e",
6195                    &winner.worktree,
6196                    &head,
6197                    verify_timeout,
6198                    &context,
6199                    &wait,
6200                    |state, budget| {
6201                        let shell = shell.clone();
6202                        let e2e_commands = e2e_commands.clone();
6203                        let worktree = winner.worktree.clone();
6204                        let context = context.clone();
6205                        async move {
6206                            run_e2e_with_retry(
6207                                state,
6208                                &shell,
6209                                &e2e_commands,
6210                                &worktree,
6211                                budget,
6212                                &context,
6213                            )
6214                            .await
6215                        }
6216                    },
6217                )
6218                .await?;
6219                (e2e, verify_retried, false, None)
6220            };
6221
6222            let expected = records.len();
6223            let answered = records.iter().filter(|r| r.failed.is_none()).count();
6224            let incomplete = answered < expected;
6225            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
6226            let policy = self.state.config.graph.incomplete_review;
6227            let clean = round_is_clean(
6228                blocking,
6229                e2e_ok,
6230                answered,
6231                expected,
6232                round_quota_missing,
6233                policy,
6234            );
6235
6236            let mut round_record = ReviewRound {
6237                round,
6238                head: head.clone(),
6239                verified_head: None,
6240                verified_at: None,
6241                reviews: records,
6242                e2e,
6243                verify_retried,
6244                e2e_deferred,
6245                e2e_defer_reason,
6246                fix: None,
6247                blocking,
6248                answered,
6249                expected,
6250                clean,
6251                progressed: false,
6252                vote_split,
6253                reconsideration,
6254                verdict: None,
6255            };
6256            // The final vote per seat is its revote where reconsideration
6257            // ran and answered, its initial vote otherwise — the same
6258            // fallback `tally` uses for a judge whose private vote failed.
6259            round_record.verdict = ReviewVote::worst(
6260                round_record
6261                    .final_votes()
6262                    .into_iter()
6263                    .map(|(_, _, vote)| vote),
6264            );
6265            // Which commit and when magi actually attempted to check —
6266            // known the moment a command was dispatched against `head`,
6267            // whether or not it finished: a resource-blocked attempt still
6268            // targeted a specific commit at a specific time, and leaving
6269            // that unrecorded is exactly what made `verification_summary`
6270            // report a fresh attempt as "commit unknown ... recorded before
6271            // this was tracked", indistinguishable from a genuinely old,
6272            // untracked record. Only a deferred or unconfigured round never
6273            // ran at all and has nothing to record — see
6274            // `ReviewRound::verified_head`'s own doc.
6275            if !matches!(
6276                round_record.e2e_status(),
6277                E2eStatus::Deferred | E2eStatus::NotConfigured
6278            ) {
6279                round_record.verified_head = Some(head.clone());
6280                round_record.verified_at = Some(Timestamp::now());
6281            }
6282            let this_round_verification = round_record.verification_summary(&head);
6283
6284            if incomplete {
6285                let missing: Vec<String> = round_record
6286                    .reviews
6287                    .iter()
6288                    .filter(|r| r.failed.is_some())
6289                    .map(|r| format!("review-{}", r.reviewer))
6290                    .collect();
6291                self.state.event(
6292                    "review",
6293                    format!(
6294                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6295                        missing.join(", ")
6296                    ),
6297                );
6298            }
6299
6300            if clean {
6301                self.state.event(
6302                    "review",
6303                    if incomplete && policy == IncompleteReviewPolicy::Warn {
6304                        format!(
6305                            "round {round}: clean (warn policy, incomplete panel) — no \
6306                             blocking findings from the seats that answered, verification green"
6307                        )
6308                    } else if incomplete {
6309                        format!(
6310                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6311                             quorum) — no blocking findings from the seats that answered, \
6312                             verification green",
6313                            expected - answered
6314                        )
6315                    } else {
6316                        format!("round {round}: clean — no blocking findings, verification green")
6317                    },
6318                );
6319                self.state.reviews.push(round_record);
6320                self.state.status = RunStatus::Gating;
6321                self.state.save()?;
6322                return Ok(());
6323            }
6324
6325            // Nothing was raised and verification passed, but not every seat
6326            // answered and `round_is_clean` still refused to call it clean —
6327            // either a seat is missing for a reason other than its own quota
6328            // (a crash, a timeout, unparsable output — worth another try), or
6329            // every seat that could have answered lost its quota and nobody
6330            // is left to decide on: re-review rather than send the fixer
6331            // after a round with nothing to fix.
6332            if incomplete && blocking == 0 && e2e_ok {
6333                self.state.reviews.push(round_record);
6334                self.state.save()?;
6335                if round == max_rounds {
6336                    self.state.status = RunStatus::Blocked;
6337                    self.state.event(
6338                        "review",
6339                        format!(
6340                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6341                             refusing to call it clean",
6342                            expected - answered
6343                        ),
6344                    );
6345                    return Ok(());
6346                }
6347                continue;
6348            }
6349
6350            // Nothing for the fixer to act on (`blocking == 0`) and the only
6351            // reason this round is not clean is that magi itself never got
6352            // a command to run — the shared build cache, not the patch (see
6353            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
6354            // the fixer would invite a change to appease contention that has
6355            // nothing to do with the diff, and would leave this attempt
6356            // sitting in the next round's prompt as if it were about an
6357            // earlier, superseded commit rather than what it actually is:
6358            // the same head, still waiting to be checked. Wait for it the
6359            // same way the final round's own contention is already handled,
6360            // whatever round this happens to be.
6361            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6362                self.state.reviews.push(round_record);
6363                return self
6364                    .stop_reviewing(
6365                        "the round's own verification could not run",
6366                        &shell,
6367                        &winner.worktree,
6368                    )
6369                    .await;
6370            }
6371
6372            if round == max_rounds {
6373                self.state.reviews.push(round_record);
6374                return self
6375                    .stop_reviewing(
6376                        &format!(
6377                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6378                        ),
6379                        &shell,
6380                        &winner.worktree,
6381                    )
6382                    .await;
6383            }
6384
6385            // Fix. The winner's own implementer seat continues its conversation:
6386            // the competition is over, so context is pure benefit now.
6387            let blocking_findings: Vec<_> = all_findings
6388                .iter()
6389                .filter(|f| f.severity.blocks())
6390                .cloned()
6391                .collect();
6392            let fix_prompt = prompt::fix_in(
6393                &self.state.instruction,
6394                &blocking_findings,
6395                this_round_verification.as_ref(),
6396                round,
6397                max_rounds,
6398                &language,
6399                &self.state.config.graph.github_language,
6400            );
6401            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6402            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6403            let (job, seat, out) = self
6404                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6405                    prompt: fix_prompt.clone(),
6406                    spec,
6407                    seat,
6408                    cwd: winner.worktree.clone(),
6409                    timeout,
6410                    allow_write: true,
6411                    sessions,
6412                    artifacts: artifacts.clone(),
6413                    stem: format!("fix-{round}"),
6414                    handover: None,
6415                })
6416                .await;
6417            // Not asked: the round is not recorded (so a resume repeats it),
6418            // no fixer was handed over to, and the seat is left as it was.
6419            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6420                return self.stop_for_resource();
6421            }
6422            let agent_id = seat.agent.clone();
6423
6424            let mut fix = FixRecord {
6425                agent: agent_id,
6426                addressed: Vec::new(),
6427                rejected: Vec::new(),
6428                notes: String::new(),
6429                committed: false,
6430                failed: None,
6431                duration_ms: 0,
6432                continuation: None,
6433            };
6434            let mut continuation = ContinuationRecord::not_needed();
6435            let mut final_seat = seat.clone();
6436            match out {
6437                AgentOutcome::Ok(o) => {
6438                    fix.duration_ms = o.duration_ms;
6439                    let parsed = verdict::extract_json::<FixReport>(&o.text);
6440                    // A parsed report standing next to a command this same
6441                    // reply's own CLI never confirmed the exit status of is
6442                    // not a resolved answer — the identical `CommandEvidence`
6443                    // `state.jobs` renders, read here instead of only on
6444                    // display, per the completion judgment and the shown
6445                    // record needing to agree.
6446                    let incomplete_reason = match &parsed {
6447                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6448                            "the reply parsed, but it reported a command whose own CLI never \
6449                             confirmed an exit status"
6450                                .to_owned(),
6451                        ),
6452                        Ok(_) => None,
6453                        Err(e) => Some(e.to_string()),
6454                    };
6455                    match incomplete_reason {
6456                        None => {
6457                            let report = parsed.expect("checked Ok above");
6458                            fix.addressed = report.addressed;
6459                            fix.rejected = report.rejected;
6460                            fix.notes =
6461                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
6462                        }
6463                        Some(reason) => {
6464                            let (resumed_seat, resolved, failure, cont) = self
6465                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6466                                .await;
6467                            fix.duration_ms += cont.cumulative_wait_ms;
6468                            continuation = cont;
6469                            final_seat = resumed_seat;
6470                            match resolved {
6471                                Some(report) => {
6472                                    fix.addressed = report.addressed;
6473                                    fix.rejected = report.rejected;
6474                                    fix.notes = blind::sanitize_prose(
6475                                        &report.notes,
6476                                        &self.state.config.blind,
6477                                    );
6478                                }
6479                                None => fix.failed = failure,
6480                            }
6481                        }
6482                    }
6483                }
6484                // The CLI's raw error JSON is not a fix report to parse.
6485                AgentOutcome::Dropped(o) => {
6486                    fix.duration_ms = o.duration_ms;
6487                    let why = o
6488                        .dropped
6489                        .as_ref()
6490                        .map(|d| d.why.as_str())
6491                        .unwrap_or("the CLI ended the stream without delivering its answer");
6492                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6493                }
6494                AgentOutcome::Quota(o) => {
6495                    self.state.quota.push(QuotaLoss {
6496                        seat: final_seat.key.clone(),
6497                        node: "fix".to_owned(),
6498                        at: Timestamp::now(),
6499                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6500                    });
6501                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6502                }
6503                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6504            }
6505            fix.continuation = Some(continuation);
6506            self.state.seats.insert(final_seat.key.clone(), final_seat);
6507            let coordination = self.coordinate(&winner.worktree).await?;
6508            if let Ok(r) = git::rescue_commit(
6509                &winner.worktree,
6510                &format!("magi: review round {round} fixes (uncommitted work)"),
6511            )
6512            .await
6513            {
6514                self.state.note_withheld("fix", &r.withheld);
6515            }
6516            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6517            fix.committed = after != before;
6518            // Judged by what `git` says moved against base, never by the
6519            // fixer's own `addressed`/`rejected` count — see
6520            // `ReviewRound::progressed`. Propagated with `?`, the same as the
6521            // `patch` snapshot above: swallowing this error would default
6522            // `diff_after` to empty, which almost always differs from a
6523            // non-empty `patch` and reads as "progressed" — exactly backwards
6524            // for a `git` failure the stagnation check cannot see through.
6525            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6526            drop(coordination);
6527            let progressed = diff_after != patch;
6528            let commit_note = if fix.committed {
6529                "committed"
6530            } else {
6531                "NO new commit"
6532            };
6533            let tree_note = if progressed {
6534                "changed vs base"
6535            } else {
6536                "unchanged vs base"
6537            };
6538            self.state.event(
6539                "fix",
6540                match &fix.failed {
6541                    // Distinct on purpose from "0 addressed, 0 rejected": the
6542                    // fixer's own diff still landed (blocking counts do keep
6543                    // falling round over round), only its adoption report did
6544                    // not come back, so this must never read like every
6545                    // finding was reviewed and declined.
6546                    Some(reason) => {
6547                        format!(
6548                            "round {round}: fixer's adoption report was lost ({reason}); \
6549                             {commit_note}, tree {tree_note}"
6550                        )
6551                    }
6552                    None => format!(
6553                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6554                         {tree_note}{}",
6555                        fix.addressed.len(),
6556                        fix.rejected.len(),
6557                        if continuation.outcome == ContinuationOutcome::Resumed {
6558                            format!(
6559                                " (adoption report recovered after {} continuation(s))",
6560                                continuation.attempts
6561                            )
6562                        } else {
6563                            String::new()
6564                        },
6565                    ),
6566                },
6567            );
6568            round_record.fix = Some(fix);
6569            round_record.progressed = progressed;
6570            self.state.reviews.push(round_record);
6571            self.state.save()?;
6572
6573            // The fixer's own report never came back this round, even after
6574            // `continue_fix_report`'s own budget was spent on it — not an
6575            // ordinary "no report" (dropped stream, quota, plain failure),
6576            // which already reads that way and is left to the existing round
6577            // budget. Stopping here, rather than opening another round, is
6578            // what keeps a next reviewer/fixer wave from ever being
6579            // dispatched onto `winner.worktree` while whatever the seat's
6580            // last call may still have running there is unaccounted for: no
6581            // process liveness check exists (and none is being added — see
6582            // AGENTS.md/this task's own scope), so the only way to honour
6583            // "nothing starts before a valid report returns" is to not start
6584            // anything further on this worktree from this run at all.
6585            if matches!(
6586                continuation.outcome,
6587                ContinuationOutcome::Exhausted
6588                    | ContinuationOutcome::QuotaLost
6589                    | ContinuationOutcome::NoSession
6590            ) {
6591                return self
6592                    .stop_reviewing(
6593                        "the fixer's adoption report never came back, even after resuming its \
6594                         own seat; refusing to start another round against the same worktree \
6595                         while that is unresolved",
6596                        &shell,
6597                        &winner.worktree,
6598                    )
6599                    .await;
6600            }
6601
6602            let streak = self
6603                .state
6604                .reviews
6605                .iter()
6606                .rev()
6607                .take_while(|r| !r.progressed)
6608                .count();
6609            if streak >= STAGNANT_LIMIT {
6610                return self
6611                    .stop_reviewing(
6612                        &format!(
6613                            "the tree has not moved against base for {streak} round(s) in a row"
6614                        ),
6615                        &shell,
6616                        &winner.worktree,
6617                    )
6618                    .await;
6619            }
6620        }
6621        Ok(())
6622    }
6623
6624    /// Decide, from the last recorded round's own verification, whether
6625    /// stopping the review loop is a hand-off or a genuine block.
6626    ///
6627    /// Called once the loop has given up trying — the round budget is spent,
6628    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
6629    /// findings still open, never while a round is still clean or the
6630    /// incomplete-panel case handled inline above. Gate and e2e are facts
6631    /// about the tree; a lingering review finding is an opinion, and this
6632    /// workload's own `magi stats` puts reviewer precision low enough
6633    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
6634    /// must not by itself stand between a green, verified change and the
6635    /// human who decides what to do with it. A red e2e is not an opinion, so
6636    /// that case still blocks, with the failing command and a tail of its
6637    /// output recorded here rather than left in `run.json` for someone to go
6638    /// find.
6639    ///
6640    /// A round that deferred its own e2e (see [`Config::graph`]'s
6641    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
6642    /// only because nothing ran, and treating an empty list as a passing one
6643    /// here is exactly the "deferred painted green" bug this function exists
6644    /// to not have. When the last round's own verification never resolved —
6645    /// deferred on purpose, or a real attempt the shared build cache blocked
6646    /// — this makes (or retries) the real run, on the actual worktree this
6647    /// loop is about to stop touching, before deciding anything. A
6648    /// resource-blocked attempt is likewise never read as either green or
6649    /// red: it is evidence about the machine, not the patch (see
6650    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
6651    /// blocked cache leaves this call without deciding rather than guessing
6652    /// — the caller retries on a later reentry.
6653    /// Record, once, that the review loop handed off over a blocking finding
6654    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
6655    /// `land` asks the owner even with `land_approval` off. Called from every
6656    /// path that concludes `Gating`; a reentry keeps the first record.
6657    fn record_contested_handoff(&mut self) {
6658        if self.state.contested_handoff.is_some() {
6659            return;
6660        }
6661        let Some(contested) = self
6662            .state
6663            .reviews
6664            .last()
6665            .and_then(ReviewRound::contested_handoff)
6666        else {
6667            return;
6668        };
6669        self.state.event(
6670            "review",
6671            format!(
6672                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6673                 wait for the owner's approval",
6674                contested.findings.len(),
6675                contested.rejecters.len()
6676            ),
6677        );
6678        self.state.contested_handoff = Some(contested);
6679    }
6680
6681    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6682        let round_idx = self.state.reviews.len() - 1;
6683        // A deferred round and a resource-blocked one are the same shape
6684        // here: neither has a real result yet, and both get one more
6685        // attempt. Read off `e2e_status` — the single source for this —
6686        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
6687        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
6688        // still retries instead of being read as a settled result the
6689        // instant it stops being empty.
6690        let needs_catchup_run = matches!(
6691            self.state.reviews[round_idx].e2e_status(),
6692            E2eStatus::Deferred | E2eStatus::ResourceBlocked
6693        );
6694        if needs_catchup_run {
6695            let round = self.state.reviews[round_idx].round;
6696            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6697            let commands = self.state.config.verify.e2e.clone();
6698            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6699            let cache_dir = self.state.config.cache_dir();
6700            let context = format!(
6701                "round {round}: verification unresolved, catching up before the final decision"
6702            );
6703            let wait = self.repository_wait();
6704            let (outcomes, verify_retried) = with_cache_lease(
6705                &mut self.state,
6706                cache_dir.as_deref(),
6707                "e2e",
6708                "e2e",
6709                worktree,
6710                &attempted_head,
6711                timeout,
6712                &context,
6713                &wait,
6714                |state, budget| {
6715                    let shell = shell.to_vec();
6716                    let commands = commands.clone();
6717                    let context = context.clone();
6718                    async move {
6719                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6720                            .await
6721                    }
6722                },
6723            )
6724            .await?;
6725            let last = &mut self.state.reviews[round_idx];
6726            last.e2e = outcomes;
6727            last.verify_retried = verify_retried;
6728            // Always the commit and time this attempt actually targeted,
6729            // whether or not it happens to equal the reviewed `head` and
6730            // whether or not a command finished — see
6731            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
6732            // attempt is recorded too, so a later reader sees "attempted
6733            // again at T2" rather than silence.
6734            last.verified_head = Some(attempted_head);
6735            last.verified_at = Some(Timestamp::now());
6736            if verify_inconclusive(&last.e2e) {
6737                // Still not a real result: `e2e_deferred` is left exactly
6738                // as it was, so `needs_catchup_run` above reads
6739                // `ResourceBlocked` (via `e2e_status`, which checks
6740                // `resource_blocked` before `e2e_deferred`) and retries
6741                // again on the next reentry, rather than recording
6742                // contention as a red e2e and blocking the run on it.
6743                self.state.save()?;
6744                return Ok(());
6745            }
6746            last.e2e_deferred = false;
6747        }
6748        let last = &self.state.reviews[round_idx];
6749        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6750
6751        match last.e2e_status() {
6752            E2eStatus::Failed => {
6753                let red: Vec<String> = last
6754                    .e2e
6755                    .iter()
6756                    .filter(|o| !o.ok())
6757                    .map(|o| {
6758                        format!(
6759                            "`{}` -> {:?}\n{}",
6760                            o.command,
6761                            o.code,
6762                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6763                        )
6764                    })
6765                    .collect();
6766                self.state
6767                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6768                self.state.status = RunStatus::Blocked;
6769            }
6770            // `needs_catchup_run` above already retried once this call; if
6771            // it is still blocked, this is magi's own admission it could
6772            // not get a command to run, never a verdict on the patch — the
6773            // run is left exactly where a later reentry can retry again.
6774            E2eStatus::ResourceBlocked => {
6775                self.state.event(
6776                    "review",
6777                    format!(
6778                        "{why}; e2e could not run (shared build cache unavailable); not \
6779                         deciding yet"
6780                    ),
6781                );
6782            }
6783            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6784                self.state.event(
6785                    "review",
6786                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6787                );
6788                self.record_contested_handoff();
6789                self.state.status = RunStatus::Gating;
6790            }
6791        }
6792        self.state.save()?;
6793        Ok(())
6794    }
6795
6796    // ----------------------------------------------------------------- gate
6797
6798    async fn gate(&mut self) -> Result<()> {
6799        // Judged by the review record itself, not by `status`: a solo
6800        // candidate's `judge`/`deliberate` skip rewrites `status` on every
6801        // reentry (see `judge`), and trusting it here is exactly how a run
6802        // that exhausted its review budget got gated and merged a second
6803        // time around. `review_conclusion` recomputes the review loop's own
6804        // verdict from the round records themselves — `Gating` for a clean
6805        // round or a hand-off (see `stop_reviewing`), anything else means the
6806        // loop is still going or genuinely blocked.
6807        // A base the winner could not be replayed onto is a decision, not a
6808        // round: there is no landing tree to gate. Read as its own record for
6809        // the same reason the review verdict is.
6810        if self.state.status == RunStatus::Failed
6811            || self
6812                .state
6813                .base_sync
6814                .as_ref()
6815                .is_some_and(|s| s.conflict.is_some())
6816            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6817                != Some(RunStatus::Gating)
6818        {
6819            return Ok(());
6820        }
6821        if self.state.gate_ran {
6822            // `review_loop` derives its conclusion from the clean review
6823            // record on every reentry and therefore puts a completed run back
6824            // in `Gating`. A recorded gate is a stronger, terminal fact:
6825            // retain its original command output (or lack of any, for a repo
6826            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
6827            // doc) and restore `Blocked` on a real failure rather than
6828            // pretending the command is still running or running it a second
6829            // time. `gate_ran == false` remains the only shape — unattempted,
6830            // or a resource-blocked retry — that may still need to execute a
6831            // command.
6832            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6833                self.state.status = RunStatus::Blocked;
6834                self.state.save()?;
6835            }
6836            return Ok(());
6837        }
6838        let Some(winner) = self.state.winner().cloned() else {
6839            return Ok(());
6840        };
6841        self.state.status = RunStatus::Gating;
6842        let mut outcomes = self.run_gate(&winner).await?;
6843        loop {
6844            // A resource-blocked outcome means the gate command never actually
6845            // ran - the shared build cache could not be acquired or confirmed
6846            // fresh in time - which is evidence about the machine, not about
6847            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
6848            // Recording it as a red gate would mark a run `Blocked` on nothing
6849            // but contention magi has already logged; leaving `self.state.gate`
6850            // empty and `self.state.gate_ran` false instead keeps the shape
6851            // this function already treats as "still needs to run" (see the
6852            // early-return above), so the next call retries the command
6853            // rather than concluding anything.
6854            if verify_inconclusive(&outcomes) {
6855                self.state.save()?;
6856                return Ok(());
6857            }
6858            if outcomes.iter().all(CommandOutcome::ok) {
6859                break;
6860            }
6861            match self.gate_fix_round(&winner, &outcomes).await? {
6862                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6863                GateFix::Stop => break,
6864                GateFix::Defer => {
6865                    self.state.save()?;
6866                    return Ok(());
6867                }
6868            }
6869        }
6870        let passed = outcomes.iter().all(CommandOutcome::ok);
6871        self.state.gate = outcomes;
6872        self.state.gate_ran = true;
6873        self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6874        if !passed {
6875            self.state.status = RunStatus::Blocked;
6876            let spent = self.state.gate_fixes.len();
6877            self.state.event(
6878                "gate",
6879                if spent == 0 {
6880                    "gate failed; not merging".to_owned()
6881                } else {
6882                    format!("gate failed after {spent} gate-fix round(s); not merging")
6883                },
6884            );
6885        }
6886        self.state.save()?;
6887        Ok(())
6888    }
6889
6890    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6891    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6892    /// after review is clean and never on a candidate awaiting judging.
6893    ///
6894    /// Never fails the run: a non-zero exit or timeout is a warning and a
6895    /// recorded outcome, and the gate remains the single arbiter. Nothing
6896    /// configured means nothing happens - no event, no commit. `commit_all`
6897    /// commits any leftover change under the neutral identity and returns
6898    /// `false` when the tree is clean, so no empty commit is ever made.
6899    async fn run_pre_gate(state: &mut RunState, winner: &Candidate) {
6900        let commands = state.config.verify.pre_gate.clone();
6901        if commands.is_empty() {
6902            return;
6903        }
6904        let shell = state.config.shell();
6905        let timeout = Duration::from_secs(state.config.graph.verify_timeout());
6906        let (outcomes, _) = run_commands(
6907            state,
6908            "pre_gate",
6909            "pre_gate",
6910            0,
6911            &shell,
6912            &commands,
6913            &winner.worktree,
6914            timeout,
6915        )
6916        .await;
6917        for o in &outcomes {
6918            if !o.ok() {
6919                tracing::warn!(
6920                    "pre_gate `{}` failed ({:?}); the gate decides",
6921                    o.command,
6922                    o.code
6923                );
6924            }
6925            state.event(
6926                "pre_gate",
6927                format!(
6928                    "`{}` -> {}",
6929                    o.command,
6930                    if o.ok() {
6931                        "pass".to_owned()
6932                    } else {
6933                        format!(
6934                            "FAIL ({:?})\n{}",
6935                            o.code,
6936                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6937                        )
6938                    }
6939                ),
6940            );
6941        }
6942        state.pre_gate = outcomes;
6943        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6944            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6945                Ok(head) => {
6946                    state.event("pre_gate", format!("committed mechanical fixes ({head})"));
6947                    state.pre_gate_commit = Some(head);
6948                }
6949                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6950            },
6951            Ok(false) => {}
6952            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6953        }
6954        if let Err(e) = state.save() {
6955            tracing::warn!("could not persist the pre_gate record: {e:#}");
6956        }
6957    }
6958
6959    /// Run `verify.gate` once against the winner's current tree, logging one
6960    /// event per command. Empty when nothing is configured.
6961    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6962        let shell = self.state.config.shell();
6963        let gate_commands = self.state.config.verify.gate.clone();
6964        let has_pre_gate = !self.state.config.verify.pre_gate.is_empty();
6965        let outcomes = if gate_commands.is_empty() && !has_pre_gate {
6966            Vec::new()
6967        } else {
6968            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6969            let cache_dir = self.state.config.cache_dir();
6970            let wait = self.repository_wait();
6971            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6972            let (outcomes, _) = with_cache_lease(
6973                &mut self.state,
6974                cache_dir.as_deref(),
6975                "gate",
6976                "gate",
6977                &winner.worktree,
6978                &head,
6979                timeout,
6980                "final gate",
6981                &wait,
6982                |state, budget| {
6983                    let shell = shell.clone();
6984                    let gate_commands = gate_commands.clone();
6985                    let winner = winner.clone();
6986                    async move {
6987                        Self::run_pre_gate(state, &winner).await;
6988                        let (outcomes, timed_out_pids) = run_commands(
6989                            state,
6990                            "gate",
6991                            "gate",
6992                            0,
6993                            &shell,
6994                            &gate_commands,
6995                            &winner.worktree,
6996                            budget,
6997                        )
6998                        .await;
6999                        (outcomes, false, timed_out_pids)
7000                    }
7001                },
7002            )
7003            .await?;
7004            outcomes
7005        };
7006        if outcomes.is_empty() {
7007            // Nothing configured to check — distinct from every other
7008            // silence in this run's event log, since an empty `gate` alone
7009            // no longer says whether the gate ran at all (see
7010            // `RunState::gate_ran`'s own doc).
7011            self.state.event(
7012                "gate",
7013                "no gate commands configured; nothing to check, passing",
7014            );
7015        }
7016        for o in &outcomes {
7017            self.state.event(
7018                "gate",
7019                format!(
7020                    "`{}` -> {}",
7021                    o.command,
7022                    if o.ok() {
7023                        "pass".to_owned()
7024                    } else {
7025                        format!(
7026                            "FAIL ({:?})\n{}",
7027                            o.code,
7028                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
7029                        )
7030                    }
7031                ),
7032            );
7033        }
7034        Ok(outcomes)
7035    }
7036
7037    /// One bounded fix round for a failing gate.
7038    ///
7039    /// The fixer is told the failure came from the gate itself, not from a
7040    /// reviewer, and is shown the failed commands, their exit codes and a tail
7041    /// of their output - whatever `[verify].gate` holds, nothing here knows
7042    /// what those commands run. Only a normal non-zero exit that printed
7043    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
7044    /// command or a full disk says nothing about the code, and a fixer sent
7045    /// after it can only appease the machine. The round is judged by what git
7046    /// says moved, never by the fixer's own report, and `verify.e2e` runs
7047    /// again before the gate does, so a fix cannot trade a green gate for a
7048    /// red e2e unnoticed.
7049    async fn gate_fix_round(
7050        &mut self,
7051        winner: &Candidate,
7052        outcomes: &[CommandOutcome],
7053    ) -> Result<GateFix> {
7054        let cap = self.state.config.graph.gate_fix_rounds;
7055        let spent = self.state.gate_fixes.len();
7056        if spent >= cap {
7057            if cap > 0 {
7058                self.state.event(
7059                    "gate",
7060                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
7061                );
7062            }
7063            return Ok(GateFix::Stop);
7064        }
7065        if !gate_fixable(outcomes) {
7066            self.state.event(
7067                "gate",
7068                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
7069                 command or similar); not spending a fix round on it",
7070            );
7071            return Ok(GateFix::Stop);
7072        }
7073        let min_free = self.state.config.disk.min_free_bytes;
7074        if min_free > 0 {
7075            match crate::disk::free_bytes(&winner.worktree) {
7076                Ok(free) if crate::disk::enough_space(free, min_free) => {}
7077                Ok(free) => {
7078                    self.state.event(
7079                        "gate",
7080                        format!(
7081                            "only {free} bytes free ({min_free} required by `[disk] \
7082                             min_free_bytes`); not spending a fix round on a failure the disk \
7083                             may explain"
7084                        ),
7085                    );
7086                    return Ok(GateFix::Stop);
7087                }
7088                Err(e) => {
7089                    self.state.event(
7090                        "gate",
7091                        format!("free disk space could not be measured ({e:#}); no fix round"),
7092                    );
7093                    return Ok(GateFix::Stop);
7094                }
7095            }
7096        }
7097
7098        let attempt = spent + 1;
7099        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
7100        let base = self.landing_base();
7101        let fix_prompt = prompt::gate_fix_in(
7102            &self.state.instruction,
7103            &failed,
7104            attempt,
7105            cap,
7106            &self.state.config.graph.language,
7107            &self.state.config.graph.github_language,
7108        );
7109        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
7110        let sessions = self.state.config.graph.sessions;
7111        let artifacts = self.merge_repair_artifacts();
7112        self.state.event(
7113            "gate",
7114            format!("gate failed; gate-fix round {attempt} of {cap}"),
7115        );
7116        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
7117        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
7118        let (_, seat, out) = self
7119            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
7120                prompt: fix_prompt.clone(),
7121                spec,
7122                seat,
7123                cwd: winner.worktree.clone(),
7124                timeout,
7125                allow_write: true,
7126                sessions,
7127                artifacts: artifacts.clone(),
7128                stem: format!("gate-fix-{attempt}"),
7129                handover: None,
7130            })
7131            .await;
7132        // The fixer was never asked: no round is spent (nothing is recorded)
7133        // and a later reentry asks the same agent again.
7134        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
7135            return Ok(GateFix::Defer);
7136        }
7137        let mut record = GateFixRecord {
7138            agent: seat.agent.clone(),
7139            failed,
7140            notes: String::new(),
7141            committed: false,
7142            error: None,
7143        };
7144        match out {
7145            AgentOutcome::Ok(o) => {
7146                // A missing report is not a failed fix: the round is judged
7147                // by the tree below, and the report only carries prose.
7148                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
7149                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
7150                }
7151            }
7152            AgentOutcome::Dropped(_) => {
7153                record.error = Some("the CLI dropped the stream".to_owned());
7154            }
7155            AgentOutcome::Quota(o) => {
7156                self.state.quota.push(QuotaLoss {
7157                    seat: seat.key.clone(),
7158                    node: "gate-fix".to_owned(),
7159                    at: Timestamp::now(),
7160                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
7161                });
7162                record.error = Some("rate limited (quota); fixer could not run".to_owned());
7163            }
7164            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
7165        }
7166        self.state.seats.insert(seat.key.clone(), seat);
7167        let coordination = self.coordinate(&winner.worktree).await?;
7168        if let Ok(r) = git::rescue_commit(
7169            &winner.worktree,
7170            &format!("magi: gate fix {attempt} (uncommitted work)"),
7171        )
7172        .await
7173        {
7174            self.state.note_withheld("gate-fix", &r.withheld);
7175        }
7176        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
7177        record.committed = after != before;
7178        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
7179        let note = record.error.clone();
7180        self.state.gate_fixes.push(record);
7181        self.state.save()?;
7182        if !changed {
7183            self.state.event(
7184                "gate",
7185                match note {
7186                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
7187                    None => format!("gate-fix round {attempt}: the tree did not change"),
7188                },
7189            );
7190            return Ok(GateFix::Stop);
7191        }
7192        self.state.event(
7193            "gate",
7194            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
7195        );
7196
7197        drop(coordination);
7198        let commands = self.state.config.verify.e2e.clone();
7199        if !commands.is_empty() {
7200            let shell = self.state.config.shell();
7201            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
7202            let cache_dir = self.state.config.cache_dir();
7203            let context = format!("gate-fix round {attempt}");
7204            let wait = self.repository_wait();
7205            let (e2e, _) = with_cache_lease(
7206                &mut self.state,
7207                cache_dir.as_deref(),
7208                "e2e",
7209                "e2e",
7210                &winner.worktree,
7211                &after,
7212                timeout,
7213                &context,
7214                &wait,
7215                |state, budget| {
7216                    let shell = shell.clone();
7217                    let commands = commands.clone();
7218                    let context = context.clone();
7219                    let worktree = winner.worktree.clone();
7220                    async move {
7221                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
7222                            .await
7223                    }
7224                },
7225            )
7226            .await?;
7227            if verify_inconclusive(&e2e) {
7228                return Ok(GateFix::Defer);
7229            }
7230            if e2e.iter().any(|o| !o.ok()) {
7231                self.state.event(
7232                    "gate",
7233                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7234                );
7235                return Ok(GateFix::Stop);
7236            }
7237        }
7238        Ok(GateFix::Retry)
7239    }
7240
7241    // ---------------------------------------------------------------- merge
7242
7243    /// One read-only rewrite by the summary's author; a text the gate still
7244    /// withholds goes to the owner (see `github_text`) and the fixed fallback
7245    /// applies on their word or on silence. `None` means the run is parked on
7246    /// that question and nothing may be pushed or opened yet.
7247    async fn guarded_pr_message(
7248        &mut self,
7249        winner: &Candidate,
7250        facts: Option<&BranchFacts>,
7251        posting: bool,
7252    ) -> Result<Option<PrMessage>> {
7253        let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7254        let head = match final_head.as_deref() {
7255            Some(h) => crate::pr_outcome::FinalHead::At(h),
7256            None => crate::pr_outcome::FinalHead::Unknown,
7257        };
7258        // Only a pull request that is about to be posted pays for a summary.
7259        let summarized = if posting {
7260            Some(self.summarize_change(winner, final_head.as_deref()).await)
7261        } else {
7262            None
7263        };
7264        // A busy shared resource is not a permanent cause: publishing an
7265        // "unavailable" description would stick (a later head move never
7266        // edits the body). Stop before anything is pushed or opened; the
7267        // run is `Blocked` and a resume asks the summary again.
7268        if self.state.resource_block.is_some() {
7269            self.state.event(
7270                "pr-summary",
7271                "a shared resource was busy; nothing was published, resume to summarize again",
7272            );
7273            return Ok(None);
7274        }
7275        if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7276            anyhow::bail!(
7277                "the final commit moved during public description generation; retry before publishing"
7278            );
7279        }
7280        let note = match (&summarized, &self.state.change_summary) {
7281            (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7282            (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7283            (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7284            (None, _) => ChangeNote::NotAsked,
7285        };
7286        let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7287        if !posting {
7288            let identity = crate::scrub::Identity::current();
7289            return Ok(Some(PrMessage {
7290                title: crate::scrub::scrub(&pr.title, &identity),
7291                body: crate::scrub::scrub(&pr.body, &identity),
7292            }));
7293        }
7294        // This very text was already rejected and asked about: no second
7295        // judge call, rewrite or question, just carry the record on.
7296        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7297        if let Some(known) = self
7298            .state
7299            .github_text
7300            .clone()
7301            .filter(|g| g.fingerprint == raw_fp)
7302        {
7303            return self.settle_withheld(winner, &pr, known).await;
7304        }
7305        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7306        let mut violations =
7307            crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7308        let identity = crate::scrub::Identity::current();
7309        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7310            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7311            && !violations.contains(&crate::github_text::Violation::SensitiveData)
7312        {
7313            violations.push(crate::github_text::Violation::SensitiveData);
7314        }
7315        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
7316        // only a language violation starts one; sensitive data alone goes
7317        // straight to the owner, with the redacted text as the fallback.
7318        let has_language = violations
7319            .iter()
7320            .any(|v| *v != crate::github_text::Violation::SensitiveData);
7321        if self.state.config.graph.github_text_guard && !violations.is_empty() {
7322            self.state.event(
7323                "github-text",
7324                if has_language {
7325                    format!("description rejected: {violations:?}; requesting one rewrite")
7326                } else {
7327                    format!("description rejected: {violations:?}; asking the owner")
7328                },
7329            );
7330            let mut rewritten = false;
7331            let mut rejected_rewrite = None;
7332            if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7333            {
7334                let output = text;
7335                if let Some(title) = summary_title(&output) {
7336                    // Judge the redacted candidate that will actually be posted.
7337                    // Sensitive data alone must not discard a useful rewrite.
7338                    let raw_body = summary_without_title(&output);
7339                    let clean_title = crate::scrub::scrub(&title, &identity);
7340                    let mut body = crate::scrub::scrub(&raw_body, &identity);
7341                    let redacted = clean_title != title || body != raw_body;
7342                    let title = clean_title;
7343                    let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7344                    if !complete {
7345                        self.state.event(
7346                            "github-text",
7347                            "description rewrite omitted required findings",
7348                        );
7349                    }
7350                    if !body.trim().is_empty() {
7351                        body.push_str("\n\n");
7352                        body.push_str(&public_pr_evidence(&self.state, head));
7353                    }
7354                    let marker = format!("magi:run/{}", self.state.id);
7355                    if !body.trim().is_empty() && !body.contains(&marker) {
7356                        body.push_str(&format!("\n\n{marker}"));
7357                    }
7358                    let rewrite = PrMessage {
7359                        title: title.clone(),
7360                        body: body.clone(),
7361                    };
7362                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7363                    let mut failed = crate::github_text::check_for(
7364                        &self.state.config.graph,
7365                        &title,
7366                        &body,
7367                        redecision,
7368                    );
7369                    if (!crate::github_text::shareable(&title)
7370                        || !crate::github_text::shareable(&body))
7371                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
7372                    {
7373                        failed.push(crate::github_text::Violation::SensitiveData);
7374                    }
7375                    if complete
7376                        && !title.trim().is_empty()
7377                        && !body.trim().is_empty()
7378                        && !failed.is_empty()
7379                    {
7380                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
7381                            title: title.clone(),
7382                            body: body.clone(),
7383                            failed: failed
7384                                .iter()
7385                                .map(|v| crate::github_text::category(*v).to_owned())
7386                                .collect(),
7387                        });
7388                    }
7389                    if complete
7390                        && !title.trim().is_empty()
7391                        && !raw_body.trim().is_empty()
7392                        && failed.is_empty()
7393                    {
7394                        decision = redecision;
7395                        pr = PrMessage { title, body };
7396                        rewritten = true;
7397                        self.state
7398                            .event("github-text", "description rewrite passed");
7399                        if redacted {
7400                            self.state.event(
7401                                "github-text",
7402                                "sensitive data removed from description rewrite",
7403                            );
7404                        }
7405                    }
7406                }
7407            }
7408            if !rewritten {
7409                self.state.event(
7410                    "github-text",
7411                    "description rewrite unavailable or rejected; asking the owner",
7412                );
7413                if let Some(w) =
7414                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7415                {
7416                    // A different text than the one asked about before: that
7417                    // question no longer describes anything.
7418                    self.retire_github_text_question("the withheld text changed");
7419                    let record = crate::run::GithubTextAsk {
7420                        rewrite: rejected_rewrite,
7421                        fingerprint: raw_fp,
7422                        title: w.title,
7423                        body: w.body,
7424                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7425                        question: None,
7426                        asks: 0,
7427                        resolved: false,
7428                        chosen_title: None,
7429                        choice: None,
7430                    };
7431                    return self.settle_withheld(winner, &pr, record).await;
7432                }
7433            }
7434        }
7435        let (title, body) =
7436            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7437        Ok(Some(PrMessage { title, body }))
7438    }
7439
7440    /// Describe the whole change from its final diff in either run mode.
7441    ///
7442    /// The summary is tied to the final commit and to the merge base of the
7443    /// resolved remote base; a record for any other pair is dropped and the
7444    /// agent asked again, so a description never outlives the diff it was
7445    /// written from. The seat is read-only and the prompt tells it to read the
7446    /// diff and the code around it. `[roles] synthesizer` selects the agents
7447    /// (unset: the default pick), each asked at most once, advancing on an
7448    /// error, quota, unusable answer or a reply with no `TITLE:` line. `Err`
7449    /// carries the reason it is unavailable; nothing is invented then.
7450    async fn summarize_change(
7451        &mut self,
7452        winner: &Candidate,
7453        head: Option<&str>,
7454    ) -> std::result::Result<(), String> {
7455        let Some(head) = head else {
7456            return Err("the final commit could not be read".to_owned());
7457        };
7458        let remote = self.state.config.merge.remote.clone();
7459        let tracking = format!("{remote}/{}", self.state.base_branch);
7460        if !git::rev_exists(&winner.worktree, &tracking).await {
7461            return Err(format!("the remote base `{tracking}` could not be read"));
7462        }
7463        let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7464            Ok(b) if !b.is_empty() => b,
7465            _ => return Err("the merge base with the remote base could not be read".to_owned()),
7466        };
7467        let evidence = crate::pr_outcome::summary_evidence(
7468            &self.state,
7469            crate::pr_outcome::FinalHead::At(head),
7470        );
7471        let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7472        if self
7473            .state
7474            .change_summary
7475            .as_ref()
7476            .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7477        {
7478            return Ok(());
7479        }
7480        if self.state.change_summary.take().is_some() {
7481            self.state.event(
7482                "pr-summary",
7483                "the final diff or evidence changed; the earlier change summary was discarded",
7484            );
7485        }
7486        // Save the source snapshot even if no generation agent is available.
7487        if let Err(e) = write_artifact(
7488            &self.state,
7489            &format!("public-description-{fingerprint}.md"),
7490            &format!("Final diff: {base}..{head}\n\n{evidence}"),
7491        ) {
7492            tracing::warn!("could not save public description evidence: {e:#}");
7493        }
7494        let chain = match agent::pick_chain(
7495            &self.state.config.agents,
7496            self.state.config.roles.synthesizer.as_ref(),
7497            &agent::installed,
7498            "synthesizer",
7499        ) {
7500            Ok(chain) => chain,
7501            Err(e) => {
7502                tracing::warn!("no agent for the change summary: {e:#}");
7503                self.state.event("pr-summary", "no summary agent available");
7504                return Err("no agent was available to summarize it".to_owned());
7505            }
7506        };
7507        let subjects = git::subjects(&winner.worktree, &base, head)
7508            .await
7509            .unwrap_or_default();
7510        let stat = git::diff_stat(&winner.worktree, &base, head)
7511            .await
7512            .unwrap_or_default();
7513        let stat: String = stat.chars().take(6_000).collect();
7514        let mut prompt = crate::prompt::change_summary_in(
7515            &base,
7516            head,
7517            &subjects,
7518            stat.trim(),
7519            &self.state.config.graph.github_language,
7520        );
7521        prompt.push_str(&format!("\n\n# Final-state evidence\n{evidence}\n\nUse these records as evidence, not public text to copy. Paraphrase every open or declined finding and its reason in the requested GitHub language, retaining its exact ID and severity when available. Do not translate or quote internal operator reports. Explain unresolved risks for a reviewer without the task conversation. Historical sandbox or commit failures are not the final state. Verification is appended from these records; do not make additional validation claims."));
7522        let sessions = self.state.config.graph.sessions;
7523        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7524        let prompts = self.state.config.prompts.clone();
7525        let cache = self.state.config.cache_dir();
7526        let run = self.state.id.clone();
7527        for (i, spec) in chain.into_iter().enumerate() {
7528            let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7529            let job = SeatJob {
7530                spec: spec.clone(),
7531                seat,
7532                cwd: winner.worktree.clone(),
7533                prompt: prompt.clone(),
7534                timeout,
7535                allow_write: false,
7536                sessions,
7537                artifacts: agent::artifacts_dir(&self.state.dir()),
7538                stem: if i == 0 {
7539                    "change-summary".to_owned()
7540                } else {
7541                    format!("change-summary-{}", spec.id)
7542                },
7543                handover: None,
7544            };
7545            let ctx = WaveCtx {
7546                run: &run,
7547                node: "pr-summary",
7548                prompts: &prompts,
7549                cache: cache.as_deref(),
7550                round: None,
7551                carry_seats: false,
7552                pauses: Some((&self.pause, &self.interrupt)),
7553            };
7554            let (seat, outcome) =
7555                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7556            self.state.seats.insert(seat.key.clone(), seat);
7557            let text = match outcome {
7558                AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7559                AgentOutcome::ResourceBlocked(why) => {
7560                    // No agent was asked: the run stops resumably before
7561                    // anything is published (see `guarded_pr_message`).
7562                    if self.state.resource_block.is_none() {
7563                        self.state.resource_block = Some(crate::run::ResourceBlock {
7564                            node: "pr-summary".to_owned(),
7565                            kind: "shared resource".to_owned(),
7566                            detail: why,
7567                            at: Timestamp::now(),
7568                        });
7569                    }
7570                    return Err("a shared resource was busy".to_owned());
7571                }
7572                _ => continue,
7573            };
7574            let blind = &self.state.config.blind;
7575            let Some(title) = summary_title(&text) else {
7576                continue;
7577            };
7578            let body: String = summary_without_title(&text)
7579                .lines()
7580                .filter(|l| !l.contains("magi:run/"))
7581                .collect::<Vec<_>>()
7582                .join("\n");
7583            let body = blind::sanitize_prose(body.trim(), blind);
7584            let title = blind::sanitize_prose(&title, blind);
7585            if body.trim().is_empty() || title.trim().is_empty() {
7586                continue;
7587            }
7588            if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7589                continue;
7590            }
7591            // The branch must still be the one the agent was shown.
7592            if git::rev_parse(&winner.worktree, "HEAD")
7593                .await
7594                .ok()
7595                .as_deref()
7596                != Some(head)
7597            {
7598                return Err("the branch moved while the summary was written".to_owned());
7599            }
7600            if git::merge_base(&winner.worktree, &tracking, head)
7601                .await
7602                .ok()
7603                .as_deref()
7604                != Some(base.as_str())
7605                || crate::pr_outcome::summary_evidence(
7606                    &self.state,
7607                    crate::pr_outcome::FinalHead::At(head),
7608                ) != evidence
7609            {
7610                return Err("the summary evidence changed while it was written".to_owned());
7611            }
7612            self.state.change_summary = Some(crate::run::ChangeSummary {
7613                head: head.to_owned(),
7614                base,
7615                evidence: fingerprint,
7616                title,
7617                body,
7618            });
7619            self.state.event(
7620                "pr-summary",
7621                format!("change summary written by {} for the final diff", spec.id),
7622            );
7623            if let Err(e) = self.state.save() {
7624                tracing::warn!("could not save the change summary: {e:#}");
7625            }
7626            return Ok(());
7627        }
7628        self.state
7629            .event("pr-summary", "no agent produced a change summary");
7630        Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7631    }
7632
7633    /// One read-only rewrite request for a withheld description. The
7634    /// implementer's own conversation is reused only when it exists and can be
7635    /// resumed; otherwise the `[roles] language_judge` chain (or the default
7636    /// pick) writes it in a separate conversation. The prompt carries the whole
7637    /// text, so no agent needs any memory. Each agent is asked at most once and
7638    /// the chain advances on an error, quota, unusable answer or a reply with
7639    /// no `TITLE:` line. Nothing is recorded as a handover.
7640    async fn rewrite_pr_text(
7641        &mut self,
7642        winner: &Candidate,
7643        pr: &PrMessage,
7644        violations: &[crate::github_text::Violation],
7645    ) -> Option<String> {
7646        let prompt = format!(
7647            "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 rewritten description. Include an explanation of every open or declined finding with its exact ID. Omit the generated Review rounds, Final verification and Open review findings sections, the Evidence line and run markers: magi appends those from its records.\n\nTITLE: {}\n{}",
7648            pr.title, pr.body
7649        );
7650        let sessions = self.state.config.graph.sessions;
7651        let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7652        // (spec, seat, resumed)
7653        let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7654        let key = format!("impl-{}", winner.label);
7655        if let Some(spec) = self
7656            .state
7657            .config
7658            .agents
7659            .iter()
7660            .find(|a| a.id == winner.agent)
7661            .cloned()
7662            && let Some(existing) = self.state.seats.get(&key)
7663            && existing.agent == spec.id
7664            && agent::has_session(spec.kind, existing, sessions)
7665        {
7666            attempts.push((spec, existing.clone(), true));
7667        } else {
7668            let chain = agent::pick_chain(
7669                &self.state.config.agents,
7670                self.state.config.roles.language_judge.as_ref(),
7671                &agent::installed,
7672                "language_judge",
7673            );
7674            match chain {
7675                Ok(chain) => {
7676                    for spec in chain {
7677                        let seat = crate::fixer::seat_for(
7678                            &mut self.state,
7679                            "github-text-rewrite",
7680                            &spec.id,
7681                        );
7682                        attempts.push((spec, seat, false));
7683                    }
7684                }
7685                Err(e) => {
7686                    tracing::warn!("no agent for the description rewrite: {e:#}");
7687                    self.state
7688                        .event("github-text", "no rewrite agent available");
7689                    return None;
7690                }
7691            }
7692        }
7693        let prompts = self.state.config.prompts.clone();
7694        let cache = self.state.config.cache_dir();
7695        let run = self.state.id.clone();
7696        for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7697            let job = SeatJob {
7698                spec: spec.clone(),
7699                seat,
7700                cwd: winner.worktree.clone(),
7701                prompt: prompt.clone(),
7702                timeout: retry_budget(timeout_full, resumed),
7703                allow_write: false,
7704                sessions,
7705                artifacts: agent::artifacts_dir(&self.state.dir()),
7706                stem: if i == 0 {
7707                    "github-text-rewrite".to_owned()
7708                } else {
7709                    format!("github-text-rewrite-{}", spec.id)
7710                },
7711                handover: None,
7712            };
7713            let ctx = WaveCtx {
7714                run: &run,
7715                node: "github-text",
7716                prompts: &prompts,
7717                cache: cache.as_deref(),
7718                round: None,
7719                carry_seats: false,
7720                pauses: Some((&self.pause, &self.interrupt)),
7721            };
7722            let (seat, outcome) =
7723                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7724            self.state.seats.insert(seat.key.clone(), seat);
7725            match outcome {
7726                AgentOutcome::Ok(o)
7727                    if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7728                {
7729                    return Some(o.text);
7730                }
7731                AgentOutcome::ResourceBlocked(_) => return None,
7732                _ => {}
7733            }
7734        }
7735        None
7736    }
7737
7738    /// Abandon the standing posting-gate question, if any.
7739    fn retire_github_text_question(&mut self, why: &str) {
7740        let Some(id) = self
7741            .state
7742            .github_text
7743            .as_ref()
7744            .and_then(|g| g.question.clone())
7745        else {
7746            return;
7747        };
7748        let _ = ask::Questions::open().update(&id, |q| {
7749            q.abandon(why);
7750            Ok(())
7751        });
7752    }
7753
7754    /// Carry a withheld text to its end: file the question, park while it is
7755    /// open, and on an answer, silence or timeout pick the text to post. The
7756    /// decision is saved before it is used, so it applies once.
7757    async fn settle_withheld(
7758        &mut self,
7759        winner: &Candidate,
7760        pr: &PrMessage,
7761        mut g: crate::run::GithubTextAsk,
7762    ) -> Result<Option<PrMessage>> {
7763        use crate::github_text as gt;
7764        let store = ask::Questions::open();
7765        let timeout = self.state.config.graph.answer_timeout;
7766        if !g.resolved {
7767            let mut standing = g
7768                .question
7769                .as_ref()
7770                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7771            if let Some(q) = standing.as_ref()
7772                && q.status.open()
7773                && gt::expired(q, timeout)
7774            {
7775                let why = format!("no answer within {}s of asking", timeout.max(1));
7776                standing = store
7777                    .update(&q.id, |q| {
7778                        q.abandon(&why);
7779                        Ok(())
7780                    })
7781                    .ok()
7782                    .map(|(q, ())| q);
7783            }
7784            let mut ask_again: Option<Option<Vec<String>>> = None;
7785            match standing {
7786                // Recorded but never written (a stop between the two saves),
7787                // or removed: ask again without spending the retry.
7788                None if g.asks <= 1 => {
7789                    g.asks = 0;
7790                    ask_again = Some(None);
7791                }
7792                None => {
7793                    g.resolved = true;
7794                    g.chosen_title = None;
7795                    g.choice = Some(GithubTextChoice::Fallback);
7796                }
7797                Some(q) if q.status.open() => {
7798                    self.park_on_github_text(&g)?;
7799                    return Ok(None);
7800                }
7801                Some(q) => match gt::read_reply(&q) {
7802                    gt::Reply::Fallback => {
7803                        g.resolved = true;
7804                        g.chosen_title = None;
7805                        g.choice = Some(GithubTextChoice::Fallback);
7806                    }
7807                    gt::Reply::Original => {
7808                        g.resolved = true;
7809                        g.chosen_title = None;
7810                        g.choice = Some(GithubTextChoice::Original);
7811                    }
7812                    gt::Reply::Rewrite => {
7813                        g.resolved = true;
7814                        g.chosen_title = None;
7815                        g.choice = Some(if g.rewrite.is_some() {
7816                            GithubTextChoice::Rewrite
7817                        } else {
7818                            GithubTextChoice::Fallback
7819                        });
7820                    }
7821                    gt::Reply::Title(text) => {
7822                        let first = text
7823                            .lines()
7824                            .map(str::trim)
7825                            .find(|l| !l.is_empty())
7826                            .unwrap_or("");
7827                        let decision = if first.is_empty() {
7828                            None
7829                        } else {
7830                            let candidate = PrMessage {
7831                                title: first.to_owned(),
7832                                body: String::new(),
7833                            };
7834                            self.judge_pr_language(&winner.worktree, &candidate).await
7835                        };
7836                        match gt::vet_title(
7837                            &text,
7838                            decision,
7839                            self.state.config.graph.github_english(),
7840                        ) {
7841                            Ok(title) => {
7842                                g.resolved = true;
7843                                g.chosen_title = Some(title);
7844                                g.choice = Some(GithubTextChoice::MyTitle);
7845                            }
7846                            Err(cats) if g.asks < 2 => {
7847                                ask_again = Some(Some(
7848                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7849                                ))
7850                            }
7851                            Err(_) => {
7852                                g.resolved = true;
7853                                g.chosen_title = None;
7854                                g.choice = Some(GithubTextChoice::Fallback);
7855                            }
7856                        }
7857                    }
7858                },
7859            }
7860            if let Some(rejected) = ask_again {
7861                // A retry (after a rejected replacement title) is still about
7862                // the original text: the same ORIGINAL / FALLBACK comparison.
7863                let w = gt::Withheld {
7864                    title: g.title,
7865                    body: g.body,
7866                    categories: g.categories.clone(),
7867                };
7868                // Named by the original text's fingerprint alone, so a resume
7869                // rewrites the same file and the detail stays identical.
7870                let artifact = match crate::run::write_artifact(
7871                    &self.state,
7872                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
7873                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7874                ) {
7875                    Ok(p) => Some(p),
7876                    Err(e) => {
7877                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
7878                        None
7879                    }
7880                };
7881                let (detail, comparison) = gt::question_content(
7882                    &w,
7883                    &pr.title,
7884                    &pr.body,
7885                    &self.state.id,
7886                    rejected.as_deref(),
7887                    artifact.as_deref(),
7888                    g.rewrite.as_ref(),
7889                );
7890                let mut q = ask::Question::new(
7891                    self.state.id.clone(),
7892                    gt::ASK_NODE.to_owned(),
7893                    gt::ASK_SEAT.to_owned(),
7894                    gt::question_summary(&self.state.config.graph.language, &w),
7895                    detail,
7896                    gt::question_choices(&w, g.rewrite.as_ref()),
7897                );
7898                q.posting_comparison = Some(comparison);
7899                q.answer_timeout = timeout;
7900                // Recorded before it is written: a stop in between leaves a
7901                // record naming a question the resume then files again.
7902                g.question = Some(q.id.clone());
7903                g.asks += 1;
7904                self.state.github_text = Some(g.clone());
7905                self.state.event(
7906                    "github-text",
7907                    format!("asking the owner about the withheld text ({})", q.short()),
7908                );
7909                self.state.save()?;
7910                store
7911                    .put(&mut q)
7912                    .context("file the posting-gate question")?;
7913                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7914                    tracing::warn!(
7915                        "could not notify about posting-gate question {}: {e:#}",
7916                        q.short()
7917                    );
7918                }
7919                self.park_on_github_text(&g)?;
7920                return Ok(None);
7921            }
7922        }
7923        let resolved = g.chosen_title.clone();
7924        let original = g.choice == Some(GithubTextChoice::Original);
7925        // Field names only: the event log never records the text.
7926        self.state.event(
7927            "github-text",
7928            if original {
7929                format!(
7930                    "posting the original text for: {}",
7931                    match (g.title, g.body) {
7932                        (true, true) => "title, description",
7933                        (true, false) => "title",
7934                        _ => "description",
7935                    }
7936                )
7937            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7938                "posting the saved rewrite title and description".to_owned()
7939            } else if resolved.is_some() {
7940                "posting the owner's replacement title".to_owned()
7941            } else {
7942                "posting the neutral text for the withheld fields".to_owned()
7943            },
7944        );
7945        self.state.github_text = Some(g.clone());
7946        self.state.save()?;
7947        if g.choice == Some(GithubTextChoice::Rewrite)
7948            && let Some(r) = g.rewrite.as_ref()
7949        {
7950            return Ok(Some(PrMessage {
7951                title: r.title.clone(),
7952                body: r.body.clone(),
7953            }));
7954        }
7955        let w = gt::Withheld {
7956            title: g.title,
7957            body: g.body,
7958            categories: g.categories.clone(),
7959        };
7960        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7961        if let Some(t) = resolved {
7962            title = t;
7963        }
7964        // Every withheld field is now neutral or vetted English.
7965        let english = gt::LanguageDecision {
7966            title_english: true,
7967            body_english: true,
7968            body_complete: true,
7969        };
7970        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7971        if original {
7972            // The owner's explicit choice overrides the gate for the fields it
7973            // was made for; the others keep what the gate produced.
7974            if g.title {
7975                title = pr.title.clone();
7976            }
7977            if g.body {
7978                body = pr.body.clone();
7979            }
7980        }
7981        Ok(Some(PrMessage { title, body }))
7982    }
7983
7984    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7985        self.state.github_text = Some(g.clone());
7986        self.state.end_pass_parked(ParkCause::Question);
7987        self.state.parked = true;
7988        self.state.event(
7989            "github-text",
7990            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7991        );
7992        self.state.save()
7993    }
7994
7995    /// Ask `[roles] language_judge` about this text and record which source
7996    /// decided; the text itself is never recorded.
7997    async fn judge_pr_language(
7998        &mut self,
7999        cwd: &Path,
8000        pr: &PrMessage,
8001    ) -> Option<crate::github_text::LanguageDecision> {
8002        self.state.config.roles.language_judge.as_ref()?;
8003        if !self.state.config.graph.language_gate() {
8004            return None;
8005        }
8006        let decision =
8007            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
8008        self.state.event(
8009            "github-text",
8010            if decision.is_some() {
8011                "language decided by the language judge"
8012            } else {
8013                "language judge unavailable; using built-in heuristics"
8014            },
8015        );
8016        decision
8017    }
8018
8019    async fn merge(&mut self) -> Result<()> {
8020        // Same reasoning as `gate`: ask the review and gate records directly
8021        // rather than `status`, which a solo-candidate `judge`/`deliberate`
8022        // skip can rewrite on reentry to something that no longer says
8023        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
8024        // so a hand-off (open findings, green verification) reaches merge
8025        // exactly like a genuinely clean round does.
8026        //
8027        // A run resumed mid-`land` never reaches here at all: `execute`
8028        // recognises `RunStatus::Landing` before it even calls `prep`, and
8029        // routes straight to `run_land` instead. That has to happen a level
8030        // up from this function, not with a check in here, because
8031        // `review_loop`'s own status recomputation (see its doc) runs
8032        // *before* `merge` on every reentry and would otherwise overwrite
8033        // the `Landing` marker with `Gating` before this node ever saw it.
8034        if self
8035            .state
8036            .base_sync
8037            .as_ref()
8038            .is_some_and(|s| s.conflict.is_some())
8039            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8040                != Some(RunStatus::Gating)
8041            // `gate_ran == false` is not "passed" - `gate` leaves it false
8042            // both before it has ever run and when its last attempt was
8043            // resource-blocked (see `Runner::gate`'s own doc), and neither is
8044            // permission to merge on nothing but the review record. Only a
8045            // gate that actually ran - zero commands configured and
8046            // vacuously passed, or one or more that all exited 0 - may
8047            // proceed; `RunState::gate_status` is the single place that
8048            // reading is computed.
8049            || !self.state.gate_status().ok()
8050        {
8051            return Ok(());
8052        }
8053        // This node's own record, not `status`: `status == Ready` is not
8054        // unique to the harmless `MergeMode::None` path this line was
8055        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
8056        // run's PR was closed without merging — and on that run `mode` is
8057        // still `Pr`, so a reentry that fell through here would push and
8058        // open a second pull request. `self.state.merge` is set exactly once
8059        // this node (or `land`) has already produced a verdict, under every
8060        // mode, which is what "already done" actually means here.
8061        if self.state.merge.is_some() && !self.state.pr_publication_pending {
8062            return Ok(());
8063        }
8064        let Some(winner) = self.state.winner().cloned() else {
8065            return Ok(());
8066        };
8067        let repo = self.state.repo.clone();
8068        let base = self.state.base_branch.clone();
8069        let mode = self.state.config.merge.mode;
8070        let style = self.state.config.merge.style;
8071        let facts = if is_review_run(&self.state) {
8072            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
8073            let start = review_base(
8074                &repo,
8075                &self.state.config.merge.remote,
8076                &base,
8077                &self.state.base_commit,
8078                &winner.branch,
8079            )
8080            .await;
8081            branch_facts(&repo, &start, &winner.branch).await
8082        } else {
8083            None
8084        };
8085        // `None` when the base could not be freshly read: then an adopted
8086        // pull request's title is left alone.
8087        let leaked = if is_review_run(&self.state) {
8088            leaked_subjects(&self.state, &winner.branch).await
8089        } else {
8090            Some(Vec::new())
8091        };
8092        // `None`: parked on the owner's word about a withheld title or body.
8093        // Nothing has been pushed or opened, and `merge` stays unrecorded so
8094        // the resume arrives here again.
8095        let Some(pr) = self
8096            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
8097            .await?
8098        else {
8099            return Ok(());
8100        };
8101        let message = pr.commit_message();
8102
8103        let outcome = match mode {
8104            MergeMode::None => MergeOutcome {
8105                mode,
8106                ok: true,
8107                detail: manual_merge_command(style, &repo, &winner.branch, &message),
8108                empty: false,
8109            },
8110            MergeMode::Pr | MergeMode::Local
8111                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
8112            {
8113                MergeOutcome {
8114                    mode,
8115                    ok: false,
8116                    detail: empty_candidate_detail(&self.state, &base),
8117                    empty: true,
8118                }
8119            }
8120            MergeMode::Local => {
8121                let on = git::current_branch(&repo).await?;
8122                if on.as_deref() != Some(base.as_str()) {
8123                    MergeOutcome {
8124                        mode,
8125                        ok: false,
8126                        detail: format!(
8127                            "{} has {} checked out, not the base branch {base}",
8128                            repo.display(),
8129                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
8130                        ),
8131                        empty: false,
8132                    }
8133                } else if !git::is_clean(&repo).await? {
8134                    MergeOutcome {
8135                        mode,
8136                        ok: false,
8137                        detail: format!("{} is dirty; refusing to merge", repo.display()),
8138                        empty: false,
8139                    }
8140                } else {
8141                    let out = match style {
8142                        MergeStyle::Merge => {
8143                            git::merge_no_ff(&repo, &winner.branch, &message).await?
8144                        }
8145                        MergeStyle::Squash => {
8146                            git::merge_squash(&repo, &winner.branch, &message).await?
8147                        }
8148                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
8149                    };
8150                    MergeOutcome {
8151                        mode,
8152                        ok: out.ok(),
8153                        detail: if out.ok() { out.stdout } else { out.stderr },
8154                        empty: false,
8155                    }
8156                }
8157            }
8158            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
8159                self.state.pr_publication_pending = true;
8160                self.state.save()?;
8161                let out =
8162                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
8163                self.state.pr_publication_pending = out.is_err();
8164                MergeOutcome {
8165                    mode,
8166                    ok: out.is_ok(),
8167                    detail: match out {
8168                        Ok(url) => url,
8169                        Err(e) => format!("{e:#}"),
8170                    },
8171                    empty: false,
8172                }
8173            }
8174            MergeMode::Pr => {
8175                let remote = self.state.config.merge.remote.clone();
8176                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
8177                if !pushed.ok() {
8178                    MergeOutcome {
8179                        mode,
8180                        ok: false,
8181                        detail: pushed.stderr,
8182                        empty: false,
8183                    }
8184                } else {
8185                    // A retry or resume of a run whose branch already has an
8186                    // open pull request adopts it rather than failing on a
8187                    // duplicate. Only this winner branch into this base:
8188                    // `branch_for` derives the name from the run id, so a
8189                    // different run's pull request never matches.
8190                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
8191                    let out = match pr_merge_plan(found) {
8192                        PrPlan::Create => {
8193                            gh_pr_create(
8194                                &winner.worktree,
8195                                &base,
8196                                &winner.branch,
8197                                &pr.title,
8198                                &pr.body,
8199                            )
8200                            .await
8201                        }
8202                        PrPlan::Adopt { url, title } => {
8203                            self.state
8204                                .event("merge", format!("Pr: adopted open pull request {url}"));
8205                            if self.state.github_text.as_ref().is_some_and(|g| {
8206                                g.resolved
8207                                    && g.choice == Some(GithubTextChoice::Rewrite)
8208                                    && g.rewrite.is_some()
8209                            }) {
8210                                // The owner chose the entire pair, even if the
8211                                // existing title already matches or was edited.
8212                                land::set_pr_text_verbatim(
8213                                    &winner.worktree,
8214                                    &url,
8215                                    &pr.title,
8216                                    &pr.body,
8217                                )
8218                                .await
8219                                .map(|()| url)
8220                            } else {
8221                                if title != pr.title
8222                                    && (!is_review_run(&self.state)
8223                                        || leaked
8224                                            .as_deref()
8225                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
8226                                    && let Err(e) =
8227                                        if self.state.github_text.as_ref().is_some_and(|g| {
8228                                            g.resolved
8229                                                && g.title
8230                                                && g.choice == Some(GithubTextChoice::Original)
8231                                        }) {
8232                                            // The owner chose to post this title unmodified.
8233                                            land::set_pr_title_verbatim(
8234                                                &winner.worktree,
8235                                                &url,
8236                                                &pr.title,
8237                                            )
8238                                            .await
8239                                        } else {
8240                                            land::set_pr_title(
8241                                                &mut self.state,
8242                                                &winner.worktree,
8243                                                &url,
8244                                                &pr.title,
8245                                            )
8246                                            .await
8247                                        }
8248                                {
8249                                    tracing::warn!("could not refresh title of {url}: {e:#}");
8250                                    self.state
8251                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
8252                                }
8253                                Ok(url)
8254                            }
8255                        }
8256                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8257                    };
8258                    match out {
8259                        Ok(url) => MergeOutcome {
8260                            mode,
8261                            ok: true,
8262                            detail: url,
8263                            empty: false,
8264                        },
8265                        Err(e) => MergeOutcome {
8266                            mode,
8267                            ok: false,
8268                            detail: e.to_string(),
8269                            empty: false,
8270                        },
8271                    }
8272                }
8273            }
8274        };
8275
8276        self.state.status = match (mode, outcome.ok) {
8277            (MergeMode::None, _) => RunStatus::Ready,
8278            (_, true) => RunStatus::Merged,
8279            (_, false) => RunStatus::Blocked,
8280        };
8281        self.state.event(
8282            "merge",
8283            format!(
8284                "{:?}: {}",
8285                mode,
8286                outcome.detail.lines().next().unwrap_or("")
8287            ),
8288        );
8289        self.state.merge = Some(outcome);
8290        self.state.save()?;
8291
8292        // The PR is open and the run would historically stop here, leaving the
8293        // operator to watch checks, feed review comments back to a fixer, and
8294        // merge. That was done by hand six times in one session before this
8295        // existed. Opt-in, because merging is the one irreversible thing magi
8296        // can do to a repository.
8297        if self.state.config.graph.land
8298            && mode == MergeMode::Pr
8299            && self.state.status == RunStatus::Merged
8300        {
8301            self.run_land().await?;
8302        }
8303        // `run_land` may have left `status` at `Landing` - still waiting on
8304        // CI or the owner's approval, not actually settled - so this has to
8305        // read whatever `status` ended up as here, not the `Merged` this
8306        // function set a few lines up.
8307        self.settle_questions();
8308        Ok(())
8309    }
8310
8311    /// Enter `land`.
8312    ///
8313    /// Shared between a fresh run's first pass through [`Runner::merge`] and
8314    /// a resumed run's re-entry. `land::land` itself is what serialises the
8315    /// two git-mutating moments inside the loop — the rebase push and
8316    /// `gh pr merge` — per repository (see its own doc); nothing here needs
8317    /// to hold a lock across the whole call, and doing so would serialise
8318    /// this run's CI wait against a *different* run's land-approval resume
8319    /// in the same repository, which is exactly the "must not wait on
8320    /// another task" property the daemon's slot-freeing exists to give.
8321    async fn run_land(&mut self) -> Result<()> {
8322        crate::pr_head::ensure_owner(&self.state)?;
8323        let url = self
8324            .state
8325            .merge
8326            .as_ref()
8327            .map(|m| m.detail.clone())
8328            .unwrap_or_default();
8329        let url = url.lines().next().unwrap_or("").trim().to_owned();
8330        if !url.starts_with("http") {
8331            return Ok(());
8332        }
8333        // A land failure is not a lost run: the work is on a branch and the
8334        // pull request is open, which is exactly where a human takes over.
8335        loop {
8336            match land::land(&mut self.state, &url).await {
8337                Ok(_)
8338                    if self
8339                        .state
8340                        .merge_repairs
8341                        .last()
8342                        .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8343                {
8344                    if self.repair_merge_review().await? {
8345                        continue;
8346                    }
8347                }
8348                Ok(pr) if self.state.parked => {
8349                    // `land` already saved the parked marker; nothing here
8350                    // overrides `status` back to a terminal value while an
8351                    // approval is still outstanding.
8352                    let _ = pr;
8353                }
8354                Ok(_) if self.state.merge_decision.as_ref().is_some_and(|d| d.closed) => {
8355                    self.state.status = RunStatus::Superseded;
8356                    self.state.save()?;
8357                }
8358                Ok(pr) => {
8359                    self.state.status = match pr.state {
8360                        land::PrLifecycle::Merged => RunStatus::Merged,
8361                        _ => RunStatus::Blocked,
8362                    };
8363                    // Downstream of a confirmed merge only - see
8364                    // `bump::should_release_bump`'s own doc for why this one
8365                    // check covers all three of `land`'s success paths.
8366                    // Best-effort: the run already landed, so a failure here
8367                    // (the decision call, `gh`, `cargo`) is recorded and never
8368                    // turns a landed run into a failed one.
8369                    if bump::should_release_bump(self.state.status)
8370                        && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8371                    {
8372                        // The event is the run's own record. Not-eligible cases
8373                        // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
8374                        // `Err` is a bump that was tried and failed:
8375                        // `after_merge` itself raises the operator notice for
8376                        // that, whether or not a release PR exists yet.
8377                        self.state
8378                            .event("bump", format!("release bump skipped: {e:#}"));
8379                    }
8380                    // Independent of the bump, and best-effort in the same way:
8381                    // findings the merge left open become follow-up tasks.
8382                    if self.state.status == RunStatus::Merged {
8383                        crate::followup::after_merge(&mut self.state, &pr.url).await;
8384                    }
8385                    self.state.save()?;
8386                }
8387                Err(e) => {
8388                    if e.is::<crate::repo_lock::ResourceBlocked>() {
8389                        return Err(e);
8390                    }
8391                    self.state.status = RunStatus::Blocked;
8392                    self.state.event("land", format!("gave up: {e}"));
8393                    self.state.save()?;
8394                }
8395            }
8396            break;
8397        }
8398        Ok(())
8399    }
8400
8401    /// One repair cycle stays under the existing driver/task claim. An
8402    /// interrupted fixer is rolled back and held rather than run twice.
8403    async fn repair_merge_review(&mut self) -> Result<bool> {
8404        use crate::run::MergeRepairPhase as Phase;
8405        crate::pr_head::ensure_owner(&self.state)?;
8406        let i = self.state.merge_repairs.len() - 1;
8407        let request = self.state.merge_repairs[i].clone();
8408        if request.phase == Phase::Fixing {
8409            let restored = match self.state.winner().cloned() {
8410                Some(winner) => self.graft_restore(&winner.worktree, &request.head).await,
8411                None => Err(anyhow::anyhow!("repair has no winning branch")),
8412            };
8413            let why = match restored {
8414                Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8415                Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
8416                Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8417            };
8418            self.hold_merge_repair(i, why)?;
8419            return Ok(false);
8420        }
8421        let failure = if i >= self.state.config.graph.land_rounds
8422            || self.state.config.graph.review_rounds == 0
8423        {
8424            Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8425        } else {
8426            None
8427        };
8428        if let Some(why) = failure {
8429            self.hold_merge_repair(i, why)?;
8430            return Ok(false);
8431        }
8432        if request.phase == Phase::Requested {
8433            let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8434                Ok(pr) => pr,
8435                Err(e) => {
8436                    self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8437                    return Ok(false);
8438                }
8439            };
8440            self.state.merge_repairs[i].phase = Phase::Fixing;
8441            self.state.land_approval = None;
8442            self.state.land_armed_head = None;
8443            self.state.save()?;
8444            // Serialize the exact records, including finding ids/details,
8445            // reconsideration reasons, final votes and verification output.
8446            let evidence = serde_json::to_string_pretty(&request)?;
8447            let reason = format!(
8448                "The owner requested fix findings and re-review, without merging. Address the unresolved Major findings and final rejecting votes in this exact review evidence. Do not merge or approve the PR.\n\n{evidence}"
8449            );
8450            let budget = self.state.config.graph.land_rounds;
8451            let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8452            match result {
8453                Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8454                Ok(land::Fixed::Failed(why)) => {
8455                    self.hold_merge_repair(
8456                        i,
8457                        format!(
8458                            "repair failed: {why}; inspect the branch and run an explicit review"
8459                        ),
8460                    )?;
8461                    return Ok(false);
8462                }
8463                Err(e) => {
8464                    self.hold_merge_repair(
8465                        i,
8466                        format!(
8467                            "repair failed: {e:#}; inspect the branch and run an explicit review"
8468                        ),
8469                    )?;
8470                    return Ok(false);
8471                }
8472            }
8473            // Even no change gets a real new reviewer wave. The previous
8474            // evidence remains in the request; a former clean/concluded round
8475            // cannot short-circuit the new review or gate.
8476            self.begin_merge_repair_review(i)?;
8477        }
8478        self.review_loop().await?;
8479        self.gate().await?;
8480        if self.resource_halt()? || self.state.parked {
8481            return Ok(false);
8482        }
8483        if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8484            != Some(RunStatus::Gating)
8485            || !self.state.gate_status().ok()
8486        {
8487            self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8488            return Ok(false);
8489        }
8490        // Review and gate may have added commits after the initial repair.
8491        // Publish exactly their final head to the existing PR, with a lease.
8492        let winner = self
8493            .state
8494            .winner()
8495            .cloned()
8496            .context("repair has no winning branch")?;
8497        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8498        if self.state.gate_head.as_deref() != Some(after.as_str())
8499            || !git::is_clean(&winner.worktree).await?
8500        {
8501            self.hold_merge_repair(i, "repair branch changed after its recorded gate, or the verified head is unknown; nothing was published. Inspect the branch and run an explicit review before requesting a new merge decision".to_owned())?;
8502            return Ok(false);
8503        }
8504        let expected = self
8505            .state
8506            .pr_pushed_head
8507            .as_deref()
8508            .unwrap_or(&request.head)
8509            .to_owned();
8510        let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8511        // Push the checked commit itself: a local ref moving after the check
8512        // must not change which commit the forge receives.
8513        let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8514        let refspec = format!("{after}:refs/heads/{public}");
8515        let push = git::git_raw(
8516            &winner.worktree,
8517            &["push", &lease, &self.state.config.merge.remote, &refspec],
8518        )
8519        .await;
8520        match push {
8521            Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8522            Ok(out) => {
8523                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8524                return Ok(false);
8525            }
8526            Err(e) => {
8527                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8528                return Ok(false);
8529            }
8530        }
8531        self.state.merge_repairs[i].phase = Phase::Complete;
8532        self.state.status = RunStatus::Landing;
8533        self.state.event(
8534            "repair",
8535            "new review and gate completed; fresh owner merge decision required",
8536        );
8537        self.state.save()?;
8538        Ok(true)
8539    }
8540
8541    fn merge_repair_artifacts(&self) -> PathBuf {
8542        let artifacts = agent::artifacts_dir(&self.state.dir());
8543        match self
8544            .state
8545            .merge_repairs
8546            .last()
8547            .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8548        {
8549            Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8550            None => artifacts,
8551        }
8552    }
8553
8554    fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8555        self.state.reviews.clear();
8556        self.state.gate.clear();
8557        self.state.gate_ran = false;
8558        self.state.gate_head = None;
8559        self.state.gate_fixes.clear();
8560        self.state.pre_gate.clear();
8561        self.state.pre_gate_commit = None;
8562        self.state.contested_handoff = None;
8563        self.state.land_reruns = None;
8564        self.state.land_approval = None;
8565        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8566        self.state.status = RunStatus::Reviewing;
8567        self.state.save()
8568    }
8569
8570    fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8571        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8572        self.state.merge_repairs[index].failure = Some(why.clone());
8573        self.state.status = RunStatus::Blocked;
8574        self.state.event("repair", why);
8575        self.state.save()
8576    }
8577
8578    // -------------------------------------------------------------- helpers
8579
8580    /// Fetch or create a seat, keeping its conversation across nodes.
8581    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8582        if let Some(existing) = self.state.seats.get(key)
8583            && existing.agent == agent
8584        {
8585            return existing.clone();
8586        }
8587        // A seat that changes agent mints its session id from the agent too,
8588        // like a handover: the old agent's uuid is already taken by the CLI.
8589        let fresh = if self.state.seats.contains_key(key) {
8590            handover_seat(key, agent, self.state.next_seat_seed())
8591        } else {
8592            SeatState::new(key, agent, self.state.seed)
8593        };
8594        self.state.seats.insert(key.to_owned(), fresh.clone());
8595        fresh
8596    }
8597
8598    /// The agent now holding seat `key`: `spec`, unless a handover moved the
8599    /// seat to another roster agent, in which case that agent. Nodes that
8600    /// continue a seat's conversation (deliberation, the votes, a reviewer's
8601    /// reconsideration) must keep talking to whoever answered it, not slip
8602    /// back to the agent that failed it.
8603    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8604        match self.state.seats.get(key) {
8605            Some(s) if s.agent != spec.id => {
8606                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8607            }
8608            _ => spec,
8609        }
8610    }
8611
8612    /// A candidate rendered for judging, with the leak policy applied.
8613    fn view(&self, c: &Candidate) -> CandidateView {
8614        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8615            .unwrap_or_default();
8616        let (patch, _) = blind::sanitize_patch(
8617            &format!("candidate {} patch", c.label),
8618            &raw,
8619            &self.state.config.blind,
8620        );
8621        CandidateView {
8622            label: c.label,
8623            branch: c.branch.clone(),
8624            summary: c.summary.clone(),
8625            stat: c.stat.clone(),
8626            patch,
8627        }
8628    }
8629
8630    /// The full candidate set as prompt text, for seats with no live session.
8631    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8632        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8633        prompt::judge(
8634            "(see above)",
8635            &views,
8636            self.roles.judges.len(),
8637            base_short,
8638            "en",
8639        )
8640    }
8641
8642    /// The final-vote prompt with everything a seat that has no session of its
8643    /// own needs: the candidates, the seat's own ranking and reasons, and the
8644    /// anonymised deliberation it took part in (only when there was one, so a
8645    /// handed-over seat never sees more than the seat it replaces did). The
8646    /// `Final vote` heading stays first.
8647    fn vote_prompt_full(
8648        &self,
8649        j: usize,
8650        labels: &[char],
8651        language: &str,
8652        candidates: &[Candidate],
8653        base_short: &str,
8654    ) -> String {
8655        let mut text = format!(
8656            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8657            prompt::final_vote(labels, language),
8658            self.state.instruction,
8659            self.candidate_block(candidates, base_short)
8660        );
8661        if let Some(own) = self
8662            .state
8663            .judgements
8664            .get(j)
8665            .filter(|r| !r.ranking.is_empty())
8666        {
8667            let reasons = own
8668                .reasons
8669                .iter()
8670                .map(|(k, v)| format!("- {k}: {v}"))
8671                .collect::<Vec<_>>()
8672                .join("\n");
8673            text.push_str(&format!(
8674                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8675                own.ranking.iter().collect::<String>(),
8676                if reasons.is_empty() {
8677                    ""
8678                } else {
8679                    ", because:\n"
8680                }
8681            ));
8682        }
8683        if !self.state.deliberation.is_empty() {
8684            text.push_str("\n# What was argued before this vote\n");
8685            for t in self.transcript(&[], j) {
8686                text.push_str(&format!(
8687                    "\n## {}{}\n\n{}\n",
8688                    t.who,
8689                    if t.is_self { " (you)" } else { "" },
8690                    t.body.trim()
8691                ));
8692            }
8693        }
8694        text
8695    }
8696
8697    /// Anonymised transcript for judge `self_idx`.
8698    ///
8699    /// The initial rankings are always the opening statements. Seeding them
8700    /// only when no turn had been taken yet meant every judge after the first
8701    /// argued against a single voice instead of against the actual split — the
8702    /// disagreement is the information, so it is always on the table.
8703    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8704        let mut turns = Vec::new();
8705        for j in &self.state.judgements {
8706            if j.ranking.is_empty() {
8707                continue;
8708            }
8709            let reasons = j
8710                .reasons
8711                .iter()
8712                .map(|(k, v)| format!("- {k}: {v}"))
8713                .collect::<Vec<_>>()
8714                .join("\n");
8715            turns.push(Turn {
8716                who: format!("Judge {} (opening ranking)", j.judge),
8717                is_self: j.judge == self_idx + 1,
8718                body: format!(
8719                    "Ranked {}{}{reasons}",
8720                    j.ranking.iter().collect::<String>(),
8721                    if reasons.is_empty() {
8722                        ""
8723                    } else {
8724                        ", because:\n"
8725                    }
8726                ),
8727            });
8728        }
8729        for t in self
8730            .state
8731            .deliberation
8732            .iter()
8733            .flat_map(|r| r.turns.iter())
8734            .chain(current)
8735        {
8736            turns.push(Turn {
8737                who: format!("Judge {}", t.judge),
8738                is_self: t.judge == self_idx + 1,
8739                body: t.body.clone(),
8740            });
8741        }
8742        turns
8743    }
8744}
8745
8746/// Does this seat still hold the context a follow-up prompt would rely on?
8747fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8748    agent::has_session(spec.kind, seat, sessions)
8749}
8750
8751/// The next entry in `roster` after `start`, never wrapping back to the
8752/// front, whose id is not in `tried` yet.
8753///
8754/// Starts one past `start` rather than at the front of `roster`: `start` is
8755/// the seat's own original position, and a seat whose candidate slot already
8756/// sits on the roster's second entry must fall through to the third next, not
8757/// restart at the first — which is very likely a different candidate's own
8758/// agent already. Never wraps back past `start`, for the same reason: an
8759/// entry earlier in the roster than the seat's own position is almost
8760/// certainly some *other* candidate slot's own agent, and once the tail of
8761/// the roster is exhausted there are no more untried agents for *this* seat
8762/// to fall through to — the caller's fallback chain ends there, exactly as
8763/// "no further untried agents remain in the list for that seat" asks for.
8764///
8765/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
8766/// the same id twice (an operator's `roles.implementers` typo, or a
8767/// `[[agents]]` list reused across roles) must not let
8768/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
8769/// over `roster` either finds an untried id or runs out, so this always
8770/// terminates regardless of duplicates.
8771fn next_untried_in_roster<'a>(
8772    roster: &'a [AgentSpec],
8773    start: usize,
8774    tried: &BTreeSet<String>,
8775) -> Option<&'a AgentSpec> {
8776    roster
8777        .get(start + 1..)?
8778        .iter()
8779        .find(|s| !tried.contains(&s.id))
8780}
8781
8782/// The successor for a seat, shared by all four seat kinds (implement, judge,
8783/// review, advise). `others` holds the ids that *currently* occupy the other
8784/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
8785/// sees them), so roster entries beyond the seat count act as spares: a failed
8786/// seat goes to an agent no other seat holds whenever the roster permits.
8787///
8788/// `carried` is `Some` only for the review loop's carried failure history.
8789/// Order: (1) forward from `start`, never wrapping, untried, not a carried
8790/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
8791/// whole roster: untried and not another seat's occupant; (3) the plain walk
8792/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
8793/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
8794/// a better outcome than an empty seat, and it keeps the answer to "is there
8795/// a successor at all" exactly what it was before occupants were considered,
8796/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
8797/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
8798/// carried failure is a cheaper bet than doubling an agent on the panel, and
8799/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
8800/// the same round are handled one at a time, so a seat later in the batch
8801/// sees an earlier one's new occupant but not yet its own freed agent.
8802fn pick_successor<'a>(
8803    roster: &'a [AgentSpec],
8804    start: usize,
8805    tried: &BTreeSet<String>,
8806    carried: Option<&BTreeSet<String>>,
8807    others: &BTreeSet<String>,
8808) -> Option<&'a AgentSpec> {
8809    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8810    roster
8811        .get(start + 1..)
8812        .and_then(|tail| {
8813            tail.iter()
8814                .filter(free)
8815                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8816        })
8817        .or_else(|| {
8818            carried
8819                .is_some()
8820                .then(|| roster.iter().find(free))
8821                .flatten()
8822        })
8823        .or_else(|| match carried {
8824            Some(c) => next_for_seat(roster, start, tried, c),
8825            None => next_untried_in_roster(roster, start, tried),
8826        })
8827}
8828
8829/// The next agent for a seat that carries its failure history across rounds
8830/// (the review loop). `round_tried` is this round's own bound and starts
8831/// empty every round; `carried_failed` only decides priority.
8832///
8833/// First: an id walking forward from `start`, never wrapping, that is neither
8834/// tried this round nor failed in an earlier one. Only when that is exhausted
8835/// does it rescue: the first roster id (in roster order, so this one *does*
8836/// look before `start`) not yet tried this round, which is by then a carried
8837/// failure. Each id is rescued at most once per round, so it cannot loop.
8838fn next_for_seat<'a>(
8839    roster: &'a [AgentSpec],
8840    start: usize,
8841    round_tried: &BTreeSet<String>,
8842    carried_failed: &BTreeSet<String>,
8843) -> Option<&'a AgentSpec> {
8844    roster
8845        .get(start + 1..)?
8846        .iter()
8847        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8848        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8849}
8850
8851/// Where a reviewer seat starts a round: the agent that last answered it when
8852/// it is still on the roster and not marked failed, else the spec's own agent
8853/// unless it failed, else the next roster agent that has not failed, else the
8854/// spec's own agent again (the whole roster failed). Ids no longer on the
8855/// roster are ignored. An empty roster has no handover, so the spec stands.
8856fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8857    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8858        return spec;
8859    };
8860    let ok = |id: &str| !h.failed.contains(id);
8861    if let Some(last) = h.last_ok.as_deref()
8862        && ok(last)
8863        && let Some(s) = roster.iter().find(|s| s.id == last)
8864    {
8865        return s.clone();
8866    }
8867    if ok(&spec.id) {
8868        return spec;
8869    }
8870    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8871    roster
8872        .get(from + 1..)
8873        .into_iter()
8874        .flatten()
8875        .chain(roster.iter())
8876        .find(|s| ok(&s.id))
8877        .cloned()
8878        .unwrap_or(spec)
8879}
8880
8881/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
8882/// seed so a CLI that mints its session id up front (`--session-id`) never
8883/// reuses the uuid the previous agent already opened under the same seat key.
8884pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8885    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8886}
8887
8888/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
8889/// so the classifier below and the code that builds the message cannot drift.
8890const TIMED_OUT: &str = "timed out";
8891
8892impl FailClass {
8893    /// `None` for an answer; otherwise how the turn failed.
8894    fn of(out: &AgentOutcome) -> Option<Self> {
8895        match out {
8896            AgentOutcome::Ok(_) => None,
8897            AgentOutcome::Quota(_) => Some(Self::Quota),
8898            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8899            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8900            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8901            // Not a failure of the agent. Callers check for it first; `None`
8902            // here means "no class to hand over on".
8903            AgentOutcome::ResourceBlocked(_) => None,
8904        }
8905    }
8906
8907    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
8908    fn stem_word(&self) -> &'static str {
8909        match self {
8910            Self::Quota => "quota",
8911            _ => "handover",
8912        }
8913    }
8914}
8915
8916/// The message's first line with its variable parts removed — digit runs and
8917/// path-like tokens — so "exit code 2" and "exit code 7" read
8918/// as one kind of failure.
8919fn failure_signature(msg: &str) -> String {
8920    let mut line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8921    // `SeatHistory::last_fail` persists these signatures across resumes. Keep
8922    // the historical machine key while the process diagnosis uses readable prose.
8923    if let Some(code) = line
8924        .strip_prefix("process exited with no usable output (exit code ")
8925        .and_then(|s| s.strip_suffix(')'))
8926        .and_then(|s| s.parse::<i32>().ok())
8927    {
8928        line = format!("exited with some({code}) and no usable output");
8929    } else if matches!(
8930        line.as_str(),
8931        "process exited with no usable output (no exit code reported)"
8932            | "process exited with no usable output (exit code unavailable)"
8933    ) {
8934        line = "exited with none and no usable output".to_owned();
8935    }
8936    let mut out = Vec::new();
8937    for word in line.split_whitespace() {
8938        if word.contains('/') || word.contains('\\') {
8939            out.push("<path>".to_owned());
8940            continue;
8941        }
8942        let mut w = String::new();
8943        let mut in_digits = false;
8944        for c in word.chars() {
8945            if c.is_ascii_digit() {
8946                if !in_digits {
8947                    w.push('#');
8948                }
8949                in_digits = true;
8950            } else {
8951                in_digits = false;
8952                w.push(c);
8953            }
8954        }
8955        out.push(w);
8956    }
8957    out.join(" ").chars().take(120).collect()
8958}
8959
8960/// Whether a seat that just failed with `cur` may go to the next roster agent.
8961/// A quota or a timeout always may. Any other failure may not when the agent
8962/// before it failed the same way: an error the prompt causes would otherwise
8963/// walk the whole roster. `prev` is the class of the immediately preceding
8964/// agent's failure, so a quota or timeout in between breaks the run of
8965/// identical failures by itself.
8966fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8967    match cur {
8968        FailClass::Quota | FailClass::Timeout => true,
8969        FailClass::Other(_) => prev != Some(cur),
8970    }
8971}
8972
8973/// A short human reason for a failed outcome, for the handover record.
8974fn fail_reason(out: &AgentOutcome) -> String {
8975    match out {
8976        AgentOutcome::Ok(_) => String::new(),
8977        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8978        AgentOutcome::Dropped(o) => format!(
8979            "the CLI dropped the stream ({})",
8980            o.dropped
8981                .as_ref()
8982                .map(|d| d.why.as_str())
8983                .unwrap_or("it ended without delivering its answer")
8984        ),
8985        AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8986            e.lines().next().unwrap_or("").chars().take(160).collect()
8987        }
8988    }
8989}
8990
8991/// Note one handover in the run: the structured record and, in the timeline,
8992/// the sentence a person reads. A quota keeps the wording it always had.
8993pub(crate) fn record_handover(
8994    state: &mut RunState,
8995    node: &str,
8996    seat: &str,
8997    from: &str,
8998    to: &str,
8999    class: &FailClass,
9000    reason: &str,
9001) {
9002    let message = if *class == FailClass::Quota {
9003        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
9004    } else {
9005        format!("{seat}: handed over {from} -> {to} ({reason})")
9006    };
9007    state.event(node, message);
9008    state.handovers.push(Handover {
9009        at: Timestamp::now(),
9010        node: node.to_owned(),
9011        seat: seat.to_owned(),
9012        from: from.to_owned(),
9013        to: to.to_owned(),
9014        reason: reason.to_owned(),
9015    });
9016}
9017
9018/// Did this reply report running a command whose own CLI never confirmed an
9019/// exit status?
9020///
9021/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
9022/// command *finished* (see that type's own doc), so this can only be `true`
9023/// for a command whose completion event carried no readable exit code — not
9024/// for one that simply is not mentioned at all. That is the one signal this
9025/// crate can read, from the same record `state.jobs` renders, about a reply
9026/// standing next to work its own CLI cannot vouch for finishing; it is
9027/// deliberately not a check on the exit code's *value* (a fixer legitimately
9028/// runs a command that fails mid-iteration before it succeeds) and not a
9029/// guess at a command still running in the background (which emits no event
9030/// at all, and so leaves no evidence here to find).
9031fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
9032    commands.iter().any(|c| c.exit_code.is_none())
9033}
9034
9035/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
9036/// trusted as a verified no-op — the adoption guard's own text-level half.
9037///
9038/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
9039/// timed out): a marker only earns the benefit of the doubt from a turn the
9040/// CLI itself vouches for finishing properly, the same house style
9041/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
9042/// *fix* report to for `commands`. A candidate that timed out, exited
9043/// non-zero, or left a command unconfirmed is read as the ordinary loss it
9044/// is, whatever prose it wrote — this returns `None` before it ever looks at
9045/// `text`. The remaining guards (the tree really is empty, the evidence is
9046/// non-empty) are the caller's: this only reads what the reply *claimed*.
9047fn verified_noop_claim(
9048    usable: bool,
9049    commands: &[agent::CommandEvidence],
9050    text: &str,
9051) -> Option<String> {
9052    (usable && !has_unconfirmed_command(commands))
9053        .then(|| verdict::verified_noop(text))
9054        .flatten()
9055}
9056
9057fn short(commit: &str) -> String {
9058    commit.chars().take(7).collect()
9059}
9060
9061fn make_executable(path: &Path) -> Result<()> {
9062    #[cfg(unix)]
9063    {
9064        use std::os::unix::fs::PermissionsExt as _;
9065        let mut perms = std::fs::metadata(path)?.permissions();
9066        perms.set_mode(0o755);
9067        std::fs::set_permissions(path, perms)?;
9068    }
9069    #[cfg(not(unix))]
9070    {
9071        let _ = path;
9072    }
9073    Ok(())
9074}
9075
9076/// What every seat in one batch shares: where the answers are attributed, the
9077/// prompt overlay they inherit, and the build cache they are told to use.
9078///
9079/// A struct rather than four more parameters: `wave` also needs the run's
9080/// state (to record who is answering right now) and the attempt number, and
9081/// eight positional arguments is both unreadable and a clippy error.
9082struct WaveCtx<'a> {
9083    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
9084    /// paid for it.
9085    run: &'a str,
9086    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
9087    node: &'a str,
9088    prompts: &'a Prompts,
9089    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
9090    cache: Option<&'a Path>,
9091    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
9092    /// `JobRecord::round`. `None` for every other node.
9093    round: Option<usize>,
9094    /// Carry each seat's failed-agent history across waves (the review loop
9095    /// only): start-of-round priority and handover choice read
9096    /// [`RunState::seat_history`], and every answer or failure writes it.
9097    carry_seats: bool,
9098    /// The run's park / interrupt requests, so a wait for a shared resource
9099    /// can be abandoned the moment the operator asks to park.
9100    pauses: Option<(&'a Pause, &'a Pause)>,
9101}
9102
9103/// Shared scheduling policy for operations and spawned seat invocations.
9104fn repository_wait(
9105    state: &RunState,
9106    pauses: Option<(&Pause, &Pause)>,
9107) -> crate::repo_lock::WaitPolicy {
9108    let pauses = pauses.map(|(a, b)| (a.clone(), b.clone()));
9109    let inherited = crate::repo_lock::WaitPolicy::current();
9110    crate::repo_lock::WaitPolicy::new(
9111        Duration::from_secs(state.config.graph.timeout_resource_wait),
9112        move || {
9113            inherited.cancelled()
9114                || pauses
9115                    .as_ref()
9116                    .is_some_and(|(a, b)| a.parked() || b.parked())
9117        },
9118    )
9119}
9120
9121fn record_repository_block(state: &mut RunState, error: &anyhow::Error) {
9122    if error.is::<crate::repo_lock::ResourceBlocked>() {
9123        state.resource_block = Some(crate::run::ResourceBlock {
9124            node: "repository".into(),
9125            kind: "repository coordination".into(),
9126            detail: error.to_string(),
9127            at: Timestamp::now(),
9128        });
9129        state.event("resource", error.to_string());
9130    }
9131}
9132
9133async fn coordinate_operation(
9134    state: &mut RunState,
9135    cwd: &Path,
9136    wait: &crate::repo_lock::WaitPolicy,
9137) -> Result<Option<crate::repo_lock::SeatLease>> {
9138    let result =
9139        crate::repo_lock::SeatLease::operation_with(cwd, state.config.coordination.as_ref(), wait)
9140            .await;
9141    if let Err(e) = &result {
9142        record_repository_block(state, e);
9143    }
9144    result
9145}
9146
9147/// Run one job, honouring the parallelism budget.
9148async fn run_one(
9149    job: SeatJob,
9150    sem: Arc<Semaphore>,
9151    ctx: &WaveCtx<'_>,
9152    state: &mut RunState,
9153    attempt: usize,
9154) -> (SeatState, AgentOutcome) {
9155    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
9156        .await
9157        .pop()
9158        .expect("one job in, one result out");
9159    (seat, out)
9160}
9161
9162/// Run every job concurrently, capped by the semaphore, preserving order.
9163///
9164/// Every seat in the batch is recorded into [`RunState::active`] before the
9165/// wave starts and cleared as each answer lands, so the run's own record says
9166/// who is still being waited on rather than only who finished.
9167async fn wave(
9168    jobs: Vec<SeatJob>,
9169    sem: Arc<Semaphore>,
9170    ctx: &WaveCtx<'_>,
9171    state: &mut RunState,
9172    attempt: usize,
9173) -> Vec<(usize, SeatState, AgentOutcome)> {
9174    let WaveCtx {
9175        run,
9176        node,
9177        prompts,
9178        cache,
9179        round,
9180        carry_seats: _,
9181        pauses,
9182    } = *ctx;
9183    for job in &jobs {
9184        state.seat_started(node, &job.seat.key, job.timeout, attempt);
9185    }
9186    if let Err(e) = state.save() {
9187        // A failed persist of "who is answering right now" must not abort the
9188        // wave: the seats are already being asked, and the alternative is
9189        // losing the answers to save a status line nobody may even be
9190        // watching.
9191        tracing::warn!("could not persist in-progress seats: {e:#}");
9192    }
9193    // Hold the shared build cache's lease for the whole batch, not per job:
9194    // several candidates (an implement wave) or a fixer legitimately share
9195    // one cache concurrently within this run, and that stays untouched — a
9196    // single lease taken once for the whole wave and released once it is
9197    // done is what stops a *different* borrower (another run's own wave, its
9198    // e2e/gate, a human's `magi review`) from interleaving a build into the
9199    // same directory while this one is in flight. Best-effort, not
9200    // all-or-nothing: a wave that cannot get the lease within its own
9201    // longest job's budget still runs — an hour of paid implementer calls is
9202    // not thrown away over cache contention — but every write-allowed seat
9203    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
9204    // below), the same fallback a read-only seat always gets, rather than
9205    // building into a directory this run was never granted. The identity
9206    // record is still invalidated below either way, so the next tracked
9207    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
9208    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
9209    // The wait for the lease has its own bound, `timeout_resource_wait`; it is
9210    // never borrowed from a job's agent budget (and never charged to it either,
9211    // see below). The agent's clock starts in `agent::invoke`, at spawn.
9212    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
9213    let cache_guard = if let Some(cache_dir) = cache {
9214        if jobs_had_a_writer {
9215            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
9216            let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
9217            match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
9218            {
9219                Ok(Some(g)) => Some(g),
9220                // Parking: leave without spawning anything. The run parks at
9221                // the next boundary and the seats are asked afresh on resume.
9222                Ok(None) => {
9223                    state.resource_block = Some(crate::run::ResourceBlock {
9224                        node: node.to_owned(),
9225                        kind: "shared build cache".to_owned(),
9226                        detail: "the run is parking".to_owned(),
9227                        at: Timestamp::now(),
9228                    });
9229                    return block_wave(
9230                        jobs,
9231                        state,
9232                        node,
9233                        attempt,
9234                        "parked while waiting for the shared build cache",
9235                    );
9236                }
9237                // An unreadable lease is not contention: keep the old
9238                // fallback (no shared cache for this wave's seats).
9239                Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
9240                Err(e) => {
9241                    // Writing seats may not build into a cache this run does
9242                    // not hold, and an agent that cannot be given one is not
9243                    // asked at all: the run stops resumably instead.
9244                    let why = format!("{e:#}");
9245                    state.resource_block = Some(crate::run::ResourceBlock {
9246                        node: node.to_owned(),
9247                        kind: "shared build cache".to_owned(),
9248                        detail: why.clone(),
9249                        at: Timestamp::now(),
9250                    });
9251                    state.event(
9252                        "resource",
9253                        format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
9254                    );
9255                    return block_wave(jobs, state, node, attempt, &why);
9256                }
9257            }
9258        } else {
9259            None
9260        }
9261    } else {
9262        None
9263    };
9264    // Every job keeps its whole budget: the wait above was not the agent's.
9265    // Restart the seats' clocks so "started" means "being spawned now".
9266    state.seats_spawning(node, attempt);
9267    if let Err(e) = state.save() {
9268        tracing::warn!("could not persist the start of a wave: {e:#}");
9269    }
9270    // Queueing behind this wave's own seats for a parallelism slot is normal
9271    // (each of them is bounded by its own agent timeout); only a wait beyond
9272    // all of them plus the resource limit means the slots never came back.
9273    let slot_limit = resource_limit
9274        + jobs
9275            .iter()
9276            .map(|j| j.timeout)
9277            .max()
9278            .unwrap_or_default()
9279            .saturating_mul(jobs.len() as u32);
9280    let mut set = tokio::task::JoinSet::new();
9281    // Seats announce themselves here the moment they hold a parallelism
9282    // permit, i.e. just before their CLI is spawned.
9283    let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9284    let overlay = prompts.overlay(node);
9285    let repository_wait = repository_wait(state, pauses);
9286    for (i, mut job) in jobs.into_iter().enumerate() {
9287        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
9288        if cache.is_some() {
9289            job.prompt.push('\n');
9290            job.prompt
9291                .push_str(&prompt::build_cache_note(node, job.allow_write));
9292        }
9293        let sem = Arc::clone(&sem);
9294        let run = run.to_owned();
9295        let node = node.to_owned();
9296        // Only implementers were told about the task's attachments, so only
9297        // their seats get the directory widened for reading.
9298        let attachments = if node == "implement" {
9299            state.attachments.clone()
9300        } else {
9301            Vec::new()
9302        };
9303        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
9304        // `prompt::build_cache_note`'s doc for why setting it anyway is
9305        // exactly how a sandboxed reviewer's write refusal got reported as a
9306        // defect in the patch, not a property of its own seat. And a
9307        // write-allowed one is handed it only when the lease above was
9308        // actually acquired: a wave that could not get it (`cache_guard` is
9309        // `None`, see its own comment) must not send seats to build into a
9310        // directory this run does not hold - that is the exact concurrent,
9311        // unmanaged-write race this module exists to prevent, not something
9312        // "proceeding anyway" is allowed to reintroduce.
9313        let cache = cache
9314            .filter(|_| job.allow_write && cache_guard.is_some())
9315            .map(Path::to_path_buf);
9316        let spawned_tx = spawned_tx.clone();
9317        let repository_wait = repository_wait.clone();
9318        let coordination = state.config.coordination.clone();
9319        set.spawn(async move {
9320            // A wait for a parallelism slot is scheduling, not the agent: it
9321            // is bounded by its own limit and ends as a resource block, never
9322            // as a timeout of the agent that was not started.
9323            let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9324                Ok(p) => p,
9325                Err(_) => {
9326                    return (
9327                        i,
9328                        job.seat,
9329                        AgentOutcome::ResourceBlocked(format!(
9330                            "no parallel slot became free within {}s",
9331                            slot_limit.as_secs()
9332                        )),
9333                    );
9334                }
9335            };
9336            spawned_tx.send(job.seat.key.clone()).ok();
9337            let mut seat = job.seat;
9338            let out = repository_wait
9339                .scope(agent::invoke(
9340                    &job.spec,
9341                    &mut seat,
9342                    &Invocation {
9343                        coordination: coordination.as_ref(),
9344                        cwd: &job.cwd,
9345                        prompt: &job.prompt,
9346                        timeout: job.timeout,
9347                        allow_write: job.allow_write,
9348                        unsandboxed: false,
9349                        sessions: job.sessions,
9350                        artifacts: &job.artifacts,
9351                        stem: &job.stem,
9352                        run: &run,
9353                        node: &node,
9354                        cache_dir: cache.as_deref(),
9355                        attachments: &attachments,
9356                        writable: &[],
9357                    },
9358                ))
9359                .await;
9360            let out = match out {
9361                Ok(o) if o.usable() => AgentOutcome::Ok(o),
9362                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9363                // Billed work the CLI failed to hand over is not an ordinary
9364                // failure, but its text is the CLI's raw error JSON, not an
9365                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
9366                // read it as one by forgetting to check. `usable()` is always
9367                // false here (dropped implies an empty response), so this has
9368                // to be checked before the catch-all `Failed` below or the
9369                // one shape this exists for is lost with the rest.
9370                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9371                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9372                Ok(o) => AgentOutcome::Failed(format!(
9373                    "process exited with no usable output ({})",
9374                    crate::notices::exit_status(o.exit_code)
9375                )),
9376                Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
9377                    AgentOutcome::ResourceBlocked(e.to_string())
9378                }
9379                Err(e) => AgentOutcome::Failed(e.to_string()),
9380            };
9381            (i, seat, out)
9382        });
9383    }
9384    drop(spawned_tx);
9385    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9386    loop {
9387        let joined = tokio::select! {
9388            Some(key) = spawned_rx.recv() => {
9389                // The clock of this seat starts now, not when it queued.
9390                state.seat_spawned(&key);
9391                if let Err(e) = state.save() {
9392                    tracing::warn!("could not persist a seat's start: {e:#}");
9393                }
9394                continue;
9395            }
9396            joined = set.join_next() => joined,
9397        };
9398        let Some(joined) = joined else {
9399            break;
9400        };
9401        let (i, seat, out) = match joined {
9402            Ok(v) => v,
9403            // No seat to clear: a panicked task never reported which one it
9404            // was. The defensive sweep below this loop is what stops that
9405            // seat's `active` entry from surviving forever.
9406            Err(e) => {
9407                tracing::error!("agent task panicked: {e}");
9408                continue;
9409            }
9410        };
9411        state.seat_finished(&seat.key);
9412        if let AgentOutcome::ResourceBlocked(why) = &out
9413            && state.resource_block.is_none()
9414        {
9415            state.resource_block = Some(crate::run::ResourceBlock {
9416                node: node.to_owned(),
9417                kind: if why.contains("repository coordination") {
9418                    "repository coordination"
9419                } else {
9420                    "parallel slot"
9421                }
9422                .to_owned(),
9423                detail: why.clone(),
9424                at: Timestamp::now(),
9425            });
9426            state.event("resource", format!("{node}: no agent was started - {why}"));
9427        }
9428        record_jobs(state, node, round, &seat.key, &out);
9429        if let Err(e) = state.save() {
9430            tracing::warn!("could not persist a seat's completion: {e:#}");
9431        }
9432        if collected.len() <= i {
9433            collected.resize_with(i + 1, || None);
9434        }
9435        collected[i] = Some((i, seat, out));
9436    }
9437    // Belt-and-braces for the panic branch above: every seat this exact batch
9438    // started shares this `(node, attempt)` pair, and every seat that finished
9439    // normally already cleared itself, so anything left tagged with it here
9440    // can only be a panicked task's leftover. Cleared unconditionally rather
9441    // than left to read as still answering forever.
9442    if state
9443        .active
9444        .values()
9445        .any(|a| a.node == node && a.attempt == attempt)
9446    {
9447        state
9448            .active
9449            .retain(|_, a| !(a.node == node && a.attempt == attempt));
9450        if let Err(e) = state.save() {
9451            tracing::warn!("could not persist the end of a wave: {e:#}");
9452        }
9453    }
9454    // Whether or not the lease above was actually held, several worktrees
9455    // may just have built into the cache with nothing here able to name one
9456    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
9457    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
9458    // clean it might not have strictly needed; trusting a stale match would
9459    // cost it a wrong answer.
9460    if let Some(cache_dir) = cache
9461        && jobs_had_a_writer
9462    {
9463        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9464    }
9465    if let Some(guard) = cache_guard {
9466        guard.release();
9467    }
9468    collected.into_iter().flatten().collect()
9469}
9470
9471/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
9472/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
9473/// this is data collection, not the fix-specific completion contract in
9474/// [`Runner::continue_fix_report`], and applies regardless of which node
9475/// asked.
9476///
9477/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
9478/// evidence from; `Failed` does not, and correctly contributes nothing — a
9479/// timeout or crash is not itself evidence about a command the seat may have
9480/// started.
9481fn record_jobs(
9482    state: &mut RunState,
9483    node: &str,
9484    round: Option<usize>,
9485    seat: &str,
9486    out: &AgentOutcome,
9487) {
9488    let commands: &[agent::CommandEvidence] = match out {
9489        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9490        AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9491    };
9492    let checked_at = Timestamp::now();
9493    for c in commands {
9494        state.jobs.push(JobRecord {
9495            node: node.to_owned(),
9496            round,
9497            seat: seat.to_owned(),
9498            id: c.id.clone(),
9499            description: c.description.clone(),
9500            checked_at,
9501            status: match c.exit_code {
9502                Some(0) => JobStatus::Completed,
9503                Some(_) => JobStatus::Failed,
9504                None => JobStatus::Unknown,
9505            },
9506            exit_code: c.exit_code,
9507            result_summary: c.result_summary.clone(),
9508            source: c.source.clone(),
9509        });
9510    }
9511}
9512
9513/// Is a review round clean, given how many reviewer seats answered against
9514/// how many the round expected?
9515///
9516/// A seat that never answered (timeout, crash, unparsable output) is not a
9517/// seat that read the patch and found nothing — treating it as such is
9518/// exactly the bug this function exists to close. Under the default `block`
9519/// policy a missing seat can never be clean; `warn` still requires the seats
9520/// that *did* answer to have found nothing blocking and verification to be
9521/// green.
9522///
9523/// `quota_missing` narrows that `block` default for exactly one cause of
9524/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
9525/// a session limit lifts by the very next round buys nothing — the seat is
9526/// asked again with the same quota — so once every missing seat is accounted
9527/// for by a quota loss (and at least one seat *did* answer, so a decision has
9528/// something to rest on) the round is decided on the panel that could answer,
9529/// same as `warn` would. A panel that lost every seat to quota is not
9530/// decided here: `answered == 0` falls through to the existing `block`
9531/// fallback so a fully collapsed panel still waits rather than landing on no
9532/// review at all.
9533fn round_is_clean(
9534    blocking: usize,
9535    e2e_ok: bool,
9536    answered: usize,
9537    expected: usize,
9538    quota_missing: usize,
9539    policy: IncompleteReviewPolicy,
9540) -> bool {
9541    if blocking != 0 || !e2e_ok {
9542        return false;
9543    }
9544    if answered == expected || policy == IncompleteReviewPolicy::Warn {
9545        return true;
9546    }
9547    answered > 0 && expected - answered <= quota_missing
9548}
9549
9550/// The review loop's own conclusion, derived entirely from its persisted
9551/// round records and the round budget that produced them — never from
9552/// `status`, so a reentry (or `gate`/`merge` reading it independently)
9553/// recomputes the identical answer regardless of what an earlier node in the
9554/// same walk, or a previous walk, did to `status`.
9555///
9556/// `None` while more rounds remain to try, including when review never ran
9557/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
9558/// gone clean, or the budget is spent, or the tree has stopped moving (see
9559/// [`STAGNANT_LIMIT`]), the answer is one of two things:
9560///
9561/// - An incomplete panel that raised nothing is missing input, not a
9562///   verified tree — never a hand-off candidate, whatever verification said
9563///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
9564/// - Otherwise, green e2e on the last round hands off (see
9565///   [`Runner::stop_reviewing`]); red e2e blocks.
9566///
9567/// A last round whose own verification is still `ResourceBlocked` — magi
9568/// itself never got a command to run, not evidence the patch is broken —
9569/// is neither: this returns `None` for it too, the same as "more rounds
9570/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
9571/// handling of that shape) instead of this cheap recomputation guessing a
9572/// verdict a real attempt never produced.
9573fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9574    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9575        return Some(RunStatus::Gating);
9576    }
9577    let last = reviews.last()?;
9578    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9579    if reviews.len() < max_rounds && !stagnant {
9580        return None;
9581    }
9582    if last.incomplete() && last.blocking == 0 {
9583        return Some(RunStatus::Blocked);
9584    }
9585    if last.e2e_status() == E2eStatus::ResourceBlocked {
9586        return None;
9587    }
9588    Some(if last.e2e.iter().all(CommandOutcome::ok) {
9589        RunStatus::Gating
9590    } else {
9591        RunStatus::Blocked
9592    })
9593}
9594
9595/// How long a re-ask may take, given the budget the first attempt had.
9596///
9597/// A `nudged` retry is a request to restate an answer the seat has already
9598/// worked out: it carries no new work, so it does not deserve the original
9599/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
9600/// 133 seconds while a third sat for over ten minutes on a resumed session
9601/// holding 410 KB of prior output - and because the retry had inherited the
9602/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
9603/// judging round whose other seats were long finished.
9604///
9605/// A quarter of the budget, with a floor so that a deliberately short timeout
9606/// does not collapse to nothing. A retry that re-sends the whole prompt
9607/// (because the seat kept no context) is the original job again, and keeps the
9608/// original budget.
9609pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9610    if nudged {
9611        (full / 4).max(Duration::from_secs(120)).min(full)
9612    } else {
9613        full
9614    }
9615}
9616
9617/// Run a wave and parse each reply, re-asking the seats whose reply was
9618/// unusable.
9619///
9620/// The re-ask is a nudge rather than the whole prompt again when the seat still
9621/// holds its conversation, which is the difference between a cheap retry and
9622/// paying for the entire candidate set twice.
9623///
9624/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
9625/// successor in `roster` is handed to it instead of being re-asked: the
9626/// handover is the retry. A seat with no successor left (a single-agent
9627/// roster, the roster's tail) is nudged as before, up to `retries` times. So
9628/// the asks to one seat in one node number at most
9629/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
9630/// is asked once (a dropped stream is nudged first), and only the last agent
9631/// of the chain gets the `retries` same-agent nudges. Each roster agent is
9632/// tried at most once per seat, walking forward from the seat's own position and never wrapping
9633/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
9634/// other failure stops the chain when the previous agent failed the same way
9635/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
9636/// [`has_context`] is false and the job's own full prompt and full budget are
9637/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
9638/// intermediate ones are not losses) and is returned as a failure like any
9639/// other absent seat — the caller decides whether the panel still has a
9640/// quorum. An empty `roster` disables handover: failures are nudged as they
9641/// always were, and a quota is simply lost. A reply that fails to parse or
9642/// validate is the prompt's doing and is only ever nudged, never handed over.
9643///
9644/// The returned [`SeatState`] names the agent that answered (or tried last).
9645#[allow(clippy::too_many_arguments)]
9646async fn ask_json_wave<T>(
9647    jobs: Vec<SeatJob>,
9648    sem: Arc<Semaphore>,
9649    retries: usize,
9650    roster: &[AgentSpec],
9651    ctx: &WaveCtx<'_>,
9652    losses: &mut Vec<QuotaLoss>,
9653    state: &mut RunState,
9654    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9655) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9656where
9657    T: serde::de::DeserializeOwned + Send + 'static,
9658{
9659    ask_wave_with(
9660        jobs,
9661        sem,
9662        retries,
9663        roster,
9664        ctx,
9665        losses,
9666        state,
9667        &|text: &str| {
9668            let v = verdict::extract_json::<T>(text)?;
9669            validate(&v)?;
9670            Ok(v)
9671        },
9672    )
9673    .await
9674}
9675
9676/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
9677/// a node whose answer is prose (deliberation) shares the same handover,
9678/// failure classification, quota bookkeeping and bounds instead of a copy.
9679///
9680/// A seat handed to another roster agent is sent the job's `handover` prompt
9681/// (when it has one) rather than `prompt`: the new agent has no session, so a
9682/// resume-style prompt would be incomplete. That holds for the handover ask
9683/// and for every nudge to that agent whose `has_context` is false.
9684#[allow(clippy::too_many_arguments)]
9685async fn ask_wave_with<T>(
9686    jobs: Vec<SeatJob>,
9687    sem: Arc<Semaphore>,
9688    retries: usize,
9689    roster: &[AgentSpec],
9690    ctx: &WaveCtx<'_>,
9691    losses: &mut Vec<QuotaLoss>,
9692    state: &mut RunState,
9693    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9694) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9695where
9696    T: Send + 'static,
9697{
9698    let n = jobs.len();
9699    let originals: Vec<SeatJob> = jobs;
9700    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9701    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9702    // Nudges each seat's *current* agent has taken — 0 for a first-ask
9703    // answer, N once it has gone through N nudges. Read back once this
9704    // returns, so a caller building a history record (`ReviewRecord`) can
9705    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
9706    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
9707    // field's own doc.
9708    let mut nudges: Vec<usize> = vec![0; n];
9709    // The agent now occupying each seat, the ids it has already been through,
9710    // where in the roster the walk began, the class of the last failure, and
9711    // the stem word of a handover not yet asked (full prompt, full budget).
9712    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9713    let mut tried: Vec<BTreeSet<String>> = specs
9714        .iter()
9715        .map(|s| BTreeSet::from([s.id.clone()]))
9716        .collect();
9717    let starts: Vec<usize> = specs
9718        .iter()
9719        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9720        .collect();
9721    let carry = ctx.carry_seats && !roster.is_empty();
9722    // Carried across rounds: ids that failed the seat earlier, and how the
9723    // last failure went (so a repeat of it is not handed over again).
9724    let carried: Vec<BTreeSet<String>> = originals
9725        .iter()
9726        .map(|j| {
9727            state
9728                .seat_history
9729                .get(&j.seat.key)
9730                .filter(|_| carry)
9731                .map(|h| h.failed.clone())
9732                .unwrap_or_default()
9733        })
9734        .collect();
9735    let mut prev: Vec<Option<FailClass>> = originals
9736        .iter()
9737        .map(|j| {
9738            state
9739                .seat_history
9740                .get(&j.seat.key)
9741                .filter(|_| carry)
9742                .and_then(|h| h.last_fail.clone())
9743        })
9744        .collect();
9745    let next_agent =
9746        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9747            let others: BTreeSet<String> = specs
9748                .iter()
9749                .enumerate()
9750                .filter(|(j, _)| *j != i)
9751                .map(|(_, s)| s.id.clone())
9752                .collect();
9753            pick_successor(
9754                roster,
9755                starts[i],
9756                tried,
9757                carry.then(|| &carried[i]),
9758                &others,
9759            )
9760            .cloned()
9761        };
9762    let mut fresh: Vec<Option<String>> = vec![None; n];
9763    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9764    let mut pending: Vec<usize> = (0..n).collect();
9765
9766    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
9767    // asks: an agent with a successor is asked once and handed over, and only
9768    // a seat with no successor spends `retries` nudges on the same agent. This
9769    // only guarantees the loop's own termination whatever those say.
9770    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9771    for round in 0..max_rounds {
9772        if pending.is_empty() {
9773            break;
9774        }
9775        let mut batch = Vec::with_capacity(pending.len());
9776        let mut renudged: Vec<&str> = Vec::new();
9777        for &i in &pending {
9778            let src = &originals[i];
9779            // A seat now held by another agent than the job named has no
9780            // session of its own: it gets the full-context prompt whenever
9781            // it is asked in full (the handover ask, a nudge it cannot
9782            // resume).
9783            let full: &str = match &src.handover {
9784                Some(h) if specs[i].id != src.spec.id => h,
9785                _ => &src.prompt,
9786            };
9787            // The prompt and the budget are one decision: a nudge restates
9788            // finished work, a re-sent prompt redoes it.
9789            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9790                (
9791                    full.to_owned(),
9792                    src.timeout,
9793                    format!("{}-{word}-{}", src.stem, specs[i].id),
9794                )
9795            } else if nudges[i] == 0 {
9796                (full.to_owned(), src.timeout, src.stem.clone())
9797            } else {
9798                renudged.push(src.seat.key.as_str());
9799                let why = done[i]
9800                    .as_ref()
9801                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
9802                    .unwrap_or_else(|| "no parsable answer".to_owned());
9803                let nudge = prompt::nudge(&why);
9804                let nudged = has_context(&specs[i], &seats[i], src.sessions);
9805                let prompt = if nudged {
9806                    nudge
9807                } else {
9808                    format!("{full}\n\n---\n\n{nudge}")
9809                };
9810                (
9811                    prompt,
9812                    retry_budget(src.timeout, nudged),
9813                    format!("{}-retry{}", src.stem, nudges[i]),
9814                )
9815            };
9816            batch.push(SeatJob {
9817                spec: specs[i].clone(),
9818                seat: seats[i].clone(),
9819                cwd: src.cwd.clone(),
9820                prompt,
9821                timeout,
9822                allow_write: src.allow_write,
9823                sessions: src.sessions,
9824                artifacts: src.artifacts.clone(),
9825                stem,
9826                handover: None,
9827            });
9828        }
9829
9830        if !renudged.is_empty() {
9831            state.event(
9832                ctx.node,
9833                format!("retry {round}: re-asking {}", renudged.join(", ")),
9834            );
9835        }
9836        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9837        let mut still = Vec::new();
9838        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9839            seats[i] = seat;
9840            // No agent was asked: nothing to hand over, nudge, remember or
9841            // charge. The caller sees `resource_block` and stops the node.
9842            if let AgentOutcome::ResourceBlocked(why) = &out {
9843                done[i] = Some(Err(anyhow::anyhow!("{why}")));
9844                continue;
9845            }
9846            let class = FailClass::of(&out);
9847            // A dropped stream is nudged first (the conversation is still
9848            // there to pick up); only a seat whose nudges are spent hands over.
9849            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9850                && nudges[i] < retries
9851                && !roster.is_empty();
9852            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9853                let next = should_hand_over(prev[i].as_ref(), &cur)
9854                    .then(|| next_agent(i, &tried[i], &specs))
9855                    .flatten();
9856                if carry {
9857                    let h = state
9858                        .seat_history
9859                        .entry(originals[i].seat.key.clone())
9860                        .or_default();
9861                    h.failed.insert(specs[i].id.clone());
9862                    h.last_fail = Some(cur.clone());
9863                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9864                        h.last_ok = None;
9865                    }
9866                    // Saved before the next agent is asked, so a restart in
9867                    // between does not forget who failed.
9868                    if let Err(e) = state.save() {
9869                        tracing::warn!("could not persist a seat's failure history: {e:#}");
9870                    }
9871                }
9872                if let Some(next) = next {
9873                    record_handover(
9874                        state,
9875                        ctx.node,
9876                        &originals[i].seat.key,
9877                        &specs[i].id,
9878                        &next.id,
9879                        &cur,
9880                        &fail_reason(&out),
9881                    );
9882                    tried[i].insert(next.id.clone());
9883                    prev[i] = Some(cur.clone());
9884                    seats[i] =
9885                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9886                    specs[i] = next;
9887                    fresh[i] = Some(cur.stem_word().to_owned());
9888                    nudges[i] = 0;
9889                    done[i] = Some(Err(anyhow::anyhow!(
9890                        "handed over after: {}",
9891                        fail_reason(&out)
9892                    )));
9893                    still.push(i);
9894                    continue;
9895                }
9896            }
9897            if carry
9898                && !nudge_first
9899                && let Some(cur) = class.clone()
9900            {
9901                // The chain ended here (no successor, or a repeated failure).
9902                let h = state
9903                    .seat_history
9904                    .entry(originals[i].seat.key.clone())
9905                    .or_default();
9906                h.failed.insert(specs[i].id.clone());
9907                h.last_fail = Some(cur);
9908            }
9909            let parsed = match out {
9910                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9911                AgentOutcome::Quota(o) => {
9912                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9913                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9914                }
9915                // Not a parseable answer: the nudge loop re-asks it, which is
9916                // exactly what a dropped stream needs. Just don't hand its raw
9917                // error JSON to `extract_json`.
9918                AgentOutcome::Dropped(o) => {
9919                    let why = o
9920                        .dropped
9921                        .as_ref()
9922                        .map(|d| d.why.as_str())
9923                        .unwrap_or("the CLI ended the stream without delivering its answer");
9924                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9925                }
9926                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9927                    Err(anyhow::anyhow!(e))
9928                }
9929            };
9930            let quota = class == Some(FailClass::Quota);
9931            let failed = parsed.is_err();
9932            done[i] = Some(parsed);
9933            if carry && !failed {
9934                let h = state
9935                    .seat_history
9936                    .entry(originals[i].seat.key.clone())
9937                    .or_default();
9938                h.failed.remove(&specs[i].id);
9939                h.last_ok = Some(specs[i].id.clone());
9940                h.last_fail = None;
9941            }
9942            // Do not re-ask a rate-limited seat (quota) — a retry is known to
9943            // fail the same way; and never re-ask a seat that already parsed.
9944            // A failed agent that still has a successor is not re-asked
9945            // either: the handover was its remedy and has just been refused
9946            // (the chain stops on a repeated failure class). A seat with no
9947            // successor left (a single-agent roster, the roster's tail, or an
9948            // empty roster) keeps the same-agent nudge, bounded by `retries`.
9949            let agent_failure = class.is_some()
9950                && !nudge_first
9951                && !roster.is_empty()
9952                && next_agent(i, &tried[i], &specs).is_some();
9953            if failed && !quota && !agent_failure && nudges[i] < retries {
9954                nudges[i] += 1;
9955                still.push(i);
9956            }
9957        }
9958        pending = still;
9959    }
9960
9961    // One loss per seat whose chain ended on a quota: the intermediate ones
9962    // were absorbed by a handover and are not losses.
9963    for (i, q) in last_quota.into_iter().enumerate() {
9964        if let Some(reset) = q {
9965            losses.push(QuotaLoss {
9966                seat: originals[i].seat.key.clone(),
9967                node: ctx.node.to_owned(),
9968                at: Timestamp::now(),
9969                reset,
9970            });
9971        }
9972    }
9973
9974    seats
9975        .into_iter()
9976        .zip(done)
9977        .zip(nudges)
9978        .map(|((seat, res), attempts)| {
9979            (
9980                seat,
9981                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9982                attempts,
9983            )
9984        })
9985        .collect()
9986}
9987
9988/// Did the wave end without asking any agent (see [`block_wave`])?
9989fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9990    results
9991        .iter()
9992        .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9993}
9994
9995/// Settle a wave that never spawned anything: every job answers
9996/// [`AgentOutcome::ResourceBlocked`], its seat leaves `active`, and nothing
9997/// else about the seat (session, history, quota) is touched.
9998fn block_wave(
9999    jobs: Vec<SeatJob>,
10000    state: &mut RunState,
10001    node: &str,
10002    attempt: usize,
10003    why: &str,
10004) -> Vec<(usize, SeatState, AgentOutcome)> {
10005    state.resource_wait = None;
10006    state
10007        .active
10008        .retain(|_, a| !(a.node == node && a.attempt == attempt));
10009    if let Err(e) = state.save() {
10010        tracing::warn!("could not persist a resource-blocked wave: {e:#}");
10011    }
10012    jobs.into_iter()
10013        .enumerate()
10014        .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
10015        .collect()
10016}
10017
10018/// Acquire the shared build cache's lease, waiting out contention within
10019/// `budget` (never past it — see AGENTS.md's build-cache section on why an
10020/// unbounded wait is never acceptable).
10021///
10022/// A first, non-blocking check happens before ever waiting; if it finds the
10023/// lease busy, that fact is logged as a `verify` event *and* flushed with
10024/// [`RunState::save`] immediately — not only once the wait finally succeeds
10025/// or gives up — so a `magi show` run by a different process while this one
10026/// is still waiting reads a `run.json` that says so, rather than whatever it
10027/// looked like before the wait started. The same applies to the terminal
10028/// failure: logged and saved before this returns `Err`, so a caller that
10029/// could not get the lease at all still leaves a legible record of why.
10030async fn acquire_cache_lease(
10031    state: &mut RunState,
10032    cache_dir: &Path,
10033    owner: &crate::cache::Owner,
10034    budget: Duration,
10035    context: &str,
10036    cancel: &(dyn Fn() -> bool + Sync),
10037) -> Result<Option<crate::cache::Guard>> {
10038    let home = crate::run::home();
10039    let started = Instant::now();
10040    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
10041        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
10042        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
10043        Err(e) => {
10044            state.event(
10045                "verify",
10046                format!("{context}: could not check the shared build cache: {e:#}"),
10047            );
10048            if let Err(e2) = state.save() {
10049                tracing::warn!("could not persist a cache-check failure: {e2:#}");
10050            }
10051            return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
10052        }
10053    };
10054    state.event(
10055        "verify",
10056        format!(
10057            "{context}: waiting for the shared build cache at {} ({})",
10058            cache_dir.display(),
10059            busy.describe()
10060        ),
10061    );
10062    // Seats of this node are waiting, not running: say so, so the countdown a
10063    // reader sees only ever measures an agent that has been spawned.
10064    state.resource_wait = Some(crate::run::ResourceWait {
10065        node: context.to_owned(),
10066        kind: "shared build cache".to_owned(),
10067        holder: busy.describe(),
10068        since: Timestamp::now(),
10069        limit_secs: budget.as_secs(),
10070    });
10071    if let Err(e) = state.save() {
10072        tracing::warn!("could not persist a cache wait: {e:#}");
10073    }
10074    let remaining = budget.saturating_sub(started.elapsed());
10075    let waited = crate::cache::wait_for_unless(
10076        &home,
10077        cache_dir,
10078        owner,
10079        remaining,
10080        Duration::from_secs(5),
10081        cancel,
10082    )
10083    .await;
10084    state.resource_wait = None;
10085    match waited {
10086        Ok(g) => Ok(g),
10087        Err(e) => {
10088            state.event("verify", format!("{context}: {e:#}"));
10089            if let Err(e2) = state.save() {
10090                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
10091            }
10092            Err(e)
10093        }
10094    }
10095}
10096
10097/// The lease state could not be read at all (unreadable lease file, a
10098/// permission problem). Unlike a busy lease that outlasts its bound, nothing is
10099/// known to be contended, so a wave degrades to running without a shared cache
10100/// instead of stopping on a hold that [`crate::triage`] could never release.
10101#[derive(Debug)]
10102struct CacheCheckError(String);
10103
10104impl std::fmt::Display for CacheCheckError {
10105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10106        f.write_str(&self.0)
10107    }
10108}
10109
10110impl std::error::Error for CacheCheckError {}
10111
10112/// Run `body` — a verify command batch — while holding the shared build
10113/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
10114/// never interleave with another borrower's build against the same
10115/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
10116/// timeout, or a human's own `magi review`. See the `cache` module doc for
10117/// why this matters more than Cargo's own per-target locking covers — two
10118/// *different* worktrees building the same package name/version into one
10119/// cache directory is a staleness bug, not a lock contention one.
10120///
10121/// Cache acquisition has its own bound, `timeout_resource_wait`, just as
10122/// agent waves do. Repository scheduling also has its own bounded,
10123/// cancellable resource-wait policy. Contention must not consume the
10124/// verification budget: `body` receives the full `budget` after both leases
10125/// are acquired and the cache is fresh. Without a shared cache, only
10126/// repository coordination is acquired before handing over the full budget.
10127///
10128/// A lease that cannot be acquired within the resource wait is reported as a single
10129/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
10130/// verification — the same shape a spawn failure already takes in
10131/// [`run_commands`], so a caller need not special-case it.
10132#[allow(clippy::too_many_arguments)]
10133async fn with_cache_lease<'s, F, Fut>(
10134    state: &'s mut RunState,
10135    cache_dir: Option<&Path>,
10136    node: &str,
10137    seat: &str,
10138    worktree: &Path,
10139    head: &str,
10140    budget: Duration,
10141    context: &str,
10142    wait: &crate::repo_lock::WaitPolicy,
10143    body: F,
10144) -> Result<(Vec<CommandOutcome>, bool)>
10145where
10146    F: FnOnce(&'s mut RunState, Duration) -> Fut,
10147    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
10148{
10149    // Match editing waves: cache first, repository second. In particular,
10150    // pre_gate must never hold the repository while waiting for a wave's cache.
10151    let started = Instant::now();
10152    let home = crate::run::home();
10153    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
10154    let guard = if let Some(cache_dir) = cache_dir {
10155        let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
10156        match acquire_cache_lease(state, cache_dir, &owner, resource_limit, context, &|| {
10157            wait.cancelled()
10158        })
10159        .await
10160        .and_then(|g| {
10161            g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
10162        }) {
10163            Ok(g) => Some(g),
10164            Err(e) => {
10165                return Ok((
10166                    vec![CommandOutcome {
10167                        command: "(waiting for the shared build cache)".into(),
10168                        code: None,
10169                        output_tail: e.to_string(),
10170                        duration_ms: started.elapsed().as_millis() as u64,
10171                        resource_blocked: true,
10172                    }],
10173                    false,
10174                ));
10175            }
10176        }
10177    } else {
10178        None
10179    };
10180    let _coordination = coordinate_operation(state, worktree, wait).await?;
10181    if let Some(cache_dir) = cache_dir {
10182        let identity = crate::cache::Identity::new(worktree, head);
10183        if let Err(e) = crate::cache::ensure_fresh(
10184            &home,
10185            cache_dir,
10186            &identity,
10187            &state.config.verify.cargo_manifests(),
10188        ) {
10189            state.event(
10190                "verify",
10191                format!(
10192                    "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
10193                    worktree.display(),
10194                    short(head)
10195                ),
10196            );
10197            if let Some(guard) = guard {
10198                guard.release();
10199            }
10200            return Ok((
10201                vec![CommandOutcome {
10202                    command: "(confirming the shared build cache is fresh)".into(),
10203                    code: None,
10204                    output_tail: e.to_string(),
10205                    duration_ms: started.elapsed().as_millis() as u64,
10206                    resource_blocked: true,
10207                }],
10208                false,
10209            ));
10210        }
10211    }
10212    let (outcomes, retried, timed_out_pids) = body(state, budget).await;
10213    if !timed_out_pids.is_empty() {
10214        wait_for_timed_out_children_to_die(&timed_out_pids).await;
10215    }
10216    // Drop repository coordination before releasing the cache, reversing the
10217    // acquisition order. A cancelled/error future also drops both guards.
10218    drop(_coordination);
10219    if let Some(guard) = guard {
10220        guard.release();
10221    }
10222    Ok((outcomes, retried))
10223}
10224
10225/// Poll `pids` — commands [`run_commands`] reports as still running when its
10226/// own timeout elapsed — until every one is confirmed gone, or
10227/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
10228///
10229/// Real confirmation where confirmation is possible, not a substitute for
10230/// full process-tree observation: a grandchild the timed-out process spawned
10231/// and that survives independently of it is invisible to a pid check the
10232/// same way it always was, and continuing to observe and collect *that*
10233/// stays a different piece of work with its own owner. This only narrows a
10234/// fixed blind wait into an actual check of the pids this process does know
10235/// about.
10236async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
10237    wait_for_pids_with(
10238        pids,
10239        crate::proc::pid_alive,
10240        LEASE_RELEASE_POLL,
10241        LEASE_RELEASE_MAX_WAIT,
10242    )
10243    .await;
10244}
10245
10246/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
10247/// interval and ceiling supplied by the caller, so the polling *logic* -
10248/// returns as soon as every pid reports dead, gives up at the ceiling
10249/// otherwise - is testable on millisecond durations without asking the real
10250/// OS about a pid at all.
10251async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10252    pids: &[u32],
10253    alive: F,
10254    poll: Duration,
10255    max_wait: Duration,
10256) {
10257    let deadline = Instant::now() + max_wait;
10258    loop {
10259        if pids.iter().all(|&pid| !alive(pid)) {
10260            return;
10261        }
10262        if Instant::now() >= deadline {
10263            return;
10264        }
10265        tokio::time::sleep(poll).await;
10266    }
10267}
10268
10269/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
10270/// admission that it could not even get a verify command to run, as opposed
10271/// to evidence the command actually produced? A caller that would otherwise
10272/// read a resource-blocked outcome as a red command must check this first:
10273/// see [`Runner::gate`], which retries rather than records `Blocked` when
10274/// this is true.
10275fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10276    outcomes.iter().any(|o| o.resource_blocked)
10277}
10278
10279/// What [`Runner::gate_fix_round`] decided.
10280enum GateFix {
10281    /// The tree changed and `verify.e2e` is still green: run the gate again.
10282    Retry,
10283    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
10284    /// last failure stands and the run ends blocked.
10285    Stop,
10286    /// `verify.e2e` could not run after the fix (magi's own contention):
10287    /// decide nothing now, a later reentry retries.
10288    Defer,
10289}
10290
10291/// Is every red command in `outcomes` an ordinary failure the code could
10292/// explain: it ran, exited non-zero, and said something?
10293///
10294/// A timeout, a spawn failure and a killed process all leave `code` `None`;
10295/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
10296/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
10297/// what the command is stays the gate's business.
10298fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10299    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10300    red.peek().is_some()
10301        && red.all(|o| {
10302            !o.resource_blocked
10303                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10304                && !o.output_tail.trim().is_empty()
10305        })
10306}
10307
10308/// Describe one verify command's outcome for the event log, distinguishing a
10309/// build/link failure — the toolchain never produced a binary to run — from
10310/// an actual test failure, since only the latter is a verdict on the patch.
10311fn e2e_outcome_label(o: &CommandOutcome) -> String {
10312    if o.ok() {
10313        return "pass".to_owned();
10314    }
10315    let reason = if o.build_failed() {
10316        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10317    } else {
10318        format!("FAIL ({:?})", o.code)
10319    };
10320    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10321}
10322
10323/// Run `verify.e2e`, retrying once if the first attempt could not build or
10324/// link — a build/link failure is frequently a race against a shared
10325/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
10326/// `verify` event per command, tagged with `context` (normally `"round N"`)
10327/// so the two call sites that need this — the ordinary per-round leg in
10328/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
10329/// it will ever call a round green — read identically in the event log.
10330async fn run_e2e_with_retry(
10331    state: &mut RunState,
10332    shell: &[String],
10333    commands: &[String],
10334    worktree: &Path,
10335    timeout: Duration,
10336    context: &str,
10337) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10338    let (mut e2e, mut timed_out_pids) = run_commands(
10339        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10340    )
10341    .await;
10342    for o in &e2e {
10343        state.event(
10344            "verify",
10345            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10346        );
10347    }
10348    // A build/link failure is not a verdict on the patch — it is frequently a
10349    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
10350    // one retry before letting a red like that decide the round.
10351    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10352    if verify_retried {
10353        state.event(
10354            "verify",
10355            format!(
10356                "{context}: verify could not build/link, not a test result — retrying once \
10357                 before concluding"
10358            ),
10359        );
10360        let retried = run_commands(
10361            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10362        )
10363        .await;
10364        e2e = retried.0;
10365        // Both attempts' timeouts matter, not just the last one: the first
10366        // attempt's descendants may still be alive alongside the retry's.
10367        timed_out_pids.extend(retried.1);
10368        for o in &e2e {
10369            state.event(
10370                "verify",
10371                format!(
10372                    "{context}: retry `{}` -> {}",
10373                    o.command,
10374                    e2e_outcome_label(o)
10375                ),
10376            );
10377        }
10378    }
10379    (e2e, verify_retried, timed_out_pids)
10380}
10381
10382/// Run configured shell commands in `cwd`, in order. The second element is
10383/// the pid of every command that hit `timeout` and was still running when
10384/// this stopped waiting on it (best-effort: `None` when the platform did not
10385/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
10386/// that releases a shared resource afterward needs to know.
10387///
10388/// Records `task` into [`RunState::active`] at every command boundary
10389/// (`RunState::task_command`) and clears it once the whole list has run
10390/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
10391/// for minutes with no seat and no output of its own to show for it (see
10392/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
10393/// apart from "not yet run" without this), and this is the only place that
10394/// knows which command is running right now and how many are left. Three
10395/// saves per command — start, not per second — matching the same "only at a
10396/// boundary" rule [`wave`] already follows for seats.
10397#[allow(clippy::too_many_arguments)]
10398async fn run_commands(
10399    state: &mut RunState,
10400    node: &str,
10401    task: &str,
10402    attempt: usize,
10403    shell: &[String],
10404    commands: &[String],
10405    cwd: &Path,
10406    timeout: Duration,
10407) -> (Vec<CommandOutcome>, Vec<u32>) {
10408    if commands.is_empty() {
10409        // Nothing to mark as running and nothing to clear — an empty list
10410        // means "not configured", and touching `active` (or the disk) over
10411        // that would be a write for every round of a repo with no
10412        // `verify.e2e` / `verify.gate` commands at all.
10413        return (Vec::new(), Vec::new());
10414    }
10415    let mut out = Vec::new();
10416    let mut timed_out_pids = Vec::new();
10417    let total = commands.len();
10418    for (idx, command) in commands.iter().enumerate() {
10419        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10420        if let Err(e) = state.save() {
10421            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10422        }
10423        let started = Instant::now();
10424        let mut cmd = tokio::process::Command::new(&shell[0]);
10425        cmd.quiet();
10426        cmd.args(&shell[1..])
10427            .arg(command)
10428            .current_dir(cwd)
10429            .stdin(std::process::Stdio::null())
10430            .stdout(std::process::Stdio::piped())
10431            .stderr(std::process::Stdio::piped())
10432            .kill_on_drop(true);
10433        let spawned = cmd.spawn();
10434        let (code, body) = match spawned {
10435            Ok(child) => {
10436                // Captured before the child is consumed below: `kill_on_drop`
10437                // only *asks* the process to die when the timeout branch
10438                // drops it, and the pid is the only way anyone downstream can
10439                // later check whether that request actually took.
10440                let pid = child.id();
10441                match tokio::time::timeout(timeout, child.wait_with_output()).await {
10442                    Ok(Ok(o)) => {
10443                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10444                        body.push_str(&String::from_utf8_lossy(&o.stderr));
10445                        (o.status.code(), body)
10446                    }
10447                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
10448                    Err(_) => {
10449                        if let Some(pid) = pid {
10450                            timed_out_pids.push(pid);
10451                        }
10452                        (None, format!("timed out after {}s", timeout.as_secs()))
10453                    }
10454                }
10455            }
10456            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10457        };
10458        out.push(CommandOutcome {
10459            command: command.clone(),
10460            code,
10461            output_tail: tail(&body, OUTPUT_TAIL),
10462            duration_ms: started.elapsed().as_millis() as u64,
10463            resource_blocked: false,
10464        });
10465    }
10466    state.task_finished(task);
10467    if let Err(e) = state.save() {
10468        tracing::warn!("could not persist the end of {task}: {e:#}");
10469    }
10470    (out, timed_out_pids)
10471}
10472
10473/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
10474/// section (`report::run`) and in the `merge` event this node records — for
10475/// the operator to run by hand.
10476///
10477/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
10478/// branch whose ruleset forbids merge commits (GitHub's "must not contain
10479/// merge commits", or "require linear history") rejects the push a `--no-ff`
10480/// merge would produce, which is exactly the guidance this function replaces.
10481/// `message`'s first line becomes the squash commit's subject, matching the
10482/// note `report::run` prints alongside this command — see that function for
10483/// why an explicit subject is not optional there.
10484fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10485    let repo = repo.display();
10486    match style {
10487        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10488        MergeStyle::Squash => {
10489            // The subject sits inside double quotes, and a title an agent
10490            // wrote may carry the characters that break out of them.
10491            let subject = message
10492                .lines()
10493                .next()
10494                .unwrap_or(branch)
10495                .replace(['\\', '"', '$', '`'], "");
10496            format!(
10497                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10498            )
10499        }
10500        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10501    }
10502}
10503
10504/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
10505/// under the hood, rejects a `title` over 256 characters and the whole
10506/// command fails — no PR at all, for a run whose body was otherwise fine
10507/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
10508/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
10509/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
10510/// this bound. It is a margin, not a guarantee — a title packed
10511/// with multi-unit characters could still in principle land close to the
10512/// edge, but a real task title's occasional emoji or accented letter fits
10513/// comfortably inside it.
10514const PR_TITLE_MAX: usize = 240;
10515
10516/// A pull request title from the opening line of `text`, or `None` when that
10517/// line is not English (GitHub text is) or has no letters.
10518///
10519/// A line within `max` is kept as is. A longer one is cut at the end of its
10520/// first sentence when that falls inside `max`, else at a word boundary with a
10521/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
10522/// make every merely-truncated title look non-English). The language check
10523/// runs on the kept text before any mark is added, so only what GitHub will
10524/// show is judged: an English opening followed by non-ASCII far past the cut
10525/// still passes.
10526fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10527    let line = queue::first_line(text)?;
10528    let chars: Vec<char> = line.chars().collect();
10529    let (kept, mark) = if chars.len() <= max {
10530        (line.to_owned(), "")
10531    } else if let Some(end) = sentence_end(&chars, max) {
10532        (chars[..end].iter().collect::<String>(), "")
10533    } else {
10534        let room = max.saturating_sub(3);
10535        // Cut at the last space inside the room; when the char just past the
10536        // room is a space the room already ends on a word.
10537        let cut = if chars[room].is_whitespace() {
10538            room
10539        } else {
10540            chars[..room]
10541                .iter()
10542                .rposition(|c| c.is_whitespace())
10543                .unwrap_or(room)
10544        };
10545        let head: String = chars[..cut].iter().collect();
10546        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10547        (head.to_owned(), "...")
10548    };
10549    if kept.is_empty() {
10550        return None;
10551    }
10552    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10553        return None;
10554    }
10555    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10556        return None;
10557    }
10558    Some(format!("{kept}{mark}"))
10559}
10560
10561/// The char length of the first sentence of `chars` when it ends within `max`
10562/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
10563/// abbreviations so `e.g. foo` does not end a title early.
10564fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10565    const MIN: usize = 20;
10566    for i in MIN..max.min(chars.len()) {
10567        if !matches!(chars[i], '.' | '!' | '?') {
10568            continue;
10569        }
10570        let Some(&next) = chars.get(i + 1) else {
10571            continue;
10572        };
10573        if !next.is_whitespace() {
10574            continue;
10575        }
10576        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10577        if after.is_some_and(|c| c.is_ascii_lowercase()) {
10578            continue;
10579        }
10580        let word: String = chars[..i]
10581            .iter()
10582            .rev()
10583            .take_while(|c| !c.is_whitespace())
10584            .collect::<Vec<_>>()
10585            .into_iter()
10586            .rev()
10587            .collect();
10588        let word = word.to_ascii_lowercase();
10589        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10590            continue;
10591        }
10592        let end = chars[..i]
10593            .iter()
10594            .rposition(|c| !c.is_whitespace())
10595            .map_or(i, |p| p + 1);
10596        return Some(end);
10597    }
10598    None
10599}
10600
10601/// What `merge = "pr"` (and the merge commit of the other modes) says about a
10602/// change: a title and a body describing what was *implemented*, not the task
10603/// that asked for it. A task reads as a request; a reader of the merged
10604/// history wants the change.
10605struct PrMessage {
10606    title: String,
10607    body: String,
10608}
10609
10610impl PrMessage {
10611    /// Title, blank line, body. The first line is the squash/merge commit
10612    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
10613    /// has to stay one sensible line.
10614    fn commit_message(&self) -> String {
10615        format!("{}\n\n{}", self.title, self.body)
10616    }
10617}
10618
10619/// The text after a leading `TITLE:` (any case) on `line`.
10620fn title_marker(line: &str) -> Option<&str> {
10621    let line = line.trim();
10622    let head = line.get(..6)?;
10623    head.eq_ignore_ascii_case("title:")
10624        .then(|| line[6..].trim())
10625}
10626
10627/// The implementer's own one-line title: the `TITLE:` line the implement
10628/// prompt asks for at the top of its SUMMARY. Candidate commits are all
10629/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
10630/// source, and a title that says as much is refused here too.
10631fn summary_title(summary: &str) -> Option<String> {
10632    let first = summary.lines().find(|l| !l.trim().is_empty())?;
10633    let raw = title_marker(first)?;
10634    if raw.is_empty() {
10635        return None;
10636    }
10637    let title = queue::title_from(raw, PR_TITLE_MAX);
10638    let lower = title.to_ascii_lowercase();
10639    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10640        return None;
10641    }
10642    Some(title)
10643}
10644
10645/// How `open_review`'s instruction begins; see [`landing_title`].
10646const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10647
10648/// Marker `open_review` gives a candidate that nothing in the roster wrote.
10649pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10650
10651/// Does this run review work that already existed, rather than implement a
10652/// task? Runs recorded before `reviewed_commits` existed carry only the
10653/// candidate marker.
10654fn is_review_run(state: &RunState) -> bool {
10655    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10656}
10657
10658/// The title of a review-only run: the subject of the oldest commit under
10659/// review. Later commits are usually fixups, and `instruction` is the review
10660/// prompt, which says nothing about the change. GitHub text is English, so a
10661/// non-ASCII or blank subject yields `None` and the caller's neutral title.
10662fn review_title(state: &RunState) -> Option<String> {
10663    english_subject(
10664        state.reviewed_commits.as_ref()?.first()?,
10665        state.config.graph.github_english(),
10666    )
10667}
10668
10669/// `raw` as a pull request title, or `None` when it is blank, not English
10670/// (GitHub text is), or one of magi's own candidate commit subjects.
10671fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10672    let raw = raw.trim();
10673    if raw.is_empty() {
10674        return None;
10675    }
10676    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10677        return None;
10678    }
10679    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10680        return None;
10681    }
10682    let title = queue::title_from(raw, PR_TITLE_MAX);
10683    let lower = title.to_ascii_lowercase();
10684    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10685        return None;
10686    }
10687    Some(title)
10688}
10689
10690/// What a review-only run's branch says about itself, read at the moment the
10691/// pull request is opened.
10692#[derive(Debug, Clone, PartialEq, Eq)]
10693struct BranchFacts {
10694    /// `(subject, body)` of each commit, oldest first.
10695    commits: Vec<(String, String)>,
10696    /// Trimmed `git diff --stat`.
10697    stat: String,
10698}
10699
10700/// Longest diff stat shown: this many file lines plus the summary line.
10701const STAT_FILE_LINES: usize = 25;
10702/// Cap on the commit list, well inside GitHub's 65536-character body limit.
10703const COMMITS_MAX_CHARS: usize = 20_000;
10704
10705/// Read the commits and diff stat of `base..branch`. `None` when git cannot
10706/// say or finds nothing, so the caller falls back to what the run recorded.
10707async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10708    let commits = git::commit_log(repo, base, branch).await.ok()?;
10709    if commits.is_empty() {
10710        return None;
10711    }
10712    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10713    let lines: Vec<&str> = stat.lines().collect();
10714    let stat = if lines.len() > STAT_FILE_LINES + 1 {
10715        let omitted = lines.len() - 1 - STAT_FILE_LINES;
10716        let more = format!(" ... {omitted} more file(s)");
10717        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10718        kept.push(&more);
10719        kept.push(lines[lines.len() - 1]);
10720        kept.join("\n")
10721    } else {
10722        lines.join("\n")
10723    };
10724    Some(BranchFacts { commits, stat })
10725}
10726
10727/// Defang what would break the surrounding markdown: a closing `</details>`
10728/// and a code fence.
10729fn markdown_safe(text: &str) -> String {
10730    text.replace("</details>", "&lt;/details&gt;")
10731        .replace("\x60\x60\x60", "~~~")
10732}
10733
10734fn neutral_title(state: &RunState, winner: char) -> String {
10735    format!(
10736        "chore: land candidate {} of run {}",
10737        winner.to_ascii_uppercase(),
10738        state.id
10739    )
10740}
10741
10742/// The pull request title to hand to `land::merge_subject`. A review-only run
10743/// opened by an earlier build titled its pull request with the review prompt;
10744/// that title is dropped (empty, so the fallback applies) rather than landed.
10745/// Any other title, including an operator's rename, passes through untouched,
10746/// and so does every title of a run that implements a task.
10747pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10748    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10749        ""
10750    } else {
10751        pr_title
10752    }
10753}
10754
10755/// What the squash subject falls back to when the pull request title is empty
10756/// or candidate-shaped: for a review-only run the derived title, never the
10757/// review prompt held in `instruction`.
10758pub fn landing_subject_source(state: &RunState) -> String {
10759    if is_review_run(state) {
10760        let winner = state.candidates.first().map_or('A', |c| c.label);
10761        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10762    }
10763    state.instruction.clone()
10764}
10765
10766/// `summary` without its `TITLE:` line, which the pull request title already
10767/// carries.
10768fn summary_without_title(summary: &str) -> String {
10769    let mut lines = summary.trim().lines().peekable();
10770    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10771        lines.next();
10772    }
10773    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10774}
10775
10776/// The pull request title and body for the winning candidate.
10777///
10778/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10779/// the task's own opening line via [`queue::title_from`] when there is none.
10780/// `state.instruction` can open with blank lines (`task_text` only rejects a
10781/// body that is blank *entirely*), which `title_from` skips.
10782///
10783/// Publishing uses an independent description generated from the final diff
10784/// and structured evidence. Internal summaries and reports remain local.
10785/// Non-publishing previews may display the original task and candidate summary.
10786/// The footer retains the run and candidate provenance.
10787#[cfg(test)]
10788fn pr_message(state: &RunState, winner: char) -> PrMessage {
10789    pr_message_with(state, winner, None)
10790}
10791
10792/// [`pr_message`] with what the branch of a review-only run says about itself.
10793/// `facts` is ignored for a run that implements a task.
10794#[cfg(test)]
10795fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10796    let raw = pr_message_raw(
10797        state,
10798        winner,
10799        facts,
10800        crate::pr_outcome::FinalHead::Unreported,
10801        ChangeNote::NotAsked,
10802    );
10803    let id = crate::scrub::Identity::current();
10804    PrMessage {
10805        title: crate::scrub::scrub(&raw.title, &id),
10806        body: crate::scrub::scrub(&raw.body, &id),
10807    }
10808}
10809
10810/// What the public description may say about the change itself.
10811#[derive(Debug, Clone, Copy)]
10812enum ChangeNote<'a> {
10813    /// A local preview or non-publishing merge mode: no public generation needed.
10814    NotAsked,
10815    /// A summary written from the final diff.
10816    Summary(&'a crate::run::ChangeSummary),
10817    /// A summary was asked for and could not be produced; the text says why.
10818    Unavailable(&'a str),
10819}
10820
10821fn pr_message_raw(
10822    state: &RunState,
10823    winner: char,
10824    facts: Option<&BranchFacts>,
10825    head: crate::pr_outcome::FinalHead<'_>,
10826    change: ChangeNote<'_>,
10827) -> PrMessage {
10828    if !matches!(change, ChangeNote::NotAsked) {
10829        let (title, mut body) = match change {
10830            ChangeNote::Summary(c) => (
10831                c.title.clone(),
10832                format!("## Summary\n\n{}\n\n", c.body.trim()),
10833            ),
10834            ChangeNote::Unavailable(why) => (
10835                neutral_title(state, winner),
10836                format!(
10837                    "## Summary\n\nNo description of what this change does could be generated ({why}). Review the final diff; internal reports remain in local records.\n\n"
10838                ),
10839            ),
10840            ChangeNote::NotAsked => unreachable!(),
10841        };
10842        body.push_str(&public_pr_evidence(state, head));
10843        body.push_str(&format!(
10844            "\n---\nmagi:run/{} magi:candidate-{}\n",
10845            state.id,
10846            winner.to_ascii_lowercase()
10847        ));
10848        return PrMessage { title, body };
10849    }
10850    pr_preview(state, winner, facts, head)
10851}
10852
10853/// Append public facts from the run records after every prose generation,
10854/// including posting-gate rewrites. The agent cannot drop this provenance.
10855fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10856    let mut body = crate::pr_outcome::review_rounds(state);
10857    body.push_str(&crate::pr_outcome::final_verification(state, head));
10858    let open = state.last_round_findings();
10859    if !open.is_empty() {
10860        body.push_str("## Open review findings\n\n");
10861        for f in open {
10862            body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10863        }
10864        body.push('\n');
10865    }
10866    if let Some(c) = state.change_summary.as_ref()
10867        && head == crate::pr_outcome::FinalHead::At(&c.head)
10868    {
10869        body.push_str(&format!(
10870            "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10871            c.base, c.head
10872        ));
10873    }
10874    body
10875}
10876
10877/// Local-only preview of the operator's task and candidate summary.
10878fn pr_preview(
10879    state: &RunState,
10880    winner: char,
10881    facts: Option<&BranchFacts>,
10882    head: crate::pr_outcome::FinalHead<'_>,
10883) -> PrMessage {
10884    let summary = state
10885        .candidates
10886        .iter()
10887        .find(|c| c.label == winner)
10888        .map(|c| c.summary.as_str())
10889        .unwrap_or_default();
10890    // The fallback is the operator's own words and may not be English; GitHub
10891    // text always is, so a non-English task gets a neutral title instead.
10892    let review = is_review_run(state);
10893    let title = if review {
10894        facts
10895            .and_then(|f| {
10896                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10897            })
10898            .or_else(|| review_title(state))
10899            .or_else(|| {
10900                state
10901                    .candidates
10902                    .iter()
10903                    .find(|c| c.label == winner)
10904                    .filter(|c| !c.branch.starts_with("magi/"))
10905                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10906            })
10907            .unwrap_or_else(|| neutral_title(state, winner))
10908    } else {
10909        summary_title(summary).unwrap_or_else(|| {
10910            english_title(
10911                &state.instruction,
10912                PR_TITLE_MAX,
10913                state.config.graph.github_english(),
10914            )
10915            .unwrap_or_else(|| neutral_title(state, winner))
10916        })
10917    };
10918
10919    let mut body = String::new();
10920    let what = summary_without_title(summary);
10921    if !what.is_empty() {
10922        body.push_str("## Summary\n\n");
10923        body.push_str(&what);
10924        body.push_str("\n\n");
10925    }
10926
10927    // Review history uses structured facts across all rounds, never reports.
10928    body.push_str(&crate::pr_outcome::review_rounds(state));
10929    body.push_str(&crate::pr_outcome::final_verification(state, head));
10930    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10931
10932    let open = state.open_findings();
10933    if !open.is_empty() {
10934        body.push_str("## Open review findings\n\n");
10935        for f in &open {
10936            body.push_str(&format!(
10937                "- `{}` [{:?}] {}\n",
10938                f.id, f.severity, "Context retained in local review records."
10939            ));
10940        }
10941        body.push('\n');
10942    }
10943
10944    if let Some(fix) = fix
10945        && !fix.rejected.is_empty()
10946    {
10947        body.push_str("## Declined by the fixer\n\n");
10948        for r in &fix.rejected {
10949            body.push_str(&format!(
10950                "- `{}`: {}\n",
10951                r.id, "Reason retained in local review records."
10952            ));
10953        }
10954        body.push('\n');
10955    }
10956
10957    if review {
10958        // The review prompt is not the task; list what the branch carries.
10959        body.push_str("## Commits under review\n\n");
10960        if let Some(facts) = facts {
10961            let mut left = COMMITS_MAX_CHARS;
10962            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10963                let mut entry = format!("- {}\n", markdown_safe(subject));
10964                for l in markdown_safe(text).lines() {
10965                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10966                }
10967                if left == 0 {
10968                    body.push_str(&format!(
10969                        "- ... {} more commit(s)\n",
10970                        facts.commits.len() - i
10971                    ));
10972                    break;
10973                }
10974                if entry.len() > left {
10975                    // Even the first commit is cut: one huge body must not
10976                    // push the whole description past GitHub's limit.
10977                    let mut end = left;
10978                    while !entry.is_char_boundary(end) {
10979                        end -= 1;
10980                    }
10981                    entry.truncate(end);
10982                    entry.push_str("\n  ... (truncated)\n");
10983                    left = 0;
10984                } else {
10985                    left -= entry.len();
10986                }
10987                body.push_str(&entry);
10988            }
10989            if !facts.stat.trim().is_empty() {
10990                body.push_str(&format!(
10991                    "\n## Diff stat\n\n```\n{}\n```\n",
10992                    markdown_safe(facts.stat.trim())
10993                ));
10994            }
10995        } else {
10996            match &state.reviewed_commits {
10997                Some(subjects) => {
10998                    for s in subjects {
10999                        body.push_str(&format!("- {}\n", s.trim()));
11000                    }
11001                }
11002                None => {
11003                    // An older run kept only the prompt, with the commit list
11004                    // after its first paragraph.
11005                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
11006                    body.push_str(rest.trim());
11007                    body.push('\n');
11008                }
11009            }
11010        }
11011    } else {
11012        let task = state.instruction.trim();
11013        let task = if task.is_empty() {
11014            "(empty task)"
11015        } else {
11016            task
11017        };
11018        body.push_str(&format!(
11019            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
11020            task.replace("</details>", "&lt;/details&gt;")
11021        ));
11022    }
11023
11024    body.push_str(&format!(
11025        "\n---\nmagi:run/{} magi:candidate-{}\n",
11026        state.id,
11027        winner.to_ascii_lowercase()
11028    ));
11029
11030    PrMessage { title, body }
11031}
11032
11033/// The task with what the repository says about the existing work it names
11034/// appended, so an implementer knows what it started from and what it must
11035/// not redo. Unchanged when the task names nothing.
11036fn seeded_instruction(state: &RunState) -> String {
11037    match refs::describe(&state.seeds) {
11038        Some(facts) => format!(
11039            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
11040             Candidates start from the unmerged branch named above, when there \
11041             is one, and carry any unmerged commit named by sha as a \
11042             cherry-pick. Check that this is what the task meant before \
11043             building on it.",
11044            state.instruction
11045        ),
11046        None => state.instruction.clone(),
11047    }
11048}
11049
11050/// Does the winner have no commits ahead of the base it would land on?
11051/// Any failure to find out reads as "not empty": the merge then behaves as it
11052/// always did rather than refusing on a guess.
11053async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
11054    let base = &state.base_branch;
11055    let mut against = base.clone();
11056    if mode == MergeMode::Pr {
11057        // A pull request lands on the remote's base, never the local branch
11058        // of the same name: if that cannot be read, "not empty" is the safe
11059        // answer.
11060        let remote = &state.config.merge.remote;
11061        let tracking = format!("{remote}/{base}");
11062        let fetched = git::fetch(repo, remote, base).await;
11063        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
11064            against = tracking;
11065        } else {
11066            return false;
11067        }
11068    }
11069    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
11070}
11071
11072/// Why nothing was opened for an empty winner, with what the task's own
11073/// references resolved to.
11074fn empty_candidate_detail(state: &RunState, base: &str) -> String {
11075    let mut detail = format!(
11076        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
11077         nothing to open a pull request for"
11078    );
11079    match refs::describe(&state.seeds) {
11080        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
11081        None => detail.push_str(
11082            "\nThe task names no existing branch or commit; if it means to land work \
11083             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
11084        ),
11085    }
11086    detail
11087}
11088
11089/// What the `Pr` merge does once it knows whether the branch already has an
11090/// open pull request.
11091#[derive(Debug, PartialEq, Eq)]
11092enum PrPlan {
11093    Create,
11094    Adopt { url: String, title: String },
11095    Stop(String),
11096}
11097
11098/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
11099/// a failed lookup -> stop with the real reason. Never guesses.
11100fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
11101    match found {
11102        Ok(land::OpenPr::None) => PrPlan::Create,
11103        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
11104        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
11105            "several open pull requests exist for this branch, not picking one: {}",
11106            urls.join(" ")
11107        )),
11108        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
11109    }
11110}
11111
11112/// `gh pr create`, returning the PR url.
11113pub(crate) async fn gh_pr_create(
11114    cwd: &Path,
11115    base: &str,
11116    head: &str,
11117    title: &str,
11118    body: &str,
11119) -> Result<String> {
11120    let out = tokio::process::Command::new("gh")
11121        .args([
11122            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
11123        ])
11124        .current_dir(cwd)
11125        .quiet()
11126        .stdin(std::process::Stdio::null())
11127        .output()
11128        .await
11129        .context("spawn gh")?;
11130    if out.status.success() {
11131        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
11132    } else {
11133        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
11134    }
11135}
11136
11137/// Never force deletion of uncommitted work, including unknown legacy trees.
11138async fn remove_preserved_worktree(repo: &Path, path: &Path) -> Result<()> {
11139    match std::fs::symlink_metadata(path) {
11140        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
11141            // A previous cleanup may have removed the tree before a branch
11142            // deletion or state save failed. Unregister it if still present.
11143            git::worktree_remove_clean(repo, path).await?;
11144            return Ok(());
11145        }
11146        Err(e) => return Err(e.into()),
11147        Ok(_) => {}
11148    }
11149    if !git::is_clean(path).await? {
11150        bail!(
11151            "worktree has uncommitted work; refusing removal: {}",
11152            path.display()
11153        );
11154    }
11155    if !git::worktree_remove_clean(repo, path).await? {
11156        bail!("worktree removal refused: {}", path.display());
11157    }
11158    Ok(())
11159}
11160
11161/// Tear a run's worktrees and branches down.
11162///
11163/// `home` is where the updated `run.json` is saved (via
11164/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
11165/// a housekeeping pass already has its own honest `home` handed to it, and
11166/// falling through to the global here would write back through whichever
11167/// directory some other process or test pinned into that `OnceLock` first,
11168/// not the one the caller actually resolved its `runs` and `state` from.
11169pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
11170    if crate::pr_recovery::sources_retained(home, &state.id) {
11171        return Ok(Vec::new());
11172    }
11173    if !state.candidate_preservation.is_empty() {
11174        bail!("candidate preservation incomplete; refusing cleanup");
11175    }
11176    let repo = state.repo.clone();
11177    // CLI, Web and automatic cleanup all enter here, independently of
11178    // Runner::fold_losers. Protect deletion and shared config release too.
11179    let wait = repository_wait(state, None);
11180    let _coordination = match coordinate_operation(state, &repo, &wait).await {
11181        Ok(lease) => lease,
11182        Err(e) => {
11183            state.save_under(home)?;
11184            return Err(e);
11185        }
11186    };
11187    let root = state.worktree_root();
11188    let winner = state.tally.as_ref().map(|t| t.winner);
11189    let mut removed = Vec::new();
11190
11191    for i in 0..state.candidates.len() {
11192        let c = state.candidates[i].clone();
11193        let is_winner = Some(c.label) == winner;
11194        if is_winner && !drop_winner {
11195            continue;
11196        }
11197        let existed = c.worktree.exists();
11198        // A vanished directory can still have a Git registration that keeps
11199        // its branch checked out. Remove that registration before the branch.
11200        remove_preserved_worktree(&repo, &c.worktree).await?;
11201        if existed {
11202            removed.push(c.worktree.to_string_lossy().into_owned());
11203        }
11204        // A branch handed to a later run (and its pull request) is not this
11205        // run's to delete.
11206        let handed_over = state.released_branches.contains(&c.branch);
11207        if !handed_over && git::branch_exists(&repo, &c.branch).await? {
11208            if !git::branch_delete(&repo, &c.branch).await? {
11209                bail!("candidate branch removal refused");
11210            }
11211            removed.push(c.branch.clone());
11212        }
11213        state.candidates[i].folded = true;
11214    }
11215
11216    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
11217        let path = name.path();
11218        let keep = !drop_winner
11219            && winner.is_some_and(|w| {
11220                path.file_name()
11221                    .is_some_and(|n| n == format!("cand-{w}").as_str())
11222            });
11223        if keep {
11224            continue;
11225        }
11226        remove_preserved_worktree(&repo, &path).await?;
11227        removed.push(path.to_string_lossy().into_owned());
11228    }
11229
11230    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
11231    // judge worktrees, so once the loop above has cleared all of them out,
11232    // the parent is a bare directory nobody else was ever going to remove -
11233    // git only ever managed what was inside it. Left alone, one of these
11234    // accumulates per fully-folded run; the operator's own machine had 74.
11235    // `remove_if_empty` re-checks rather than assuming: a run whose winner
11236    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
11237    // so does anything a run never claimed that happens to share the bay.
11238    remove_if_empty(&root);
11239
11240    if state.enabled_worktree_config && drop_winner {
11241        // A release, not a raw disable: some sibling run in this repository
11242        // may still hold its own reference (see `git::acquire_worktree_config`),
11243        // and only the last release actually turns the setting back off.
11244        git::release_worktree_config(&repo).await.ok();
11245        state.enabled_worktree_config = false;
11246    }
11247    state.save_under(home)?;
11248    Ok(removed)
11249}
11250
11251/// Remove `dir` if it exists and has nothing in it.
11252///
11253/// Best-effort and silent by design: a directory that is not empty (a run
11254/// whose winner is still parked there, a stray file some other process left)
11255/// is exactly the case this must refuse, and a directory that is already gone
11256/// is not a failure worth reporting either. `std::fs::remove_dir` itself
11257/// already refuses a non-empty directory, so the emptiness check below is
11258/// belt, not suspenders - it is what keeps this from ever attempting the
11259/// removal in the case that matters, rather than trusting `remove_dir`'s
11260/// error path to have no side effects if it ever changed.
11261fn remove_if_empty(dir: &Path) {
11262    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
11263        std::fs::remove_dir(dir).ok();
11264    }
11265}
11266
11267/// Severity of the worst open finding in the last review round, for reporting.
11268pub fn worst_open(state: &RunState) -> Option<Severity> {
11269    state
11270        .reviews
11271        .last()?
11272        .reviews
11273        .iter()
11274        .flat_map(|r| r.findings.iter())
11275        .map(|f| f.severity)
11276        .max()
11277}
11278
11279/// `[worktree] setup` hides its products per worktree, which needs
11280/// `extensions.worktreeConfig`. Taken once per run and released with the run's
11281/// worktrees, like the hook's; every place that runs setup calls this first.
11282async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
11283    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
11284        git::acquire_worktree_config(repo).await?;
11285        state.enabled_worktree_config = true;
11286    }
11287    Ok(())
11288}
11289
11290#[cfg(test)]
11291mod tests {
11292    #[test]
11293    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11294        let leaked = vec!["chore(deps): update a crate".to_owned()];
11295        let own = "fix(daemon): apply a chosen action";
11296        assert!(should_retitle("", own, &leaked));
11297        assert!(should_retitle(
11298            &format!("{REVIEW_PROMPT_OPENING} `x`"),
11299            own,
11300            &leaked
11301        ));
11302        assert!(should_retitle(
11303            "chore: land candidate A of run 1",
11304            own,
11305            &leaked
11306        ));
11307        assert!(should_retitle(
11308            "magi: candidate A (uncommitted work)",
11309            own,
11310            &leaked
11311        ));
11312        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11313        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11314        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11315    }
11316
11317    #[test]
11318    fn pr_merge_plan_creates_adopts_or_stops() {
11319        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11320        assert_eq!(
11321            pr_merge_plan(Ok(land::OpenPr::One {
11322                url: "u".into(),
11323                title: "t".into()
11324            })),
11325            PrPlan::Adopt {
11326                url: "u".into(),
11327                title: "t".into()
11328            }
11329        );
11330        let PrPlan::Stop(many) =
11331            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11332        else {
11333            panic!("many must stop");
11334        };
11335        assert!(many.contains('a') && many.contains('b'));
11336        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11337            panic!("a failed lookup must stop");
11338        };
11339        assert!(err.contains("bad token"));
11340    }
11341
11342    use super::*;
11343    use crate::run::GateStatus;
11344    use std::collections::BTreeMap;
11345    use std::time::Duration;
11346
11347    fn conductor() -> AgentSpec {
11348        AgentSpec {
11349            id: "conductor".to_owned(),
11350            kind: crate::config::AgentKind::Command,
11351            model: None,
11352            command: vec!["true".to_owned()],
11353            extra_args: Vec::new(),
11354            env: BTreeMap::new(),
11355            prompt_delivery: None,
11356        }
11357    }
11358
11359    fn spec(id: &str) -> AgentSpec {
11360        AgentSpec {
11361            id: id.to_owned(),
11362            kind: crate::config::AgentKind::Command,
11363            model: None,
11364            command: vec!["true".to_owned()],
11365            extra_args: Vec::new(),
11366            env: BTreeMap::new(),
11367            prompt_delivery: None,
11368        }
11369    }
11370
11371    fn ids(xs: &[&str]) -> BTreeSet<String> {
11372        xs.iter().map(|s| (*s).to_owned()).collect()
11373    }
11374
11375    #[test]
11376    fn pick_successor_skips_an_agent_another_seat_holds() {
11377        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
11378        let roster = [spec("a"), spec("b"), spec("c")];
11379        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11380        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11381    }
11382
11383    #[test]
11384    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11385        // The other seat started on c but was handed to d; c is free again.
11386        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11387        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11388        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11389        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11390        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11391    }
11392
11393    #[test]
11394    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11395        let roster = [spec("a"), spec("b")];
11396        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11397        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11398        let carried = ids(&["b"]);
11399        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11400        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11401    }
11402
11403    #[test]
11404    fn pick_successor_returns_none_without_any_untried_successor() {
11405        let roster = [spec("a"), spec("b")];
11406        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11407        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11408        let carried = ids(&["a"]);
11409        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11410    }
11411
11412    #[test]
11413    fn pick_successor_rescue_avoids_another_seats_occupant() {
11414        // b is a carried failure and free; a is held by the other seat.
11415        let roster = [spec("a"), spec("b"), spec("c")];
11416        let carried = ids(&["b", "c"]);
11417        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11418        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11419    }
11420
11421    #[test]
11422    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11423        let roster = [spec("a"), spec("b"), spec("c")];
11424        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11425        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11426    }
11427
11428    #[test]
11429    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11430        let roster = [spec("a"), spec("b"), spec("c")];
11431        let failed = ids(&["a", "b", "c"]);
11432        // Rescue looks at the whole roster, once per id, then runs out.
11433        let mut tried = ids(&["b"]);
11434        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11435        assert_eq!(first.id, "a");
11436        tried.insert(first.id.clone());
11437        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11438        assert_eq!(second.id, "c");
11439        tried.insert(second.id.clone());
11440        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11441    }
11442
11443    #[test]
11444    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11445        let roster = [spec("a"), spec("b")];
11446        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11447        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11448    }
11449
11450    #[test]
11451    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11452        let roster = [spec("a"), spec("b"), spec("c")];
11453        let h = SeatHistory {
11454            failed: ids(&["a"]),
11455            last_ok: Some("b".to_owned()),
11456            last_fail: None,
11457        };
11458        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11459        // A last answerer that left the roster, or later failed, is ignored.
11460        let gone = SeatHistory {
11461            last_ok: Some("zzz".to_owned()),
11462            ..h.clone()
11463        };
11464        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11465        let failed = SeatHistory {
11466            failed: ids(&["a", "b"]),
11467            last_ok: Some("b".to_owned()),
11468            last_fail: None,
11469        };
11470        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11471    }
11472
11473    #[test]
11474    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11475        let first = SeatState::new("review-1", "alpha", 7);
11476        let next = handover_seat("review-1", "gamma", 7);
11477        assert_ne!(first.claude_session, next.claude_session);
11478    }
11479
11480    #[test]
11481    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11482        let mut state = state_with_summary("x", "y");
11483        let a = handover_seat("review-1", "beta", state.next_seat_seed());
11484        let b = handover_seat("review-1", "beta", state.next_seat_seed());
11485        assert_ne!(a.claude_session, b.claude_session);
11486    }
11487
11488    #[test]
11489    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11490        let roster = [spec("a"), spec("b")];
11491        let h = SeatHistory {
11492            failed: ids(&["a", "b"]),
11493            ..SeatHistory::default()
11494        };
11495        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11496        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11497        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11498    }
11499
11500    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
11501    // fallback loop depends on to terminate: it must walk forward from the
11502    // seat's own position, never restart at the front of the roster, and it
11503    // must never hand back an id already tried, however many times that id
11504    // happens to appear.
11505
11506    #[test]
11507    fn failure_signature_ignores_numbers_and_paths() {
11508        assert_eq!(
11509            failure_signature("exited with Some(2) and no usable output"),
11510            failure_signature("exited with Some(137) and no usable output")
11511        );
11512        assert_eq!(
11513            failure_signature("process exited with no usable output (exit code 2)"),
11514            failure_signature("process exited with no usable output (exit code 137)")
11515        );
11516        for (old, new) in [
11517            (
11518                "exited with Some(2) and no usable output",
11519                "process exited with no usable output (exit code 2)",
11520            ),
11521            (
11522                "exited with None and no usable output",
11523                "process exited with no usable output (exit code unavailable)",
11524            ),
11525        ] {
11526            let previous = FailClass::Other(failure_signature(old));
11527            let current = FailClass::Other(failure_signature(new));
11528            assert_eq!(previous, current, "resumption retains the failure class");
11529            assert!(!should_hand_over(Some(&previous), &current));
11530        }
11531        assert_eq!(
11532            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11533            failure_signature("cannot open /var/x.txt: denied")
11534        );
11535        assert_ne!(failure_signature("boom"), failure_signature("bang"));
11536    }
11537
11538    #[test]
11539    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11540        let other = FailClass::Other("x".into());
11541        assert!(should_hand_over(None, &FailClass::Quota));
11542        assert!(should_hand_over(Some(&other), &FailClass::Quota));
11543        assert!(should_hand_over(
11544            Some(&FailClass::Timeout),
11545            &FailClass::Timeout
11546        ));
11547        assert!(should_hand_over(None, &other));
11548        assert!(!should_hand_over(Some(&other), &other));
11549        assert!(should_hand_over(
11550            Some(&other),
11551            &FailClass::Other("y".into())
11552        ));
11553        // A quota or timeout in between ends the run of identical failures.
11554        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11555        assert!(should_hand_over(Some(&FailClass::Quota), &other));
11556    }
11557
11558    #[test]
11559    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11560        let a = SeatState::new("judge-1", "alpha", 7);
11561        let b = handover_seat("judge-1", "beta", 7);
11562        assert_ne!(a.claude_session, b.claude_session);
11563        assert_eq!(b.turns, 0);
11564    }
11565
11566    #[test]
11567    fn a_timeout_is_classified_apart_from_other_failures() {
11568        assert_eq!(
11569            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11570            Some(FailClass::Timeout)
11571        );
11572        assert!(matches!(
11573            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11574            Some(FailClass::Other(_))
11575        ));
11576    }
11577
11578    #[test]
11579    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11580        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11581        let tried = BTreeSet::from(["beta".to_owned()]);
11582        // beta sits at index 1; the next candidate is gamma, never alpha —
11583        // which is very likely a different candidate slot's own agent.
11584        let next = next_untried_in_roster(&roster, 1, &tried);
11585        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11586    }
11587
11588    #[test]
11589    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11590        let roster = vec![spec("alpha"), spec("beta")];
11591        let tried = BTreeSet::from(["beta".to_owned()]);
11592        // beta is the roster's last entry: nothing follows it, and alpha —
11593        // earlier in the roster, almost certainly a different candidate
11594        // slot's own agent — must not be reached by wrapping back to it.
11595        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11596    }
11597
11598    #[test]
11599    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11600        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11601        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11602        // beta (index 1) and gamma (index 2, the only entry after it) have
11603        // both been tried; alpha (index 0) never has, but it comes before
11604        // beta's own position, so there is nothing further for this seat.
11605        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11606    }
11607
11608    #[test]
11609    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11610        let roster = vec![spec("a"), spec("a"), spec("b")];
11611        let tried = BTreeSet::from(["a".to_owned()]);
11612        let next = next_untried_in_roster(&roster, 0, &tried);
11613        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11614    }
11615
11616    #[test]
11617    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11618        let roster = vec![spec("a"), spec("b")];
11619        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11620        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11621    }
11622
11623    #[test]
11624    fn remove_if_empty_only_ever_takes_a_bare_directory() {
11625        let dir = tempfile::tempdir().unwrap();
11626        let bay = dir.path().join("ffff");
11627
11628        // Not there yet: nothing to do, nothing to panic on.
11629        remove_if_empty(&bay);
11630        assert!(!bay.exists());
11631
11632        // Something still inside - the winner's worktree, or a stray file -
11633        // keeps the directory standing.
11634        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11635        remove_if_empty(&bay);
11636        assert!(bay.exists(), "non-empty directory must survive");
11637
11638        // Once the last entry is gone, so is the directory itself.
11639        std::fs::remove_dir(bay.join("cand-A")).unwrap();
11640        remove_if_empty(&bay);
11641        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11642    }
11643
11644    // `round_is_clean` is the exact decision this task fixed: a round with a
11645    // seat that never answered must not read the same as a round every seat
11646    // actually reviewed. These are deterministic and process-free by design —
11647    // the equivalent end-to-end check (a real reviewer timing out under a
11648    // live graph run) is a genuine race against wall-clock contention, and a
11649    // spawn slow enough to blow even a generous budget under a loaded test
11650    // run must not turn this specific regression check flaky.
11651
11652    #[test]
11653    fn a_full_panel_that_found_nothing_is_clean() {
11654        assert!(round_is_clean(
11655            0,
11656            true,
11657            2,
11658            2,
11659            0,
11660            IncompleteReviewPolicy::Block
11661        ));
11662    }
11663
11664    #[test]
11665    fn a_missing_seat_is_never_clean_under_the_default_policy() {
11666        assert!(!round_is_clean(
11667            0,
11668            true,
11669            1,
11670            2,
11671            0,
11672            IncompleteReviewPolicy::Block
11673        ));
11674    }
11675
11676    #[test]
11677    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11678        assert!(!round_is_clean(
11679            1,
11680            true,
11681            1,
11682            2,
11683            0,
11684            IncompleteReviewPolicy::Warn
11685        ));
11686    }
11687
11688    #[test]
11689    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11690        assert!(round_is_clean(
11691            0,
11692            true,
11693            1,
11694            2,
11695            0,
11696            IncompleteReviewPolicy::Warn
11697        ));
11698    }
11699
11700    #[test]
11701    fn a_full_panel_with_an_open_finding_is_not_clean() {
11702        assert!(!round_is_clean(
11703            1,
11704            true,
11705            2,
11706            2,
11707            0,
11708            IncompleteReviewPolicy::Block
11709        ));
11710    }
11711
11712    #[test]
11713    fn a_full_panel_with_a_red_e2e_is_not_clean() {
11714        assert!(!round_is_clean(
11715            0,
11716            false,
11717            2,
11718            2,
11719            0,
11720            IncompleteReviewPolicy::Block
11721        ));
11722    }
11723
11724    // The stall this task closes: under the default `block` policy, a seat
11725    // missing only because it was rate limited must not force a wait for a
11726    // session limit that will not lift by the next round. `round_is_clean`
11727    // is where that quorum carve-out lives; the review loop around it never
11728    // changes what a reviewer's vote or a finding's severity means.
11729
11730    #[test]
11731    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11732        // 1 of 2 answered, and the one missing was quota'd — the exact
11733        // "review-2 rate limited (quota)" shape from the field report.
11734        assert!(round_is_clean(
11735            0,
11736            true,
11737            1,
11738            2,
11739            1,
11740            IncompleteReviewPolicy::Block
11741        ));
11742    }
11743
11744    #[test]
11745    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11746        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
11747        // not a quota loss (`quota_missing` stays 0) — worth another try.
11748        assert!(!round_is_clean(
11749            0,
11750            true,
11751            1,
11752            2,
11753            0,
11754            IncompleteReviewPolicy::Block
11755        ));
11756    }
11757
11758    #[test]
11759    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11760        assert!(!round_is_clean(
11761            1,
11762            true,
11763            1,
11764            2,
11765            1,
11766            IncompleteReviewPolicy::Block
11767        ));
11768        assert!(!round_is_clean(
11769            0,
11770            false,
11771            1,
11772            2,
11773            1,
11774            IncompleteReviewPolicy::Block
11775        ));
11776    }
11777
11778    #[test]
11779    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11780        // Every seat quota'd, nobody answered: there is no panel to decide
11781        // on, so this must fall through to the existing block-and-retry
11782        // fallback rather than call an unreviewed patch clean.
11783        assert!(!round_is_clean(
11784            0,
11785            true,
11786            0,
11787            2,
11788            2,
11789            IncompleteReviewPolicy::Block
11790        ));
11791    }
11792
11793    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11794        CommandOutcome {
11795            command: "test".to_owned(),
11796            code,
11797            output_tail: String::new(),
11798            duration_ms: 0,
11799            resource_blocked,
11800        }
11801    }
11802
11803    #[test]
11804    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11805        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11806        assert!(
11807            !verify_inconclusive(&[outcome(Some(1), false)]),
11808            "an ordinary failure is still evidence about the patch"
11809        );
11810        assert!(verify_inconclusive(&[outcome(None, true)]));
11811        assert!(
11812            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11813            "one inconclusive outcome taints the whole batch"
11814        );
11815        assert!(!verify_inconclusive(&[]));
11816    }
11817
11818    #[tokio::test]
11819    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11820        // Alive for the first two checks, then dead - confirms the loop
11821        // actually re-polls rather than deciding once and sleeping out the
11822        // ceiling regardless.
11823        let calls = std::sync::atomic::AtomicUsize::new(0);
11824        let started = Instant::now();
11825        wait_for_pids_with(
11826            &[123],
11827            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11828            Duration::from_millis(5),
11829            Duration::from_secs(5),
11830        )
11831        .await;
11832        assert!(
11833            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11834            "must keep checking rather than deciding on the first answer"
11835        );
11836        assert!(
11837            started.elapsed() < Duration::from_secs(1),
11838            "must return the moment it is confirmed dead, not wait out the ceiling"
11839        );
11840    }
11841
11842    #[tokio::test]
11843    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11844        let started = Instant::now();
11845        wait_for_pids_with(
11846            &[123],
11847            |_| true, // never reports dead
11848            Duration::from_millis(5),
11849            Duration::from_millis(30),
11850        )
11851        .await;
11852        let elapsed = started.elapsed();
11853        assert!(
11854            elapsed >= Duration::from_millis(30),
11855            "must not give up before its own ceiling: {elapsed:?}"
11856        );
11857        assert!(
11858            elapsed < Duration::from_secs(1),
11859            "must not wait past its own ceiling either: {elapsed:?}"
11860        );
11861    }
11862
11863    #[tokio::test]
11864    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11865        let started = Instant::now();
11866        wait_for_pids_with(
11867            &[],
11868            |_| true,
11869            Duration::from_secs(5),
11870            Duration::from_secs(5),
11871        )
11872        .await;
11873        assert!(
11874            started.elapsed() < Duration::from_millis(200),
11875            "an empty pid list has nothing to confirm"
11876        );
11877    }
11878
11879    // `review_conclusion` is the exact decision the review hand-off task
11880    // fixed: a round budget spent (or a tree that stopped moving) must not
11881    // collapse into `Blocked` regardless of what verification actually
11882    // said. Deterministic and process-free for the same reason the
11883    // `round_is_clean` family above is.
11884    fn review_round(
11885        clean: bool,
11886        blocking: usize,
11887        answered: usize,
11888        expected: usize,
11889        progressed: bool,
11890        e2e_ok: bool,
11891    ) -> ReviewRound {
11892        ReviewRound {
11893            round: 1,
11894            head: "h".to_owned(),
11895            verified_head: None,
11896            verified_at: None,
11897            reviews: Vec::new(),
11898            e2e: vec![CommandOutcome {
11899                command: "test".to_owned(),
11900                code: Some(if e2e_ok { 0 } else { 1 }),
11901                output_tail: String::new(),
11902                duration_ms: 0,
11903                resource_blocked: false,
11904            }],
11905            verify_retried: false,
11906            e2e_deferred: false,
11907            e2e_defer_reason: None,
11908            fix: None,
11909            blocking,
11910            answered,
11911            expected,
11912            clean,
11913            progressed,
11914            vote_split: false,
11915            reconsideration: Vec::new(),
11916            verdict: None,
11917        }
11918    }
11919
11920    #[test]
11921    fn review_conclusion_is_none_when_nothing_has_run() {
11922        assert_eq!(review_conclusion(&[], 3), None);
11923    }
11924
11925    #[test]
11926    fn review_conclusion_is_none_while_rounds_remain() {
11927        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11928        assert_eq!(review_conclusion(&rounds, 3), None);
11929    }
11930
11931    #[test]
11932    fn review_conclusion_is_gating_once_a_round_is_clean() {
11933        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11934        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11935    }
11936
11937    #[test]
11938    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11939        let rounds = vec![
11940            review_round(false, 1, 2, 2, true, true),
11941            review_round(false, 1, 2, 2, true, true),
11942        ];
11943        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11944    }
11945
11946    #[test]
11947    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11948        let rounds = vec![
11949            review_round(false, 1, 2, 2, true, true),
11950            review_round(false, 1, 2, 2, true, false),
11951        ];
11952        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11953    }
11954
11955    #[test]
11956    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11957        // Magi never got a command to run against this round's own head — a
11958        // resource-blocked attempt, not a red one — so this must never
11959        // settle on `Blocked` the way a genuine e2e failure would. `None`
11960        // here is what tells `Runner::review_loop` to retry the check
11961        // itself rather than trust this cheap recomputation with a verdict
11962        // it cannot actually produce.
11963        let mut blocked = review_round(false, 1, 2, 2, true, false);
11964        blocked.e2e[0].resource_blocked = true;
11965        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11966        assert_eq!(review_conclusion(&rounds, 2), None);
11967    }
11968
11969    #[test]
11970    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11971        // Missing input, not a verified tree — never a hand-off candidate.
11972        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11973        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11974    }
11975
11976    #[test]
11977    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11978        let rounds = vec![
11979            review_round(false, 1, 2, 2, false, true),
11980            review_round(false, 1, 2, 2, false, true),
11981        ];
11982        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11983    }
11984
11985    fn secs(n: u64) -> Duration {
11986        Duration::from_secs(n)
11987    }
11988
11989    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11990    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11991    #[tokio::test]
11992    async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11993        ask_test_home();
11994        let tmp = tempfile::tempdir().unwrap();
11995        let repo = tmp.path().join("repo");
11996        std::fs::create_dir_all(&repo).unwrap();
11997        init_repo(&repo);
11998        let g = |args: &[&str]| {
11999            let out = std::process::Command::new("git")
12000                .args(args)
12001                .current_dir(&repo)
12002                .quiet()
12003                .output()
12004                .unwrap();
12005            assert!(
12006                out.status.success(),
12007                "{}",
12008                String::from_utf8_lossy(&out.stderr)
12009            );
12010        };
12011        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
12012        g(&["checkout", "-b", "historical"]);
12013        std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
12014        g(&["add", "."]);
12015        g(&["commit", "-m", "old alternative"]);
12016        let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
12017        g(&["checkout", "-b", "magi/task-selected", &base]);
12018        std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
12019        g(&["add", "."]);
12020        g(&["commit", "-m", "measurement infrastructure"]);
12021        let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
12022        std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
12023        g(&["add", "."]);
12024        g(&["commit", "-m", "dependent implementation"]);
12025        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
12026        g(&["checkout", "main"]);
12027        std::fs::write(repo.join("unrelated"), "current main change").unwrap();
12028        g(&["add", "."]);
12029        g(&["commit", "-m", "unrelated base change"]);
12030        let current = git::rev_parse(&repo, "HEAD").await.unwrap();
12031        let origin = tmp.path().join("origin.git");
12032        g(&[
12033            "clone",
12034            "--bare",
12035            repo.to_str().unwrap(),
12036            origin.to_str().unwrap(),
12037        ]);
12038        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
12039        g(&["fetch", "origin"]);
12040        let selection = crate::pr_recovery::Selection {
12041            url: "https://example.test/project/pull/1".into(),
12042            branch: "magi/task-selected".into(),
12043            head: head.clone(),
12044            instruction: "preserve measured behavior".into(),
12045            approved_at: jiff::Timestamp::now(),
12046        };
12047        let mut template = runner_at(RunStatus::Reviewing);
12048        template.state.repo = repo.clone();
12049        template.state.base_commit = current.clone();
12050        template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
12051        template.state.origin = Some(crate::run::Origin::queue("selected"));
12052        template.state.pr_head = Some(selection.branch.clone());
12053        template.state.pr_recovery = Some(selection.clone());
12054        let mut runner =
12055            Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
12056                .await
12057                .unwrap();
12058        let wt = runner.state.winner().unwrap().worktree.clone();
12059        assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
12060        assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
12061        assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
12062        assert!(runner.state.seeds.is_empty());
12063        assert_eq!(runner.state.instruction, "preserve measured behavior");
12064        runner.sync_to_base().await.unwrap();
12065        assert_eq!(
12066            std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
12067            "prerequisite"
12068        );
12069        assert_eq!(
12070            std::fs::read_to_string(wt.join("implementation")).unwrap(),
12071            "measured implementation"
12072        );
12073        assert_eq!(
12074            std::fs::read_to_string(wt.join("unrelated")).unwrap(),
12075            "current main change"
12076        );
12077        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
12078        assert_eq!(
12079            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
12080            head
12081        );
12082
12083        // A failed selected-source setup stays inspectable, never resumable as a new competition.
12084        let mut partial = runner_at(RunStatus::Reviewing);
12085        partial.state.repo = repo.clone();
12086        partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
12087        partial.state.config.worktree.setup = vec![crate::config::SetupStep {
12088            run: Some("exit 1".into()),
12089            ..Default::default()
12090        }];
12091        let selection = runner.state.pr_recovery.clone().unwrap();
12092        partial.state.pr_recovery = Some(selection.clone());
12093        partial.state.pr_head = Some(selection.branch.clone());
12094        partial.state.origin = Some(crate::run::Origin::queue("selected"));
12095        let id = partial.state.id.clone();
12096        let error = match Runner::open_selected(
12097            &repo,
12098            partial.state,
12099            partial.roles,
12100            1,
12101            runner.state.base_commit.clone(),
12102            selection,
12103        )
12104        .await
12105        {
12106            Ok(_) => panic!("setup must fail"),
12107            Err(e) => e,
12108        };
12109        assert_eq!(
12110            error
12111                .downcast_ref::<crate::pr_recovery::StartupFailed>()
12112                .unwrap()
12113                .run,
12114            id
12115        );
12116        let failed = RunState::load(&id).unwrap();
12117        assert_eq!(failed.status, RunStatus::Blocked);
12118        assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
12119        assert!(Runner::resume(&id).is_err());
12120        runner.state.status = RunStatus::Ready;
12121        runner.state.driver_exited = true;
12122        runner.state.save().unwrap();
12123        let mut retry = runner_at(RunStatus::Reviewing);
12124        retry.state.repo = repo.clone();
12125        retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
12126        let mut reselected = runner.state.pr_recovery.clone().unwrap();
12127        reselected.approved_at = jiff::Timestamp::now();
12128        retry.state.origin = Some(crate::run::Origin::queue("selected"));
12129        retry.state.pr_head = Some(reselected.branch.clone());
12130        retry.state.pr_recovery = Some(reselected.clone());
12131        crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
12132            .await
12133            .unwrap();
12134        let restarted = Runner::open_selected(
12135            &repo,
12136            retry.state,
12137            retry.roles,
12138            1,
12139            runner.state.base_commit.clone(),
12140            reselected,
12141        )
12142        .await
12143        .unwrap();
12144        assert_eq!(
12145            git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
12146                .await
12147                .unwrap(),
12148            head
12149        );
12150        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
12151        assert_eq!(
12152            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
12153            head
12154        );
12155    }
12156
12157    #[tokio::test]
12158    #[cfg(unix)]
12159    async fn fallback_and_interrupted_restoration_require_coordination() {
12160        use std::os::fd::AsRawFd as _;
12161        ask_test_home();
12162        let tmp = tempfile::tempdir().unwrap();
12163        let mut runner = registered_runner(tmp.path()).await;
12164        runner.state.config.graph.timeout_resource_wait = 1;
12165        let winner = runner.state.candidates[0].clone();
12166        let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12167        let leftover = winner.worktree.join("leftover");
12168        std::fs::write(&leftover, "untrusted edits").unwrap();
12169        let next = tmp.path().join("next-fixer");
12170        let mut failed = spec("first");
12171        failed.command = vec!["sh".into(), "-c".into(), "exit 1".into()];
12172        let mut successor = spec("second");
12173        successor.command = vec![
12174            "sh".into(),
12175            "-c".into(),
12176            format!("touch '{}'; echo done", next.display()),
12177        ];
12178        let attempts = vec![
12179            (failed.clone(), "first".into()),
12180            (successor.clone(), "second".into()),
12181        ];
12182        let lock_path = runner.state.repo.join(".git/updater.lock");
12183        let external = std::fs::File::create(&lock_path).unwrap();
12184        assert_eq!(
12185            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12186            0
12187        );
12188        let artifacts = tmp.path().join("artifacts");
12189        let build = |spec, seat| SeatJob {
12190            spec,
12191            seat,
12192            cwd: winner.worktree.clone(),
12193            prompt: "fixture".into(),
12194            timeout: Duration::from_secs(10),
12195            allow_write: true,
12196            sessions: false,
12197            artifacts: artifacts.clone(),
12198            stem: "fix".into(),
12199            handover: None,
12200        };
12201        let (_, _, out) = runner
12202            .ask_fixer_among(
12203                attempts.clone(),
12204                "graft",
12205                None,
12206                Some((&winner.worktree, &head)),
12207                build,
12208            )
12209            .await;
12210        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
12211        assert!(leftover.exists(), "contention must prevent clean");
12212        assert!(!next.exists());
12213        assert!(runner.state.handovers.is_empty());
12214        // Early interrupted-repair rollback uses the same protected route.
12215        runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12216            from_run: "earlier".into(),
12217            generation: 1,
12218            mode: crate::recovery::Mode::VerifyRepair,
12219            reasons: Vec::new(),
12220            reviewed_head: None,
12221            rounds: Vec::new(),
12222            open_findings: Vec::new(),
12223            evidence: None,
12224            criteria_inherited: true,
12225            repair: crate::recovery::Repair::Started {
12226                before: head.clone(),
12227            },
12228        });
12229        assert!(
12230            runner
12231                .recovery_repair(&winner)
12232                .await
12233                .unwrap_err()
12234                .is::<crate::repo_lock::ResourceBlocked>()
12235        );
12236        assert!(matches!(
12237            runner.state.recovery.as_ref().unwrap().repair,
12238            crate::recovery::Repair::Started { .. }
12239        ));
12240        assert!(leftover.exists());
12241        drop(external);
12242        runner.recovery_repair(&winner).await.unwrap();
12243        assert!(!leftover.exists());
12244        assert!(matches!(
12245            runner.state.recovery.as_ref().unwrap().repair,
12246            crate::recovery::Repair::Done { .. }
12247        ));
12248        // A hard lock-open failure also leaves HEAD and untracked work intact.
12249        std::fs::remove_file(&lock_path).unwrap();
12250        std::fs::create_dir(&lock_path).unwrap();
12251        std::fs::write(&leftover, "untrusted edits").unwrap();
12252        let (_, _, out) = runner
12253            .ask_fixer_among(
12254                attempts,
12255                "graft",
12256                None,
12257                Some((&winner.worktree, &head)),
12258                build,
12259            )
12260            .await;
12261        assert!(matches!(out, AgentOutcome::Failed(_)));
12262        assert!(leftover.exists());
12263        assert!(!next.exists());
12264        assert_eq!(
12265            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12266            head
12267        );
12268    }
12269
12270    #[cfg(unix)]
12271    async fn registered_runner(root: &Path) -> Runner {
12272        ask_test_home();
12273        let repo = root.join("repo");
12274        std::fs::create_dir_all(&repo).unwrap();
12275        init_repo(&repo);
12276        std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12277        let mut runner = runner_at(RunStatus::Prep);
12278        runner.state.repo = repo.clone();
12279        runner.state.config.coordination = crate::config::test_coordination();
12280        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12281        runner.state.config.graph.worktree_root = Some(root.join("wt"));
12282        runner.state.config.graph.advise = false;
12283        runner.roles.implementers = vec![spec("implement")];
12284        runner.roles.judges = vec![spec("judge")];
12285        runner.prep().await.unwrap();
12286        runner
12287    }
12288
12289    #[tokio::test]
12290    #[cfg(unix)]
12291    async fn parked_implementer_persists_completed_work() {
12292        for registered in [false, true] {
12293            let tmp = tempfile::tempdir().unwrap();
12294            let mut runner = registered_runner(tmp.path()).await;
12295            if !registered {
12296                std::fs::remove_file(runner.state.repo.join(".git/updater.contract")).unwrap();
12297            }
12298            let marker = tmp.path().join("started");
12299            let release = tmp.path().join("release");
12300            let mut implementer = spec("implement");
12301            implementer.command = vec![
12302                "sh".into(),
12303                "-c".into(),
12304                format!(
12305                    "touch '{}'; while [ ! -f '{}' ]; do sleep 0.01; done; echo completed > completed; echo saved-result",
12306                    marker.display(),
12307                    release.display()
12308                ),
12309            ];
12310            runner.roles.implementers = vec![implementer.clone()];
12311            runner.state.config.agents = vec![implementer];
12312            let pause = runner.pause.clone();
12313            let (result, ()) = tokio::join!(runner.implement(), async {
12314                tokio::time::timeout(Duration::from_secs(10), async {
12315                    while !marker.exists() {
12316                        tokio::time::sleep(Duration::from_millis(10)).await;
12317                    }
12318                })
12319                .await
12320                .unwrap();
12321                pause.park();
12322                std::fs::write(&release, "go").unwrap();
12323            });
12324            result.unwrap();
12325            let candidate = &runner.state.candidates[0];
12326            assert!(candidate.commits > 0, "{candidate:?}");
12327            assert!(candidate.summary.contains("saved-result"));
12328            assert_eq!(candidate.files, 1);
12329            let before = candidate.commits;
12330            runner.implement().await.unwrap();
12331            assert_eq!(runner.state.candidates[0].commits, before);
12332        }
12333    }
12334
12335    #[tokio::test]
12336    #[cfg(unix)]
12337    async fn resource_blocked_recovery_retries_the_unstarted_fixer() {
12338        let tmp = tempfile::tempdir().unwrap();
12339        let mut runner = registered_runner(tmp.path()).await;
12340        let winner = runner.state.candidates[0].clone();
12341        let cache = tmp.path().join("cache");
12342        runner.state.config.verify.e2e = vec![format!("CARGO_TARGET_DIR={} true", cache.display())];
12343        runner.state.config.graph.timeout_resource_wait = 0;
12344        let owner = crate::cache::Owner::here("other", "e2e", "e2e", &cache, "abc");
12345        let crate::cache::AcquireOutcome::Acquired(held) =
12346            crate::cache::try_acquire(&crate::run::home(), &cache, &owner).unwrap()
12347        else {
12348            panic!("cache must be free")
12349        };
12350        runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12351            from_run: "earlier".into(),
12352            generation: 1,
12353            mode: crate::recovery::Mode::VerifyRepair,
12354            reasons: Vec::new(),
12355            reviewed_head: None,
12356            rounds: Vec::new(),
12357            open_findings: Vec::new(),
12358            evidence: None,
12359            criteria_inherited: true,
12360            repair: crate::recovery::Repair::NotStarted,
12361        });
12362        runner.recovery_repair(&winner).await.unwrap();
12363        assert!(matches!(
12364            runner.state.recovery.as_ref().unwrap().repair,
12365            crate::recovery::Repair::WaitingForRestore { .. }
12366        ));
12367        drop(held);
12368        runner.state.resource_block = None;
12369        runner.state.config.graph.timeout_resource_wait = 1;
12370        runner.recovery_repair(&winner).await.unwrap();
12371        assert!(matches!(
12372            runner.state.recovery.as_ref().unwrap().repair,
12373            crate::recovery::Repair::Done { .. }
12374        ));
12375        assert!(runner.state.seats.values().any(|seat| seat.turns > 0));
12376    }
12377
12378    #[tokio::test]
12379    #[cfg(unix)]
12380    async fn repository_blocked_graft_and_recovery_retry_after_deferred_restore() {
12381        use std::os::fd::AsRawFd as _;
12382        for graft in [false, true] {
12383            let tmp = tempfile::tempdir().unwrap();
12384            let mut runner = registered_runner(tmp.path()).await;
12385            runner.state.config.graph.timeout_resource_wait = 1;
12386            let winner = runner.state.candidates[0].clone();
12387            let marker = tmp.path().join("fixer-started");
12388            let mut fixer = spec("fixer");
12389            fixer.command = vec![
12390                "sh".into(),
12391                "-c".into(),
12392                format!("echo started >> '{}'; echo done", marker.display()),
12393            ];
12394            runner.roles.fixer = Some(vec![fixer]);
12395            if graft {
12396                let mut loser = winner.clone();
12397                loser.label = 'B';
12398                loser.index = 1;
12399                runner.state.candidates.push(loser);
12400                runner.state.config.graph.graft_runner_up = true;
12401                runner.state.tally = Some(Tally {
12402                    first_choice: BTreeMap::new(),
12403                    borda: BTreeMap::new(),
12404                    winner: winner.label,
12405                    rankings: 1,
12406                    unanimous_initial: true,
12407                    deliberated: false,
12408                    changed_votes: 0,
12409                    unanimous_final: true,
12410                    tie_break: None,
12411                    judges: 1,
12412                    present: 1,
12413                    quorum: 1,
12414                    met_quorum: true,
12415                    uncontested: None,
12416                });
12417                runner.state.judgements.push(crate::run::Judgement {
12418                    judge: 1,
12419                    seat: "judge-1".into(),
12420                    agent: "judge".into(),
12421                    ranking: vec![winner.label, 'B'],
12422                    reasons: BTreeMap::new(),
12423                    confidence: None,
12424                    strengths: BTreeMap::from([("B".into(), vec!["handles empty input".into()])]),
12425                    order: vec![0, 1],
12426                    failed: None,
12427                    duration_ms: 0,
12428                });
12429            } else {
12430                runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12431                    from_run: "earlier".into(),
12432                    generation: 1,
12433                    mode: crate::recovery::Mode::VerifyRepair,
12434                    reasons: Vec::new(),
12435                    reviewed_head: None,
12436                    rounds: Vec::new(),
12437                    open_findings: Vec::new(),
12438                    evidence: None,
12439                    criteria_inherited: true,
12440                    repair: crate::recovery::Repair::NotStarted,
12441                });
12442            }
12443            let reader = crate::repo_lock::SeatLease::for_checkout(
12444                &winner.worktree,
12445                false,
12446                crate::config::test_coordination().as_ref(),
12447            )
12448            .await
12449            .unwrap();
12450            let pause = runner.pause.clone();
12451            let (result, ()) = tokio::join!(
12452                async {
12453                    if graft {
12454                        runner.graft().await
12455                    } else {
12456                        runner.recovery_repair(&winner).await
12457                    }
12458                },
12459                async {
12460                    tokio::time::sleep(Duration::from_millis(100)).await;
12461                    pause.park();
12462                }
12463            );
12464            result.unwrap();
12465            assert!(!marker.exists(), "a blocked seat must not spawn");
12466            // Exercise the persisted state, as a binary replacement/resume does.
12467            runner.state = RunState::load(&runner.state.id).unwrap();
12468            if graft {
12469                assert_eq!(
12470                    runner.state.graft.as_ref().unwrap().outcome,
12471                    GraftOutcome::WaitingForRestore
12472                );
12473            } else {
12474                assert!(matches!(
12475                    runner.state.recovery.as_ref().unwrap().repair,
12476                    crate::recovery::Repair::WaitingForRestore { .. }
12477                ));
12478            }
12479            drop(reader);
12480            runner.pause = Pause::new();
12481            runner.state.resource_block = None;
12482            // Deferred restoration must still respect an external lock owner.
12483            let external =
12484                std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12485            assert_eq!(
12486                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12487                0
12488            );
12489            let leftover = winner.worktree.join("leftover");
12490            std::fs::write(&leftover, "earlier fallback edits").unwrap();
12491            let result = if graft {
12492                runner.graft().await
12493            } else {
12494                runner.recovery_repair(&winner).await
12495            };
12496            assert!(
12497                result
12498                    .unwrap_err()
12499                    .is::<crate::repo_lock::ResourceBlocked>()
12500            );
12501            assert!(leftover.exists());
12502            assert!(!marker.exists());
12503            drop(external);
12504            runner.state.resource_block = None;
12505            let result = if graft {
12506                runner.graft().await
12507            } else {
12508                runner.recovery_repair(&winner).await
12509            };
12510            result.unwrap();
12511            assert!(!leftover.exists(), "restore must precede retry");
12512            assert_eq!(std::fs::read_to_string(&marker).unwrap(), "started\n");
12513            if graft {
12514                assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12515            } else {
12516                assert!(matches!(
12517                    runner.state.recovery.as_ref().unwrap().repair,
12518                    crate::recovery::Repair::Done { .. }
12519                ));
12520            }
12521        }
12522    }
12523
12524    #[tokio::test]
12525    #[cfg(unix)]
12526    async fn interrupted_graft_restore_waits_and_preserves_its_record() {
12527        use std::os::fd::AsRawFd as _;
12528        let tmp = tempfile::tempdir().unwrap();
12529        let mut runner = registered_runner(tmp.path()).await;
12530        runner.state.config.graph.timeout_resource_wait = 1;
12531        let winner = runner.state.candidates[0].clone();
12532        let pre = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12533        runner.state.tally = Some(Tally {
12534            first_choice: BTreeMap::new(),
12535            borda: BTreeMap::new(),
12536            winner: winner.label,
12537            rankings: 0,
12538            unanimous_initial: false,
12539            deliberated: false,
12540            changed_votes: 0,
12541            unanimous_final: false,
12542            tie_break: None,
12543            judges: 0,
12544            present: 0,
12545            quorum: 0,
12546            met_quorum: true,
12547            uncontested: Some("fixture".into()),
12548        });
12549        runner.state.graft = Some(GraftRecord {
12550            pre: pre.clone(),
12551            post: None,
12552            lines: None,
12553            limit: None,
12554            agent: None,
12555            outcome: GraftOutcome::Running,
12556            requested: Vec::new(),
12557            adoption: None,
12558            adoption_error: None,
12559        });
12560        std::fs::write(winner.worktree.join("README.md"), "unfinished graft\n").unwrap();
12561        git::commit_all(&winner.worktree, "unfinished graft")
12562            .await
12563            .unwrap();
12564        let unfinished = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12565        let leftover = winner.worktree.join("leftover");
12566        std::fs::write(&leftover, "untrusted edits").unwrap();
12567        let path = runner.state.repo.join(".git/updater.lock");
12568        let external = std::fs::File::create(&path).unwrap();
12569        assert_eq!(
12570            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12571            0
12572        );
12573        assert!(
12574            runner
12575                .graft()
12576                .await
12577                .unwrap_err()
12578                .is::<crate::repo_lock::ResourceBlocked>()
12579        );
12580        assert!(matches!(
12581            runner.state.graft.as_ref().unwrap().outcome,
12582            GraftOutcome::Running
12583        ));
12584        assert_eq!(
12585            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12586            unfinished
12587        );
12588        assert!(leftover.exists());
12589        drop(external);
12590        std::fs::remove_file(&path).unwrap();
12591        std::fs::create_dir(&path).unwrap();
12592        assert!(runner.graft().await.is_err());
12593        assert!(matches!(
12594            runner.state.graft.as_ref().unwrap().outcome,
12595            GraftOutcome::Running
12596        ));
12597        assert_eq!(
12598            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12599            unfinished
12600        );
12601        assert!(leftover.exists());
12602        std::fs::remove_dir(&path).unwrap();
12603        runner.graft().await.unwrap();
12604        assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12605        assert_eq!(git::rev_parse(&winner.worktree, "HEAD").await.unwrap(), pre);
12606        assert!(!leftover.exists());
12607    }
12608
12609    #[tokio::test]
12610    #[cfg(unix)]
12611    async fn repository_contention_blocks_and_parks_without_poisoning_preparation() {
12612        use std::os::fd::AsRawFd as _;
12613        let tmp = tempfile::tempdir().unwrap();
12614        let mut runner = registered_runner(tmp.path()).await;
12615        runner.state.prep_complete = Some(false);
12616        runner.state.config.graph.timeout_resource_wait = 1;
12617        runner.state.save().unwrap();
12618        let external = std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12619        assert_eq!(
12620            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12621            0
12622        );
12623        runner.execute().await.unwrap();
12624        let disk = RunState::load(&runner.state.id).unwrap();
12625        assert!(disk.driver_exited);
12626        assert_eq!(disk.driver_pid, None);
12627        assert_eq!(runner.state.status, RunStatus::Blocked);
12628        assert_eq!(
12629            runner.state.resource_block.as_ref().unwrap().kind,
12630            "repository coordination"
12631        );
12632        assert!(runner.state.prep_failure.is_none());
12633        assert!(runner.state.seats.is_empty());
12634        runner.pause.park();
12635        // Include headroom for Git subprocesses under parallel test load;
12636        // the repository contention deadline itself remains one second.
12637        tokio::time::timeout(Duration::from_secs(10), runner.execute())
12638            .await
12639            .unwrap()
12640            .unwrap();
12641        assert!(runner.state.parked);
12642        assert!(runner.state.prep_failure.is_none());
12643        let disk = RunState::load(&runner.state.id).unwrap();
12644        assert!(disk.driver_exited);
12645        assert_eq!(disk.driver_pid, None);
12646    }
12647
12648    #[tokio::test]
12649    #[cfg(unix)]
12650    async fn dropping_execute_during_a_repository_wait_releases_the_driver() {
12651        use std::os::fd::AsRawFd as _;
12652        let tmp = tempfile::tempdir().unwrap();
12653        let mut runner = registered_runner(tmp.path()).await;
12654        runner.state.prep_complete = Some(false);
12655        runner.state.config.graph.timeout_resource_wait = 30;
12656        runner.state.save().unwrap();
12657        let external = std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12658        assert_eq!(
12659            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12660            0
12661        );
12662        assert!(
12663            tokio::time::timeout(Duration::from_millis(100), runner.execute())
12664                .await
12665                .is_err()
12666        );
12667        let disk = RunState::load(&runner.state.id).unwrap();
12668        assert!(disk.driver_exited);
12669        assert_eq!(disk.driver_pid, None);
12670        assert!(disk.status.resumable());
12671        assert!(runner.state.driver_exited);
12672        assert_eq!(runner.state.driver_pid, None);
12673        runner.state.save().unwrap();
12674        assert_eq!(RunState::load(&runner.state.id).unwrap().driver_pid, None);
12675    }
12676
12677    #[tokio::test]
12678    #[cfg(unix)]
12679    async fn gate_takes_cache_before_repository_so_an_editing_wave_can_finish() {
12680        let tmp = tempfile::tempdir().unwrap();
12681        let mut runner = registered_runner(tmp.path()).await;
12682        let winner = runner.state.candidates[0].clone();
12683        let cache = tmp.path().join("cache");
12684        let started = tmp.path().join("pre-gate-started");
12685        runner.state.config.verify.pre_gate = vec![format!("touch '{}'", started.display())];
12686        runner.state.config.verify.gate =
12687            vec![format!("CARGO_TARGET_DIR='{}' true", cache.display())];
12688        let home = crate::run::home();
12689        let owner =
12690            crate::cache::Owner::here("editing-wave", "implement", "*", &winner.worktree, "HEAD");
12691        let guard = match crate::cache::try_acquire(&home, &cache, &owner).unwrap() {
12692            crate::cache::AcquireOutcome::Acquired(g) => g,
12693            crate::cache::AcquireOutcome::Busy(busy) => panic!("cache must be free: {busy:?}"),
12694        };
12695        let mut gate = Box::pin(runner.run_gate(&winner));
12696        assert!(
12697            tokio::time::timeout(Duration::from_millis(150), &mut gate)
12698                .await
12699                .is_err()
12700        );
12701        assert!(!started.exists(), "pre-gate must wait for the cache too");
12702        // Simulate the wave that already owns the cache entering invoke.
12703        // It must obtain the editor reservation and finish without waiting
12704        // for the gate that is queued behind its cache.
12705        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_millis(200), || false);
12706        let editor = crate::repo_lock::SeatLease::checkout_with(
12707            &winner.worktree,
12708            true,
12709            crate::config::test_coordination().as_ref(),
12710            &wait,
12711        )
12712        .await
12713        .unwrap();
12714        drop(editor);
12715        guard.release();
12716        let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12717            .await
12718            .unwrap()
12719            .unwrap();
12720        drop(gate);
12721        assert!(outcomes.iter().all(CommandOutcome::ok));
12722        assert!(started.exists());
12723    }
12724
12725    #[tokio::test]
12726    #[cfg(unix)]
12727    async fn post_seat_rescue_and_gate_wait_for_shared_lock() {
12728        use std::os::fd::AsRawFd as _;
12729
12730        ask_test_home();
12731        let tmp = tempfile::tempdir().unwrap();
12732        let repo = tmp.path().join("repo");
12733        std::fs::create_dir_all(&repo).unwrap();
12734        init_repo(&repo);
12735        let common = repo.join(".git");
12736        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12737        let mut runner = runner_at(RunStatus::Prep);
12738        runner.state.repo = repo.clone();
12739        runner.state.config.coordination = crate::config::test_coordination();
12740        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12741        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12742        runner.state.config.graph.advise = false;
12743        runner.roles.implementers = vec![spec("implement")];
12744        runner.roles.judges = vec![spec("judge")];
12745        runner.prep().await.unwrap();
12746        let winner = runner.state.candidates[0].clone();
12747        // Emulate edits already left by a completed seat. The fixture CLI
12748        // only reports completion; it performs no repository operations.
12749        std::fs::write(winner.worktree.join("leftover"), "seat work\n").unwrap();
12750        let completed = tmp.path().join("seat-completed");
12751        runner.roles.implementers[0].command = vec![
12752            "sh".into(),
12753            "-c".into(),
12754            format!("touch '{}'; echo completed", completed.display()),
12755        ];
12756        let outsider = std::fs::File::create(common.join("updater.lock")).unwrap();
12757        assert_eq!(
12758            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12759            0
12760        );
12761        let before = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12762        let mut implement = Box::pin(runner.implement());
12763        tokio::select! {
12764            result = &mut implement => panic!("rescue must wait: {result:?}"),
12765            result = tokio::time::timeout(Duration::from_secs(10), async {
12766                while !completed.exists() {
12767                    tokio::time::sleep(Duration::from_millis(20)).await;
12768                }
12769            }) => result.unwrap(),
12770        }
12771        assert!(
12772            tokio::time::timeout(Duration::from_millis(200), &mut implement)
12773                .await
12774                .is_err()
12775        );
12776        assert_eq!(
12777            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12778            before
12779        );
12780        assert_eq!(
12781            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12782            0
12783        );
12784        tokio::time::timeout(Duration::from_secs(10), &mut implement)
12785            .await
12786            .unwrap()
12787            .unwrap();
12788        drop(implement);
12789        assert_ne!(
12790            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12791            before
12792        );
12793        assert!(git::is_clean(&winner.worktree).await.unwrap());
12794
12795        let started = tmp.path().join("pre-gate-started");
12796        let proceed = tmp.path().join("pre-gate-proceed");
12797        let checked = tmp.path().join("gate-ran");
12798        runner.state.config.verify.pre_gate = vec![format!(
12799            "echo mechanical >> README.md; touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12800            started.display(),
12801            proceed.display()
12802        )];
12803        runner.state.config.verify.gate = vec![format!("touch '{}'", checked.display())];
12804        assert_eq!(
12805            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12806            0
12807        );
12808        let mut gate = Box::pin(runner.run_gate(&winner));
12809        assert!(
12810            tokio::time::timeout(Duration::from_millis(200), &mut gate)
12811                .await
12812                .is_err()
12813        );
12814        assert!(!started.exists());
12815        assert!(!checked.exists());
12816        assert!(git::is_clean(&winner.worktree).await.unwrap());
12817        assert_eq!(
12818            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12819            0
12820        );
12821        tokio::select! {
12822            result = &mut gate => panic!("pre-gate must wait for signal: {result:?}"),
12823            result = tokio::time::timeout(Duration::from_secs(10), async {
12824                while !started.exists() {
12825                    tokio::time::sleep(Duration::from_millis(20)).await;
12826                }
12827            }) => result.unwrap(),
12828        }
12829        assert_eq!(
12830            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12831            -1,
12832            "mechanical edits must hold the real lock"
12833        );
12834        std::fs::write(proceed, "ready").unwrap();
12835        let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12836            .await
12837            .unwrap()
12838            .unwrap();
12839        drop(gate);
12840        assert!(outcomes.iter().all(CommandOutcome::ok));
12841        assert!(checked.exists());
12842        assert!(runner.state.pre_gate_commit.is_some());
12843        assert!(git::is_clean(&winner.worktree).await.unwrap());
12844        assert_eq!(
12845            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12846            0,
12847            "gate completion must release coordination"
12848        );
12849        drop(outsider);
12850        std::fs::remove_file(common.join("updater.lock")).unwrap();
12851        std::fs::create_dir(common.join("updater.lock")).unwrap();
12852        std::fs::remove_file(&started).unwrap();
12853        std::fs::remove_file(&checked).unwrap();
12854        let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12855        let error = runner.run_gate(&winner).await.unwrap_err();
12856        assert!(
12857            error
12858                .to_string()
12859                .contains("acquire mandatory repository lock")
12860        );
12861        assert!(
12862            !started.exists(),
12863            "failed acquisition must prevent pre-gate"
12864        );
12865        assert!(!checked.exists(), "failed acquisition must prevent gate");
12866        assert!(git::is_clean(&winner.worktree).await.unwrap());
12867        assert_eq!(
12868            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12869            head
12870        );
12871    }
12872
12873    #[tokio::test]
12874    #[cfg(unix)]
12875    async fn preparation_waits_for_shared_lock_before_checkout_and_holds_it_through_setup() {
12876        use std::os::fd::AsRawFd as _;
12877        use std::time::Duration;
12878
12879        ask_test_home();
12880        let tmp = tempfile::tempdir().unwrap();
12881        let repo = tmp.path().join("repo");
12882        std::fs::create_dir_all(&repo).unwrap();
12883        init_repo(&repo);
12884        let common = repo.join(".git");
12885        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12886        let lock = std::fs::File::create(common.join("updater.lock")).unwrap();
12887        assert_eq!(
12888            unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12889            0
12890        );
12891        let mut runner = runner_at(RunStatus::Prep);
12892        runner.state.repo = repo.clone();
12893        runner.state.config.coordination = crate::config::test_coordination();
12894        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12895        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12896        runner.state.config.graph.advise = true;
12897        runner.state.config.graph.advisors = 1;
12898        runner.roles.implementers = vec![spec("implement")];
12899        runner.roles.judges = vec![spec("judge")];
12900        let root = runner.state.worktree_root();
12901        let started = tmp.path().join("setup-started");
12902        let proceed = tmp.path().join("setup-proceed");
12903        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
12904            run: Some(format!(
12905                "touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12906                started.display(),
12907                proceed.display()
12908            )),
12909            ..Default::default()
12910        }];
12911        let branches = git::git(&repo, &["branch", "--list"]).await.unwrap();
12912        let config = git::git(&repo, &["config", "--local", "--list"])
12913            .await
12914            .unwrap();
12915        let mut prep = Box::pin(runner.prep());
12916        assert!(
12917            tokio::time::timeout(Duration::from_millis(200), &mut prep)
12918                .await
12919                .is_err()
12920        );
12921        assert!(
12922            !root.exists(),
12923            "contention must prevent even checkout creation"
12924        );
12925        assert!(!started.exists(), "contention must prevent setup");
12926        assert_eq!(
12927            git::git(&repo, &["branch", "--list"]).await.unwrap(),
12928            branches
12929        );
12930        assert_eq!(
12931            git::git(&repo, &["config", "--local", "--list"])
12932                .await
12933                .unwrap(),
12934            config
12935        );
12936        drop(lock);
12937        tokio::select! {
12938            result = &mut prep => panic!("setup must wait for its signal: {result:?}"),
12939            result = tokio::time::timeout(Duration::from_secs(10), async {
12940                while !started.exists() {
12941                    tokio::time::sleep(Duration::from_millis(20)).await;
12942                }
12943            }) => result.unwrap(),
12944        }
12945        let outsider = std::fs::File::open(common.join("updater.lock")).unwrap();
12946        assert_eq!(
12947            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12948            -1
12949        );
12950        assert!(
12951            tokio::time::timeout(
12952                Duration::from_millis(100),
12953                crate::repo_lock::SeatLease::for_checkout(
12954                    &repo,
12955                    true,
12956                    crate::config::test_coordination().as_ref()
12957                )
12958            )
12959            .await
12960            .is_err(),
12961            "preparation must also exclude local editors"
12962        );
12963        std::fs::write(proceed, "ready").unwrap();
12964        tokio::time::timeout(Duration::from_secs(10), &mut prep)
12965            .await
12966            .unwrap()
12967            .unwrap();
12968        drop(prep);
12969        assert_eq!(runner.state.prep_complete, Some(true));
12970        assert!(root.join("judge-1").exists());
12971        assert!(root.join("advisor-1").exists());
12972        assert_eq!(
12973            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12974            0
12975        );
12976    }
12977
12978    #[tokio::test]
12979    #[cfg(unix)]
12980    async fn preparation_lock_failure_prevents_repository_changes() {
12981        ask_test_home();
12982        let tmp = tempfile::tempdir().unwrap();
12983        let repo = tmp.path().join("repo");
12984        std::fs::create_dir_all(&repo).unwrap();
12985        init_repo(&repo);
12986        std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12987        // A directory at the required lock path makes opening the real lock
12988        // fail, rather than merely exercising an invalid registration.
12989        std::fs::create_dir(repo.join(".git/updater.lock")).unwrap();
12990        let mut runner = runner_at(RunStatus::Prep);
12991        runner.state.repo = repo.clone();
12992        runner.state.config.coordination = crate::config::test_coordination();
12993        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12994        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12995        runner.roles.implementers = vec![spec("implement")];
12996        let error = runner.prep().await.unwrap_err();
12997        assert!(
12998            error
12999                .to_string()
13000                .contains("acquire mandatory repository lock")
13001        );
13002        assert!(!runner.state.worktree_root().exists());
13003        assert!(runner.state.candidates.is_empty());
13004        assert!(!repo.join(".git/config.worktree").exists());
13005        assert_eq!(
13006            git::git(&repo, &["branch", "--list"]).await.unwrap().trim(),
13007            "* main"
13008        );
13009    }
13010
13011    #[tokio::test]
13012    async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
13013        ask_test_home();
13014        let tmp = tempfile::tempdir().unwrap();
13015        let repo = tmp.path().join("repo");
13016        std::fs::create_dir_all(&repo).unwrap();
13017        init_repo(&repo);
13018        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
13019        git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
13020        std::fs::write(repo.join("referenced"), "required commit").unwrap();
13021        git::git(&repo, &["add", "."]).await.unwrap();
13022        git::git(&repo, &["commit", "-m", "required seed"])
13023            .await
13024            .unwrap();
13025        let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
13026        git::git(&repo, &["checkout", "main"]).await.unwrap();
13027        let mut runner = runner_at(RunStatus::Prep);
13028        runner.state.repo = repo.clone();
13029        runner.state.base_commit = base.clone();
13030        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
13031        runner.state.config.graph.advise = false;
13032        runner.state.config.blind.commit_msg_hook = false;
13033        runner.state.seeds = vec![refs::Seed {
13034            token: seed.clone(),
13035            sha: seed.clone(),
13036            kind: refs::SeedKind::Unmerged,
13037            branch: false,
13038            detail: String::new(),
13039        }];
13040        runner.roles.implementers = vec![spec("one"), spec("two")];
13041        runner.roles.judges = vec![spec("judge")];
13042        let label = blind::assign_labels(2, runner.state.seed)[0];
13043        let branch = runner.state.branch_for(label);
13044        let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
13045        git::worktree_add_branch(&repo, &worktree, &branch, &base)
13046            .await
13047            .unwrap();
13048        runner.state.candidates.push(Candidate {
13049            index: 0,
13050            label,
13051            agent: "one".into(),
13052            branch,
13053            worktree,
13054            summary: String::new(),
13055            stat: String::new(),
13056            files: 0,
13057            commits: 0,
13058            empty: false,
13059            failed: None,
13060            verified_noop: None,
13061            duration_ms: 0,
13062            folded: false,
13063        });
13064        runner.state.save().unwrap();
13065        // Reload the checkpoint immediately before its first cherry-pick.
13066        runner.state = RunState::load(&runner.state.id).unwrap();
13067        assert_eq!(runner.state.prep_complete, Some(false));
13068        runner.prep().await.unwrap();
13069        assert_eq!(runner.state.prep_complete, Some(true));
13070        assert_eq!(runner.state.candidates.len(), 2);
13071        for candidate in &runner.state.candidates {
13072            assert_eq!(
13073                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
13074                git::tree_of(&repo, &seed).await.unwrap()
13075            );
13076            assert_eq!(
13077                std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
13078                "required commit"
13079            );
13080        }
13081        assert!(runner.state.worktree_root().join("judge-1").exists());
13082        let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
13083        runner.prep().await.unwrap();
13084        assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
13085
13086        runner.state.prep_complete = Some(false);
13087        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
13088            run: Some(format!(
13089                "test -f '{}'",
13090                tmp.path().join("dependency-ready").display()
13091            )),
13092            ..Default::default()
13093        }];
13094        assert!(runner.prep().await.is_err());
13095        assert!(runner.state.prep_failure.is_some());
13096        std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
13097        runner.state = RunState::load(&runner.state.id).unwrap();
13098        assert!(
13099            runner.prep().await.is_err(),
13100            "correcting the cause alone must not auto-retry"
13101        );
13102        let failed_at = runner.state.events.last().unwrap().at;
13103        assert!(
13104            runner.retry_preparation(failed_at).is_err(),
13105            "old approvals cannot unlock the hold"
13106        );
13107        let mut task = crate::queue::Task::new(
13108            "retry".into(),
13109            "retry setup".into(),
13110            repo.clone(),
13111            crate::queue::Source::Human,
13112        );
13113        task.start(runner.state.id.clone());
13114        task.hold_machine(Some("setup failed".into()));
13115        task.release_operator("cli", None);
13116        runner
13117            .retry_preparation(task.resumption.as_ref().unwrap().at)
13118            .unwrap();
13119        runner.prep().await.unwrap();
13120        assert!(runner.state.prep_failure.is_none());
13121        assert_eq!(runner.state.prep_complete, Some(true));
13122        assert_eq!(runner.state.candidates.len(), 2);
13123        assert!(runner.state.worktree_root().join("judge-1").exists());
13124        for candidate in &runner.state.candidates {
13125            assert_eq!(
13126                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
13127                git::tree_of(&repo, &seed).await.unwrap()
13128            );
13129        }
13130    }
13131
13132    async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
13133        let mut heads = Vec::new();
13134        for candidate in candidates {
13135            heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
13136        }
13137        heads
13138    }
13139
13140    async fn preservation_resume_fixture() -> (tempfile::TempDir, Runner) {
13141        ask_test_home();
13142        let tmp = tempfile::tempdir().unwrap();
13143        let repo = tmp.path().join("repo");
13144        std::fs::create_dir(&repo).unwrap();
13145        init_repo(&repo);
13146        let mut runner = runner_at(RunStatus::Reviewing);
13147        runner.state.repo = repo.clone();
13148        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
13149        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
13150        for (index, label) in ['A', 'B'].into_iter().enumerate() {
13151            let branch = format!("candidate-{label}");
13152            let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
13153            git::worktree_add_branch(&repo, &worktree, &branch, "main")
13154                .await
13155                .unwrap();
13156            if label == 'B' {
13157                std::fs::write(worktree.join("change"), "winner").unwrap();
13158                git::rescue_commit(&worktree, "candidate change")
13159                    .await
13160                    .unwrap();
13161            }
13162            runner.state.candidates.push(Candidate {
13163                index,
13164                label,
13165                agent: "alpha".into(),
13166                branch,
13167                worktree,
13168                summary: "completed reply".into(),
13169                stat: String::new(),
13170                files: usize::from(label == 'B'),
13171                commits: usize::from(label == 'B'),
13172                empty: label == 'A',
13173                failed: None,
13174                verified_noop: None,
13175                duration_ms: 1,
13176                folded: false,
13177            });
13178        }
13179        runner.state.judge_skipped = true;
13180        runner.state.tally = Some(Tally {
13181            first_choice: BTreeMap::new(),
13182            borda: BTreeMap::new(),
13183            winner: 'B',
13184            rankings: 0,
13185            unanimous_initial: false,
13186            deliberated: false,
13187            changed_votes: 0,
13188            unanimous_final: false,
13189            tie_break: None,
13190            judges: 0,
13191            present: 0,
13192            quorum: 0,
13193            met_quorum: true,
13194            uncontested: Some("only B produced a change".into()),
13195        });
13196        runner.state.save().unwrap();
13197        (tmp, runner)
13198    }
13199
13200    #[tokio::test]
13201    async fn candidate_preservation_does_not_reflag_confirmed_or_folded_empty_candidates() {
13202        let (_tmp, mut runner) = preservation_resume_fixture().await;
13203        // Schema 15 already confirmed the unfolded no-change result.
13204        runner.implement().await.unwrap();
13205        assert!(runner.state.candidate_preservation.is_empty());
13206        assert!(
13207            !runner
13208                .state
13209                .dir()
13210                .join("artifacts/candidate-A-recovery")
13211                .exists()
13212        );
13213        runner.fold_losers().await.unwrap();
13214        assert!(!runner.state.candidates[0].worktree.exists());
13215        for schema in [14, crate::run::SCHEMA] {
13216            runner.state.schema = schema;
13217            runner.state = crate::run::migrate_schema(runner.state.clone()).unwrap();
13218            runner.state.save().unwrap();
13219            runner.state = RunState::load(&runner.state.id).unwrap();
13220            runner.implement().await.unwrap();
13221            assert!(runner.state.candidate_preservation.is_empty());
13222            assert!(runner.state.candidates[0].folded);
13223            assert_eq!(runner.state.tally.as_ref().unwrap().winner, 'B');
13224        }
13225    }
13226
13227    #[tokio::test]
13228    async fn candidate_preservation_legacy_recovery_blocks_stale_verdict_on_every_resume() {
13229        let (_tmp, mut runner) = preservation_resume_fixture().await;
13230        let wt = runner.state.candidates[0].worktree.clone();
13231        std::fs::write(wt.join("recovered"), "owned edits").unwrap();
13232        runner.state.schema = 14;
13233        runner.state = crate::run::migrate_schema(runner.state.clone()).unwrap();
13234        assert!(runner.state.candidate_preservation.contains_key(&'A'));
13235        let tally = serde_json::to_value(&runner.state.tally).unwrap();
13236        for _ in 0..2 {
13237            assert!(runner.implement().await.is_err());
13238            assert_eq!(runner.state.status, RunStatus::Blocked);
13239            assert!(runner.state.candidate_preservation[&'A'].contains("supported recovery"));
13240            assert!(
13241                runner.state.candidates[0].empty,
13242                "old verdict must remain unusable"
13243            );
13244            assert_eq!(serde_json::to_value(&runner.state.tally).unwrap(), tally);
13245            assert!(runner.state.judge_skipped);
13246            assert!(runner.state.judgements.is_empty() && runner.state.votes.is_empty());
13247            assert!(
13248                crate::run::read_artifact(&runner.state, "cand-A.patch")
13249                    .unwrap()
13250                    .contains("owned edits")
13251            );
13252            assert!(wt.exists());
13253            assert_eq!(
13254                git::commits_ahead(&wt, &runner.state.base_commit, "HEAD")
13255                    .await
13256                    .unwrap(),
13257                1
13258            );
13259            runner.state = RunState::load(&runner.state.id).unwrap();
13260        }
13261    }
13262
13263    #[tokio::test]
13264    async fn candidate_preservation_legacy_failed_seat_keeps_edits_before_judging() {
13265        let (_tmp, mut runner) = preservation_resume_fixture().await;
13266        let wt = runner.state.candidates[0].worktree.clone();
13267        runner.state.candidates[0].failed = Some("CLI failed before commit".into());
13268        std::fs::write(wt.join("recovered"), "failed seat edits").unwrap();
13269        let index_lock = PathBuf::from(
13270            git::git(
13271                &wt,
13272                &[
13273                    "rev-parse",
13274                    "--path-format=absolute",
13275                    "--git-path",
13276                    "index.lock",
13277                ],
13278            )
13279            .await
13280            .unwrap(),
13281        );
13282        std::fs::write(&index_lock, "fixture-owned lock").unwrap();
13283        runner.state.schema = 14;
13284        runner.state = crate::run::migrate_schema(runner.state.clone()).unwrap();
13285        assert!(runner.state.candidate_preservation.contains_key(&'A'));
13286        assert!(runner.implement().await.is_err());
13287        assert!(runner.after_implement().is_err());
13288        assert!(runner.fold_losers().await.is_err());
13289        assert_eq!(
13290            std::fs::read_to_string(wt.join("recovered")).unwrap(),
13291            "failed seat edits"
13292        );
13293        assert!(runner.state.candidate_preservation.contains_key(&'A'));
13294        runner.state = RunState::load(&runner.state.id).unwrap();
13295        std::fs::remove_file(index_lock).unwrap();
13296        // Recovery finds changes that the old uncontested result never judged.
13297        assert!(runner.implement().await.is_err());
13298        assert!(runner.state.candidate_preservation[&'A'].contains("supported recovery"));
13299        assert!(runner.state.candidates[0].empty);
13300        assert_eq!(runner.state.tally.as_ref().unwrap().winner, 'B');
13301        assert!(
13302            crate::run::read_artifact(&runner.state, "cand-A.patch")
13303                .unwrap()
13304                .contains("failed seat edits")
13305        );
13306        assert!(wt.exists());
13307    }
13308
13309    #[tokio::test]
13310    async fn candidate_preservation_loser_cleanup_resumes_after_branch_and_judge_failures() {
13311        let (_tmp, mut runner) = preservation_resume_fixture().await;
13312        let repo = runner.state.repo.clone();
13313        let common = git::git(
13314            &repo,
13315            &["rev-parse", "--path-format=absolute", "--git-common-dir"],
13316        )
13317        .await
13318        .unwrap();
13319        let branch_lock = PathBuf::from(common).join("refs/heads/candidate-A.lock");
13320        std::fs::write(&branch_lock, "fixture-owned lock").unwrap();
13321        assert!(runner.fold_losers().await.is_err());
13322        assert!(!runner.state.candidates[0].worktree.exists());
13323        assert!(!runner.state.candidates[0].folded);
13324        assert!(git::branch_exists(&repo, "candidate-A").await.unwrap());
13325        runner.state = RunState::load(&runner.state.id).unwrap();
13326        std::fs::remove_file(branch_lock).unwrap();
13327        let judge = runner.state.worktree_root().join("judge-1");
13328        git::worktree_add_detached(&repo, &judge, "main")
13329            .await
13330            .unwrap();
13331        std::fs::write(judge.join("uncommitted"), "retain").unwrap();
13332        runner.roles.judges = vec![spec("judge")];
13333        assert!(runner.fold_losers().await.is_err());
13334        runner.state = RunState::load(&runner.state.id).unwrap();
13335        assert!(
13336            runner.state.candidates[0].folded,
13337            "candidate progress saved before judge failure"
13338        );
13339        assert!(!git::branch_exists(&repo, "candidate-A").await.unwrap());
13340        assert!(judge.join("uncommitted").exists());
13341        std::fs::remove_file(judge.join("uncommitted")).unwrap();
13342        runner.fold_losers().await.unwrap();
13343        assert!(!judge.exists());
13344        runner.fold_losers().await.unwrap();
13345    }
13346
13347    #[tokio::test]
13348    async fn candidate_preservation_selected_implementer_resumes_through_execute_graph() {
13349        let (_tmp, mut runner) = preservation_resume_fixture().await;
13350        runner.state.candidates.retain(|c| c.label == 'B');
13351        runner.state.tally = None;
13352        runner.state.judge_skipped = false;
13353        runner.state.prep_complete = Some(true);
13354        runner.state.config.graph.advise = false;
13355        runner.state.config.graph.selected_implementer = Some("alpha".into());
13356        let wt = runner.state.candidates[0].worktree.clone();
13357        std::fs::write(wt.join("leftover"), "completed implementation").unwrap();
13358        runner
13359            .state
13360            .candidate_preservation
13361            .insert('B', "awaiting persistence".into());
13362        let index_lock = git::git(
13363            &wt,
13364            &[
13365                "rev-parse",
13366                "--path-format=absolute",
13367                "--git-path",
13368                "index.lock",
13369            ],
13370        )
13371        .await
13372        .unwrap();
13373        std::fs::write(&index_lock, "fixture-owned lock").unwrap();
13374        runner.state.save().unwrap();
13375        assert!(runner.execute_graph().await.is_err());
13376        assert_eq!(runner.state.status, RunStatus::Blocked);
13377        assert!(runner.state.candidate_preservation.contains_key(&'B'));
13378        std::fs::remove_file(index_lock).unwrap();
13379        // Park at the next graph boundary: this tests the real selected-seat
13380        // hold guard without dispatching reviewers or publishing anything.
13381        for _ in 0..2 {
13382            runner.state = RunState::load(&runner.state.id).unwrap();
13383            runner.pause.park();
13384            runner.execute_graph().await.unwrap();
13385            assert_eq!(runner.state.status, RunStatus::Implementing);
13386            assert!(runner.state.candidate_preservation.is_empty());
13387            assert_eq!(runner.state.candidates[0].commits, 2);
13388            assert!(runner.state.tally.is_none());
13389        }
13390        runner.implement().await.unwrap();
13391        assert_eq!(runner.state.status, RunStatus::Judging);
13392        // A genuine selected-implementer hold must still require release.
13393        runner.state.status = RunStatus::Blocked;
13394        runner.state.candidates[0].failed = Some("implementation failed".into());
13395        runner.state.save().unwrap();
13396        let error = runner.execute_graph().await.unwrap_err();
13397        assert!(
13398            error
13399                .to_string()
13400                .contains("selected implementation is held")
13401        );
13402    }
13403
13404    #[tokio::test]
13405    async fn candidate_preservation_cleanup_unregisters_a_vanished_worktree() {
13406        let (_tmp, mut runner) = preservation_resume_fixture().await;
13407        let repo = runner.state.repo.clone();
13408        let candidate = runner.state.candidates[0].clone();
13409        // Remove only the disposable fixture directory, leaving Git's linked
13410        // worktree registration and branch intact, as in a vanished checkout.
13411        let registered = git::worktree_holding(&repo, &candidate.branch)
13412            .await
13413            .unwrap()
13414            .unwrap();
13415        assert_eq!(
13416            registered.canonicalize().unwrap(),
13417            candidate.worktree.canonicalize().unwrap()
13418        );
13419        std::fs::remove_dir_all(&candidate.worktree).unwrap();
13420        assert_eq!(
13421            git::worktree_holding(&repo, &candidate.branch)
13422                .await
13423                .unwrap(),
13424            Some(registered)
13425        );
13426        assert!(
13427            !git::branch_delete(&repo, &candidate.branch).await.unwrap(),
13428            "the stale registration initially keeps the branch checked out"
13429        );
13430        runner.fold_losers().await.unwrap();
13431        assert!(runner.state.candidates[0].folded);
13432        assert!(!git::branch_exists(&repo, &candidate.branch).await.unwrap());
13433        assert_eq!(
13434            git::worktree_holding(&repo, &candidate.branch)
13435                .await
13436                .unwrap(),
13437            None
13438        );
13439        runner.state = RunState::load(&runner.state.id).unwrap();
13440        runner.fold_losers().await.unwrap();
13441    }
13442
13443    #[tokio::test]
13444    async fn fold_run_unregisters_a_vanished_worktree_and_can_retry() {
13445        let (_tmp, mut runner) = preservation_resume_fixture().await;
13446        let repo = runner.state.repo.clone();
13447        let candidate = runner.state.candidates[0].clone();
13448        let winner = runner.state.candidates[1].clone();
13449        let registered = git::worktree_holding(&repo, &candidate.branch)
13450            .await
13451            .unwrap()
13452            .unwrap();
13453        assert_eq!(
13454            registered.canonicalize().unwrap(),
13455            candidate.worktree.canonicalize().unwrap()
13456        );
13457        std::fs::remove_dir_all(&candidate.worktree).unwrap();
13458        assert_eq!(
13459            git::worktree_holding(&repo, &candidate.branch)
13460                .await
13461                .unwrap(),
13462            Some(registered)
13463        );
13464        assert!(!git::branch_delete(&repo, &candidate.branch).await.unwrap());
13465
13466        for _ in 0..2 {
13467            fold_run(&mut runner.state, false, &crate::run::home())
13468                .await
13469                .unwrap();
13470            runner.state = RunState::load(&runner.state.id).unwrap();
13471            assert!(runner.state.candidates[0].folded);
13472            assert!(!git::branch_exists(&repo, &candidate.branch).await.unwrap());
13473            assert_eq!(
13474                git::worktree_holding(&repo, &candidate.branch)
13475                    .await
13476                    .unwrap(),
13477                None
13478            );
13479            assert!(!runner.state.candidates[1].folded);
13480            assert!(winner.worktree.exists());
13481            assert!(git::branch_exists(&repo, &winner.branch).await.unwrap());
13482        }
13483    }
13484
13485    #[tokio::test]
13486    async fn candidate_preservation_retries_after_upgrade_without_rerunning_seats() {
13487        ask_test_home();
13488        let tmp = tempfile::tempdir().unwrap();
13489        let repo = tmp.path().join("repo");
13490        std::fs::create_dir(&repo).unwrap();
13491        init_repo(&repo);
13492        let mut runner = runner_at(RunStatus::Implementing);
13493        runner.state.repo = repo.clone();
13494        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
13495        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
13496        for (index, label) in ['A', 'B'].into_iter().enumerate() {
13497            let branch = format!("candidate-{label}");
13498            let wt = runner.state.worktree_root().join(format!("cand-{label}"));
13499            git::worktree_add_branch(&repo, &wt, &branch, "main")
13500                .await
13501                .unwrap();
13502            std::fs::write(wt.join("README.md"), "tracked edits\n").unwrap();
13503            git::git(&wt, &["add", "README.md"]).await.unwrap();
13504            std::fs::write(wt.join("README.md"), "later tracked edits\n").unwrap();
13505            std::fs::write(wt.join("new-file"), "untracked edits\n").unwrap();
13506            runner.state.candidates.push(Candidate {
13507                index,
13508                label,
13509                agent: "alpha".to_owned(),
13510                branch,
13511                worktree: wt,
13512                summary: "completed implementation".to_owned(),
13513                stat: String::new(),
13514                files: 0,
13515                commits: 0,
13516                empty: false,
13517                failed: None,
13518                verified_noop: None,
13519                duration_ms: 42,
13520                folded: false,
13521            });
13522            runner
13523                .state
13524                .candidate_preservation
13525                .insert(label, "awaiting persistence".to_owned());
13526        }
13527        let wt = runner.state.candidates[0].worktree.clone();
13528        let index_lock = git::git(
13529            &wt,
13530            &[
13531                "rev-parse",
13532                "--path-format=absolute",
13533                "--git-path",
13534                "index.lock",
13535            ],
13536        )
13537        .await
13538        .unwrap();
13539        std::fs::write(&index_lock, "fixture-owned lock").unwrap();
13540        runner.state.save().unwrap();
13541        assert!(runner.persist_candidates().await.is_err());
13542        assert!(runner.state.candidate_preservation.contains_key(&'A'));
13543        assert!(!runner.state.candidate_preservation.contains_key(&'B'));
13544        assert_eq!(
13545            runner.state.candidates[1].commits, 1,
13546            "other results are still collected"
13547        );
13548        assert!(
13549            !runner.state.candidates[0].empty,
13550            "failed rescue is not no change"
13551        );
13552        assert_eq!(
13553            runner.state.candidates[0].summary,
13554            "completed implementation"
13555        );
13556        assert!(runner.after_implement().is_err());
13557        let home = tmp.path().join("fold-home");
13558        assert!(fold_run(&mut runner.state, true, &home).await.is_err());
13559        assert!(wt.join("new-file").exists());
13560        let recovery = runner.state.dir().join("artifacts/candidate-A-recovery");
13561        assert!(recovery.join("staged.patch").exists());
13562        assert!(recovery.join("unstaged.patch").exists());
13563        assert_eq!(
13564            std::fs::read_to_string(recovery.join("untracked/new-file")).unwrap(),
13565            "untracked edits\n"
13566        );
13567        runner.state.begin_pass();
13568        runner.state.end_pass_parked(ParkCause::Upgrade {
13569            from: "1.0".into(),
13570            to: Some("1.1".into()),
13571        });
13572        runner.state.save().unwrap();
13573        runner.state = RunState::load(&runner.state.id).unwrap();
13574        assert!(matches!(
13575            runner.state.passes.last().unwrap().end,
13576            Some(crate::run::PassEnd::Parked {
13577                cause: ParkCause::Upgrade { .. }
13578            })
13579        ));
13580        std::fs::remove_file(index_lock).unwrap();
13581        // No implementer is configured: a persistence retry cannot dispatch a seat.
13582        runner.implement().await.unwrap();
13583        assert!(runner.state.candidate_preservation.is_empty());
13584        assert!(
13585            runner
13586                .state
13587                .candidates
13588                .iter()
13589                .all(|c| c.commits == 1 && !c.empty)
13590        );
13591        assert!(git::is_clean(&wt).await.unwrap());
13592        assert!(
13593            crate::run::read_artifact(&runner.state, "cand-A.patch")
13594                .unwrap()
13595                .contains("later tracked edits")
13596        );
13597    }
13598
13599    #[tokio::test]
13600    async fn candidate_preservation_git_read_and_cleanup_fail_closed() {
13601        ask_test_home();
13602        let tmp = tempfile::tempdir().unwrap();
13603        let repo = tmp.path().join("repo");
13604        std::fs::create_dir(&repo).unwrap();
13605        init_repo(&repo);
13606        let wt = tmp.path().join("candidate");
13607        git::worktree_add_branch(&repo, &wt, "candidate", "main")
13608            .await
13609            .unwrap();
13610        std::fs::write(wt.join("uncommitted"), "keep").unwrap();
13611        assert!(remove_preserved_worktree(&repo, &wt).await.is_err());
13612        assert!(wt.join("uncommitted").exists());
13613        assert!(git::branch_exists(&repo, "candidate").await.unwrap());
13614        std::fs::remove_file(wt.join("uncommitted")).unwrap();
13615        let mut runner = runner_at(RunStatus::Implementing);
13616        runner.state.repo = repo.clone();
13617        runner.state.base_commit = "missing-base".to_owned();
13618        runner.state.candidates.push(Candidate {
13619            index: 0,
13620            label: 'A',
13621            agent: "alpha".into(),
13622            branch: "candidate".into(),
13623            worktree: wt.clone(),
13624            summary: "completed".into(),
13625            stat: String::new(),
13626            files: 0,
13627            commits: 0,
13628            empty: false,
13629            failed: None,
13630            verified_noop: None,
13631            duration_ms: 1,
13632            folded: false,
13633        });
13634        std::fs::write(wt.join("uncommitted"), "keep").unwrap();
13635        assert!(
13636            fold_run(&mut runner.state, true, &tmp.path().join("home"))
13637                .await
13638                .is_err()
13639        );
13640        assert!(!runner.state.candidates[0].folded);
13641        assert!(git::branch_exists(&repo, "candidate").await.unwrap());
13642        std::fs::remove_file(wt.join("uncommitted")).unwrap();
13643        runner
13644            .state
13645            .candidate_preservation
13646            .insert('A', "pending".into());
13647        assert!(runner.persist_candidates().await.is_err());
13648        assert!(!runner.state.candidates[0].empty);
13649        assert!(!runner.state.candidates[0].folded);
13650        assert!(wt.exists());
13651        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
13652        let patch_path = runner.state.dir().join("artifacts/cand-A.patch");
13653        std::fs::create_dir_all(&patch_path).unwrap();
13654        assert!(
13655            runner.persist_candidates().await.is_err(),
13656            "artifact failure stays pending"
13657        );
13658        assert!(runner.state.candidate_preservation.contains_key(&'A'));
13659        assert!(!runner.state.candidates[0].empty);
13660        std::fs::remove_dir(patch_path).unwrap();
13661        runner.persist_candidates().await.unwrap();
13662        assert!(
13663            runner.state.candidates[0].empty,
13664            "only successful inspection confirms no change"
13665        );
13666    }
13667
13668    fn init_repo(dir: &Path) {
13669        let run = |args: &[&str]| {
13670            let out = std::process::Command::new("git")
13671                .args(args)
13672                .current_dir(dir)
13673                .quiet()
13674                .output()
13675                .expect("spawn git");
13676            assert!(
13677                out.status.success(),
13678                "git {args:?} failed: {}",
13679                String::from_utf8_lossy(&out.stderr)
13680            );
13681        };
13682        run(&["init", "-b", "main"]);
13683        run(&["config", "user.name", "magi test"]);
13684        run(&["config", "user.email", "magi@example.com"]);
13685        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
13686        run(&["add", "-A"]);
13687        run(&["commit", "-m", "init"]);
13688    }
13689
13690    // `settle_questions` is what closes the ghost the phone showed: a run's
13691    // seat asked something, the run then ended, and nothing was left to
13692    // abandon the question it left `open`. `HOME` is a process-wide
13693    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
13694    // first time it runs in the binary — every test below still reaches the
13695    // same directory whichever call won, and each gets its own run id from
13696    // `RunState::new`, so they never collide there.
13697    fn ask_test_home() {
13698        crate::run::pin_test_home();
13699    }
13700
13701    /// A minimal, git-free `Runner` at a given status — `settle_questions`
13702    /// reads nothing else off it.
13703    fn runner_at(status: RunStatus) -> Runner {
13704        let mut state = RunState::new(
13705            PathBuf::from("/nonexistent/repo"),
13706            "main".to_owned(),
13707            "deadbeef".to_owned(),
13708            "task".to_owned(),
13709            Config::default(),
13710        );
13711        // These fixtures share one home and save concurrently. Short clock
13712        // IDs can collide, making an ownership test read another test's run.
13713        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13714        state.id = format!(
13715            "20000101-000000-{:04x}",
13716            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
13717        );
13718        state.status = status;
13719        Runner {
13720            state,
13721            roles: ResolvedRoles {
13722                implementers: Vec::new(),
13723                judges: Vec::new(),
13724                reviewers: Vec::new(),
13725                fixer: None,
13726                conductor: conductor(),
13727                implementer_roster: Vec::new(),
13728                judge_roster: Vec::new(),
13729                reviewer_roster: Vec::new(),
13730            },
13731            sem: Arc::new(Semaphore::new(1)),
13732            pause: Pause::new(),
13733            interrupt: Pause::new(),
13734        }
13735    }
13736
13737    #[test]
13738    fn a_failed_driver_lease_record_releases_the_claim_and_retained_state() {
13739        crate::run::pin_test_home();
13740        let mut runner = runner_at(RunStatus::Gating);
13741        runner.state.save().unwrap();
13742        // run.json can commit, but the subsequent lease metadata write fails.
13743        // Cleanup must run after releasing the short ownership lock.
13744        std::fs::create_dir(runner.state.dir().join("driver.json.tmp")).unwrap();
13745        assert!(runner.claim_driver().is_err());
13746        assert!(runner.state.driver_exited);
13747        assert_eq!(runner.state.driver_pid, None);
13748        let disk = RunState::load(&runner.state.id).unwrap();
13749        assert!(disk.driver_exited);
13750        assert_eq!(disk.driver_pid, None);
13751        assert_eq!(disk.status, RunStatus::Gating);
13752    }
13753
13754    #[tokio::test]
13755    async fn an_early_graph_error_releases_its_driver() {
13756        crate::run::pin_test_home();
13757        let mut runner = runner_at(RunStatus::Blocked);
13758        runner.state.config.graph.selected_implementer = Some("selected".into());
13759        runner.state.save().unwrap();
13760        assert!(runner.execute().await.is_err());
13761        let disk = RunState::load(&runner.state.id).unwrap();
13762        assert!(disk.driver_exited);
13763        assert_eq!(disk.driver_pid, None);
13764        assert_eq!(disk.status, RunStatus::Blocked);
13765    }
13766
13767    fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
13768        crate::run::MergeRepair {
13769            question: "repair-question".into(),
13770            head: "old-head".into(),
13771            pr: "https://github.com/o/r/pull/42".into(),
13772            reviews: vec![review_round(false, 1, 1, 1, false, true)],
13773            gate: vec![outcome(Some(0), false)],
13774            gate_head: Some("old-head".into()),
13775            gate_fixes: Vec::new(),
13776            pre_gate: Vec::new(),
13777            phase,
13778            failure: None,
13779        }
13780    }
13781
13782    #[tokio::test]
13783    async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
13784        crate::run::pin_test_home();
13785        let mut runner = runner_at(RunStatus::Landing);
13786        runner.state.merge_repairs.push(merge_repair_request(
13787            crate::run::MergeRepairPhase::Requested,
13788        ));
13789        runner.state.save().unwrap();
13790        let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
13791        assert!(
13792            runner
13793                .execute()
13794                .await
13795                .unwrap_err()
13796                .to_string()
13797                .contains("already running")
13798        );
13799        assert_eq!(
13800            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13801            crate::run::MergeRepairPhase::Requested
13802        );
13803        drop(claim);
13804        let mut disk = runner.state.clone();
13805        disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
13806        disk.save().unwrap();
13807        assert!(
13808            runner
13809                .execute()
13810                .await
13811                .unwrap_err()
13812                .to_string()
13813                .contains("decision changed")
13814        );
13815        assert_eq!(
13816            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13817            crate::run::MergeRepairPhase::Complete
13818        );
13819    }
13820
13821    #[test]
13822    fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
13823        crate::run::pin_test_home();
13824        let mut runner = runner_at(RunStatus::Landing);
13825        let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
13826        runner.state.reviews = request.reviews.clone();
13827        runner.state.gate = request.gate.clone();
13828        runner.state.gate_ran = true;
13829        runner.state.land_approval = Some(crate::run::LandApproval {
13830            question: "old".into(),
13831            head: "old-head".into(),
13832        });
13833        runner.state.merge_repairs.push(request);
13834        runner.begin_merge_repair_review(0).unwrap();
13835        assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
13836        assert!(!runner.state.gate_status().ok());
13837        assert!(runner.state.land_approval.is_none());
13838        let disk = RunState::load(&runner.state.id).unwrap();
13839        assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
13840        assert!(disk.merge_repairs[0].gate[0].ok());
13841        assert_eq!(
13842            disk.merge_repairs[0].phase,
13843            crate::run::MergeRepairPhase::Reviewing
13844        );
13845        assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
13846        assert!(disk.reviews.is_empty());
13847    }
13848
13849    #[tokio::test]
13850    async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
13851        crate::run::pin_test_home();
13852        for phase in [
13853            crate::run::MergeRepairPhase::Fixing,
13854            crate::run::MergeRepairPhase::Requested,
13855        ] {
13856            let mut runner = runner_at(RunStatus::Landing);
13857            runner.state.config.graph.land_rounds = 0;
13858            runner.state.merge_repairs.push(merge_repair_request(phase));
13859            runner.state.save().unwrap();
13860            assert!(!runner.repair_merge_review().await.unwrap());
13861            assert_eq!(runner.state.status, RunStatus::Blocked);
13862            assert_eq!(
13863                runner.state.merge_repairs[0].phase,
13864                crate::run::MergeRepairPhase::Failed
13865            );
13866            let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
13867            assert!(
13868                reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
13869                    "interrupted"
13870                } else {
13871                    "budget exhausted"
13872                })
13873            );
13874            assert!(runner.state.merge.is_none());
13875        }
13876    }
13877
13878    /// `park_here` folding in the reason `Pause::park_because` recorded -
13879    /// this is what lets an operator reading a run's events tell an
13880    /// interrupt-driven park from an ordinary shutdown park.
13881    #[test]
13882    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
13883        crate::run::pin_test_home();
13884        let mut runner = runner_at(RunStatus::Implementing);
13885        let interrupt = Pause::new();
13886        runner.watch_interrupt(interrupt.clone());
13887
13888        interrupt.park_because("task a1b2 asked to run first");
13889
13890        assert!(runner.park_here().expect("park_here"));
13891        assert!(runner.state.parked);
13892        let last = runner.state.events.last().expect("a park event");
13893        assert_eq!(last.node, "park");
13894        assert!(
13895            last.message.contains("task a1b2 asked to run first"),
13896            "expected the interrupt reason in {:?}",
13897            last.message
13898        );
13899    }
13900
13901    /// The cause is stored on the pass when it parks, and the first cause a
13902    /// shared pause was given is the one that stays.
13903    #[test]
13904    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
13905        crate::run::pin_test_home();
13906        let mut runner = runner_at(RunStatus::Implementing);
13907        runner.state.begin_pass();
13908        let shutdown = Pause::new();
13909        runner.on_pause(shutdown.clone());
13910        let upgrade = ParkCause::Upgrade {
13911            from: "0.121.0".to_owned(),
13912            to: Some("0.121.1".to_owned()),
13913        };
13914        shutdown.park_for(upgrade.clone());
13915        shutdown.park_for(ParkCause::Operator);
13916
13917        assert!(runner.park_here().expect("park_here"));
13918        assert_eq!(
13919            runner.state.passes[0].end,
13920            Some(crate::run::PassEnd::Parked { cause: upgrade })
13921        );
13922        let last = runner.state.events.last().expect("a park event");
13923        assert!(last.message.contains("0.121.0"), "{}", last.message);
13924    }
13925
13926    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
13927    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
13928    /// cleared) must not make a *different* run - one only watching its own,
13929    /// unshared interrupt `Pause` - see itself as parked. If a future change
13930    /// ever collapsed these back into one handle, the interrupt scheduler
13931    /// would park every run for the rest of the daemon's life, not just the
13932    /// one it meant to interrupt.
13933    #[test]
13934    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
13935        crate::run::pin_test_home();
13936        let mut runner = runner_at(RunStatus::Implementing);
13937        let shutdown = Pause::new();
13938        runner.on_pause(shutdown.clone());
13939        let interrupt = Pause::new();
13940        runner.watch_interrupt(interrupt.clone());
13941
13942        // Nobody has asked for anything yet.
13943        assert!(!runner.park_here().expect("park_here"));
13944        assert!(!runner.state.parked);
13945
13946        // Only the interrupt handle fires; the shutdown handle stays clear.
13947        interrupt.park_because("test");
13948        assert!(!shutdown.parked());
13949        assert!(runner.park_here().expect("park_here"));
13950    }
13951
13952    /// The property every prior attempt at this feature failed to pin down:
13953    /// asking a run to park while one of its nodes has a real, in-flight
13954    /// async operation running (an agent call, in production) must not cut
13955    /// that operation short. `park_here` is only ever consulted *between*
13956    /// `execute`'s node calls - see its own doc - so nothing inside a node
13957    /// can observe a park request until the node itself returns. This proves
13958    /// that structurally, with real `tokio` concurrency and a channel
13959    /// handshake (never a sleep, which would only prove "usually", not
13960    /// "cannot"): the "node" below reports that it has genuinely started,
13961    /// and only then is the park requested; the node still has to be told to
13962    /// finish before `park_here` is ever called, exactly mirroring every
13963    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
13964    /// in `execute`.
13965    #[tokio::test]
13966    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
13967        crate::run::pin_test_home();
13968        let mut runner = runner_at(RunStatus::Implementing);
13969        let interrupt = Pause::new();
13970        runner.watch_interrupt(interrupt.clone());
13971
13972        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
13973        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
13974
13975        // Stands in for one node's in-flight agent call: it proves it has
13976        // genuinely started, then blocks - exactly as a spawned CLI process
13977        // does - until told to finish.
13978        let node = async move {
13979            started_tx.send(()).expect("send started");
13980            finish_rx.await.expect("recv finish");
13981            "node finished"
13982        };
13983
13984        let interrupter = async move {
13985            started_rx.await.expect("recv started");
13986            // The call is now genuinely in flight. Ask it to park.
13987            interrupt.park_because("higher-priority task waiting");
13988            // Nothing the node does can observe this yet - there is no
13989            // check inside it, by construction - so let the executor run
13990            // anything pending and then let the node finish on its own.
13991            tokio::task::yield_now().await;
13992            finish_tx.send(()).expect("send finish");
13993        };
13994
13995        let (node_result, ()) = tokio::join!(node, interrupter);
13996        assert_eq!(
13997            node_result, "node finished",
13998            "the in-flight call ran to completion"
13999        );
14000
14001        // Only now, at the boundary the real `execute` would check right
14002        // after this node, does the park take effect.
14003        assert!(runner.park_here().expect("park_here"));
14004        assert!(runner.state.parked);
14005    }
14006
14007    /// A run parked mid-competition carries every field it had accumulated
14008    /// through the exact same disk round-trip an ordinary resume uses -
14009    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
14010    /// Nothing about parking for an interrupt is a special case of that path;
14011    /// this is what proves it rather than assuming it.
14012    #[test]
14013    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
14014        crate::run::pin_test_home();
14015        let mut runner = runner_at(RunStatus::Judging);
14016        // `Runner::resume` re-resolves roles from the saved config, which
14017        // refuses an empty roster - give it the same minimal one `conductor`
14018        // itself uses.
14019        runner.state.config.agents = vec![conductor()];
14020        runner.state.candidates = vec![Candidate {
14021            index: 0,
14022            label: 'A',
14023            agent: "alpha".to_owned(),
14024            branch: "magi/x/A".to_owned(),
14025            worktree: PathBuf::from("/nonexistent/worktree"),
14026            summary: "did the thing".to_owned(),
14027            stat: "1 file changed".to_owned(),
14028            files: 1,
14029            commits: 1,
14030            empty: false,
14031            failed: None,
14032            verified_noop: None,
14033            duration_ms: 1234,
14034            folded: false,
14035        }];
14036        let run_id = runner.state.id.clone();
14037
14038        let interrupt = Pause::new();
14039        runner.watch_interrupt(interrupt.clone());
14040        interrupt.park_because("task c3d4 asked to run first");
14041        assert!(runner.park_here().expect("park_here"));
14042
14043        let resumed = Runner::resume(&run_id).expect("resume");
14044        assert_eq!(resumed.state.candidates.len(), 1);
14045        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
14046        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
14047        assert_eq!(resumed.state.status, runner.state.status);
14048        assert!(
14049            resumed.state.parked,
14050            "still parked until `execute` actually walks the graph again"
14051        );
14052        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
14053    }
14054
14055    /// A fresh open question on `run`, stored and handed back for assertions.
14056    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
14057        let mut q = ask::Question::new(
14058            run.to_owned(),
14059            "implement".to_owned(),
14060            "impl-A".to_owned(),
14061            "Which storage backend should the cache use?".to_owned(),
14062            String::new(),
14063            vec!["SQLite".to_owned(), "Redis".to_owned()],
14064        );
14065        store.put(&mut q).unwrap();
14066        q
14067    }
14068
14069    #[test]
14070    fn a_failed_runs_open_question_is_abandoned() {
14071        ask_test_home();
14072        let store = ask::Questions::open();
14073        let mut runner = runner_at(RunStatus::Failed);
14074        let run = runner.state.id.clone();
14075        let q = ask_open_question(&store, &run);
14076
14077        runner.settle_questions();
14078
14079        let back = store.get(&q.id).unwrap();
14080        assert!(
14081            !back.status.open(),
14082            "the seat that asked died with the run; nobody is left to read an answer"
14083        );
14084        assert!(
14085            back.detail.contains(&run) && back.detail.contains("failed"),
14086            "the reason names what the run became, not just that it is gone: {}",
14087            back.detail
14088        );
14089    }
14090
14091    #[test]
14092    fn a_merged_runs_open_question_is_abandoned_too() {
14093        ask_test_home();
14094        let store = ask::Questions::open();
14095        // A run that finishes cleanly still leaves nobody to read an answer -
14096        // this is not only a failure-path cleanup.
14097        for status in [RunStatus::Merged, RunStatus::Ready] {
14098            let mut runner = runner_at(status);
14099            let run = runner.state.id.clone();
14100            let q = ask_open_question(&store, &run);
14101
14102            runner.settle_questions();
14103
14104            let back = store.get(&q.id).unwrap();
14105            assert!(
14106                !back.status.open(),
14107                "{status:?} run's question must not outlive the run"
14108            );
14109        }
14110    }
14111
14112    #[test]
14113    fn a_still_resumable_runs_open_question_is_left_alone() {
14114        ask_test_home();
14115        let store = ask::Questions::open();
14116        // `Blocked` and `Stalled` can still be resumed — the candidates, the
14117        // review round and the seat sessions are all still on disk — so a
14118        // question asked mid-round may yet get a real answer from a real
14119        // resume. Sweeping it here would be exactly the failure mode this
14120        // whole feature exists to avoid on the other side.
14121        for status in [RunStatus::Blocked, RunStatus::Stalled] {
14122            let mut runner = runner_at(status);
14123            let run = runner.state.id.clone();
14124            let q = ask_open_question(&store, &run);
14125
14126            runner.settle_questions();
14127
14128            let back = store.get(&q.id).unwrap();
14129            assert!(
14130                back.status.open(),
14131                "{status:?} is still alive; the question must still be waiting"
14132            );
14133        }
14134    }
14135
14136    #[test]
14137    fn settle_questions_never_touches_an_already_answered_question() {
14138        ask_test_home();
14139        let store = ask::Questions::open();
14140        let mut runner = runner_at(RunStatus::Failed);
14141        let run = runner.state.id.clone();
14142        let mut q = ask_open_question(&store, &run);
14143        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
14144            .unwrap();
14145        store.put(&mut q).unwrap();
14146
14147        // Called twice, the way a crash-recovered daemon reclaim and the
14148        // graph's own cleanup both can for the same run — `abandon_for_run`
14149        // only ever touches what is still open, so this must be inert both
14150        // times, not merely the second.
14151        runner.settle_questions();
14152        runner.settle_questions();
14153
14154        let back = store.get(&q.id).unwrap();
14155        assert_eq!(
14156            back.status,
14157            ask::QuestionStatus::Answered,
14158            "a real answer is a decision on record, never overwritten by a sweep"
14159        );
14160    }
14161
14162    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
14163    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
14164    /// without merging, whose winner is still the operator's answer to read.
14165    /// Nothing previously called `fold_run` itself with a real `tally`, so
14166    /// this is the first test to pin down the one distinction the whole
14167    /// automatic-fold feature depends on: the winner's worktree and branch
14168    /// must survive, everything else sharing the run's worktree bay - a
14169    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
14170    /// second sweep treats every non-winner directory under the bay alike -
14171    /// must not.
14172    #[tokio::test]
14173    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
14174        crate::run::pin_test_home();
14175        let tmp = tempfile::tempdir().expect("tempdir");
14176        let repo = tmp.path().join("repo");
14177        std::fs::create_dir_all(&repo).unwrap();
14178        init_repo(&repo);
14179
14180        let mut config = Config::default();
14181        config.graph.worktree_root = Some(tmp.path().join("wt"));
14182
14183        let mut state = RunState::new(
14184            repo.clone(),
14185            "main".to_owned(),
14186            "deadbeef".to_owned(),
14187            "task".to_owned(),
14188            config,
14189        );
14190        let root = state.worktree_root();
14191        let wt_a = root.join("cand-A");
14192        let wt_b = root.join("cand-B");
14193        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
14194            .await
14195            .expect("worktree A");
14196        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
14197            .await
14198            .expect("worktree B");
14199
14200        state.candidates = vec![
14201            Candidate {
14202                index: 0,
14203                label: 'A',
14204                agent: "alpha".to_owned(),
14205                branch: "magi/x/A".to_owned(),
14206                worktree: wt_a.clone(),
14207                summary: String::new(),
14208                stat: String::new(),
14209                files: 0,
14210                commits: 0,
14211                empty: false,
14212                failed: None,
14213                verified_noop: None,
14214                duration_ms: 0,
14215                folded: false,
14216            },
14217            Candidate {
14218                index: 1,
14219                label: 'B',
14220                agent: "beta".to_owned(),
14221                branch: "magi/x/B".to_owned(),
14222                worktree: wt_b.clone(),
14223                summary: String::new(),
14224                stat: String::new(),
14225                files: 0,
14226                commits: 0,
14227                empty: false,
14228                failed: None,
14229                verified_noop: None,
14230                duration_ms: 0,
14231                folded: false,
14232            },
14233        ];
14234        state.tally = Some(Tally {
14235            first_choice: BTreeMap::from([('A', 1)]),
14236            borda: BTreeMap::new(),
14237            winner: 'A',
14238            rankings: 1,
14239            unanimous_initial: true,
14240            deliberated: false,
14241            changed_votes: 0,
14242            unanimous_final: true,
14243            tie_break: None,
14244            judges: 1,
14245            present: 1,
14246            quorum: 1,
14247            met_quorum: true,
14248            uncontested: None,
14249        });
14250        state.status = RunStatus::Ready;
14251
14252        fold_run(&mut state, false, &crate::run::home())
14253            .await
14254            .expect("fold_run");
14255
14256        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
14257        assert!(
14258            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
14259            "the unmerged winner's branch survives"
14260        );
14261        assert!(
14262            !state.candidates[0].folded,
14263            "the winner is not marked folded"
14264        );
14265
14266        assert!(!wt_b.exists(), "the loser's worktree is removed");
14267        assert!(
14268            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
14269            "the loser's branch is removed"
14270        );
14271        assert!(state.candidates[1].folded, "the loser is marked folded");
14272    }
14273
14274    #[tokio::test]
14275    #[cfg(unix)]
14276    async fn fold_run_waits_for_shared_lock_before_removing_worktrees_branches_or_config() {
14277        use std::os::fd::AsRawFd as _;
14278        use std::time::Duration;
14279
14280        let tmp = tempfile::tempdir().unwrap();
14281        let repo = tmp.path().join("repo");
14282        std::fs::create_dir_all(&repo).unwrap();
14283        init_repo(&repo);
14284        let common = repo.join(".git");
14285        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
14286        let home = tmp.path().join("home");
14287        let mut config = Config::default();
14288        config.coordination = crate::config::test_coordination();
14289        config.graph.worktree_root = Some(tmp.path().join("wt"));
14290        config.graph.timeout_resource_wait = 1;
14291        let mut state = RunState::new(
14292            repo.clone(),
14293            "main".into(),
14294            "deadbeef".into(),
14295            "task".into(),
14296            config,
14297        );
14298        let root = state.worktree_root();
14299        let candidate = root.join("cand-A");
14300        let reviewer = root.join("review-1");
14301        let branch = state.branch_for('A');
14302        git::worktree_add_branch(&repo, &candidate, &branch, "main")
14303            .await
14304            .unwrap();
14305        git::worktree_add_detached(&repo, &reviewer, "main")
14306            .await
14307            .unwrap();
14308        git::acquire_worktree_config(&repo).await.unwrap();
14309        state.enabled_worktree_config = true;
14310        state.status = RunStatus::Ready;
14311        state.candidates.push(Candidate {
14312            index: 0,
14313            label: 'A',
14314            agent: "implement".into(),
14315            branch: branch.clone(),
14316            worktree: candidate.clone(),
14317            summary: String::new(),
14318            stat: String::new(),
14319            files: 0,
14320            commits: 0,
14321            empty: false,
14322            failed: None,
14323            verified_noop: None,
14324            duration_ms: 0,
14325            folded: false,
14326        });
14327        state.save_under(&home).unwrap();
14328        let config_before = std::fs::read(common.join("config")).unwrap();
14329        let worktrees_before = git::git(&repo, &["worktree", "list", "--porcelain"])
14330            .await
14331            .unwrap();
14332        // A separate OS handle bypasses the launcher's local gate, exactly as
14333        // an external updater does, and must still prevent all folding work.
14334        let external = std::fs::File::create(common.join("updater.lock")).unwrap();
14335        assert_eq!(
14336            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
14337            0
14338        );
14339        let mut fold = Box::pin(fold_run(&mut state, true, &home));
14340        assert!(
14341            tokio::time::timeout(Duration::from_millis(200), &mut fold)
14342                .await
14343                .is_err()
14344        );
14345        assert!(candidate.exists());
14346        assert!(reviewer.exists());
14347        assert!(git::branch_exists(&repo, &branch).await.unwrap());
14348        assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
14349        assert_eq!(
14350            git::git(&repo, &["worktree", "list", "--porcelain"])
14351                .await
14352                .unwrap(),
14353            worktrees_before
14354        );
14355        let error = tokio::time::timeout(Duration::from_secs(3), &mut fold)
14356            .await
14357            .unwrap()
14358            .unwrap_err();
14359        assert!(error.is::<crate::repo_lock::ResourceBlocked>());
14360        drop(fold);
14361        assert!(candidate.exists() && reviewer.exists());
14362        assert!(!state.candidates[0].folded && state.enabled_worktree_config);
14363        assert_eq!(
14364            state.resource_block.as_ref().unwrap().kind,
14365            "repository coordination"
14366        );
14367        assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
14368        drop(external);
14369        let mut fold = Box::pin(fold_run(&mut state, true, &home));
14370        let removed = tokio::time::timeout(Duration::from_secs(10), &mut fold)
14371            .await
14372            .unwrap()
14373            .unwrap();
14374        drop(fold);
14375        assert!(removed.contains(&branch));
14376        assert!(!root.exists());
14377        assert!(!git::branch_exists(&repo, &branch).await.unwrap());
14378        assert!(state.candidates[0].folded);
14379        assert!(!state.enabled_worktree_config);
14380        assert_ne!(std::fs::read(common.join("config")).unwrap(), config_before);
14381        let enabled = git::git_raw(
14382            &repo,
14383            &["config", "--local", "--get", "extensions.worktreeConfig"],
14384        )
14385        .await
14386        .unwrap();
14387        assert!(!enabled.ok(), "the last config reference was released");
14388    }
14389
14390    /// A branch handed to a later run is that run's (and its pull request's):
14391    /// folding the run that released it must not delete it.
14392    #[tokio::test]
14393    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
14394        let tmp = tempfile::tempdir().expect("tempdir");
14395        let repo = tmp.path().join("repo");
14396        std::fs::create_dir_all(&repo).unwrap();
14397        init_repo(&repo);
14398        let home = tmp.path().join("home");
14399
14400        let mut config = Config::default();
14401        config.graph.worktree_root = Some(tmp.path().join("wt"));
14402        let mut state = RunState::new(
14403            repo.clone(),
14404            "main".to_owned(),
14405            "deadbeef".to_owned(),
14406            "task".to_owned(),
14407            config,
14408        );
14409        // The worktree is already gone (released); the branch survives.
14410        git::git(&repo, &["branch", "magi/x/A", "main"])
14411            .await
14412            .expect("branch");
14413        state.candidates = vec![Candidate {
14414            index: 0,
14415            label: 'A',
14416            agent: "alpha".to_owned(),
14417            branch: "magi/x/A".to_owned(),
14418            worktree: state.worktree_root().join("cand-A"),
14419            summary: String::new(),
14420            stat: String::new(),
14421            files: 0,
14422            commits: 0,
14423            empty: false,
14424            failed: None,
14425            verified_noop: None,
14426            duration_ms: 0,
14427            folded: true,
14428        }];
14429        state.released_to = Some("20260901-000000-new1".to_owned());
14430        state.released_branches = vec!["magi/x/A".to_owned()];
14431
14432        fold_run(&mut state, true, &home).await.expect("fold_run");
14433
14434        assert!(
14435            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
14436            "the handed-over branch survives a fold"
14437        );
14438    }
14439
14440    /// A winner with nothing ahead of the base is caught before `gh` is ever
14441    /// asked for a pull request, and the message carries what the task's
14442    /// references resolved to.
14443    #[tokio::test]
14444    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
14445        let tmp = tempfile::tempdir().expect("tempdir");
14446        let repo = tmp.path().join("repo");
14447        std::fs::create_dir_all(&repo).unwrap();
14448        init_repo(&repo);
14449        let run = |args: &[&str]| {
14450            let out = std::process::Command::new("git")
14451                .quiet()
14452                .args(args)
14453                .current_dir(&repo)
14454                .output()
14455                .expect("spawn git");
14456            assert!(out.status.success(), "git {args:?}");
14457        };
14458        run(&["branch", "magi/x/A"]);
14459        run(&["checkout", "-q", "-b", "magi/x/B"]);
14460        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
14461        run(&["add", "-A"]);
14462        run(&["commit", "-q", "-m", "work"]);
14463        run(&["checkout", "-q", "main"]);
14464
14465        // A pull request is compared against the remote's base, so the
14466        // fixture needs one. Before it exists nothing can be read, and the
14467        // answer must be "not empty".
14468        let probe = RunState::new(
14469            repo.clone(),
14470            "main".to_owned(),
14471            "deadbeef".to_owned(),
14472            "task".to_owned(),
14473            Config::default(),
14474        );
14475        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
14476        let bare = tmp.path().join("origin.git");
14477        let out = std::process::Command::new("git")
14478            .quiet()
14479            .args(["init", "-q", "--bare"])
14480            .arg(&bare)
14481            .output()
14482            .expect("spawn git");
14483        assert!(out.status.success(), "git init --bare");
14484        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
14485        run(&["push", "-q", "origin", "main"]);
14486
14487        let mut state = RunState::new(
14488            repo.clone(),
14489            "main".to_owned(),
14490            "deadbeef".to_owned(),
14491            "task".to_owned(),
14492            Config::default(),
14493        );
14494        state.seeds = vec![refs::Seed {
14495            token: "magi/27b2/A".to_owned(),
14496            kind: refs::SeedKind::Unresolved,
14497            sha: String::new(),
14498            branch: true,
14499            detail: "no branch or commit named magi/27b2/A".to_owned(),
14500        }];
14501
14502        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
14503        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
14504        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
14505        let detail = empty_candidate_detail(&state, "main");
14506        assert!(detail.starts_with("empty candidate"), "{detail}");
14507        assert!(detail.contains("magi/27b2/A"), "{detail}");
14508    }
14509
14510    /// `status == Ready` used to be read as "this is the harmless
14511    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
14512    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
14513    /// run's PR was closed without merging — and reentering `merge` with
14514    /// `mode` still `Pr` does not know the difference, so it pushed and
14515    /// opened a second pull request. `mode == Local` reproduces the same
14516    /// blind spot without a network call: reentry must not attempt another
14517    /// git merge once this node has already recorded an outcome.
14518    #[tokio::test]
14519    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
14520        let tmp = tempfile::tempdir().expect("tempdir");
14521        let repo = tmp.path().join("repo");
14522        std::fs::create_dir_all(&repo).unwrap();
14523        init_repo(&repo);
14524
14525        let mut config = Config::default();
14526        config.merge.mode = MergeMode::Local;
14527
14528        let mut state = RunState::new(
14529            repo.clone(),
14530            "main".to_owned(),
14531            "deadbeef".to_owned(),
14532            "task".to_owned(),
14533            config,
14534        );
14535        state.candidates = vec![Candidate {
14536            index: 0,
14537            label: 'A',
14538            agent: "alpha".to_owned(),
14539            branch: "does-not-exist".to_owned(),
14540            worktree: repo.clone(),
14541            summary: String::new(),
14542            stat: String::new(),
14543            files: 0,
14544            commits: 0,
14545            empty: false,
14546            failed: None,
14547            verified_noop: None,
14548            duration_ms: 0,
14549            folded: false,
14550        }];
14551        state.tally = Some(Tally {
14552            first_choice: BTreeMap::from([('A', 1)]),
14553            borda: BTreeMap::new(),
14554            winner: 'A',
14555            rankings: 1,
14556            unanimous_initial: true,
14557            deliberated: false,
14558            changed_votes: 0,
14559            unanimous_final: true,
14560            tie_break: None,
14561            judges: 0,
14562            present: 0,
14563            quorum: 0,
14564            met_quorum: true,
14565            uncontested: Some("only candidate A produced a change".to_owned()),
14566        });
14567        state.reviews = vec![ReviewRound {
14568            round: 1,
14569            head: "deadbeef".to_owned(),
14570            verified_head: None,
14571            verified_at: None,
14572            reviews: Vec::new(),
14573            e2e: Vec::new(),
14574            fix: None,
14575            blocking: 0,
14576            answered: 0,
14577            expected: 0,
14578            clean: true,
14579            verify_retried: false,
14580            e2e_deferred: false,
14581            e2e_defer_reason: None,
14582            progressed: false,
14583            vote_split: false,
14584            reconsideration: Vec::new(),
14585            verdict: None,
14586        }];
14587        state.gate = vec![CommandOutcome {
14588            command: "test".to_owned(),
14589            code: Some(0),
14590            output_tail: String::new(),
14591            duration_ms: 0,
14592            resource_blocked: false,
14593        }];
14594        state.gate_ran = true;
14595        // Reached its conclusion already — e.g. `land` closing the PR without
14596        // merging it, which (like the honest `MergeMode::None` path) leaves
14597        // `status` at `Ready`. The recorded outcome is what actually marks
14598        // this node done.
14599        state.status = RunStatus::Ready;
14600        state.merge = Some(MergeOutcome {
14601            mode: MergeMode::Local,
14602            ok: false,
14603            detail: "already concluded".to_owned(),
14604            empty: false,
14605        });
14606
14607        let mut runner = Runner {
14608            state,
14609            roles: ResolvedRoles {
14610                implementers: Vec::new(),
14611                judges: Vec::new(),
14612                reviewers: Vec::new(),
14613                fixer: None,
14614                conductor: conductor(),
14615                implementer_roster: Vec::new(),
14616                judge_roster: Vec::new(),
14617                reviewer_roster: Vec::new(),
14618            },
14619            sem: Arc::new(Semaphore::new(1)),
14620            pause: Pause::new(),
14621            interrupt: Pause::new(),
14622        };
14623
14624        runner.merge().await.expect("merge");
14625
14626        assert_eq!(
14627            runner.state.status,
14628            RunStatus::Ready,
14629            "a concluded run's status must not change on reentry"
14630        );
14631        assert_eq!(
14632            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
14633            Some("already concluded"),
14634            "merge must not run again once the node already recorded an outcome"
14635        );
14636    }
14637
14638    /// `gate` leaves `state.gate_ran` false both before it has ever run and
14639    /// when its last attempt was resource-blocked (the shared build cache
14640    /// could not be acquired or confirmed fresh in time - see
14641    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
14642    /// `Vec` this also leaves behind used to read as "nothing failed" and let
14643    /// a run merge a tree the gate never actually checked - exactly the case
14644    /// a contended cache produces on every retry until it clears. `merge`
14645    /// must refuse until `gate` has actually recorded an attempt.
14646    #[tokio::test]
14647    async fn merge_refuses_a_gate_that_has_not_actually_run() {
14648        let tmp = tempfile::tempdir().expect("tempdir");
14649        let repo = tmp.path().join("repo");
14650        std::fs::create_dir_all(&repo).unwrap();
14651        init_repo(&repo);
14652
14653        let mut config = Config::default();
14654        config.merge.mode = MergeMode::Local;
14655
14656        let mut state = RunState::new(
14657            repo.clone(),
14658            "main".to_owned(),
14659            "deadbeef".to_owned(),
14660            "task".to_owned(),
14661            config,
14662        );
14663        state.candidates = vec![Candidate {
14664            index: 0,
14665            label: 'A',
14666            agent: "alpha".to_owned(),
14667            branch: "does-not-exist".to_owned(),
14668            worktree: repo.clone(),
14669            summary: String::new(),
14670            stat: String::new(),
14671            files: 0,
14672            commits: 0,
14673            empty: false,
14674            failed: None,
14675            verified_noop: None,
14676            duration_ms: 0,
14677            folded: false,
14678        }];
14679        state.tally = Some(Tally {
14680            first_choice: BTreeMap::from([('A', 1)]),
14681            borda: BTreeMap::new(),
14682            winner: 'A',
14683            rankings: 1,
14684            unanimous_initial: true,
14685            deliberated: false,
14686            changed_votes: 0,
14687            unanimous_final: true,
14688            tie_break: None,
14689            judges: 0,
14690            present: 0,
14691            quorum: 0,
14692            met_quorum: true,
14693            uncontested: Some("only candidate A produced a change".to_owned()),
14694        });
14695        state.reviews = vec![ReviewRound {
14696            round: 1,
14697            head: "deadbeef".to_owned(),
14698            verified_head: None,
14699            verified_at: None,
14700            reviews: Vec::new(),
14701            e2e: Vec::new(),
14702            fix: None,
14703            blocking: 0,
14704            answered: 0,
14705            expected: 0,
14706            clean: true,
14707            verify_retried: false,
14708            e2e_deferred: false,
14709            e2e_defer_reason: None,
14710            progressed: false,
14711            vote_split: false,
14712            reconsideration: Vec::new(),
14713            verdict: None,
14714        }];
14715        // The point: `gate` has not recorded anything yet.
14716        state.gate = Vec::new();
14717        state.gate_ran = false;
14718        state.status = RunStatus::Gating;
14719
14720        let mut runner = Runner {
14721            state,
14722            roles: ResolvedRoles {
14723                implementers: Vec::new(),
14724                judges: Vec::new(),
14725                reviewers: Vec::new(),
14726                fixer: None,
14727                conductor: conductor(),
14728                implementer_roster: Vec::new(),
14729                judge_roster: Vec::new(),
14730                reviewer_roster: Vec::new(),
14731            },
14732            sem: Arc::new(Semaphore::new(1)),
14733            pause: Pause::new(),
14734            interrupt: Pause::new(),
14735        };
14736
14737        runner.merge().await.expect("merge");
14738
14739        assert!(
14740            runner.state.merge.is_none(),
14741            "an empty gate must never be read as a passing one: {:?}",
14742            runner.state.merge
14743        );
14744    }
14745
14746    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
14747    /// commands configured and `merge.mode` is `none` (a review-only run).
14748    /// `gate` must still record a real attempt — zero commands, vacuously
14749    /// passed — rather than leaving `state.gate` empty in a way `merge`
14750    /// cannot tell apart from "never ran"; otherwise the run reaches
14751    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
14752    #[tokio::test]
14753    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
14754        let tmp = tempfile::tempdir().expect("tempdir");
14755        let repo = tmp.path().join("repo");
14756        std::fs::create_dir_all(&repo).unwrap();
14757        init_repo(&repo);
14758
14759        // Default config: `verify.gate` empty, `merge.mode` is `none`.
14760        let config = Config::default();
14761
14762        let mut state = RunState::new(
14763            repo.clone(),
14764            "main".to_owned(),
14765            "deadbeef".to_owned(),
14766            "task".to_owned(),
14767            config,
14768        );
14769        state.candidates = vec![Candidate {
14770            index: 0,
14771            label: 'A',
14772            agent: "alpha".to_owned(),
14773            branch: "does-not-exist".to_owned(),
14774            worktree: repo.clone(),
14775            summary: String::new(),
14776            stat: String::new(),
14777            files: 0,
14778            commits: 0,
14779            empty: false,
14780            failed: None,
14781            verified_noop: None,
14782            duration_ms: 0,
14783            folded: false,
14784        }];
14785        state.tally = Some(Tally {
14786            first_choice: BTreeMap::from([('A', 1)]),
14787            borda: BTreeMap::new(),
14788            winner: 'A',
14789            rankings: 1,
14790            unanimous_initial: true,
14791            deliberated: false,
14792            changed_votes: 0,
14793            unanimous_final: true,
14794            tie_break: None,
14795            judges: 0,
14796            present: 0,
14797            quorum: 0,
14798            met_quorum: true,
14799            uncontested: Some("only candidate A produced a change".to_owned()),
14800        });
14801        state.reviews = vec![ReviewRound {
14802            round: 1,
14803            head: "deadbeef".to_owned(),
14804            verified_head: None,
14805            verified_at: None,
14806            reviews: Vec::new(),
14807            e2e: Vec::new(),
14808            fix: None,
14809            blocking: 0,
14810            answered: 0,
14811            expected: 0,
14812            clean: true,
14813            verify_retried: false,
14814            e2e_deferred: false,
14815            e2e_defer_reason: None,
14816            progressed: false,
14817            vote_split: false,
14818            reconsideration: Vec::new(),
14819            verdict: None,
14820        }];
14821
14822        let mut runner = Runner {
14823            state,
14824            roles: ResolvedRoles {
14825                implementers: Vec::new(),
14826                judges: Vec::new(),
14827                reviewers: Vec::new(),
14828                fixer: None,
14829                conductor: conductor(),
14830                implementer_roster: Vec::new(),
14831                judge_roster: Vec::new(),
14832                reviewer_roster: Vec::new(),
14833            },
14834            sem: Arc::new(Semaphore::new(1)),
14835            pause: Pause::new(),
14836            interrupt: Pause::new(),
14837        };
14838
14839        runner.gate().await.expect("gate");
14840        assert!(
14841            runner.state.gate_ran,
14842            "zero configured commands is still a real attempt, not an unrun gate"
14843        );
14844        assert!(runner.state.gate.is_empty());
14845        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
14846        assert_ne!(
14847            runner.state.status,
14848            RunStatus::Blocked,
14849            "a gate with nothing to check must not read as failed"
14850        );
14851
14852        runner.merge().await.expect("merge");
14853        assert_eq!(
14854            runner.state.status,
14855            RunStatus::Ready,
14856            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
14857        );
14858    }
14859
14860    /// `Config::cache_dir` is derived from `verify.e2e` as well as
14861    /// `verify.gate` (so the e2e leg and the final gate never build against
14862    /// different directories). With zero `verify.gate` commands but a
14863    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
14864    /// that lease before discovering it had nothing to run - so a repo with
14865    /// no gate commands could come back `resource_blocked` (and therefore
14866    /// still `gate_ran == false`) on nothing but an unrelated run holding the
14867    /// cache, exactly the contention this run's own zero commands could
14868    /// never have touched. `gate` must recognise there is nothing to check
14869    /// before it ever asks for the lease.
14870    #[tokio::test]
14871    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
14872        crate::run::pin_test_home();
14873        let home = crate::run::home();
14874
14875        let tmp = tempfile::tempdir().expect("tempdir");
14876        let repo = tmp.path().join("repo");
14877        std::fs::create_dir_all(&repo).unwrap();
14878        init_repo(&repo);
14879        // Unique to this test, so holding its lease cannot collide with
14880        // another test sharing the same process-wide `home`.
14881        let cache_dir = tmp.path().join("target");
14882
14883        let mut config = Config::default();
14884        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
14885        // `verify.gate` stays empty (the default). Bounded so a regression
14886        // that does start waiting fails the test in seconds, not hangs it.
14887        config.graph.timeout_verify = Some(2);
14888        config.graph.timeout_resource_wait = 2;
14889
14890        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14891        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14892            .expect("no io error acquiring directly")
14893        {
14894            crate::cache::AcquireOutcome::Acquired(g) => g,
14895            crate::cache::AcquireOutcome::Busy(b) => {
14896                panic!("expected the direct acquire to win the lease first: {b:?}")
14897            }
14898        };
14899
14900        let mut state = RunState::new(
14901            repo.clone(),
14902            "main".to_owned(),
14903            "deadbeef".to_owned(),
14904            "task".to_owned(),
14905            config,
14906        );
14907        state.candidates = vec![Candidate {
14908            index: 0,
14909            label: 'A',
14910            agent: "alpha".to_owned(),
14911            branch: "does-not-exist".to_owned(),
14912            worktree: repo.clone(),
14913            summary: String::new(),
14914            stat: String::new(),
14915            files: 0,
14916            commits: 0,
14917            empty: false,
14918            failed: None,
14919            verified_noop: None,
14920            duration_ms: 0,
14921            folded: false,
14922        }];
14923        state.tally = Some(Tally {
14924            first_choice: BTreeMap::from([('A', 1)]),
14925            borda: BTreeMap::new(),
14926            winner: 'A',
14927            rankings: 1,
14928            unanimous_initial: true,
14929            deliberated: false,
14930            changed_votes: 0,
14931            unanimous_final: true,
14932            tie_break: None,
14933            judges: 0,
14934            present: 0,
14935            quorum: 0,
14936            met_quorum: true,
14937            uncontested: Some("only candidate A produced a change".to_owned()),
14938        });
14939        state.reviews = vec![ReviewRound {
14940            round: 1,
14941            head: "deadbeef".to_owned(),
14942            verified_head: None,
14943            verified_at: None,
14944            reviews: Vec::new(),
14945            e2e: Vec::new(),
14946            fix: None,
14947            blocking: 0,
14948            answered: 0,
14949            expected: 0,
14950            clean: true,
14951            verify_retried: false,
14952            e2e_deferred: false,
14953            e2e_defer_reason: None,
14954            progressed: false,
14955            vote_split: false,
14956            reconsideration: Vec::new(),
14957            verdict: None,
14958        }];
14959
14960        let mut runner = Runner {
14961            state,
14962            roles: ResolvedRoles {
14963                implementers: Vec::new(),
14964                judges: Vec::new(),
14965                reviewers: Vec::new(),
14966                fixer: None,
14967                conductor: conductor(),
14968                implementer_roster: Vec::new(),
14969                judge_roster: Vec::new(),
14970                reviewer_roster: Vec::new(),
14971            },
14972            sem: Arc::new(Semaphore::new(1)),
14973            pause: Pause::new(),
14974            interrupt: Pause::new(),
14975        };
14976
14977        let started = std::time::Instant::now();
14978        runner.gate().await.expect("gate");
14979        assert!(
14980            started.elapsed() < Duration::from_secs(1),
14981            "a gate with nothing to run must never wait on a lease it never needed"
14982        );
14983        assert!(
14984            runner.state.gate_ran,
14985            "zero commands is still a real, immediate attempt"
14986        );
14987        assert!(runner.state.gate.is_empty());
14988        assert_ne!(
14989            runner.state.status,
14990            RunStatus::Blocked,
14991            "must not read as resource-blocked on a lease it never asked for"
14992        );
14993    }
14994
14995    /// The addendum's second gap: a `verify.gate` command running for real
14996    /// wall-clock time had nothing at all to show for it in `active` before
14997    /// `run_commands` learned to record it — a run could sit in `Gating` for
14998    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
14999    /// what was actually happening. Proven with a genuinely still-running
15000    /// command, not just a before/after check on the final state: a poller
15001    /// task reads the same `run.json` `gate()` is writing, the same way the
15002    /// phone or `magi show` would, while the shell command is still blocked
15003    /// on its own release marker.
15004    #[tokio::test]
15005    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
15006        crate::run::pin_test_home();
15007
15008        let tmp = tempfile::tempdir().expect("tempdir");
15009        let repo = tmp.path().join("repo");
15010        std::fs::create_dir_all(&repo).unwrap();
15011        init_repo(&repo);
15012
15013        let mut config = Config::default();
15014        config.verify.gate = vec![
15015            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
15016             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
15017                .to_owned(),
15018        ];
15019
15020        let mut state = RunState::new(
15021            repo.clone(),
15022            "main".to_owned(),
15023            "deadbeef".to_owned(),
15024            "task".to_owned(),
15025            config,
15026        );
15027        let run_id = state.id.clone();
15028        state.candidates = vec![Candidate {
15029            index: 0,
15030            label: 'A',
15031            agent: "alpha".to_owned(),
15032            branch: "does-not-exist".to_owned(),
15033            worktree: repo.clone(),
15034            summary: String::new(),
15035            stat: String::new(),
15036            files: 0,
15037            commits: 0,
15038            empty: false,
15039            failed: None,
15040            verified_noop: None,
15041            duration_ms: 0,
15042            folded: false,
15043        }];
15044        state.tally = Some(Tally {
15045            first_choice: BTreeMap::from([('A', 1)]),
15046            borda: BTreeMap::new(),
15047            winner: 'A',
15048            rankings: 1,
15049            unanimous_initial: true,
15050            deliberated: false,
15051            changed_votes: 0,
15052            unanimous_final: true,
15053            tie_break: None,
15054            judges: 0,
15055            present: 0,
15056            quorum: 0,
15057            met_quorum: true,
15058            uncontested: Some("only candidate A produced a change".to_owned()),
15059        });
15060        state.reviews = vec![ReviewRound {
15061            round: 1,
15062            head: "deadbeef".to_owned(),
15063            verified_head: None,
15064            verified_at: None,
15065            reviews: Vec::new(),
15066            e2e: Vec::new(),
15067            fix: None,
15068            blocking: 0,
15069            answered: 0,
15070            expected: 0,
15071            clean: true,
15072            verify_retried: false,
15073            e2e_deferred: false,
15074            e2e_defer_reason: None,
15075            progressed: false,
15076            vote_split: false,
15077            reconsideration: Vec::new(),
15078            verdict: None,
15079        }];
15080
15081        let mut runner = Runner {
15082            state,
15083            roles: ResolvedRoles {
15084                implementers: Vec::new(),
15085                judges: Vec::new(),
15086                reviewers: Vec::new(),
15087                fixer: None,
15088                conductor: conductor(),
15089                implementer_roster: Vec::new(),
15090                judge_roster: Vec::new(),
15091                reviewer_roster: Vec::new(),
15092            },
15093            sem: Arc::new(Semaphore::new(1)),
15094            pause: Pause::new(),
15095            interrupt: Pause::new(),
15096        };
15097
15098        let started_marker = repo.join("started.marker");
15099        let release_marker = repo.join("release.marker");
15100        let poller = tokio::spawn(async move {
15101            // Bounded so a regression that never records the task entry
15102            // fails this test in seconds instead of hanging the suite —
15103            // the same shape `a_park_requested_while_a_seat_is_mid_call_
15104            // does_not_cut_it_short` uses for the same reason.
15105            for _ in 0..100 {
15106                if started_marker.exists()
15107                    && let Ok(s) = crate::run::RunState::load(&run_id)
15108                    && let Some(a) = s.active.get("gate")
15109                {
15110                    std::fs::write(&release_marker, b"go").expect("release marker");
15111                    return Some(a.clone());
15112                }
15113                tokio::time::sleep(Duration::from_millis(50)).await;
15114            }
15115            None
15116        });
15117
15118        runner.gate().await.expect("gate");
15119        let captured = poller.await.expect("poller task");
15120        let captured = captured.expect(
15121            "the poller never saw a `gate` task entry in run.json while the command was \
15122             still blocked on its own release marker",
15123        );
15124
15125        assert_eq!(captured.task.as_deref(), Some("gate"));
15126        assert_eq!(captured.node, "gate");
15127        assert_eq!(captured.index, Some(1));
15128        assert_eq!(captured.total, Some(1));
15129        assert!(
15130            captured
15131                .command
15132                .as_deref()
15133                .is_some_and(|c| c.contains("started.marker")),
15134            "{captured:?}"
15135        );
15136
15137        assert!(
15138            runner.state.active.is_empty(),
15139            "the entry must be cleared once the command actually finished: {:?}",
15140            runner.state.active
15141        );
15142        assert!(runner.state.gate_ran);
15143        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
15144    }
15145
15146    /// The hand-off over a blocking finding a reviewer rejected on leaves a
15147    /// record for `land`; one with only a Minor, or no reject, leaves none.
15148    #[tokio::test]
15149    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
15150        use crate::verdict::{Finding, ReviewVote, Severity};
15151        crate::run::pin_test_home();
15152        let tmp = tempfile::tempdir().expect("tempdir");
15153        let repo = tmp.path().join("repo");
15154        std::fs::create_dir_all(&repo).unwrap();
15155        init_repo(&repo);
15156
15157        for (severity, vote, expect) in [
15158            (Severity::Major, ReviewVote::Reject, true),
15159            (Severity::Minor, ReviewVote::Reject, false),
15160            (Severity::Major, ReviewVote::Approve, false),
15161        ] {
15162            let mut round = review_round(false, 1, 1, 1, false, true);
15163            round.reviews = vec![ReviewRecord {
15164                reviewer: 1,
15165                agent: "alpha".to_owned(),
15166                summary: String::new(),
15167                findings: vec![Finding {
15168                    id: "R1-1-1".to_owned(),
15169                    severity,
15170                    file: None,
15171                    line: None,
15172                    title: "t".to_owned(),
15173                    detail: String::new(),
15174                }],
15175                vote: Some(vote),
15176                failed: None,
15177                duration_ms: 0,
15178                attempts: 0,
15179            }];
15180            let mut state = RunState::new(
15181                repo.clone(),
15182                "main".to_owned(),
15183                "deadbeef".to_owned(),
15184                "task".to_owned(),
15185                Config::default(),
15186            );
15187            state.reviews = vec![round];
15188            let mut runner = Runner {
15189                state,
15190                roles: ResolvedRoles {
15191                    implementers: Vec::new(),
15192                    judges: Vec::new(),
15193                    reviewers: Vec::new(),
15194                    fixer: None,
15195                    conductor: conductor(),
15196                    implementer_roster: Vec::new(),
15197                    judge_roster: Vec::new(),
15198                    reviewer_roster: Vec::new(),
15199                },
15200                sem: Arc::new(Semaphore::new(1)),
15201                pause: Pause::new(),
15202                interrupt: Pause::new(),
15203            };
15204            let shell = runner.state.config.shell();
15205            runner
15206                .stop_reviewing("round budget spent", &shell, &repo)
15207                .await
15208                .expect("stop_reviewing");
15209            assert_eq!(runner.state.status, RunStatus::Gating);
15210            assert_eq!(
15211                runner.state.contested_handoff.is_some(),
15212                expect,
15213                "{severity:?} + {vote:?}"
15214            );
15215        }
15216    }
15217
15218    /// The shape the incident this whole fix responds to actually had: the
15219    /// round budget spent, the last round's own e2e blocked on the shared
15220    /// build cache (held here by a live pid — this test process — exactly
15221    /// `cache`'s own unit tests' pattern for "another owner, still alive"
15222    /// without forking a process). `stop_reviewing` must retry it — not
15223    /// silently leave the round looking untouched (the catch-up-only half of
15224    /// the bug), and not read the contention as a red `e2e` and block the
15225    /// run on it (the other half). Called directly, the same way
15226    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
15227    /// above exercises `gate`, so this never needs a real cargo build to
15228    /// reach: the lease is never released, so `with_cache_lease` never gets
15229    /// past acquiring it into anything that would need a real workspace.
15230    #[tokio::test]
15231    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
15232        crate::run::pin_test_home();
15233        let home = crate::run::home();
15234
15235        let tmp = tempfile::tempdir().expect("tempdir");
15236        let repo = tmp.path().join("repo");
15237        std::fs::create_dir_all(&repo).unwrap();
15238        init_repo(&repo);
15239        let head = crate::git::rev_parse(&repo, "HEAD")
15240            .await
15241            .expect("rev-parse");
15242        // Unique to this test, so holding its lease cannot collide with
15243        // another test sharing the same process-wide `home`.
15244        let cache_dir = tmp.path().join("target");
15245
15246        let mut config = Config::default();
15247        config.verify.e2e = vec![format!(
15248            "CARGO_TARGET_DIR='{}' test -f README.md",
15249            cache_dir.display()
15250        )];
15251        config.graph.review_rounds = 1;
15252        // Bounded so a regression that does start waiting fails the test in
15253        // seconds, not hangs it.
15254        config.graph.timeout_verify = Some(2);
15255        config.graph.timeout_resource_wait = 2;
15256
15257        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
15258        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
15259            .expect("no io error acquiring directly")
15260        {
15261            crate::cache::AcquireOutcome::Acquired(g) => g,
15262            crate::cache::AcquireOutcome::Busy(b) => {
15263                panic!("expected the direct acquire to win the lease first: {b:?}")
15264            }
15265        };
15266
15267        let mut state = RunState::new(
15268            repo.clone(),
15269            "main".to_owned(),
15270            head.clone(),
15271            "task".to_owned(),
15272            config,
15273        );
15274        state.candidates = vec![Candidate {
15275            index: 0,
15276            label: 'A',
15277            agent: "alpha".to_owned(),
15278            branch: "does-not-exist".to_owned(),
15279            worktree: repo.clone(),
15280            summary: String::new(),
15281            stat: String::new(),
15282            files: 0,
15283            commits: 0,
15284            empty: false,
15285            failed: None,
15286            verified_noop: None,
15287            duration_ms: 0,
15288            folded: false,
15289        }];
15290        state.tally = Some(Tally {
15291            first_choice: BTreeMap::from([('A', 1)]),
15292            borda: BTreeMap::new(),
15293            winner: 'A',
15294            rankings: 1,
15295            unanimous_initial: true,
15296            deliberated: false,
15297            changed_votes: 0,
15298            unanimous_final: true,
15299            tie_break: None,
15300            judges: 0,
15301            present: 0,
15302            quorum: 0,
15303            met_quorum: true,
15304            uncontested: Some("only candidate A produced a change".to_owned()),
15305        });
15306        // The round budget's last round, deferred: `needs_catchup_run`'s
15307        // other trigger. `stop_reviewing`'s retry machinery must treat this
15308        // exactly like a resource-blocked attempt once it actually runs.
15309        state.reviews = vec![ReviewRound {
15310            round: 1,
15311            head: head.clone(),
15312            verified_head: None,
15313            verified_at: None,
15314            reviews: Vec::new(),
15315            e2e: Vec::new(),
15316            fix: None,
15317            blocking: 1,
15318            answered: 1,
15319            expected: 1,
15320            clean: false,
15321            verify_retried: false,
15322            e2e_deferred: true,
15323            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
15324            progressed: false,
15325            vote_split: false,
15326            reconsideration: Vec::new(),
15327            verdict: None,
15328        }];
15329
15330        let mut runner = Runner {
15331            state,
15332            roles: ResolvedRoles {
15333                implementers: Vec::new(),
15334                judges: Vec::new(),
15335                reviewers: Vec::new(),
15336                fixer: None,
15337                conductor: conductor(),
15338                implementer_roster: Vec::new(),
15339                judge_roster: Vec::new(),
15340                reviewer_roster: Vec::new(),
15341            },
15342            sem: Arc::new(Semaphore::new(1)),
15343            pause: Pause::new(),
15344            interrupt: Pause::new(),
15345        };
15346
15347        let shell = runner.state.config.shell();
15348        runner
15349            .stop_reviewing("round budget spent", &shell, &repo)
15350            .await
15351            .expect("stop_reviewing");
15352
15353        let last = runner.state.reviews.last().expect("round record");
15354        assert_eq!(
15355            last.e2e_status(),
15356            E2eStatus::ResourceBlocked,
15357            "the shared cache is still held; the attempt must read as blocked, not deferred or \
15358             failed: {last:?}"
15359        );
15360        assert_eq!(
15361            last.verified_head.as_deref(),
15362            Some(head.as_str()),
15363            "which commit this attempt targeted is known even though nothing finished checking \
15364             it"
15365        );
15366        let first_attempt_at = last
15367            .verified_at
15368            .expect("when this attempt ran is known too");
15369        assert_ne!(
15370            runner.state.status,
15371            RunStatus::Blocked,
15372            "contention is evidence about the machine, not the patch — it must not settle the \
15373             run as blocked: {:?}",
15374            runner.state.status
15375        );
15376        assert!(
15377            !runner
15378                .state
15379                .events
15380                .iter()
15381                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
15382            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
15383            runner.state.events
15384        );
15385
15386        // The cache is still held: a later reentry must retry the same
15387        // round's verification again — not leave it looking exactly as
15388        // untouched as the first blocked attempt, which is indistinguishable
15389        // from never having tried again at all.
15390        runner
15391            .stop_reviewing("round budget spent", &shell, &repo)
15392            .await
15393            .expect("stop_reviewing retry");
15394        assert_eq!(
15395            runner.state.reviews.len(),
15396            1,
15397            "no new round was started: {:?}",
15398            runner.state.reviews
15399        );
15400        let last = runner.state.reviews.last().expect("round record");
15401        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
15402        assert!(
15403            last.verified_at.expect("still known") > first_attempt_at,
15404            "a second reentry must be a fresh attempt, not a stale copy of the first"
15405        );
15406        assert_ne!(runner.state.status, RunStatus::Blocked);
15407
15408        held.release();
15409    }
15410
15411    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
15412    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
15413    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
15414    /// own top-of-function fast path (`review_conclusion`) correctly reads
15415    /// this shape as `None` rather than guessing `Blocked`, and the loop's
15416    /// own `for` range is empty once the round budget is spent, so
15417    /// `review_loop` must retry the check itself rather than silently doing
15418    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
15419    /// above exercises directly, but through `review_loop`'s own entry point
15420    /// this time, proving the wiring between the two rather than just the
15421    /// retry logic in isolation.
15422    #[tokio::test]
15423    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
15424        crate::run::pin_test_home();
15425        let home = crate::run::home();
15426
15427        let tmp = tempfile::tempdir().expect("tempdir");
15428        let repo = tmp.path().join("repo");
15429        std::fs::create_dir_all(&repo).unwrap();
15430        init_repo(&repo);
15431        let head = crate::git::rev_parse(&repo, "HEAD")
15432            .await
15433            .expect("rev-parse");
15434        let cache_dir = tmp.path().join("target");
15435
15436        let mut config = Config::default();
15437        config.verify.e2e = vec![format!(
15438            "CARGO_TARGET_DIR='{}' test -f README.md",
15439            cache_dir.display()
15440        )];
15441        config.graph.review_rounds = 1;
15442        config.graph.timeout_verify = Some(2);
15443        config.graph.timeout_resource_wait = 2;
15444
15445        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
15446        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
15447            .expect("no io error acquiring directly")
15448        {
15449            crate::cache::AcquireOutcome::Acquired(g) => g,
15450            crate::cache::AcquireOutcome::Busy(b) => {
15451                panic!("expected the direct acquire to win the lease first: {b:?}")
15452            }
15453        };
15454
15455        let mut state = RunState::new(
15456            repo.clone(),
15457            "main".to_owned(),
15458            head.clone(),
15459            "task".to_owned(),
15460            config,
15461        );
15462        state.candidates = vec![Candidate {
15463            index: 0,
15464            label: 'A',
15465            agent: "alpha".to_owned(),
15466            branch: "does-not-exist".to_owned(),
15467            worktree: repo.clone(),
15468            summary: String::new(),
15469            stat: String::new(),
15470            files: 0,
15471            commits: 0,
15472            empty: false,
15473            failed: None,
15474            verified_noop: None,
15475            duration_ms: 0,
15476            folded: false,
15477        }];
15478        state.tally = Some(Tally {
15479            first_choice: BTreeMap::from([('A', 1)]),
15480            borda: BTreeMap::new(),
15481            winner: 'A',
15482            rankings: 1,
15483            unanimous_initial: true,
15484            deliberated: false,
15485            changed_votes: 0,
15486            unanimous_final: true,
15487            tie_break: None,
15488            judges: 0,
15489            present: 0,
15490            quorum: 0,
15491            met_quorum: true,
15492            uncontested: Some("only candidate A produced a change".to_owned()),
15493        });
15494        // The exact shape a prior process's `stop_reviewing` would have left
15495        // on disk: the round budget's last round, a real attempt already
15496        // made and already resource-blocked.
15497        state.reviews = vec![ReviewRound {
15498            round: 1,
15499            head: head.clone(),
15500            verified_head: Some(head.clone()),
15501            verified_at: Some(jiff::Timestamp::now()),
15502            reviews: Vec::new(),
15503            e2e: vec![CommandOutcome {
15504                command: format!(
15505                    "CARGO_TARGET_DIR='{}' test -f README.md",
15506                    cache_dir.display()
15507                ),
15508                code: None,
15509                output_tail: "waiting for the shared build cache".to_owned(),
15510                duration_ms: 0,
15511                resource_blocked: true,
15512            }],
15513            fix: None,
15514            blocking: 1,
15515            answered: 1,
15516            expected: 1,
15517            clean: false,
15518            verify_retried: false,
15519            e2e_deferred: false,
15520            e2e_defer_reason: None,
15521            progressed: false,
15522            vote_split: false,
15523            reconsideration: Vec::new(),
15524            verdict: None,
15525        }];
15526
15527        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
15528        let mut runner = Runner {
15529            state,
15530            roles: ResolvedRoles {
15531                implementers: Vec::new(),
15532                judges: Vec::new(),
15533                reviewers: Vec::new(),
15534                fixer: None,
15535                conductor: conductor(),
15536                implementer_roster: Vec::new(),
15537                judge_roster: Vec::new(),
15538                reviewer_roster: Vec::new(),
15539            },
15540            sem: Arc::new(Semaphore::new(1)),
15541            pause: Pause::new(),
15542            interrupt: Pause::new(),
15543        };
15544
15545        // The lease is still held throughout, so this reentry's own retry is
15546        // also contended — proving `review_loop` actually tried again (not
15547        // that it happened to succeed) is what the timestamp comparison
15548        // below is for.
15549        runner.review_loop().await.expect("review_loop");
15550
15551        assert_eq!(
15552            runner.state.reviews.len(),
15553            1,
15554            "no new round was started on top of the unresolved one: {:?}",
15555            runner.state.reviews
15556        );
15557        let last = &runner.state.reviews[0];
15558        assert_eq!(
15559            last.e2e_status(),
15560            E2eStatus::ResourceBlocked,
15561            "still contended: {last:?}"
15562        );
15563        assert!(
15564            last.verified_at.expect("still known") > first_attempt_at,
15565            "review_loop must have actually retried the check, not left it exactly as found"
15566        );
15567        assert_ne!(
15568            runner.state.status,
15569            RunStatus::Blocked,
15570            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
15571            runner.state.status
15572        );
15573
15574        held.release();
15575    }
15576
15577    #[tokio::test]
15578    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
15579        crate::run::pin_test_home();
15580        let tmp = tempfile::tempdir().expect("tempdir");
15581        let repo = tmp.path().join("repo");
15582        std::fs::create_dir_all(&repo).unwrap();
15583        init_repo(&repo);
15584
15585        let mut config = Config::default();
15586        config.merge.mode = MergeMode::Pr;
15587        config.graph.land = true;
15588        config.graph.land_approval = false;
15589
15590        let mut state = RunState::new(
15591            repo.clone(),
15592            "main".to_owned(),
15593            "deadbeef".to_owned(),
15594            "task".to_owned(),
15595            config,
15596        );
15597        state.candidates = vec![Candidate {
15598            index: 0,
15599            label: 'A',
15600            agent: "alpha".to_owned(),
15601            branch: "does-not-exist".to_owned(),
15602            worktree: repo.clone(),
15603            summary: String::new(),
15604            stat: String::new(),
15605            files: 0,
15606            commits: 0,
15607            empty: false,
15608            failed: None,
15609            verified_noop: None,
15610            duration_ms: 0,
15611            folded: false,
15612        }];
15613        state.tally = Some(Tally {
15614            first_choice: BTreeMap::from([('A', 1)]),
15615            borda: BTreeMap::new(),
15616            winner: 'A',
15617            rankings: 1,
15618            unanimous_initial: true,
15619            deliberated: false,
15620            changed_votes: 0,
15621            unanimous_final: true,
15622            tie_break: None,
15623            judges: 0,
15624            present: 0,
15625            quorum: 0,
15626            met_quorum: true,
15627            uncontested: Some("only candidate A produced a change".to_owned()),
15628        });
15629        state.reviews = vec![ReviewRound {
15630            round: 1,
15631            head: "deadbeef".to_owned(),
15632            verified_head: None,
15633            verified_at: None,
15634            reviews: Vec::new(),
15635            e2e: Vec::new(),
15636            fix: None,
15637            blocking: 0,
15638            answered: 0,
15639            expected: 0,
15640            clean: true,
15641            verify_retried: false,
15642            e2e_deferred: false,
15643            e2e_defer_reason: None,
15644            progressed: false,
15645            vote_split: false,
15646            reconsideration: Vec::new(),
15647            verdict: None,
15648        }];
15649        state.gate = vec![CommandOutcome {
15650            command: "test".to_owned(),
15651            code: Some(0),
15652            output_tail: String::new(),
15653            duration_ms: 0,
15654            resource_blocked: false,
15655        }];
15656        state.gate_ran = true;
15657        // A first pass through `merge` already pushed and opened this pull
15658        // request; `status` is `Landing` because a previous call into `land`
15659        // parked or was interrupted before it reached a terminal outcome.
15660        state.status = RunStatus::Landing;
15661        state.merge = Some(MergeOutcome {
15662            mode: MergeMode::Pr,
15663            ok: true,
15664            detail: "https://example.invalid/x/y/pull/1".to_owned(),
15665            empty: false,
15666        });
15667
15668        // The Landing-resume shortcut calls `run_land` directly rather than
15669        // through `merge`, which is exactly the call site that used to skip
15670        // `settle_questions` - see the fixture below.
15671        ask_test_home();
15672        // A real resume loads a persisted run; driver ownership is checked
15673        // against that record before the Landing shortcut is entered.
15674        state.save().expect("save landing run");
15675        let store = ask::Questions::open();
15676        let q = ask_open_question(&store, &state.id);
15677
15678        let mut runner = Runner {
15679            state,
15680            roles: ResolvedRoles {
15681                implementers: Vec::new(),
15682                judges: Vec::new(),
15683                reviewers: Vec::new(),
15684                fixer: None,
15685                conductor: conductor(),
15686                implementer_roster: Vec::new(),
15687                judge_roster: Vec::new(),
15688                reviewer_roster: Vec::new(),
15689            },
15690            sem: Arc::new(Semaphore::new(1)),
15691            pause: Pause::new(),
15692            interrupt: Pause::new(),
15693        };
15694
15695        // `execute`, not `merge` directly: the Landing-resume shortcut lives
15696        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
15697        // exactly because `review_loop` would otherwise clobber the marker
15698        // first.
15699        runner.execute().await.expect("execute");
15700
15701        assert_eq!(
15702            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
15703            Some("https://example.invalid/x/y/pull/1"),
15704            "reentry must not push again or open a second pull request over the \
15705             one `land` is already watching"
15706        );
15707        assert_ne!(
15708            runner.state.status,
15709            RunStatus::Landing,
15710            "land could not actually reach the fake pull request, so it must \
15711             have given up rather than left the run silently parked forever"
15712        );
15713        // `land` could not reach the fake pull request, so it gave up into
15714        // `Blocked` - still resumable, so the question must not have been
15715        // swept just because this branch now also calls `settle_questions`.
15716        assert_eq!(runner.state.status, RunStatus::Blocked);
15717        assert!(
15718            store.get(&q.id).unwrap().status.open(),
15719            "Blocked is still alive; settle_questions must have been a no-op here"
15720        );
15721    }
15722
15723    fn state_with_round(round: ReviewRound) -> RunState {
15724        let mut s = RunState::new(
15725            PathBuf::from("/repo"),
15726            "main".to_owned(),
15727            "abc1234".to_owned(),
15728            "add retries".to_owned(),
15729            Config::default(),
15730        );
15731        s.reviews = vec![round];
15732        s
15733    }
15734
15735    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
15736        crate::verdict::Finding {
15737            id: id.to_owned(),
15738            severity,
15739            file: None,
15740            line: None,
15741            title: title.to_owned(),
15742            detail: String::new(),
15743        }
15744    }
15745
15746    #[test]
15747    fn pr_body_names_open_findings_and_declined_ones() {
15748        let round = ReviewRound {
15749            round: 2,
15750            head: "deadbee".to_owned(),
15751            verified_head: None,
15752            verified_at: None,
15753            reviews: vec![ReviewRecord {
15754                attempts: 0,
15755                reviewer: 1,
15756                agent: "alpha".to_owned(),
15757                summary: String::new(),
15758                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
15759                vote: None,
15760                failed: None,
15761                duration_ms: 0,
15762            }],
15763            e2e: vec![CommandOutcome {
15764                command: "cargo test".to_owned(),
15765                code: Some(0),
15766                output_tail: String::new(),
15767                duration_ms: 0,
15768                resource_blocked: false,
15769            }],
15770            verify_retried: false,
15771            e2e_deferred: false,
15772            e2e_defer_reason: None,
15773            fix: Some(FixRecord {
15774                agent: "alpha".to_owned(),
15775                addressed: Vec::new(),
15776                rejected: vec![crate::verdict::Rejection {
15777                    id: "R1-1-1".to_owned(),
15778                    why: "not reachable from any caller".to_owned(),
15779                }],
15780                notes: String::new(),
15781                committed: true,
15782                failed: None,
15783                duration_ms: 0,
15784                continuation: None,
15785            }),
15786            blocking: 0,
15787            answered: 1,
15788            expected: 1,
15789            clean: false,
15790            progressed: true,
15791            vote_split: false,
15792            reconsideration: Vec::new(),
15793            verdict: None,
15794        };
15795        let state = state_with_round(round);
15796        let body = pr_message(&state, 'A').body;
15797
15798        assert!(body.contains("add retries"), "the task must still be there");
15799        assert!(body.contains("R2-1-1"), "{body}");
15800        assert!(!body.contains("unused import"), "{body}");
15801        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
15802        assert!(
15803            !body.contains("not reachable from any caller"),
15804            "the reason it was declined: {body}"
15805        );
15806    }
15807
15808    #[test]
15809    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
15810        let round = ReviewRound {
15811            round: 1,
15812            head: "deadbee".to_owned(),
15813            verified_head: None,
15814            verified_at: None,
15815            reviews: vec![ReviewRecord {
15816                attempts: 0,
15817                reviewer: 1,
15818                agent: "alpha".to_owned(),
15819                summary: String::new(),
15820                findings: Vec::new(),
15821                vote: None,
15822                failed: None,
15823                duration_ms: 0,
15824            }],
15825            e2e: Vec::new(),
15826            verify_retried: false,
15827            e2e_deferred: false,
15828            e2e_defer_reason: None,
15829            fix: None,
15830            blocking: 0,
15831            answered: 1,
15832            expected: 1,
15833            clean: true,
15834            progressed: false,
15835            vote_split: false,
15836            reconsideration: Vec::new(),
15837            verdict: None,
15838        };
15839        let state = state_with_round(round);
15840        let body = pr_message(&state, 'A').body;
15841        assert!(!body.contains("Open review findings"), "{body}");
15842        assert!(!body.contains("Declined"), "{body}");
15843    }
15844
15845    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
15846        let mut state = RunState::new(
15847            PathBuf::from("/repo"),
15848            "main".to_owned(),
15849            "abc1234".to_owned(),
15850            instruction.to_owned(),
15851            Config::default(),
15852        );
15853        state.candidates.push(Candidate {
15854            index: 0,
15855            label: 'A',
15856            agent: "alpha".to_owned(),
15857            branch: "magi/x/A".to_owned(),
15858            worktree: PathBuf::from("/wt"),
15859            summary: summary.to_owned(),
15860            stat: String::new(),
15861            files: 1,
15862            commits: 1,
15863            empty: false,
15864            failed: None,
15865            verified_noop: None,
15866            folded: false,
15867            duration_ms: 0,
15868        });
15869        state
15870    }
15871
15872    fn review_state(subjects: &[&str]) -> RunState {
15873        let mut state = state_with_summary(
15874            "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",
15875            "",
15876        );
15877        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15878        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
15879        state
15880    }
15881
15882    /// A branch rebased onto a main that moved past the recorded
15883    /// `base_commit` is titled from its own first commit, never main's.
15884    #[tokio::test]
15885    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
15886        ask_test_home();
15887        let tmp = tempfile::tempdir().unwrap();
15888        let repo = tmp.path().join("repo");
15889        std::fs::create_dir_all(&repo).unwrap();
15890        init_repo(&repo);
15891        let origin = tmp.path().join("origin.git");
15892        let g = |dir: &Path, args: &[&str]| {
15893            let out = std::process::Command::new("git")
15894                .args(args)
15895                .current_dir(dir)
15896                .quiet()
15897                .output()
15898                .expect("spawn git");
15899            assert!(
15900                out.status.success(),
15901                "git {args:?}: {}",
15902                String::from_utf8_lossy(&out.stderr)
15903            );
15904        };
15905        g(
15906            tmp.path(),
15907            &[
15908                "clone",
15909                "--bare",
15910                "-q",
15911                repo.to_str().unwrap(),
15912                origin.to_str().unwrap(),
15913            ],
15914        );
15915        g(
15916            &repo,
15917            &["remote", "add", "origin", origin.to_str().unwrap()],
15918        );
15919        let c1 = git::rev_parse(&repo, "main").await.unwrap();
15920
15921        // Main moves on; the branch is built on top of the new main.
15922        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
15923        g(&repo, &["add", "-A"]);
15924        g(
15925            &repo,
15926            &["commit", "-q", "-m", "chore(deps): update a crate"],
15927        );
15928        g(&repo, &["push", "-q", "origin", "main"]);
15929        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
15930        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
15931        g(&repo, &["add", "-A"]);
15932        g(
15933            &repo,
15934            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
15935        );
15936        g(&repo, &["checkout", "-q", "main"]);
15937
15938        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
15939        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
15940        // Without a readable tracking ref the recorded base's merge base is used.
15941        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
15942        assert_eq!(fallback, c1);
15943
15944        let mut state = review_state(&[
15945            "chore(deps): update a crate",
15946            "fix(daemon): apply a chosen action",
15947        ]);
15948        state.repo = repo.clone();
15949        state.base_branch = "main".to_owned();
15950        state.base_commit = c1;
15951        refresh_reviewed_commits(&mut state, "feat/own").await;
15952        assert_eq!(
15953            state.reviewed_commits,
15954            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
15955        );
15956        assert_eq!(
15957            review_title(&state).as_deref(),
15958            Some("fix(daemon): apply a chosen action")
15959        );
15960        assert_eq!(
15961            leaked_subjects(&state, "feat/own").await,
15962            Some(vec!["chore(deps): update a crate".to_owned()])
15963        );
15964        // A stale tracking ref is still used when the fetch fails, but the
15965        // leak list is withheld.
15966        state.config.merge.remote = "nowhere".to_owned();
15967        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
15968    }
15969
15970    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
15971    /// with `origin/main` one commit behind it, and a summarizing agent that
15972    /// prints whatever `reply` holds.
15973    struct SummaryFixture {
15974        _tmp: tempfile::TempDir,
15975        repo: PathBuf,
15976        reply: PathBuf,
15977        runner: Runner,
15978    }
15979
15980    fn summary_fixture() -> SummaryFixture {
15981        ask_test_home();
15982        let tmp = tempfile::tempdir().unwrap();
15983        let repo = tmp.path().join("repo");
15984        std::fs::create_dir_all(&repo).unwrap();
15985        init_repo(&repo);
15986        let origin = tmp.path().join("origin.git");
15987        let g = |args: &[&str]| {
15988            let out = std::process::Command::new("git")
15989                .args(args)
15990                .current_dir(&repo)
15991                .quiet()
15992                .output()
15993                .expect("spawn git");
15994            assert!(
15995                out.status.success(),
15996                "git {args:?}: {}",
15997                String::from_utf8_lossy(&out.stderr)
15998            );
15999        };
16000        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
16001        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
16002        g(&["push", "-q", "origin", "main"]);
16003        g(&["checkout", "-q", "-b", "feat/own"]);
16004        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
16005        g(&["add", "-A"]);
16006        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
16007        let reply = tmp.path().join("reply.txt");
16008        std::fs::write(
16009            &reply,
16010            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
16011        )
16012        .unwrap();
16013        let mut runner = runner_at(RunStatus::Gating);
16014        let mut state = review_state(&["feat(own): add the own file"]);
16015        state.repo = repo.clone();
16016        state.base_branch = "main".to_owned();
16017        state.candidates[0].branch = "feat/own".to_owned();
16018        state.candidates[0].worktree = repo.clone();
16019        let mut author = spec("alpha");
16020        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
16021        author
16022            .env
16023            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
16024        state.config.agents = vec![author];
16025        runner.state = state;
16026        SummaryFixture {
16027            _tmp: tmp,
16028            repo,
16029            reply,
16030            runner,
16031        }
16032    }
16033
16034    async fn posted(f: &mut SummaryFixture) -> PrMessage {
16035        let winner = f.runner.state.candidates[0].clone();
16036        f.runner
16037            .guarded_pr_message(&winner, None, true)
16038            .await
16039            .unwrap()
16040            .expect("an English description never parks")
16041    }
16042
16043    fn clean_round_with_minor_findings() -> ReviewRound {
16044        let mut round = passing_round(2, None);
16045        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
16046        round.reviews = vec![ReviewRecord {
16047            attempts: 0,
16048            reviewer: 1,
16049            agent: "alpha".into(),
16050            summary: "内部レビュー".into(),
16051            findings: vec![
16052                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
16053                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
16054            ],
16055            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
16056            failed: None,
16057            duration_ms: 0,
16058        }];
16059        round
16060    }
16061
16062    #[tokio::test]
16063    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
16064        for review_only in [false, true] {
16065            let mut f = summary_fixture();
16066            if !review_only {
16067                f.runner.state.candidates[0].agent = "alpha".into();
16068            }
16069            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
16070            assert!(f.runner.state.reviews[0].clean);
16071            assert!(f.runner.state.reviews[0].fix.is_none());
16072            assert!(f.runner.state.open_findings().is_empty());
16073            assert_eq!(
16074                crate::pr_outcome::required_finding_ids(&f.runner.state),
16075                ["R2-1-1", "R2-1-2"]
16076            );
16077            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16078            let evidence = crate::pr_outcome::summary_evidence(
16079                &f.runner.state,
16080                crate::pr_outcome::FinalHead::At(&head),
16081            );
16082            assert!(
16083                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
16084            );
16085            // The ordinary reply omits both IDs: no cached description is adopted.
16086            let omitted = posted(&mut f).await;
16087            assert!(omitted.body.contains("No description"));
16088            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
16089            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
16090            assert!(f.runner.state.change_summary.is_none());
16091            std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nAdds the own file. R2-1-1 [Minor]: improve documentation for callers. R2-1-2 [Minor]: consider the remaining edge case.").unwrap();
16092            let described = posted(&mut f).await;
16093            assert!(described.body.contains("improve documentation for callers"));
16094            assert!(described.body.contains("remaining edge case"));
16095            assert!(described.body.contains("## Open review findings"));
16096            assert!(!described.body.contains("未修正"));
16097        }
16098    }
16099
16100    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
16101        let mut f = summary_fixture();
16102        if !review_only {
16103            f.runner.state.candidates[0].agent = "alpha".into();
16104        }
16105        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16106        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
16107        fixed
16108            .fix
16109            .as_mut()
16110            .unwrap()
16111            .rejected
16112            .push(crate::verdict::Rejection {
16113                id: "R1-1-1".into(),
16114                why: "呼び出されないコードのため辞退".into(),
16115            });
16116        let mut clean = clean_round_with_minor_findings();
16117        clean.verified_head = Some(head.clone());
16118        f.runner.state.reviews = vec![fixed, clean];
16119        ok_gate(&mut f.runner.state, Some(&head));
16120        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
16121        f.runner.state.config.agents.push(scripted("beta", reply));
16122        f.runner.state.config.roles.language_judge =
16123            Some(crate::config::AgentChoice::One("beta".into()));
16124        f
16125    }
16126
16127    #[tokio::test]
16128    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
16129        for review_only in [false, true] {
16130            let mut f = rewrite_fixture_with_findings(review_only,
16131                "TITLE: fix: public description\nR1-1-1: declined because no caller reaches this code. R2-1-1 [Minor]: documentation remains incomplete. R2-1-2 [Minor]: an edge case needs follow-up.").await;
16132            let m = posted(&mut f).await;
16133            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16134            let expected =
16135                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
16136            assert!(m.body.contains(&expected));
16137            assert!(m.body.contains("## Review rounds"));
16138            assert!(
16139                m.body
16140                    .contains("Gate (`verify.gate`): passed on the final commit")
16141            );
16142            assert!(
16143                m.body
16144                    .contains("Tests (`verify.e2e`): passed on the final commit")
16145            );
16146            let c = f.runner.state.change_summary.as_ref().unwrap();
16147            assert!(
16148                m.body
16149                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
16150            );
16151            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
16152            assert!(crate::pr_outcome::covers_required_findings(
16153                &f.runner.state,
16154                &m.body
16155            ));
16156            assert!(m.body.contains("documentation remains incomplete"));
16157            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
16158            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
16159            assert!(f.runner.state.github_text.is_none());
16160        }
16161    }
16162
16163    #[tokio::test]
16164    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
16165        for review_only in [false, true] {
16166            for reply in [
16167                "TITLE: fix: public description\nA short English description.",
16168                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
16169            ] {
16170                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
16171                let winner = f.runner.state.candidates[0].clone();
16172                let posted = f
16173                    .runner
16174                    .guarded_pr_message(&winner, None, true)
16175                    .await
16176                    .unwrap();
16177                assert!(posted.is_none());
16178                assert!(f.runner.state.parked);
16179                let withheld = f.runner.state.github_text.as_ref().unwrap();
16180                assert!(
16181                    withheld.rewrite.is_none(),
16182                    "an incomplete rewrite must not be selectable"
16183                );
16184                let q = ask::Questions::open()
16185                    .get(withheld.question.as_ref().unwrap())
16186                    .unwrap();
16187                assert!(
16188                    !q.choices
16189                        .contains(&crate::github_text::USE_REWRITE.to_owned())
16190                );
16191                assert!(
16192                    q.detail.contains("R1-1-1")
16193                        && q.detail.contains("R2-1-1")
16194                        && q.detail.contains("R2-1-2")
16195                );
16196            }
16197        }
16198    }
16199
16200    #[tokio::test]
16201    async fn japanese_reports_stay_local_in_both_public_run_modes() {
16202        for review_only in [false, true] {
16203            let mut f = summary_fixture();
16204            if !review_only {
16205                f.runner.state.candidates[0].agent = "alpha".into();
16206            }
16207            f.runner.state.instruction = "内部タスクの説明".into();
16208            f.runner.state.candidates[0].summary = "内部の候補報告".into();
16209            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16210            f.runner.state.reviews = vec![
16211                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
16212                round_with_notes(2, Some("二回目の内部修正報告です。")),
16213                passing_round(3, Some(&head)),
16214            ];
16215            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
16216            ok_gate(&mut f.runner.state, Some(&head));
16217            let captured = f.repo.join("public-prompt.txt");
16218            let author = &mut f.runner.state.config.agents[0];
16219            author.command = vec![
16220                "sh".into(),
16221                "-c".into(),
16222                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
16223            ];
16224            author.env.insert(
16225                "PROMPT_FILE".into(),
16226                captured.to_string_lossy().into_owned(),
16227            );
16228            let m = posted(&mut f).await;
16229            assert!(m.body.contains("Adds `own.txt`"));
16230            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
16231            assert!(m.body.contains("R1-1-1"));
16232            assert!(
16233                m.body
16234                    .contains("Gate (`verify.gate`): passed on the final commit")
16235            );
16236            assert!(
16237                m.body
16238                    .contains("Tests (`verify.e2e`): passed on the final commit")
16239            );
16240            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
16241            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
16242            let prompt = std::fs::read_to_string(captured).unwrap();
16243            assert!(prompt.contains(&format!(
16244                "git diff {} {head}",
16245                f.runner.state.change_summary.as_ref().unwrap().base
16246            )));
16247            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
16248            assert_eq!(
16249                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
16250                "二回目の内部修正報告です。"
16251            );
16252        }
16253    }
16254
16255    #[tokio::test]
16256    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
16257        for review_only in [false, true] {
16258            let mut f = summary_fixture();
16259            if !review_only {
16260                f.runner.state.candidates[0].agent = "alpha".into();
16261            }
16262            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
16263            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
16264            std::fs::write(&f.reply, "no usable description").unwrap();
16265            let failed = posted(&mut f).await;
16266            assert!(
16267                failed
16268                    .body
16269                    .contains("No description of what this change does could be generated")
16270            );
16271            assert!(failed.body.is_ascii());
16272            assert!(f.runner.state.github_text.is_none());
16273            assert!(f.runner.state.change_summary.is_none());
16274            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16275            let evidence = crate::pr_outcome::summary_evidence(
16276                &f.runner.state,
16277                crate::pr_outcome::FinalHead::At(&head),
16278            );
16279            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
16280            assert!(
16281                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
16282                    .exists()
16283            );
16284            f.runner.state =
16285                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
16286            std::fs::write(
16287                &f.reply,
16288                "TITLE: feat(own): add the own file\nAdds the committed own file.",
16289            )
16290            .unwrap();
16291            let retried = posted(&mut f).await;
16292            assert!(retried.body.contains("Adds the committed own file."));
16293            assert_eq!(
16294                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
16295                "日本語の修正報告"
16296            );
16297        }
16298    }
16299
16300    #[tokio::test]
16301    async fn same_head_verification_and_findings_invalidate_public_prose() {
16302        let mut f = summary_fixture();
16303        posted(&mut f).await;
16304        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16305        let original = f
16306            .runner
16307            .state
16308            .change_summary
16309            .as_ref()
16310            .unwrap()
16311            .evidence
16312            .clone();
16313        ok_gate(&mut f.runner.state, Some(&head));
16314        std::fs::write(
16315            &f.reply,
16316            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
16317        )
16318        .unwrap();
16319        let verified = posted(&mut f).await;
16320        assert!(verified.body.contains("now verified"));
16321        assert_ne!(
16322            f.runner.state.change_summary.as_ref().unwrap().evidence,
16323            original
16324        );
16325        let mut round = round_with_notes(1, Some("内部報告"));
16326        round.reviews.push(ReviewRecord {
16327            attempts: 0,
16328            reviewer: 1,
16329            agent: "alpha".into(),
16330            summary: "内部レビュー".into(),
16331            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
16332            vote: None,
16333            failed: None,
16334            duration_ms: 0,
16335        });
16336        round
16337            .fix
16338            .as_mut()
16339            .unwrap()
16340            .rejected
16341            .push(crate::verdict::Rejection {
16342                id: "R1-1-1".into(),
16343                why: "呼び出し元から到達できない".into(),
16344            });
16345        f.runner.state.reviews = vec![round];
16346        // A description omitting the required ID is unusable, even with a title.
16347        let omitted = posted(&mut f).await;
16348        assert!(omitted.body.contains("No description"));
16349        assert!(omitted.body.contains("R1-1-1"));
16350        assert!(!omitted.body.contains("到達"));
16351        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: declined because the code is unreachable from callers; confirm that assumption when adding callers.").unwrap();
16352        let described = posted(&mut f).await;
16353        assert!(described.body.contains("unreachable from callers"));
16354        assert!(described.body.contains("R1-1-1"));
16355        assert!(!described.body.contains("到達"));
16356        let evidence = f
16357            .runner
16358            .state
16359            .change_summary
16360            .as_ref()
16361            .unwrap()
16362            .evidence
16363            .clone();
16364        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
16365        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
16366        assert!(posted(&mut f).await.body.contains("rationale updated"));
16367        assert_ne!(
16368            f.runner.state.change_summary.as_ref().unwrap().evidence,
16369            evidence
16370        );
16371        // Old serialized summaries have no evidence fingerprint and must regenerate.
16372        f.runner
16373            .state
16374            .change_summary
16375            .as_mut()
16376            .unwrap()
16377            .evidence
16378            .clear();
16379        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
16380        assert!(posted(&mut f).await.body.contains("current specification"));
16381    }
16382
16383    #[tokio::test]
16384    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
16385        let mut f = summary_fixture();
16386        let m = posted(&mut f).await;
16387        assert_eq!(m.title, "feat(own): add the own file");
16388        assert!(
16389            m.body
16390                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
16391            "{}",
16392            m.body
16393        );
16394        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
16395        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
16396        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
16397        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
16398        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
16399        let kept = f.runner.state.change_summary.as_ref().unwrap();
16400        assert_eq!(
16401            (kept.head.as_str(), kept.base.as_str()),
16402            (head.as_str(), base.as_str())
16403        );
16404        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
16405    }
16406
16407    #[tokio::test]
16408    async fn review_only_summary_receives_the_configured_github_language() {
16409        let mut f = summary_fixture();
16410        f.runner.state.config.graph.github_language = "ja".into();
16411        let captured = f.repo.join("summary-prompt.txt");
16412        let author = &mut f.runner.state.config.agents[0];
16413        author.command = vec![
16414            "sh".into(),
16415            "-c".into(),
16416            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
16417        ];
16418        author.env.insert(
16419            "PROMPT_FILE".into(),
16420            captured.to_string_lossy().into_owned(),
16421        );
16422        std::fs::write(
16423            &f.reply,
16424            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
16425        )
16426        .unwrap();
16427        let m = posted(&mut f).await;
16428        let prompt = std::fs::read_to_string(captured).unwrap();
16429        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
16430        assert!(!prompt.contains("in English"));
16431        assert_eq!(m.title, "feat(own): ファイルを追加");
16432        assert!(m.body.contains("own.txt を追加します。"));
16433    }
16434
16435    #[tokio::test]
16436    async fn review_fixes_come_after_the_whole_change_summary() {
16437        let mut f = summary_fixture();
16438        f.runner.state.reviews = vec![
16439            round_with_notes(1, Some("handled the PATH case")),
16440            round_with_notes(2, None),
16441        ];
16442        let m = posted(&mut f).await;
16443        let summary = m.body.find("## Summary").expect("summary");
16444        let fixes = m.body.find("## Review rounds").expect("review rounds");
16445        assert!(summary < fixes, "{}", m.body);
16446        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
16447        assert!(m.body.contains("## Final verification"), "{}", m.body);
16448    }
16449
16450    #[tokio::test]
16451    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
16452        let mut f = summary_fixture();
16453        let first = posted(&mut f).await;
16454        let first_head = f.runner.state.change_summary.clone().unwrap().head;
16455        // The same head reuses the record: a different reply is not read.
16456        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
16457        let again = posted(&mut f).await;
16458        assert_eq!((again.title, again.body), (first.title, first.body));
16459
16460        let g = |args: &[&str]| {
16461            let out = std::process::Command::new("git")
16462                .args(args)
16463                .current_dir(&f.repo)
16464                .quiet()
16465                .output()
16466                .expect("spawn git");
16467            assert!(out.status.success(), "git {args:?}");
16468        };
16469        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
16470        g(&["add", "-A"]);
16471        g(&["commit", "-q", "-m", "fix(own): handle more"]);
16472        let second = posted(&mut f).await;
16473        let kept = f.runner.state.change_summary.clone().unwrap();
16474        assert_ne!(kept.head, first_head);
16475        assert!(second.body.contains("Not read yet."), "{}", second.body);
16476        assert!(!second.body.contains("so the first summary is read"));
16477        assert_eq!(second.title, "feat(own): other");
16478    }
16479
16480    #[tokio::test]
16481    async fn an_unavailable_summary_is_stated_and_never_invented() {
16482        let mut f = summary_fixture();
16483        std::fs::write(&f.reply, "no title line here").unwrap();
16484        let m = posted(&mut f).await;
16485        assert!(
16486            m.body
16487                .contains("No description of what this change does could be generated"),
16488            "{}",
16489            m.body
16490        );
16491        assert!(m.body.contains("internal reports remain in local records"));
16492        assert!(f.runner.state.change_summary.is_none());
16493        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
16494        // A preview asks nobody and claims nothing.
16495        let winner = f.runner.state.candidates[0].clone();
16496        let preview = f
16497            .runner
16498            .guarded_pr_message(&winner, None, false)
16499            .await
16500            .unwrap()
16501            .unwrap();
16502        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
16503    }
16504
16505    #[test]
16506    fn pr_message_review_single_commit_uses_its_subject() {
16507        let state = review_state(&["feat(nats): per-role user"]);
16508        let m = pr_message(&state, 'A');
16509        assert_eq!(m.title, "feat(nats): per-role user");
16510        assert!(!m.body.contains("Review the work already"), "{}", m.body);
16511        assert!(m.body.contains("## Commits under review"), "{}", m.body);
16512    }
16513
16514    #[test]
16515    fn pr_message_review_multi_commit_takes_the_oldest() {
16516        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
16517        let m = pr_message(&state, 'A');
16518        assert_eq!(m.title, "feat: the change");
16519        for s in ["feat: the change", "fix: typo", "fix: again"] {
16520            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
16521        }
16522    }
16523
16524    #[test]
16525    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
16526        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
16527            let mut state = review_state(&[first, "fix: later fixup"]);
16528            state.candidates[0].branch = "機能/ログイン".to_owned();
16529            let m = pr_message(&state, 'A');
16530            assert!(
16531                m.title.starts_with("chore: land candidate A of run"),
16532                "{}",
16533                m.title
16534            );
16535        }
16536    }
16537
16538    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
16539        BranchFacts {
16540            commits: commits
16541                .iter()
16542                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
16543                .collect(),
16544            stat: stat.to_owned(),
16545        }
16546    }
16547
16548    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
16549        let mut r = review_round(true, 0, 1, 1, true, true);
16550        r.round = round;
16551        r.fix = notes.map(|n| FixRecord {
16552            agent: "fixer".to_owned(),
16553            addressed: Vec::new(),
16554            rejected: Vec::new(),
16555            notes: n.to_owned(),
16556            committed: true,
16557            failed: None,
16558            duration_ms: 0,
16559            continuation: None,
16560        });
16561        r
16562    }
16563
16564    #[test]
16565    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
16566        let state = review_state(&["ignored"]);
16567        let f = facts(
16568            &[
16569                (
16570                    "fix(login): resolve PATH on macOS",
16571                    "Login shells skip rc files.",
16572                ),
16573                ("fix: address review", ""),
16574            ],
16575            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
16576        );
16577        let m = pr_message_with(&state, 'A', Some(&f));
16578        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
16579        assert!(
16580            m.body
16581                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
16582            "{}",
16583            m.body
16584        );
16585        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
16586        assert!(m.body.contains("## Diff stat"), "{}", m.body);
16587        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
16588        for banned in [
16589            "Review the work already",
16590            "no task statement",
16591            "Original task",
16592        ] {
16593            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
16594        }
16595        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
16596    }
16597
16598    #[test]
16599    fn pr_message_review_truncates_a_huge_first_commit_body() {
16600        let state = review_state(&["ignored"]);
16601        let f = facts(
16602            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
16603            "s",
16604        );
16605        let m = pr_message_with(&state, 'A', Some(&f));
16606        assert!(m.body.len() < 30_000, "{}", m.body.len());
16607        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
16608        assert!(
16609            m.body.contains("- ... 1 more commit(s)"),
16610            "{}",
16611            m.body.len()
16612        );
16613        assert!(m.body.ends_with("magi:candidate-a\n"));
16614    }
16615
16616    #[test]
16617    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
16618        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
16619        assert_eq!(m.title, "feat: x");
16620        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
16621        assert!(!m.body.contains("Diff stat"), "{}", m.body);
16622        assert!(!m.body.contains("no task statement"), "{}", m.body);
16623    }
16624
16625    #[test]
16626    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
16627        let mut state = review_state(&["日本語の件名"]);
16628        state.candidates[0].branch = "fix/macos-login-path".to_owned();
16629        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
16630        state.candidates[0].branch = "機能/ログイン".to_owned();
16631        assert!(
16632            pr_message(&state, 'A')
16633                .title
16634                .starts_with("chore: land candidate A")
16635        );
16636    }
16637
16638    #[test]
16639    fn pr_message_review_fixes_survive_a_clean_final_round() {
16640        let mut state = review_state(&["feat: x"]);
16641        state.reviews = vec![
16642            round_with_notes(1, Some("handled the PATH case")),
16643            round_with_notes(2, None),
16644        ];
16645        let body = pr_message(&state, 'A').body;
16646        assert!(
16647            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
16648            "{body}"
16649        );
16650        assert!(body.contains("### Round 1"), "{body}");
16651
16652        state.reviews = vec![
16653            round_with_notes(1, Some("first fix")),
16654            round_with_notes(2, Some("")),
16655            round_with_notes(3, Some("second fix")),
16656            round_with_notes(4, None),
16657        ];
16658        let body = pr_message(&state, 'A').body;
16659        assert!(
16660            body.contains("### Round 1") && !body.contains("first fix"),
16661            "{body}"
16662        );
16663        assert!(
16664            body.contains("### Round 3") && !body.contains("second fix"),
16665            "{body}"
16666        );
16667        assert!(body.contains("### Round 2"), "{body}");
16668    }
16669
16670    const FINAL: &str = "abcdef1234567890";
16671
16672    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
16673        let mut r = round_with_notes(round, None);
16674        r.verified_head = verified.map(str::to_owned);
16675        r
16676    }
16677
16678    fn ok_gate(state: &mut RunState, head: Option<&str>) {
16679        state.gate = vec![CommandOutcome {
16680            command: "gate".to_owned(),
16681            code: Some(0),
16682            output_tail: String::new(),
16683            duration_ms: 0,
16684            resource_blocked: false,
16685        }];
16686        state.gate_ran = true;
16687        state.gate_head = head.map(str::to_owned);
16688    }
16689
16690    fn body_at(state: &RunState) -> String {
16691        pr_message_raw(
16692            state,
16693            'A',
16694            None,
16695            crate::pr_outcome::FinalHead::At(FINAL),
16696            ChangeNote::NotAsked,
16697        )
16698        .body
16699    }
16700
16701    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
16702
16703    #[test]
16704    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
16705        let mut state = review_state(&["feat: x"]);
16706        state.reviews = vec![
16707            round_with_notes(1, Some(SANDBOXED)),
16708            passing_round(2, Some(FINAL)),
16709        ];
16710        ok_gate(&mut state, Some(FINAL));
16711        let body = body_at(&state);
16712        assert!(!body.contains("Handled the PATH case."), "{body}");
16713        assert!(!body.contains("could not commit"), "{body}");
16714        assert!(!body.contains("could not run"), "{body}");
16715        assert!(
16716            body.contains("The fix is committed on the branch"),
16717            "{body}"
16718        );
16719        assert!(!body.contains("sandbox"), "{body}");
16720        assert!(
16721            body.contains("Tests (`verify.e2e`): passed on the final commit"),
16722            "{body}"
16723        );
16724        assert!(
16725            body.contains("Gate (`verify.gate`): passed on the final commit"),
16726            "{body}"
16727        );
16728        // The raw report is untouched in the record.
16729        assert!(
16730            state.reviews[0]
16731                .fix
16732                .as_ref()
16733                .unwrap()
16734                .notes
16735                .contains("could not commit")
16736        );
16737    }
16738
16739    #[test]
16740    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
16741        let mut state = review_state(&["feat: x"]);
16742        let mut r1 = round_with_notes(1, Some(SANDBOXED));
16743        r1.fix.as_mut().unwrap().committed = false;
16744        let mut r2 = passing_round(2, Some(FINAL));
16745        r2.e2e[0].code = Some(1);
16746        state.reviews = vec![r1, r2];
16747        let body = body_at(&state);
16748        assert!(!body.contains("could not commit"), "{body}");
16749        assert!(!body.contains("could not run"), "{body}");
16750        assert!(body.contains("This round produced no commit"), "{body}");
16751        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
16752        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
16753        assert!(!body.contains("passed"), "{body}");
16754    }
16755
16756    #[test]
16757    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
16758        let mut state = review_state(&["feat: x"]);
16759        state.reviews = vec![
16760            round_with_notes(1, Some(SANDBOXED)),
16761            passing_round(2, Some("0123456789abcdef")),
16762        ];
16763        ok_gate(&mut state, Some("0123456789abcdef"));
16764        let body = body_at(&state);
16765        assert!(!body.contains("HTTP tests could not run"), "{body}");
16766        // The commit claim is still settled by the structured record.
16767        assert!(!body.contains("could not commit"), "{body}");
16768        assert!(
16769            body.contains("passed on an earlier commit (`0123456`)"),
16770            "{body}"
16771        );
16772        assert!(!body.contains("passed on the final commit"), "{body}");
16773
16774        // A gate recorded before `gate_head` existed is "not recorded".
16775        ok_gate(&mut state, None);
16776        let body = body_at(&state);
16777        assert!(
16778            body.contains("passed on a commit that was not recorded"),
16779            "{body}"
16780        );
16781    }
16782
16783    #[test]
16784    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
16785        let state = state_with_summary(
16786            "add retries to the client",
16787            "TITLE: feat: retries\n\nDid it.",
16788        );
16789        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
16790        assert_eq!(m.title, "feat: retries");
16791        assert!(
16792            m.body.contains("<summary>Original task</summary>"),
16793            "{}",
16794            m.body
16795        );
16796        assert!(!m.body.contains("Commits under review"), "{}", m.body);
16797        assert!(
16798            m.body
16799                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
16800            "{}",
16801            m.body
16802        );
16803    }
16804
16805    #[test]
16806    fn pr_message_review_bounds_a_long_english_subject() {
16807        let long = format!("feat: {}", "word ".repeat(100));
16808        let m = pr_message(&review_state(&[&long]), 'A');
16809        assert!(m.title.starts_with("feat: word"), "{}", m.title);
16810        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16811    }
16812
16813    #[test]
16814    fn pr_message_implementation_run_is_unchanged_by_review_support() {
16815        let state = state_with_summary("add retries\n\ndetails", "- did some things");
16816        let m = pr_message(&state, 'A');
16817        assert_eq!(m.title, "add retries");
16818        assert!(m.body.contains("<summary>Original task</summary>"));
16819        assert!(!m.body.contains("Commits under review"));
16820        assert_eq!(landing_subject_source(&state), state.instruction);
16821    }
16822
16823    #[test]
16824    fn review_run_squash_subject_is_the_change_not_the_prompt() {
16825        let state = review_state(&["feat: the change", "fix: typo"]);
16826        let source = landing_subject_source(&state);
16827        assert_eq!(land::merge_subject("", &source), "feat: the change");
16828        assert_eq!(
16829            land::merge_subject("magi: candidate A (uncommitted work)", &source),
16830            "feat: the change"
16831        );
16832        // An operator's rename still wins.
16833        assert_eq!(
16834            land::merge_subject("feat: renamed by hand", &source),
16835            "feat: renamed by hand"
16836        );
16837        let blank = review_state(&["日本語"]);
16838        assert!(
16839            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
16840        );
16841    }
16842
16843    #[test]
16844    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
16845        let state = review_state(&["feat: the change"]);
16846        let source = landing_subject_source(&state);
16847        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
16848        assert_eq!(
16849            land::merge_subject(landing_title(&state, old), &source),
16850            "feat: the change"
16851        );
16852        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
16853        let task = state_with_summary("add retries", "");
16854        assert_eq!(landing_title(&task, old), old);
16855    }
16856
16857    #[test]
16858    fn pr_message_describes_the_change_not_the_task() {
16859        let state = state_with_summary(
16860            "今回やってほしいこと: results projector を直す",
16861            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
16862        );
16863        let m = pr_message(&state, 'A');
16864        assert_eq!(m.title, "fix(web): batch the runs list reads");
16865        assert!(
16866            m.body.starts_with("## Summary\n\n- reads run.json once"),
16867            "{}",
16868            m.body
16869        );
16870        assert!(!m.body.contains("TITLE:"), "{}", m.body);
16871        let task_at = m.body.find("今回やってほしいこと").unwrap();
16872        let details_at = m.body.find("<details>").unwrap();
16873        assert!(
16874            details_at < task_at,
16875            "the task lives inside <details>: {}",
16876            m.body
16877        );
16878        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
16879        assert!(m.body.contains("magi:candidate-a"));
16880    }
16881
16882    #[test]
16883    fn pr_message_falls_back_to_the_task_without_a_title_line() {
16884        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
16885        let m = pr_message(&state, 'A');
16886        assert_eq!(m.title, "add retries");
16887        assert!(
16888            m.body.contains("## Summary\n\n- did some things"),
16889            "{}",
16890            m.body
16891        );
16892
16893        let none = RunState::new(
16894            PathBuf::from("/repo"),
16895            "main".to_owned(),
16896            "abc1234".to_owned(),
16897            "add retries".to_owned(),
16898            Config::default(),
16899        );
16900        let m = pr_message(&none, 'A');
16901        assert_eq!(m.title, "add retries");
16902        assert!(!m.body.contains("## Summary"), "{}", m.body);
16903    }
16904
16905    #[test]
16906    fn pr_message_refuses_the_candidate_commit_subject() {
16907        for bad in [
16908            "TITLE: magi: candidate A (uncommitted work)",
16909            "TITLE: chore: stuff (uncommitted work)",
16910            "TITLE:   ",
16911        ] {
16912            let state = state_with_summary("add retries", bad);
16913            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
16914        }
16915    }
16916
16917    #[test]
16918    fn pr_message_bounds_a_very_long_task_and_title() {
16919        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
16920        let state = state_with_summary(&long, "- nothing");
16921        let m = pr_message(&state, 'A');
16922        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16923        assert!(!m.title.contains('\n'));
16924
16925        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
16926        let m = pr_message(&state, 'A');
16927        assert!(m.title.starts_with("feat: "));
16928        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16929        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
16930    }
16931
16932    fn long_title_of(instruction: &str) -> String {
16933        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
16934    }
16935
16936    #[test]
16937    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
16938        let first = "Make the landing path keep a readable title for long tasks";
16939        let line = format!(
16940            "{first}. {}",
16941            "And then keep going with more words ".repeat(20)
16942        );
16943        let t = long_title_of(&line);
16944        assert_eq!(t, first);
16945        assert!(!t.starts_with("chore: land"));
16946    }
16947
16948    #[test]
16949    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
16950        let line = "word ".repeat(200);
16951        let t = long_title_of(&line);
16952        assert!(t.ends_with("word..."), "{t}");
16953        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
16954    }
16955
16956    #[test]
16957    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
16958        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
16959            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
16960        }
16961    }
16962
16963    #[test]
16964    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
16965        let mut state = state_with_summary("再試行を追加する", "- nothing");
16966        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
16967        state.config.graph.github_language = "ja".into();
16968        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
16969    }
16970
16971    #[test]
16972    fn pr_message_title_limit_is_exact() {
16973        let at = "a".repeat(PR_TITLE_MAX);
16974        assert_eq!(long_title_of(&at), at);
16975        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
16976        assert!(over.ends_with("..."), "{over}");
16977        assert_eq!(over.chars().count(), PR_TITLE_MAX);
16978    }
16979
16980    #[test]
16981    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
16982        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
16983        let t = long_title_of(&line);
16984        assert!(t.ends_with("..."), "{t}");
16985        assert!(t.is_ascii(), "{t}");
16986    }
16987
16988    #[test]
16989    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
16990        let line = format!(
16991            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
16992            "plus more filler words ".repeat(20)
16993        );
16994        let t = long_title_of(&line);
16995        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
16996    }
16997
16998    #[test]
16999    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
17000        let line = format!("\n\n# {}", "title words ".repeat(40));
17001        let state = state_with_summary(&line, "- nothing");
17002        let m = pr_message(&state, 'A');
17003        assert!(m.title.starts_with("title words"), "{}", m.title);
17004        assert_eq!(
17005            land::merge_subject(&m.title, &landing_subject_source(&state)),
17006            m.title
17007        );
17008        // An operator's rename wins untouched.
17009        assert_eq!(
17010            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
17011            "feat: renamed by hand"
17012        );
17013    }
17014
17015    #[tokio::test]
17016    async fn github_text_rewrite_is_bounded_and_falls_back() {
17017        crate::run::pin_test_home();
17018        for (reply, accepted) in [
17019            (
17020                "TITLE: fix: retries\nAdd retries for failed requests.",
17021                true,
17022            ),
17023            (
17024                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
17025                false,
17026            ),
17027            (
17028                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
17029                true,
17030            ),
17031            (
17032                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
17033                true,
17034            ),
17035            (
17036                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
17037                true,
17038            ),
17039            (
17040                "TITLE: fix: retries\n日本語の説明です。 token=secret",
17041                false,
17042            ),
17043            ("TITLE: fix: retries\n", false),
17044        ] {
17045            let dir = tempfile::tempdir().unwrap();
17046            let mut runner = runner_at(RunStatus::Gating);
17047            runner.state = state_with_summary(
17048                "add retries",
17049                "TITLE: fix: retries\n日本語の説明を書きます。",
17050            );
17051            runner.state.repo = dir.path().to_owned();
17052            runner.state.candidates[0].worktree = dir.path().to_owned();
17053            let mut author = spec("alpha");
17054            author.command = vec![
17055                "sh".into(),
17056                "-c".into(),
17057                "printf '%s' \"$REWRITE_REPLY\"".into(),
17058            ];
17059            author.env.insert("REWRITE_REPLY".into(), reply.into());
17060            runner.state.config.agents = vec![author];
17061            seed_public_summary(&mut runner);
17062            let winner = runner.state.candidates[0].clone();
17063            let first = runner
17064                .guarded_pr_message(&winner, None, true)
17065                .await
17066                .unwrap();
17067            let message = if accepted {
17068                first.expect("a passing rewrite never asks")
17069            } else {
17070                // Withheld: the run parks on a question and posts nothing yet.
17071                assert!(first.is_none());
17072                assert!(runner.state.parked);
17073                let store = ask::Questions::open();
17074                let asked: Vec<_> = store
17075                    .list()
17076                    .into_iter()
17077                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
17078                    .collect();
17079                assert_eq!(asked.len(), 1);
17080                assert!(!asked[0].detail.contains("secret"));
17081                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
17082                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
17083                assert_eq!(
17084                    asked[0]
17085                        .choices
17086                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
17087                    rewrite.is_some()
17088                );
17089                // Silence: the question lapses and the neutral text is used,
17090                // without a second rewrite call or a second question.
17091                store
17092                    .update(&asked[0].id, |q| {
17093                        q.abandon("test");
17094                        Ok(())
17095                    })
17096                    .unwrap();
17097                let again = runner
17098                    .guarded_pr_message(&winner, None, true)
17099                    .await
17100                    .unwrap()
17101                    .expect("an abandoned question falls back");
17102                let n = store
17103                    .list()
17104                    .into_iter()
17105                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
17106                    .count();
17107                assert_eq!(n, 1, "asked once per rejected text");
17108                again
17109            };
17110            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
17111            assert!(!message.body.contains("secret"));
17112            assert!(!message.body.contains("prefix"));
17113            assert!(!message.title.contains("secret"));
17114            assert_eq!(
17115                message.body.contains("Add retries for failed requests."),
17116                accepted
17117            );
17118            assert_eq!(
17119                runner.state.seats["github-text-rewrite"].turns, 1,
17120                "only one rewrite invocation"
17121            );
17122            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
17123            if accepted {
17124                assert!(runner.state.github_text.is_none());
17125                assert!(!runner.state.parked);
17126                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
17127                assert!(message.body.contains("## Final verification"));
17128                assert!(message.body.contains("Evidence: final diff"));
17129                assert!(
17130                    message
17131                        .body
17132                        .contains(&format!("magi:run/{}", runner.state.id))
17133                );
17134            }
17135            if !accepted {
17136                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
17137                assert!(
17138                    message
17139                        .body
17140                        .contains(&format!("magi:run/{}", runner.state.id))
17141                );
17142            }
17143        }
17144    }
17145
17146    fn scripted(id: &str, reply: &str) -> AgentSpec {
17147        let mut a = spec(id);
17148        a.command = vec![
17149            "sh".into(),
17150            "-c".into(),
17151            "printf '%s' \"$REWRITE_REPLY\"".into(),
17152        ];
17153        a.env.insert("REWRITE_REPLY".into(), reply.into());
17154        a
17155    }
17156
17157    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
17158    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
17159
17160    /// Gate tests inject generated public output, not internal reports.
17161    fn seed_public_summary(runner: &mut Runner) {
17162        let repo = &runner.state.repo;
17163        let g = |args: &[&str]| {
17164            let out = std::process::Command::new("git")
17165                .args(args)
17166                .current_dir(repo)
17167                .quiet()
17168                .output()
17169                .unwrap();
17170            assert!(out.status.success(), "git {args:?}");
17171            String::from_utf8_lossy(&out.stdout).trim().to_owned()
17172        };
17173        g(&["init", "-q", "-b", "main"]);
17174        g(&[
17175            "-c",
17176            "user.name=Test",
17177            "-c",
17178            "user.email=test@example.invalid",
17179            "commit",
17180            "-q",
17181            "--allow-empty",
17182            "-m",
17183            "feat: fixture",
17184        ]);
17185        let head = g(&["rev-parse", "HEAD"]);
17186        g(&["update-ref", "refs/remotes/origin/main", &head]);
17187        let evidence = crate::pr_outcome::summary_evidence(
17188            &runner.state,
17189            crate::pr_outcome::FinalHead::At(&head),
17190        );
17191        let generated = &runner.state.candidates[0].summary;
17192        runner.state.change_summary = Some(crate::run::ChangeSummary {
17193            base: head.clone(),
17194            head,
17195            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
17196            title: summary_title(generated).unwrap(),
17197            body: summary_without_title(generated),
17198        });
17199    }
17200
17201    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
17202        let mut runner = runner_at(RunStatus::Gating);
17203        runner.state = state_with_summary("add retries", JA_SUMMARY);
17204        if review_only {
17205            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
17206        }
17207        runner.state.repo = dir.to_owned();
17208        runner.state.candidates[0].worktree = dir.to_owned();
17209        runner.state.config.agents = agents;
17210        seed_public_summary(&mut runner);
17211        runner
17212    }
17213
17214    fn gate_questions(runner: &Runner) -> usize {
17215        ask::Questions::open()
17216            .list()
17217            .into_iter()
17218            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
17219            .count()
17220    }
17221
17222    #[tokio::test]
17223    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
17224        crate::run::pin_test_home();
17225        let dir = tempfile::tempdir().unwrap();
17226        let mut runner = rewrite_runner(
17227            dir.path(),
17228            true,
17229            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
17230        );
17231        runner.state.config.roles.language_judge =
17232            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
17233        let winner = runner.state.candidates[0].clone();
17234        let msg = runner
17235            .guarded_pr_message(&winner, None, true)
17236            .await
17237            .unwrap()
17238            .expect("an English rewrite posts without asking");
17239        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
17240        assert!(msg.body.contains("<details>kept</details>"));
17241        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
17242        assert_eq!(gate_questions(&runner), 0);
17243        assert!(runner.state.github_text.is_none());
17244        assert!(!runner.state.seats.contains_key("impl-A"));
17245        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
17246    }
17247
17248    #[tokio::test]
17249    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
17250        crate::run::pin_test_home();
17251        let dir = tempfile::tempdir().unwrap();
17252        let mut runner = rewrite_runner(
17253            dir.path(),
17254            false,
17255            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
17256        );
17257        runner.state.config.roles.language_judge =
17258            Some(crate::config::AgentChoice::One("beta".into()));
17259        runner.state.seats.insert(
17260            "impl-A".into(),
17261            SeatState::new("impl-A", "alpha", runner.state.seed),
17262        );
17263        let winner = runner.state.candidates[0].clone();
17264        let msg = runner
17265            .guarded_pr_message(&winner, None, true)
17266            .await
17267            .unwrap()
17268            .expect("rewritten");
17269        assert!(msg.body.contains("<details>kept</details>"));
17270        assert_eq!(runner.state.seats["impl-A"].turns, 0);
17271        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
17272        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
17273    }
17274
17275    #[tokio::test]
17276    async fn rewrite_reuses_a_resumable_implementer_session() {
17277        crate::run::pin_test_home();
17278        let dir = tempfile::tempdir().unwrap();
17279        let mut runner = rewrite_runner(
17280            dir.path(),
17281            false,
17282            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
17283        );
17284        runner.state.config.roles.language_judge =
17285            Some(crate::config::AgentChoice::One("beta".into()));
17286        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
17287        seat.turns = 1;
17288        runner.state.seats.insert("impl-A".into(), seat);
17289        let winner = runner.state.candidates[0].clone();
17290        runner
17291            .guarded_pr_message(&winner, None, true)
17292            .await
17293            .unwrap()
17294            .expect("rewritten");
17295        assert_eq!(runner.state.seats["impl-A"].turns, 2);
17296        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
17297    }
17298
17299    #[tokio::test]
17300    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
17301        crate::run::pin_test_home();
17302        let dir = tempfile::tempdir().unwrap();
17303        let mut broken = spec("bad");
17304        broken.command = vec!["false".into()];
17305        let mut runner = rewrite_runner(
17306            dir.path(),
17307            true,
17308            vec![
17309                broken,
17310                scripted("noformat", "Add retries."),
17311                scripted("good", EN_REWRITE),
17312            ],
17313        );
17314        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
17315            "bad".into(),
17316            "noformat".into(),
17317            "good".into(),
17318        ]));
17319        let winner = runner.state.candidates[0].clone();
17320        let msg = runner
17321            .guarded_pr_message(&winner, None, true)
17322            .await
17323            .unwrap()
17324            .expect("the third agent rewrites");
17325        assert!(msg.body.contains("`src/net.rs`"));
17326        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
17327        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
17328    }
17329
17330    #[tokio::test]
17331    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
17332        crate::run::pin_test_home();
17333        // (agents, role): still Japanese; no TITLE; nothing resolvable.
17334        for (agents, role, saved) in [
17335            (
17336                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
17337                Some("beta"),
17338                true,
17339            ),
17340            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
17341            (Vec::new(), None, false),
17342        ] {
17343            let dir = tempfile::tempdir().unwrap();
17344            let mut runner = rewrite_runner(dir.path(), true, agents);
17345            runner.state.config.roles.language_judge =
17346                role.map(|r| crate::config::AgentChoice::One(r.into()));
17347            let winner = runner.state.candidates[0].clone();
17348            let first = runner
17349                .guarded_pr_message(&winner, None, true)
17350                .await
17351                .unwrap();
17352            assert!(first.is_none(), "withheld text parks the run");
17353            assert!(runner.state.parked);
17354            assert_eq!(gate_questions(&runner), 1);
17355            let g = runner.state.github_text.as_ref().unwrap();
17356            assert_eq!(g.rewrite.is_some(), saved);
17357            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
17358        }
17359    }
17360
17361    #[tokio::test]
17362    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
17363        crate::run::pin_test_home();
17364        let dir = tempfile::tempdir().unwrap();
17365        let mut runner = runner_at(RunStatus::Gating);
17366        runner.state =
17367            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
17368        runner.state.repo = dir.path().to_owned();
17369        runner.state.candidates[0].worktree = dir.path().to_owned();
17370        let mut author = spec("alpha");
17371        author.command = vec![
17372            "sh".into(),
17373            "-c".into(),
17374            "printf '%s' \"$REWRITE_REPLY\"".into(),
17375        ];
17376        author.env.insert(
17377            "REWRITE_REPLY".into(),
17378            format!(
17379                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
17380                "English sentence. ".repeat(600)
17381            ),
17382        );
17383        runner.state.config.agents = vec![author];
17384        seed_public_summary(&mut runner);
17385        let winner = runner.state.candidates[0].clone();
17386        assert!(
17387            runner
17388                .guarded_pr_message(&winner, None, true)
17389                .await
17390                .unwrap()
17391                .is_none()
17392        );
17393        let record = runner.state.github_text.clone().unwrap();
17394        let rewrite = record.rewrite.clone().unwrap();
17395        assert_eq!(rewrite.failed, ["body-language"]);
17396        assert!(
17397            rewrite
17398                .body
17399                .ends_with(&format!("magi:run/{}", runner.state.id))
17400        );
17401        let store = ask::Questions::open();
17402        let q = store.get(record.question.as_ref().unwrap()).unwrap();
17403        assert!(
17404            q.choices
17405                .contains(&crate::github_text::USE_REWRITE.to_owned())
17406        );
17407        for label in [
17408            "ORIGINAL",
17409            "FALLBACK",
17410            "REWRITE",
17411            "Rules still failed: body-language",
17412            "truncated",
17413        ] {
17414            assert!(q.detail.contains(label), "{label}");
17415        }
17416        let artifact = std::fs::read_to_string(crate::run::artifact_path(
17417            &runner.state,
17418            &crate::github_text::artifact_name(&record.fingerprint),
17419        ))
17420        .unwrap();
17421        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
17422        // Restart from serialized state, then retry after a bad owner title.
17423        runner.state =
17424            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
17425        store
17426            .update(&q.id, |q| {
17427                q.say("まだ日本語")?;
17428                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
17429            })
17430            .unwrap();
17431        assert!(
17432            runner
17433                .guarded_pr_message(&winner, None, true)
17434                .await
17435                .unwrap()
17436                .is_none()
17437        );
17438        let retry = runner.state.github_text.as_ref().unwrap();
17439        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
17440        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
17441        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
17442        assert!(
17443            q.posting_comparison
17444                .as_ref()
17445                .unwrap()
17446                .matches_detail(&q.detail)
17447        );
17448        store
17449            .update(&q.id, |q| {
17450                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
17451            })
17452            .unwrap();
17453        let message = runner
17454            .guarded_pr_message(&winner, None, true)
17455            .await
17456            .unwrap()
17457            .unwrap();
17458        assert_eq!(message.title, rewrite.title);
17459        assert_eq!(message.body, rewrite.body);
17460        assert_eq!(
17461            runner.state.github_text.as_ref().unwrap().choice,
17462            Some(GithubTextChoice::Rewrite)
17463        );
17464        let again = runner
17465            .guarded_pr_message(&winner, None, true)
17466            .await
17467            .unwrap()
17468            .unwrap();
17469        assert_eq!(again.title, message.title);
17470        assert_eq!(again.body, message.body);
17471        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
17472        assert!(
17473            !runner
17474                .state
17475                .events
17476                .iter()
17477                .any(|e| e.message.contains(&rewrite.title)
17478                    || e.message.contains("English sentence."))
17479        );
17480    }
17481
17482    #[tokio::test]
17483    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
17484        crate::run::pin_test_home();
17485        let dir = tempfile::tempdir().unwrap();
17486        let mut runner = runner_at(RunStatus::Gating);
17487        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
17488        runner.state.repo = dir.path().to_owned();
17489        runner.state.candidates[0].worktree = dir.path().to_owned();
17490        seed_public_summary(&mut runner);
17491        let winner = runner.state.candidates[0].clone();
17492        assert!(
17493            runner
17494                .guarded_pr_message(&winner, None, true)
17495                .await
17496                .unwrap()
17497                .is_none()
17498        );
17499        let record = runner.state.github_text.as_ref().unwrap();
17500        assert!(record.rewrite.is_none());
17501        // Records written before the additive field deserialize with no rewrite.
17502        let mut old = serde_json::to_value(record).unwrap();
17503        old.as_object_mut().unwrap().remove("rewrite");
17504        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
17505        assert!(old.rewrite.is_none());
17506        let id = record.question.clone().unwrap();
17507        ask::Questions::open()
17508            .update(&id, |q| {
17509                q.choices.push(crate::github_text::USE_REWRITE.into());
17510                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
17511            })
17512            .unwrap();
17513        let message = runner
17514            .guarded_pr_message(&winner, None, true)
17515            .await
17516            .unwrap()
17517            .unwrap();
17518        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
17519        assert_eq!(
17520            runner.state.github_text.as_ref().unwrap().choice,
17521            Some(GithubTextChoice::Fallback)
17522        );
17523    }
17524
17525    #[tokio::test]
17526    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
17527        crate::run::pin_test_home();
17528        let dir = tempfile::tempdir().unwrap();
17529        let mut runner = runner_at(RunStatus::Gating);
17530        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
17531        runner.state.repo = dir.path().to_owned();
17532        runner.state.candidates[0].worktree = dir.path().to_owned();
17533        seed_public_summary(&mut runner);
17534        let winner = runner.state.candidates[0].clone();
17535        assert!(
17536            runner
17537                .guarded_pr_message(&winner, None, true)
17538                .await
17539                .unwrap()
17540                .is_none()
17541        );
17542        let store = ask::Questions::open();
17543        let find = |run: &str| {
17544            store
17545                .list()
17546                .into_iter()
17547                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
17548                .unwrap()
17549        };
17550        let run = runner.state.id.clone();
17551        let q = find(&run);
17552        let fp = runner
17553            .state
17554            .github_text
17555            .as_ref()
17556            .unwrap()
17557            .fingerprint
17558            .clone();
17559        let name = crate::github_text::artifact_name(&fp);
17560        let path = crate::run::artifact_path(&runner.state, &name);
17561        assert!(q.detail.contains(&path.display().to_string()));
17562        assert!(q.detail.contains("title-language: title"));
17563        let saved = std::fs::read_to_string(&path).expect("artifact written");
17564        assert!(saved.contains("日本語のタイトル"));
17565        assert!(q.cwd.is_none());
17566        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
17567        // A bad replacement is refused and asked about once more.
17568        let say = |id: &str, text: &str, choice: &str| {
17569            store
17570                .update(id, |q| {
17571                    q.thread.push(ask::Turn {
17572                        who: ask::Who::Operator,
17573                        body: text.to_owned(),
17574                        at: jiff::Timestamp::now(),
17575                        note: None,
17576                    });
17577                    q.answer(ask::Answer::Choice(choice.to_owned()))
17578                })
17579                .unwrap();
17580        };
17581        say(&q.id, "まだ日本語", "use my text");
17582        assert!(
17583            runner
17584                .guarded_pr_message(&winner, None, true)
17585                .await
17586                .unwrap()
17587                .is_none()
17588        );
17589        let second = find(&run);
17590        assert_ne!(second.id, q.id);
17591        assert!(second.detail.contains("did not pass"));
17592        // The retry still compares the original text; the rejected
17593        // replacement is named by category only.
17594        assert!(second.detail.contains("日本語のタイトル"));
17595        assert!(!second.detail.contains("まだ日本語"));
17596        assert!(second.detail.contains(&path.display().to_string()));
17597        say(&second.id, "fix: retry failed requests", "use my text");
17598        let message = runner
17599            .guarded_pr_message(&winner, None, true)
17600            .await
17601            .unwrap()
17602            .expect("a vetted replacement is posted");
17603        assert_eq!(message.title, "fix: retry failed requests");
17604        assert_eq!(
17605            runner
17606                .state
17607                .github_text
17608                .as_ref()
17609                .unwrap()
17610                .chosen_title
17611                .as_deref(),
17612            Some("fix: retry failed requests")
17613        );
17614        assert!(runner.state.github_text.as_ref().unwrap().resolved);
17615        // Resumed again: the saved decision is applied, nothing is re-asked.
17616        let again = runner
17617            .guarded_pr_message(&winner, None, true)
17618            .await
17619            .unwrap()
17620            .unwrap();
17621        assert_eq!(again.title, message.title);
17622        assert_eq!(
17623            store
17624                .list()
17625                .into_iter()
17626                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
17627                .count(),
17628            2
17629        );
17630    }
17631
17632    #[test]
17633    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
17634        // What magi itself writes stays English under any configured language,
17635        // so a future localisation of these headings fails here. (The agents'
17636        // own text is also checked by the posting gate.)
17637        let mut state = state_with_summary(
17638            "add retries",
17639            "TITLE: fix(web): batch reads\n- reads run.json once",
17640        );
17641        state.config.graph.language = "ja".to_owned();
17642        let m = pr_message(&state, 'A');
17643        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
17644        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
17645
17646        // The task is the operator's own text: it goes in untouched, and the
17647        // fallback title (no summary) may be in its language too.
17648        let task = "今回やってほしいこと: results projector を直す";
17649        let mut state = state_with_summary(task, "- no title line");
17650        state.config.graph.language = "ja".to_owned();
17651        let m = pr_message(&state, 'A');
17652        assert_eq!(
17653            m.title,
17654            format!("chore: land candidate A of run {}", state.id)
17655        );
17656        assert!(
17657            m.body.contains(&format!(
17658                "<summary>Original task</summary>\n\n{task}\n\n</details>"
17659            )),
17660            "{}",
17661            m.body
17662        );
17663    }
17664
17665    #[test]
17666    fn pr_message_scrubs_home_paths_and_addresses() {
17667        let state = state_with_summary(
17668            "fix it in /Users/someone/src/x",
17669            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
17670        );
17671        let m = pr_message(&state, 'A');
17672        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
17673            assert!(!m.body.contains(leak), "{}", m.body);
17674        }
17675        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
17676    }
17677
17678    #[test]
17679    fn pr_message_survives_a_task_that_closes_details() {
17680        let state = state_with_summary("a </details> b", "TITLE: fix: x");
17681        let m = pr_message(&state, 'A');
17682        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
17683    }
17684
17685    #[test]
17686    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
17687        let cmd = manual_merge_command(
17688            MergeStyle::Squash,
17689            Path::new("/repo"),
17690            "b",
17691            "fix: \"quoted\" $(x) `y`\n\nbody",
17692        );
17693        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
17694    }
17695
17696    #[test]
17697    fn manual_merge_command_matches_the_configured_style() {
17698        let repo = Path::new("/repo");
17699        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
17700
17701        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
17702        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
17703
17704        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
17705        assert_eq!(
17706            squash,
17707            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
17708             \"Merge magi run 0832 (candidate A)\""
17709        );
17710
17711        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
17712        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
17713    }
17714
17715    #[test]
17716    fn a_nudge_gets_a_quarter_of_the_budget() {
17717        // The judge and implement budgets magi ships with.
17718        assert_eq!(retry_budget(secs(1200), true), secs(300));
17719        assert_eq!(retry_budget(secs(3600), true), secs(900));
17720    }
17721
17722    #[test]
17723    fn a_resent_prompt_keeps_the_whole_budget() {
17724        // The seat kept no context, so the retry is the original job again and
17725        // shortening it would only guarantee a second failure.
17726        assert_eq!(retry_budget(secs(1200), false), secs(1200));
17727        assert_eq!(retry_budget(secs(60), false), secs(60));
17728    }
17729
17730    #[test]
17731    fn the_floor_never_exceeds_the_original_budget() {
17732        // A short configured timeout must not be *raised* by the floor: the
17733        // operator asked for a bound, and a retry may not outlast the attempt
17734        // it is retrying.
17735        assert_eq!(retry_budget(secs(60), true), secs(60));
17736        assert_eq!(retry_budget(secs(480), true), secs(120));
17737        assert_eq!(retry_budget(secs(0), true), secs(0));
17738    }
17739
17740    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
17741        agent::CommandEvidence {
17742            id: "item1".to_owned(),
17743            description: "cargo test".to_owned(),
17744            exit_code,
17745            result_summary: String::new(),
17746            source: "codex".to_owned(),
17747        }
17748    }
17749
17750    #[test]
17751    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
17752        // No evidence is not the same fact as unconfirmed evidence: a
17753        // backend with no adapter, or a reply that ran no commands at all,
17754        // must not be misread as carrying a dangling job.
17755        assert!(!has_unconfirmed_command(&[]));
17756    }
17757
17758    #[test]
17759    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
17760        // Deliberately not a check on the exit code's *value*: a fixer
17761        // legitimately runs something that fails mid-iteration before it
17762        // succeeds, and that must never by itself reopen a valid report.
17763        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
17764        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
17765        assert!(!has_unconfirmed_command(&[
17766            evidence(Some(0)),
17767            evidence(Some(101))
17768        ]));
17769    }
17770
17771    #[test]
17772    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
17773        assert!(has_unconfirmed_command(&[
17774            evidence(Some(0)),
17775            evidence(None)
17776        ]));
17777    }
17778
17779    #[test]
17780    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
17781        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17782        assert_eq!(
17783            verified_noop_claim(true, &[], text).as_deref(),
17784            Some("already fixed by b32cfc4, on main.")
17785        );
17786    }
17787
17788    #[test]
17789    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
17790        // A timeout or a bad exit code reads as the ordinary loss it is,
17791        // whatever the reply's own prose claims.
17792        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17793        assert!(verified_noop_claim(false, &[], text).is_none());
17794    }
17795
17796    #[test]
17797    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
17798        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17799        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
17800        // A confirmed command alongside the marker is fine.
17801        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
17802    }
17803
17804    #[test]
17805    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
17806        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
17807    }
17808
17809    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
17810    /// pair, in order, labelled A, B, C, ...
17811    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
17812        runner.state.candidates = shape
17813            .iter()
17814            .enumerate()
17815            .map(|(i, &(empty, verified))| Candidate {
17816                index: i,
17817                label: (b'A' + i as u8) as char,
17818                agent: "sonnet".to_owned(),
17819                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
17820                worktree: PathBuf::from(format!("/wt/{i}")),
17821                summary: String::new(),
17822                stat: String::new(),
17823                files: 0,
17824                commits: 0,
17825                empty,
17826                failed: None,
17827                verified_noop: verified.map(str::to_owned),
17828                duration_ms: 0,
17829                folded: false,
17830            })
17831            .collect();
17832    }
17833
17834    #[test]
17835    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
17836        ask_test_home();
17837        let mut runner = runner_at(RunStatus::Implementing);
17838        set_candidates(
17839            &mut runner,
17840            &[
17841                (true, Some("already on main at b32cfc4")),
17842                (true, Some("same fix, see the existing test")),
17843            ],
17844        );
17845
17846        runner
17847            .after_implement()
17848            .expect("a verified no-op is not an error");
17849
17850        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
17851    }
17852
17853    #[test]
17854    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
17855        ask_test_home();
17856        let mut runner = runner_at(RunStatus::Implementing);
17857        // Candidate A declares a verified no-op; candidate B simply wrote
17858        // nothing and said nothing about why. One candidate's claim is not
17859        // the whole run's agreement.
17860        set_candidates(
17861            &mut runner,
17862            &[(true, Some("already on main at b32cfc4")), (true, None)],
17863        );
17864
17865        let err = runner
17866            .after_implement()
17867            .expect_err("an unverified empty candidate must still fail the run");
17868
17869        assert!(
17870            err.to_string().contains("no candidate produced a change"),
17871            "{err}"
17872        );
17873        assert_eq!(runner.state.status, RunStatus::Failed);
17874    }
17875
17876    #[test]
17877    fn after_implement_still_fails_an_ordinary_all_empty_run() {
17878        ask_test_home();
17879        let mut runner = runner_at(RunStatus::Implementing);
17880        set_candidates(&mut runner, &[(true, None), (true, None)]);
17881
17882        let err = runner
17883            .after_implement()
17884            .expect_err("no candidate declared anything; this is an ordinary failure");
17885
17886        assert!(
17887            err.to_string().contains("no candidate produced a change"),
17888            "{err}"
17889        );
17890        assert_eq!(runner.state.status, RunStatus::Failed);
17891    }
17892}
17893
17894/// A graft rollback whose restore could not be confirmed (see
17895/// [`Runner::graft`]).
17896#[derive(Debug)]
17897struct GraftRestoreFailed(String);
17898
17899impl std::fmt::Display for GraftRestoreFailed {
17900    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17901        write!(
17902            f,
17903            "could not restore the winner to its pre-graft commit: {}",
17904            self.0
17905        )
17906    }
17907}
17908
17909impl std::error::Error for GraftRestoreFailed {}
17910
17911/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
17912/// not the agent: they never draw on an agent's own timeout, and running out of
17913/// them never reads as an agent failure.
17914#[cfg(test)]
17915mod resource_wait_tests {
17916    use super::*;
17917    use std::collections::BTreeMap;
17918
17919    fn sh(id: &str, script: &str) -> AgentSpec {
17920        AgentSpec {
17921            id: id.to_owned(),
17922            kind: crate::config::AgentKind::Command,
17923            model: None,
17924            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
17925            extra_args: Vec::new(),
17926            env: BTreeMap::new(),
17927            prompt_delivery: None,
17928        }
17929    }
17930
17931    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
17932        SeatJob {
17933            seat: SeatState::new("impl-A", &spec.id, 1),
17934            spec,
17935            cwd: dir.to_path_buf(),
17936            prompt: "do it".to_owned(),
17937            timeout,
17938            allow_write: true,
17939            sessions: false,
17940            artifacts: dir.join("art"),
17941            stem: "impl-A".to_owned(),
17942            handover: None,
17943        }
17944    }
17945
17946    fn state(resource_wait_secs: u64) -> RunState {
17947        let mut config = Config::default();
17948        config.graph.timeout_resource_wait = resource_wait_secs;
17949        RunState::new(
17950            PathBuf::from("/repo"),
17951            "main".to_owned(),
17952            "abc1234".to_owned(),
17953            "task".to_owned(),
17954            config,
17955        )
17956    }
17957
17958    fn hold(cache: &Path) -> crate::cache::Guard {
17959        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
17960        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
17961            crate::cache::AcquireOutcome::Acquired(g) => g,
17962            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
17963        }
17964    }
17965
17966    async fn run(
17967        jobs: Vec<SeatJob>,
17968        sem: Arc<Semaphore>,
17969        cache: Option<&Path>,
17970        st: &mut RunState,
17971    ) -> Vec<(usize, SeatState, AgentOutcome)> {
17972        let prompts = Prompts::default();
17973        let ctx = WaveCtx {
17974            run: "r",
17975            node: "implement",
17976            prompts: &prompts,
17977            cache,
17978            round: None,
17979            carry_seats: false,
17980            pauses: None,
17981        };
17982        wave(jobs, sem, &ctx, st, 0).await
17983    }
17984
17985    #[tokio::test]
17986    async fn verification_cache_wait_preserves_the_command_budget() {
17987        crate::run::pin_test_home();
17988        let tmp = tempfile::tempdir().unwrap();
17989        let cache = tmp.path().join("target");
17990        let guard = hold(&cache);
17991        let release = tokio::spawn(async move {
17992            tokio::time::sleep(Duration::from_millis(100)).await;
17993            guard.release();
17994        });
17995        let mut st = state(10);
17996        let budget = Duration::from_millis(50);
17997        let wait = repository_wait(&st, None);
17998        let (outcomes, _) = with_cache_lease(
17999            &mut st,
18000            Some(&cache),
18001            "gate",
18002            "gate",
18003            tmp.path(),
18004            "abc1234",
18005            budget,
18006            "test gate",
18007            &wait,
18008            |_, available| async move {
18009                assert_eq!(
18010                    available, budget,
18011                    "contention must not shorten verification"
18012                );
18013                (Vec::new(), false, Vec::new())
18014            },
18015        )
18016        .await
18017        .unwrap();
18018        release.await.unwrap();
18019        assert!(outcomes.is_empty(), "{outcomes:?}");
18020        assert!(
18021            st.events
18022                .iter()
18023                .any(|event| event.message.contains("waiting for"))
18024        );
18025        assert!(st.resource_wait.is_none());
18026        // The verification body no longer owns the lease after returning.
18027        hold(&cache).release();
18028    }
18029
18030    #[tokio::test]
18031    async fn verification_cache_wait_expires_without_starting_commands() {
18032        crate::run::pin_test_home();
18033        let tmp = tempfile::tempdir().unwrap();
18034        let cache = tmp.path().join("target");
18035        let _held = hold(&cache);
18036        let mut st = state(0);
18037        let wait = repository_wait(&st, None);
18038        let (outcomes, _) = with_cache_lease(
18039            &mut st,
18040            Some(&cache),
18041            "gate",
18042            "gate",
18043            tmp.path(),
18044            "abc1234",
18045            Duration::from_secs(60),
18046            "test gate",
18047            &wait,
18048            |_, _| async { panic!("commands must not start without the cache lease") },
18049        )
18050        .await
18051        .unwrap();
18052        assert_eq!(outcomes.len(), 1);
18053        assert!(outcomes[0].resource_blocked);
18054        assert!(st.resource_wait.is_none());
18055    }
18056
18057    #[tokio::test]
18058    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
18059        crate::run::pin_test_home();
18060        let tmp = tempfile::tempdir().unwrap();
18061        let cache = tmp.path().join("target-a");
18062        let guard = hold(&cache);
18063        // Held for longer than the agent's whole 1s budget.
18064        tokio::spawn(async move {
18065            tokio::time::sleep(Duration::from_millis(1500)).await;
18066            guard.release();
18067        });
18068        let mut st = state(60);
18069        let results = run(
18070            vec![job(
18071                sh("alpha", "echo ok"),
18072                tmp.path(),
18073                Duration::from_secs(1),
18074            )],
18075            Arc::new(Semaphore::new(1)),
18076            Some(&cache),
18077            &mut st,
18078        )
18079        .await;
18080        assert!(
18081            matches!(results[0].2, AgentOutcome::Ok(_)),
18082            "the wait must not be charged to the agent"
18083        );
18084        assert!(st.resource_block.is_none());
18085        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
18086        assert!(st.resource_wait.is_none() && st.active.is_empty());
18087    }
18088
18089    #[tokio::test]
18090    #[cfg(unix)]
18091    async fn repository_wait_in_a_wave_is_a_bounded_cancellable_resource_block() {
18092        use std::os::fd::AsRawFd as _;
18093        crate::run::pin_test_home();
18094        let tmp = tempfile::tempdir().unwrap();
18095        crate::git::git(tmp.path(), &["init"]).await.unwrap();
18096        let common = tmp.path().join(".git");
18097        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
18098        let external = std::fs::File::create(common.join("updater.lock")).unwrap();
18099        assert_eq!(
18100            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
18101            0
18102        );
18103        let mut st = state(1);
18104        st.config.coordination = crate::config::test_coordination();
18105        let mut reader = job(
18106            sh("reader", "echo must-not-run"),
18107            tmp.path(),
18108            Duration::from_secs(30),
18109        );
18110        reader.allow_write = false;
18111        let results = run(
18112            vec![reader.clone()],
18113            Arc::new(Semaphore::new(1)),
18114            None,
18115            &mut st,
18116        )
18117        .await;
18118        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
18119        assert_eq!(results[0].1.turns, 0);
18120        assert!(FailClass::of(&results[0].2).is_none());
18121        assert_eq!(
18122            st.resource_block.as_ref().unwrap().kind,
18123            "repository coordination"
18124        );
18125        assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
18126        assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
18127        drop(external);
18128        let editor = crate::repo_lock::SeatLease::for_checkout(
18129            tmp.path(),
18130            true,
18131            crate::config::test_coordination().as_ref(),
18132        )
18133        .await
18134        .unwrap();
18135        let pause = Pause::new();
18136        let interrupt = Pause::new();
18137        let prompts = Prompts::default();
18138        let ctx = WaveCtx {
18139            run: "r",
18140            node: "review",
18141            prompts: &prompts,
18142            cache: None,
18143            round: None,
18144            carry_seats: false,
18145            pauses: Some((&pause, &interrupt)),
18146        };
18147        let (results, ()) = tokio::join!(
18148            wave(vec![reader], Arc::new(Semaphore::new(1)), &ctx, &mut st, 0),
18149            async {
18150                tokio::time::sleep(Duration::from_millis(50)).await;
18151                pause.park();
18152            }
18153        );
18154        assert!(
18155            matches!(&results[0].2, AgentOutcome::ResourceBlocked(why) if why.contains("parking"))
18156        );
18157        assert_eq!(results[0].1.turns, 0);
18158        assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
18159        assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
18160        drop(editor);
18161    }
18162
18163    #[tokio::test]
18164    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
18165        crate::run::pin_test_home();
18166        let tmp = tempfile::tempdir().unwrap();
18167        let cache = tmp.path().join("target-b");
18168        let _held = hold(&cache);
18169        let mut st = state(1);
18170        let results = run(
18171            vec![job(
18172                sh("alpha", "echo ok"),
18173                tmp.path(),
18174                Duration::from_secs(1),
18175            )],
18176            Arc::new(Semaphore::new(1)),
18177            Some(&cache),
18178            &mut st,
18179        )
18180        .await;
18181        let (_, seat, out) = &results[0];
18182        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
18183        assert!(wave_blocked(&results));
18184        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
18185        assert_eq!(seat.turns, 0, "nothing was asked");
18186        assert!(
18187            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
18188            "no agent was spawned"
18189        );
18190        assert!(FailClass::of(out).is_none(), "not a failure class");
18191        let block = st.resource_block.as_ref().expect("recorded for the resume");
18192        assert_eq!(block.kind, "shared build cache");
18193        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
18194        assert!(st.active.is_empty());
18195    }
18196
18197    #[tokio::test]
18198    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
18199        crate::run::pin_test_home();
18200        let tmp = tempfile::tempdir().unwrap();
18201        let mut st = state(1);
18202        let results = run(
18203            vec![job(
18204                sh("alpha", "echo ok"),
18205                tmp.path(),
18206                Duration::from_secs(1),
18207            )],
18208            Arc::new(Semaphore::new(0)),
18209            None,
18210            &mut st,
18211        )
18212        .await;
18213        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
18214        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
18215        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
18216    }
18217
18218    #[tokio::test]
18219    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
18220        crate::run::pin_test_home();
18221        let tmp = tempfile::tempdir().unwrap();
18222        let mut st = state(60);
18223        let results = run(
18224            vec![job(
18225                sh("alpha", "sleep 30"),
18226                tmp.path(),
18227                Duration::from_secs(1),
18228            )],
18229            Arc::new(Semaphore::new(1)),
18230            None,
18231            &mut st,
18232        )
18233        .await;
18234        let out = &results[0].2;
18235        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
18236        let class = FailClass::of(out).expect("a failure");
18237        assert_eq!(class, FailClass::Timeout);
18238        assert!(should_hand_over(None, &class), "existing handover policy");
18239        assert!(st.resource_block.is_none());
18240    }
18241}