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
581impl Runner {
582    /// Start a fresh run against `repo`.
583    pub async fn start(
584        repo: &Path,
585        instruction: String,
586        config: Config,
587        origin: Origin,
588    ) -> Result<Self> {
589        Self::start_naming(repo, instruction, "", config, origin).await
590    }
591
592    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
593    /// searched for branch and commit references along with the instruction,
594    /// since a task may name the work it is about only in its title.
595    pub async fn start_naming(
596        repo: &Path,
597        instruction: String,
598        also_scan: &str,
599        config: Config,
600        origin: Origin,
601    ) -> Result<Self> {
602        let repo = git::toplevel(repo).await?;
603        let missing = agent::missing_programs(&config.agents);
604        if !missing.is_empty() {
605            bail!(
606                "these agent programs are not on PATH: {}. Fix the roster in \
607                 magi.toml or install them.",
608                missing.join(", ")
609            );
610        }
611        let base_branch =
612            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613                .await?;
614        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615        let roles = config.resolve_roles()?;
616        let max_parallel = config.graph.max_parallel.max(1);
617        // A task that points at work already in the repository starts from
618        // it; what the repository says about each reference is recorded.
619        let seeds = refs::resolve(
620            &repo,
621            &base_commit,
622            &config.merge.remote,
623            &format!("{also_scan}\n{instruction}"),
624        )
625        .await;
626        refs::plan(&repo, &seeds).await?;
627        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628        // Recorded before the first save, so a crash right after minting
629        // cannot leave a run with no origin. Legibility only: nothing reads it
630        // to decide anything.
631        state.origin = Some(origin);
632        for seed in &seeds {
633            state.event(
634                "seed",
635                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636            );
637        }
638        state.seeds = seeds;
639        state.event("start", format!("run {} created", state.id));
640        state.save()?;
641        Ok(Self {
642            state,
643            roles,
644            sem: Arc::new(Semaphore::new(max_parallel)),
645            pause: Pause::new(),
646            interrupt: Pause::new(),
647        })
648    }
649
650    /// Open a review-only run against work that already exists on `branch`.
651    ///
652    /// The expensive half of the graph is the implement wave — measured at
653    /// 111 and 134 internal tool-loop turns on this repository, against a
654    /// handful for a judge or a reviewer. The cheap half is worth running on
655    /// hand-written work too, and there was no way to reach it.
656    ///
657    /// No new state and no schema change are needed: a run with **one** viable
658    /// candidate and a tally already decided degrades `execute` to exactly
659    /// review → gate → merge, because `judge` skips a single-candidate field,
660    /// `deliberate` has fewer than two first choices to reconcile, `vote`
661    /// returns early, `tally` is already present and `fold_losers` has no
662    /// losers. Resuming such a run therefore does the right thing as well.
663    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664        Self::review_taking_over(repo, branch, config, None, origin).await
665    }
666
667    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
668    /// at the same task still has `branch` checked out, its worktree is
669    /// released first if that is safe (see [`crate::handover`]), and the
670    /// review refuses with the reason if it is not. `None` is a hand-run
671    /// review: it has no earlier attempts, so only a worktree of a dead run
672    /// magi recorded itself can be released.
673    pub async fn review_taking_over(
674        repo: &Path,
675        branch: &str,
676        config: Config,
677        takeover: Option<crate::handover::Takeover>,
678        origin: Origin,
679    ) -> Result<Self> {
680        let repo = git::toplevel(repo).await?;
681        let missing = agent::missing_programs(&config.agents);
682        if !missing.is_empty() {
683            bail!(
684                "these agent programs are not on PATH: {}. Fix the roster in \
685                 magi.toml or install them.",
686                missing.join(", ")
687            );
688        }
689        let base_branch =
690            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691                .await?;
692        if base_branch == branch {
693            bail!("`{branch}` is the base branch; there is nothing to review against");
694        }
695        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697        let roles = config.resolve_roles()?;
698        let max_parallel = config.graph.max_parallel.max(1);
699        let mut state = RunState::new(
700            repo.clone(),
701            base_branch,
702            base_commit.clone(),
703            String::new(),
704            config,
705        );
706        state.origin = Some(origin);
707
708        // Released before anything else touches the branch: a stale local
709        // branch is moved with `git branch -f`, which git refuses while an
710        // earlier attempt's worktree still has it checked out. Everything
711        // after this point that can fail puts the old run back.
712        // A hand-run review has no task, hence no earlier attempts, but a
713        // worktree of a dead run magi made may still be released.
714        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715            earlier: Vec::new(),
716            home: crate::run::home(),
717            choice: None,
718            task_instruction: None,
719        });
720        let wait = repository_wait(&state, None);
721        let coordination_config = state.config.coordination.clone();
722        let coordination =
723            crate::repo_lock::SeatLease::operation_with(&repo, coordination_config.as_ref(), &wait)
724                .await?;
725        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
726        if let Some(released) = &released {
727            state.event(
728                "release",
729                format!(
730                    "took `{branch}` over from run {}: its worktree was released: {}",
731                    crate::run::short_of(&released.old_id),
732                    released.audit
733                ),
734            );
735        }
736        // The owner's answer to an earlier divergence question is applied
737        // here: after the release (git will not move a checked-out branch)
738        // and before the sync that would otherwise ask again.
739        if let Some(choice) = takeover.choice.as_ref()
740            && let Err(e) =
741                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
742                    .await
743        {
744            if let Some(released) = &released {
745                released.restore(&repo, branch).await;
746            }
747            return Err(e.context("applying the owner's answer about the diverged branch"));
748        }
749        // An earlier attempt of this task that published the task head, and
750        // whose work is the branch under review, hands that head over: the
751        // review run updates the same pull request. Without one (a legacy
752        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
753        // own head and its own open pull request is adopted as before.
754        for id in takeover.earlier.iter().rev() {
755            if let Ok(old) = RunState::load_under(id, &takeover.home)
756                && let Some(head) = old.pr_head.clone()
757                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
758            {
759                state.pr_head = Some(head);
760                break;
761            }
762        }
763        drop(coordination);
764        let opened = Self::open_review(
765            &repo,
766            branch,
767            state,
768            roles,
769            max_parallel,
770            base_commit,
771            &takeover,
772        )
773        .await;
774        if opened.is_err()
775            && let Some(released) = &released
776        {
777            let _coordination = crate::repo_lock::SeatLease::operation_with(
778                &repo,
779                coordination_config.as_ref(),
780                &wait,
781            )
782            .await?;
783            released.restore(&repo, branch).await;
784        }
785        opened
786    }
787
788    /// Recover exactly the operator-selected public implementation, without touching old sources.
789    pub async fn recover_pr(
790        repo: &Path,
791        task: &str,
792        selection: crate::pr_recovery::Selection,
793        config: Config,
794    ) -> Result<Self> {
795        selection.check_task(task, &selection.instruction)?;
796        let base_branch =
797            git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
798                .await?;
799        let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
800        selection
801            .verify(repo, &config.merge.remote, &base_branch, &selection.head)
802            .await?;
803        let roles = config.resolve_roles()?;
804        let max_parallel = config.graph.max_parallel.max(1);
805        let mut state = RunState::new(
806            repo.to_path_buf(),
807            base_branch,
808            base_commit.clone(),
809            selection.instruction.clone(),
810            config,
811        );
812        state.origin = Some(crate::run::Origin::queue(task));
813        state.pr_head = Some(selection.branch.clone());
814        state.pr_recovery = Some(selection.clone());
815        crate::pr_head::check_predecessors(&state, &selection.branch).await?;
816        Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
817    }
818
819    async fn open_selected(
820        repo: &Path,
821        state: RunState,
822        roles: ResolvedRoles,
823        max_parallel: usize,
824        base_commit: String,
825        selection: crate::pr_recovery::Selection,
826    ) -> Result<Self> {
827        let mut state = state;
828        let branch = state.branch_for('A');
829        let wait = repository_wait(&state, None);
830        let coordination = coordinate_operation(&mut state, repo, &wait).await?;
831        git::git(repo, &["branch", &branch, &selection.head]).await?;
832        drop(coordination);
833        state.event(
834            "recovery",
835            format!(
836                "selected public implementation {} from {}",
837                selection.head, selection.url
838            ),
839        );
840        let takeover = crate::handover::Takeover {
841            earlier: Vec::new(),
842            home: crate::run::home(),
843            choice: None,
844            task_instruction: Some(selection.instruction.clone()),
845        };
846        state.save()?;
847        let id = state.id.clone();
848        let opened = Self::open_review(
849            repo,
850            &branch,
851            state,
852            roles,
853            max_parallel,
854            base_commit,
855            &takeover,
856        )
857        .await;
858        match opened {
859            Ok(runner) => Ok(runner),
860            Err(e) => {
861                if let Ok(mut partial) = RunState::load(&id) {
862                    partial.status = RunStatus::Blocked;
863                    partial.driver_exited = true;
864                    partial.event(
865                        "recovery",
866                        format!("startup failed; retained source and candidate {branch}: {e:#}"),
867                    );
868                    partial.save()?;
869                }
870                Err(crate::pr_recovery::StartupFailed {
871                    run: id,
872                    cause: format!("{e:#}"),
873                }
874                .into())
875            }
876        }
877    }
878
879    /// What the newest earlier attempt that reviewed `branch` leaves for this
880    /// run (see [`crate::recovery`]). `None` for a hand-run review, which has
881    /// no earlier attempts. Reads the earlier record only: ownership of its
882    /// worktree and branch is [`crate::handover`]'s business.
883    async fn inherit(
884        repo: &Path,
885        branch: &str,
886        base_commit: &str,
887        takeover: &crate::handover::Takeover,
888    ) -> Option<crate::recovery::RecoveryRecord> {
889        use crate::recovery::{Relation, assess};
890        let newest = takeover.earlier.last()?;
891        let mut chosen = None;
892        for id in takeover.earlier.iter().rev() {
893            if let Ok(old) = RunState::load_under(id, &takeover.home)
894                && !old.reviews.is_empty()
895                && (old.winner().is_some_and(|w| w.branch == branch)
896                    || old.pr_head.as_deref() == Some(branch))
897            {
898                chosen = Some(old);
899                break;
900            }
901        }
902        let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
903            .await
904            .unwrap_or_default();
905        let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
906            Some(last) if last.head == tip => Relation::Same,
907            Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
908            _ => Relation::Diverged,
909        };
910        let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
911        Some(assess(from, chosen.as_ref(), base_commit, relation))
912    }
913
914    /// The half of [`Runner::review_taking_over`] that can fail after an
915    /// earlier attempt's worktree was released.
916    async fn open_review(
917        repo: &Path,
918        branch: &str,
919        mut state: RunState,
920        roles: ResolvedRoles,
921        max_parallel: usize,
922        base_commit: String,
923        takeover: &crate::handover::Takeover,
924    ) -> Result<Self> {
925        let wait = repository_wait(&state, None);
926        let _coordination = coordinate_operation(&mut state, repo, &wait).await?;
927        if state.pr_recovery.is_none() {
928            sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
929        }
930        // The commit subjects are the closest thing to a task statement that
931        // existing work carries, and the reviewers are told as much.
932        let start = review_base(
933            repo,
934            &state.config.merge.remote,
935            &state.base_branch,
936            &base_commit,
937            branch,
938        )
939        .await;
940        let log = git::log_oneline(repo, &start, branch)
941            .await
942            .unwrap_or_default();
943        let log_text = if log.trim().is_empty() {
944            "(no commit messages)"
945        } else {
946            log.trim()
947        };
948        let mut instruction = format!(
949            "Review the work already on branch `{branch}`. There is no task \
950             statement: what the change claims to do is whatever its commits \
951             say.\n\n{log_text}"
952        );
953        // A retry of a task whose earlier attempt left reviews behind carries
954        // that attempt's evidence, and the task's own statement with it: the
955        // completion criteria are the task's, not whatever the commits claim.
956        if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
957            if let Some(task) = takeover
958                .task_instruction
959                .as_deref()
960                .filter(|t| !t.trim().is_empty())
961                && rec.reviewed_head.is_some()
962            {
963                instruction = format!(
964                    "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
965                    task.trim()
966                );
967                rec.criteria_inherited = true;
968            }
969            state.event("recovery", rec.summary());
970            state.recovery = Some(rec);
971        }
972        if let Some(selected) = &state.pr_recovery {
973            instruction = selected.instruction.clone();
974        }
975        state.task_instruction = takeover.task_instruction.clone();
976        state.instruction = instruction;
977        state.reviewed_commits = Some(
978            git::subjects(repo, &start, branch)
979                .await
980                .unwrap_or_default(),
981        );
982
983        // An attached worktree, so the fixer's commits land on the branch under
984        // review rather than on a detached head nobody will look at again.
985        let worktree = state.worktree_root().join("under-review");
986        if let Some(parent) = worktree.parent() {
987            tokio::fs::create_dir_all(parent).await.ok();
988        }
989        let path = worktree.to_string_lossy().to_string();
990        git::git(repo, &["worktree", "add", &path, branch])
991            .await
992            .with_context(|| {
993                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
994            })?;
995
996        let setup = match ensure_setup_config(&mut state, repo).await {
997            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
998            Err(e) => Err(e),
999        };
1000        if let Err(e) = setup {
1001            git::worktree_remove(repo, &worktree).await.ok();
1002            return Err(e);
1003        }
1004        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
1005            .await
1006            .unwrap_or(0);
1007        if commits == 0 {
1008            git::worktree_remove(repo, &worktree).await.ok();
1009            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
1010        }
1011        let files = git::changed_files(&worktree, &base_commit, "HEAD")
1012            .await
1013            .map(|f| f.len())
1014            .unwrap_or(0);
1015        if files == 0
1016            && let (Ok(head_tree), Ok(base_tree)) = (
1017                git::tree_of(&worktree, "HEAD").await,
1018                git::tree_of(&worktree, &base_commit).await,
1019            )
1020            && head_tree == base_tree
1021        {
1022            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1023            git::worktree_remove(repo, &worktree).await.ok();
1024            bail!(
1025                "`{branch}` at {} has a tree identical to base {}; this usually means \
1026                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1027                 against `{}/{branch}`) rather than an empty change",
1028                short(&head),
1029                short(&base_commit),
1030                state.config.merge.remote
1031            );
1032        }
1033        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1034            .await
1035            .unwrap_or_default();
1036
1037        state.candidates.push(Candidate {
1038            index: 0,
1039            label: 'A',
1040            // Not an agent id on purpose: nothing in the roster wrote this, and
1041            // the stats tables must not credit anyone with a win for it.
1042            agent: EXISTING_BRANCH.to_owned(),
1043            branch: branch.to_owned(),
1044            worktree,
1045            summary: String::new(),
1046            stat,
1047            files,
1048            commits,
1049            empty: false,
1050            failed: None,
1051            verified_noop: None,
1052            duration_ms: 0,
1053            folded: false,
1054        });
1055        state.tally = Some(Tally {
1056            first_choice: BTreeMap::from([('A', 0)]),
1057            borda: BTreeMap::new(),
1058            winner: 'A',
1059            rankings: 0,
1060            unanimous_initial: false,
1061            deliberated: false,
1062            changed_votes: 0,
1063            unanimous_final: false,
1064            tie_break: None,
1065            // No panel sat, so no quorum applies. Zero judges is the correct
1066            // number for work that never competed, and must not be reported as
1067            // a collapsed panel.
1068            judges: 0,
1069            present: 0,
1070            quorum: 0,
1071            met_quorum: true,
1072            uncontested: Some("review-only run: nothing competed".to_owned()),
1073        });
1074        state.status = RunStatus::Reviewing;
1075        state.prep_complete = Some(true);
1076        state.event(
1077            "start",
1078            format!(
1079                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1080                state.id
1081            ),
1082        );
1083        state.save()?;
1084        Ok(Self {
1085            state,
1086            roles,
1087            sem: Arc::new(Semaphore::new(max_parallel)),
1088            pause: Pause::new(),
1089            interrupt: Pause::new(),
1090        })
1091    }
1092
1093    /// Reopen an existing run.
1094    pub fn resume(id: &str) -> Result<Self> {
1095        let state = RunState::load(id)?;
1096        crate::pr_head::ensure_owner(&state)?;
1097        if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1098            bail!(
1099                "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1100            );
1101        }
1102        if let Some(to) = &state.released_to {
1103            bail!(
1104                "run {} cannot be resumed: its worktree was released to run {}",
1105                state.short(),
1106                crate::run::short_of(to)
1107            );
1108        }
1109        let roles = state.config.resolve_roles()?;
1110        if let Some(id) = &state.config.graph.selected_implementer
1111            && state.candidates.iter().any(|c| c.agent != *id)
1112        {
1113            bail!(
1114                "replacement implementer pin does not match the saved candidates; task must stay held"
1115            );
1116        }
1117        let max_parallel = state.config.graph.max_parallel.max(1);
1118        Ok(Self {
1119            state,
1120            roles,
1121            sem: Arc::new(Semaphore::new(max_parallel)),
1122            pause: Pause::new(),
1123            interrupt: Pause::new(),
1124        })
1125    }
1126
1127    /// Walk the graph to a terminal state, skipping nodes already recorded.
1128    ///
1129    /// Every way a run is driven - the queue loop, `magi run`, a resume from
1130    /// the phone - ends here, so this is the one place a run that ended
1131    /// Blocked / Stalled / Failed, or died with an error, is announced to the
1132    /// notification centre. Best-effort: see [`crate::notices::raise`].
1133    pub async fn execute(&mut self) -> Result<()> {
1134        // Queue claims cover daemon work; this also serializes manual drivers
1135        // consuming a repair answer, including the new review and decision.
1136        let _repair_claim = if self.state.config.graph.selected_implementer.is_some()
1137            || self.state.land_approval.is_some()
1138            || self.state.merge_decision.is_some()
1139            || !self.state.merge_repairs.is_empty()
1140            || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1141        {
1142            let claim = FixClaim::acquire(&self.state.dir())?;
1143            let disk = RunState::load(&self.state.id)?;
1144            let checkpoint = |s: &RunState| {
1145                (
1146                    s.merge_repairs.len(),
1147                    s.merge_repairs
1148                        .last()
1149                        .map(|r| (r.question.clone(), r.phase)),
1150                    s.land_approval
1151                        .as_ref()
1152                        .map(|a| (a.question.clone(), a.head.clone())),
1153                )
1154            };
1155            if checkpoint(&disk) != checkpoint(&self.state) {
1156                bail!(
1157                    "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1158                );
1159            }
1160            Some(claim)
1161        } else {
1162            None
1163        };
1164        let wait = self.repository_wait();
1165        let result = wait.scope(self.execute_graph()).await;
1166        let result = match result {
1167            Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
1168                record_repository_block(&mut self.state, &e);
1169                self.park_here()?;
1170                Ok(())
1171            }
1172            other => other,
1173        };
1174        self.mark_driver_exited();
1175        let ended = if result.is_err() {
1176            Some(crate::notices::run_stopped(&self.state.id, &self.state))
1177        } else {
1178            crate::notices::run_ended(&self.state)
1179        };
1180        if let Some(notice) = ended {
1181            crate::notices::raise(notice);
1182        }
1183        result
1184    }
1185
1186    fn repository_wait(&self) -> crate::repo_lock::WaitPolicy {
1187        repository_wait(&self.state, Some((&self.pause, &self.interrupt)))
1188    }
1189
1190    async fn coordinate(&mut self, cwd: &Path) -> Result<Option<crate::repo_lock::SeatLease>> {
1191        let wait = self.repository_wait();
1192        coordinate_operation(&mut self.state, cwd, &wait).await
1193    }
1194
1195    /// Record that this process no longer drives the run, so its pid (a
1196    /// daemon's outlives the run) is not read as a live driver.
1197    ///
1198    /// Written onto the record as it is on disk, never this copy: another
1199    /// process may have resumed the run (recording its own pid and clearing
1200    /// the flag) or released its worktree since this copy was read, and
1201    /// saving over that would mark a running driver dead. Only a record still
1202    /// naming this process as the driver is touched.
1203    fn mark_driver_exited(&mut self) {
1204        self.state.driver_exited = true;
1205        let pid = std::process::id();
1206        let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1207            return;
1208        };
1209        let Ok(mut disk) = RunState::load(&self.state.id) else {
1210            return;
1211        };
1212        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1213            return;
1214        }
1215        disk.driver_exited = true;
1216        if let Err(e) = disk.save() {
1217            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1218        }
1219    }
1220
1221    /// Validate ownership and persist the driver claim in one critical section.
1222    fn claim_driver(&mut self) -> Result<()> {
1223        let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1224        let disk = RunState::load(&self.state.id)?;
1225        crate::pr_head::ensure_owner(&disk)?;
1226        // Moving again, so it is no longer parked. Set before the walk rather
1227        // than in `resume`, so every way of re-entering the graph clears it
1228        // and a card cannot claim a run is waiting to be resumed while the
1229        // agents are already working.
1230        self.state.parked = false;
1231        self.state.resource_wait = None;
1232        self.state.resource_block = None;
1233        // Any seat this state still lists as answering belongs to whatever
1234        // process last drove this run — this one included, if it crashed
1235        // mid-wave. Cleared and flushed immediately, before anything else
1236        // runs, so a resume can never show a seat as live when nothing is
1237        // asking it anything yet; the node that actually dispatches the next
1238        // wave repopulates it.
1239        self.state.clear_active();
1240        // Recorded in the same spot, and flushed together with the clear
1241        // above: this is the pid a reader checks (`RunState::liveness`) when
1242        // no daemon claim exists to answer "is a process still driving this
1243        // run" — a plain `magi run` / `magi review` typed into a terminal
1244        // claims nothing there. Always overwritten, never only-if-absent, so
1245        // a resumed run's stale pid from a previous, possibly-dead process
1246        // can never survive into this one's own report. Unlike
1247        // `clear_active`, this changes on every single `execute()` call, so
1248        // the save below is now unconditional rather than only-if-cleared.
1249        //
1250        // `driver_started_at` is recorded in the same breath, from this same
1251        // pid, so `liveness` can tell a live pid that is genuinely still us
1252        // apart from one the OS has since handed to an unrelated process —
1253        // see that field's own doc for why the pid alone is not enough.
1254        // A resume that raced a takeover: the record on disk says the worktree
1255        // was handed to a later run after this copy was read. Saving over it
1256        // would erase that and drive a run with nothing to run in.
1257        if let Some(to) = &disk.released_to {
1258            bail!(
1259                "run {} cannot continue: its worktree was released to run {}",
1260                self.state.short(),
1261                crate::run::short_of(to)
1262            );
1263        }
1264        let pid = std::process::id();
1265        self.state.driver_pid = Some(pid);
1266        self.state.driver_started_at = crate::proc::process_started_at(pid);
1267        self.state.driver_exited = false;
1268        self.state.begin_pass();
1269        self.state.save()?;
1270        Ok(())
1271    }
1272
1273    async fn execute_graph(&mut self) -> Result<()> {
1274        self.claim_driver()?;
1275        if self
1276            .state
1277            .merge_decision
1278            .as_ref()
1279            .is_some_and(|decision| !decision.closed)
1280            && self.state.status != RunStatus::Landing
1281        {
1282            if crate::merge_decision::resumable_hold(&self.state) {
1283                self.state.merge = Some(MergeOutcome {
1284                    mode: MergeMode::Pr,
1285                    ok: false,
1286                    detail: self
1287                        .state
1288                        .merge_decision
1289                        .as_ref()
1290                        .unwrap()
1291                        .target
1292                        .pr
1293                        .clone(),
1294                    empty: false,
1295                });
1296                self.run_land().await?;
1297                self.settle_questions();
1298                return Ok(());
1299            }
1300            bail!(
1301                "owner stop decision is held; inspect the existing PR and request an explicit fresh review/decision before further work"
1302            );
1303        }
1304        if self.state.config.graph.selected_implementer.is_some()
1305            && self.state.status == RunStatus::Blocked
1306            && self.state.tally.is_none()
1307        {
1308            bail!(
1309                "selected implementation is held; explicitly release the owning task to retry it"
1310            );
1311        }
1312
1313        if self.state.merge_repairs.last().is_some_and(|r| {
1314            matches!(
1315                r.phase,
1316                crate::run::MergeRepairPhase::Requested
1317                    | crate::run::MergeRepairPhase::Fixing
1318                    | crate::run::MergeRepairPhase::Reviewing
1319            )
1320        }) {
1321            if self.repair_merge_review().await? {
1322                self.run_land().await?;
1323            }
1324            self.settle_questions();
1325            return Ok(());
1326        }
1327        // Publication already passed review and gate. Resume only that node:
1328        // syncing to a newer base here could change the verified commit.
1329        if self.state.pr_publication_pending {
1330            self.merge().await?;
1331            if self.resource_halt()? {
1332                return Ok(());
1333            }
1334            self.state.save()?;
1335            self.settle_questions();
1336            return Ok(());
1337        }
1338        // A run that already lost its quorum never resumes into the verdict
1339        // machinery: `deliberate` and `vote` would otherwise clobber the
1340        // stalled marker back to Voting and the run would keep going past a
1341        // verdict that is no longer trustworthy. Everything already recorded is
1342        // kept, so the run stays resumable (or foldable) for a human to pick up.
1343        //
1344        // On --resume the run gets one chance to repair itself: the seats a
1345        // rate limit took out are re-asked. If their quota has since reset and
1346        // the quorum is restored, the run picks up and finishes; otherwise it
1347        // stays stale and still-resumable for a later retry. If it does not
1348        // recover, the returned status stays `Stalled` and nothing was
1349        // clobbered (the recovery only mutates entries for the lost seats).
1350        if self.state.status == RunStatus::Stalled {
1351            if self.recover_stall().await? {
1352                self.finish_after_tally().await?;
1353            } else {
1354                // Still below quorum: persist the marker and stay resumable.
1355                self.state.save()?;
1356            }
1357            return Ok(());
1358        }
1359        // A run parked inside `land` - watching CI, mid fix-round, or
1360        // waiting on the owner's merge approval - resumes directly into it,
1361        // never back through `prep`. Everything before `merge` already
1362        // concluded; that is the only way `status` reaches `Landing` in the
1363        // first place. Re-walking `review_loop` first would also be actively
1364        // wrong: its own status recomputation (see its doc) treats any
1365        // clean round as reason to set `status` to `Gating`, which would
1366        // clobber this marker before `merge` ever ran, and this run would
1367        // never find its way back into `land` at all.
1368        if self.state.status == RunStatus::Landing {
1369            self.run_land().await?;
1370            // `run_land` may have settled the run right here - CI came back
1371            // green and the PR merged, say - without ever passing back
1372            // through `merge`'s own trailing call. Whatever it left `status`
1373            // as is what this has to read.
1374            self.settle_questions();
1375            return Ok(());
1376        }
1377        // A run parked at `merge` on the owner's word about a withheld PR text
1378        // resumes straight into `merge`. Re-walking the tail would sync to the
1379        // base again (possibly rebasing, possibly spending the fixer) after the
1380        // gate had passed, and then push a tree nobody reviewed or gated.
1381        if self.state.github_text.is_some()
1382            && self.state.merge.is_none()
1383            && self.state.status == RunStatus::Gating
1384        {
1385            self.merge().await?;
1386            if self.resource_halt()? {
1387                return Ok(());
1388            }
1389            self.state.save()?;
1390            return Ok(());
1391        }
1392        if let Err(e) = self.prep().await {
1393            if e.is::<crate::repo_lock::ResourceBlocked>() {
1394                return Err(e);
1395            }
1396            self.state.status = RunStatus::Blocked;
1397            self.state
1398                .prep_failure
1399                .get_or_insert_with(|| format!("{e:#}"));
1400            self.state
1401                .event("prep", format!("preparation stopped: {e:#}"));
1402            self.state.save()?;
1403            return Err(e);
1404        }
1405        if self.park_here()? {
1406            return Ok(());
1407        }
1408        self.advise().await?;
1409        if self.park_here()? {
1410            return Ok(());
1411        }
1412        self.implement().await?;
1413        if self.park_here()? {
1414            return Ok(());
1415        }
1416        // `after_implement` already saved the state and settled any open
1417        // questions when it set this; nothing later in the graph has
1418        // anything to judge.
1419        if self.state.status == RunStatus::VerifiedNoop {
1420            return Ok(());
1421        }
1422        self.judge().await?;
1423        if self.park_here()? {
1424            return Ok(());
1425        }
1426        self.deliberate().await?;
1427        if self.park_here()? {
1428            return Ok(());
1429        }
1430        self.vote().await?;
1431        if self.park_here()? {
1432            return Ok(());
1433        }
1434        self.tally()?;
1435        // A verdict that lost its quorum is not trustworthy: do not review,
1436        // gate, or merge on it. Everything already done is kept, so the run
1437        // stays resumable (or foldable); the human can replace the agent that
1438        // ran out of quota and pick it up.
1439        if self.state.status == RunStatus::Stalled {
1440            // Persist the stalled marker now — the normal end-of-execute save
1441            // below is below this early return, and without it a resumed run
1442            // would reload a pre-tally status and keep going.
1443            self.state.save()?;
1444            return Ok(());
1445        }
1446        self.finish_after_tally().await?;
1447        Ok(())
1448    }
1449
1450    /// A wave could not start its agents because a shared resource stayed busy
1451    /// (see [`RunState::resource_block`]): leave the run `Blocked` and
1452    /// resumable. Unstarted seats have no result; completed peers retain
1453    /// their saved candidate state.
1454    fn stop_for_resource(&mut self) -> Result<()> {
1455        // A wait abandoned because the run is parking stays a park.
1456        if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1457        {
1458            self.state.status = RunStatus::Blocked;
1459        }
1460        self.state.save()
1461    }
1462
1463    /// Stop walking the graph if a node ended on a resource block.
1464    fn resource_halt(&mut self) -> Result<bool> {
1465        if self.state.resource_block.is_none() {
1466            return Ok(false);
1467        }
1468        self.stop_for_resource()?;
1469        Ok(true)
1470    }
1471
1472    /// Park here if asked to, recording it in the run's own timeline.
1473    ///
1474    /// Returns whether the caller should stop walking the graph. The state is
1475    /// saved either way by the node that just finished; this adds the event so
1476    /// the operator's card says why a run that is neither finished nor moving
1477    /// is sitting where it is.
1478    fn park_here(&mut self) -> Result<bool> {
1479        let parking = self.pause.parked() || self.interrupt.parked();
1480        if !parking && self.state.resource_block.is_some() {
1481            self.stop_for_resource()?;
1482            return Ok(true);
1483        }
1484        // Either handle asking is enough - see `Pause`'s own doc for why
1485        // they are never the same one. `interrupt` is checked second so a
1486        // reason it carries is preferred in the message below over a plain
1487        // shutdown park racing it at the same boundary.
1488        if !self.pause.parked() && !self.interrupt.parked() {
1489            return Ok(false);
1490        }
1491        let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1492        let why = match cause.as_ref().map(ParkCause::label) {
1493            Some(reason) => format!(
1494                "parked after `{}` ({reason}) — resume to carry on from here",
1495                self.state.status.as_str()
1496            ),
1497            None => format!(
1498                "parked after `{}` — resume to carry on from here",
1499                self.state.status.as_str()
1500            ),
1501        };
1502        self.state.event("park", why);
1503        self.state
1504            .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1505        self.state.parked = true;
1506        self.state.save()?;
1507        Ok(true)
1508    }
1509
1510    /// Only a new explicit operator approval may retry failed preparation.
1511    pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1512        if self.state.prep_failure.is_none() {
1513            return Ok(());
1514        }
1515        let failed_at = self
1516            .state
1517            .events
1518            .iter()
1519            .rev()
1520            .find(|event| event.node == "prep")
1521            .map_or(self.state.updated_at, |event| event.at);
1522        anyhow::ensure!(
1523            approval_at > failed_at,
1524            "preparation retry requires a new explicit release after the failure"
1525        );
1526        self.state.prep_failure = None;
1527        self.state.prep_complete = Some(false);
1528        self.state
1529            .event("prep-retry", "operator approved retry of preparation");
1530        Ok(())
1531    }
1532
1533    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1534    pub fn on_pause(&mut self, pause: Pause) {
1535        self.pause = pause;
1536    }
1537
1538    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1539    /// scheduler asking this one run - and no other - to park so a task
1540    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1541    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1542    /// handle.
1543    pub fn watch_interrupt(&mut self, pause: Pause) {
1544        self.interrupt = pause;
1545    }
1546
1547    /// Abandon this run's own open questions, once `status` has actually
1548    /// settled rather than merely paused.
1549    ///
1550    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1551    /// either back up with the candidates, the review round and the seat
1552    /// sessions already on disk, so a question an implementer asked mid-round
1553    /// may still get a real answer read by a real resume. Only the statuses
1554    /// `resumable` excludes are actually final: the run merged, it reached
1555    /// `Ready` with nothing left to do, it failed outright with no
1556    /// established point to continue from, or every candidate agreed, with
1557    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1558    /// every one of those the seat that asked is gone for good, exactly like
1559    /// the run being deleted under `magi run rm` - so the same cleanup
1560    /// applies, worded for what actually happened instead of "the run was
1561    /// deleted".
1562    ///
1563    /// Best-effort and silent on success: called from every place `status`
1564    /// can land on one of those three, including ones a resumed run revisits,
1565    /// so it must cost nothing when there was nothing open to begin with.
1566    fn settle_questions(&mut self) {
1567        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1568            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1569        }
1570    }
1571
1572    /// The tail of the graph after a trustworthy tally: fold losers, review,
1573    /// gate, merge, and persist.
1574    async fn finish_after_tally(&mut self) -> Result<()> {
1575        // Before the losers are folded: their branches are what the graft
1576        // reads from.
1577        self.graft().await?;
1578        if self.resource_halt()? {
1579            return Ok(());
1580        }
1581        self.fold_losers().await?;
1582        // Before review starts, and again right before the gate: a run's
1583        // review rounds can themselves take long enough for the base to move
1584        // a second time, and the gate is the one node whose "green" gets
1585        // acted on.
1586        self.sync_to_base().await?;
1587        if self.state.status == RunStatus::AlreadyInBase {
1588            return Ok(());
1589        }
1590        self.review_loop().await?;
1591        if self.resource_halt()? {
1592            return Ok(());
1593        }
1594        self.sync_to_base().await?;
1595        if self.state.status == RunStatus::AlreadyInBase {
1596            return Ok(());
1597        }
1598        self.gate().await?;
1599        self.merge().await?;
1600        if self.resource_halt()? {
1601            return Ok(());
1602        }
1603        self.state.save()?;
1604        Ok(())
1605    }
1606
1607    // ---------------------------------------------------------------- prep
1608
1609    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1610    /// run with the step and its output in the event; it is never swallowed.
1611    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1612    /// resume starts over instead of finding candidates and missing seats.
1613    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1614        let result = match ensure_setup_config(&mut self.state, repo).await {
1615            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1616            Err(e) => Err(e),
1617        };
1618        if let Err(e) = result {
1619            git::worktree_remove(repo, wt).await.ok();
1620            if in_prep {
1621                self.rollback_prep(repo).await;
1622            }
1623            self.state.status = RunStatus::Blocked;
1624            self.state.event(
1625                "setup",
1626                format!("worktree setup failed in {}: {e:#}", wt.display()),
1627            );
1628            self.state.save()?;
1629            return Err(e);
1630        }
1631        Ok(())
1632    }
1633
1634    /// Undo everything `prep` created, so a resume re-runs it whole.
1635    async fn rollback_prep(&mut self, repo: &Path) {
1636        let root = self.state.worktree_root();
1637        for c in std::mem::take(&mut self.state.candidates) {
1638            git::worktree_remove(repo, &c.worktree).await.ok();
1639            git::branch_delete(repo, &c.branch).await.ok();
1640        }
1641        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1642        for j in 1..=seats {
1643            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1644                .await
1645                .ok();
1646        }
1647        for k in 1..=self.state.config.graph.advisors {
1648            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1649                .await
1650                .ok();
1651        }
1652    }
1653
1654    async fn prep(&mut self) -> Result<()> {
1655        if let Some(reason) = &self.state.prep_failure {
1656            bail!(
1657                "candidate preparation previously failed: {reason}; correct the source before retrying"
1658            );
1659        }
1660        if self
1661            .state
1662            .prep_complete
1663            .unwrap_or(!self.state.candidates.is_empty())
1664        {
1665            return Ok(());
1666        }
1667        let repo = self.state.repo.clone();
1668        // Includes rollback, config, refs, checkout creation and setup. No
1669        // repository mutation may precede the real shared lock acquisition.
1670        let _coordination = self.coordinate(&repo).await?;
1671        self.rollback_prep(&repo).await;
1672        self.state.prep_complete = Some(false);
1673        self.state.status = RunStatus::Prep;
1674        self.state.save()?;
1675        let repo = self.state.repo.clone();
1676        let base = self.state.base_commit.clone();
1677        let plan = refs::plan(&repo, &self.state.seeds).await?;
1678        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1679        let root = self.state.worktree_root();
1680        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1681
1682        // The hook is the write-time half of the blindness contract; the
1683        // presentation filter in `blind` is the half that cannot be bypassed.
1684        let hooks_dir = self.state.dir().join("hooks");
1685        if self.state.config.blind.commit_msg_hook {
1686            std::fs::create_dir_all(&hooks_dir)
1687                .with_context(|| format!("create {}", hooks_dir.display()))?;
1688            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1689            let path = hooks_dir.join("commit-msg");
1690            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1691            make_executable(&path)?;
1692            // Ref-counted rather than a plain idempotent set: with more than
1693            // one run able to be in flight in the same repository at once
1694            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1695            // true?" check cannot tell "another run of mine still needs
1696            // this" from "nobody does", and the run that happens to finish
1697            // first would disable the hook out from under a sibling still
1698            // relying on it.
1699            git::acquire_worktree_config(&repo).await?;
1700            self.state.enabled_worktree_config = true;
1701        }
1702
1703        // `[worktree] setup` hides its products per worktree, which needs the
1704        // same per-worktree config the hook does. Held until fold, like it.
1705        ensure_setup_config(&mut self.state, &repo).await?;
1706
1707        for (index, (spec, label)) in self
1708            .roles
1709            .implementers
1710            .clone()
1711            .into_iter()
1712            .zip(labels)
1713            .enumerate()
1714        {
1715            let branch = self.state.branch_for(label);
1716            let worktree = root.join(format!("cand-{label}"));
1717            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1718            if self.state.config.blind.commit_msg_hook {
1719                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1720            }
1721            git::local_exclude(&worktree, "/.magi/").await?;
1722            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1723                // Nothing of this prep may survive: a resume must start over,
1724                // not find half the candidates and skip the rest.
1725                git::worktree_remove(&repo, &worktree).await.ok();
1726                git::branch_delete(&repo, &branch).await.ok();
1727                self.rollback_prep(&repo).await;
1728                self.state.status = RunStatus::Blocked;
1729                self.state
1730                    .event("prep", format!("worktree setup failed: {e:#}"));
1731                self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1732                self.state.save()?;
1733                return Err(e);
1734            }
1735            self.state.candidates.push(Candidate {
1736                index,
1737                label,
1738                agent: spec.id.clone(),
1739                branch,
1740                worktree,
1741                summary: String::new(),
1742                stat: String::new(),
1743                files: 0,
1744                commits: 0,
1745                empty: false,
1746                failed: None,
1747                verified_noop: None,
1748                duration_ms: 0,
1749                folded: false,
1750            });
1751            self.state.save()?;
1752            for pick in &plan.picks {
1753                if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1754                {
1755                    self.state.status = RunStatus::Blocked;
1756                    self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1757                    self.state
1758                        .event("prep", format!("cannot apply referenced commit: {e}"));
1759                    self.state.save()?;
1760                    return Err(e);
1761                }
1762            }
1763        }
1764
1765        for j in 1..=self.roles.judges.len() {
1766            let wt = root.join(format!("judge-{j}"));
1767            if !wt.exists() {
1768                git::worktree_add_detached(&repo, &wt, &base).await?;
1769                self.setup_seat_worktree(&repo, &wt, true).await?;
1770            }
1771        }
1772
1773        // Disposable, detached checkouts for the design-deliberation stage's
1774        // advisor seats — the same shape as the judges' above, at the same
1775        // base commit, since advisors also only ever read. Sized off the
1776        // configured count directly rather than a resolved roster: unlike
1777        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1778        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1779        // `prep` has no `ResolvedRoles` field to read a count from here.
1780        if self.state.config.graph.advise {
1781            for k in 1..=self.state.config.graph.advisors {
1782                let wt = root.join(format!("advisor-{k}"));
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
1790        // A judge cannot tell it is looking at its own patch — the seats keep
1791        // separate conversations — but a panel that shares agents with the
1792        // field is less independent than it looks, and that is worth saying out
1793        // loud once per run rather than leaving it in the config.
1794        let authors: Vec<&str> = self
1795            .roles
1796            .implementers
1797            .iter()
1798            .map(|a| a.id.as_str())
1799            .collect();
1800        let overlap: Vec<String> = self
1801            .roles
1802            .judges
1803            .iter()
1804            .enumerate()
1805            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1806            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1807            .collect();
1808        if !overlap.is_empty() {
1809            let note = format!(
1810                "{} also authored a candidate; blind, but the panel is less \
1811                 independent than {} distinct agents would be",
1812                overlap.join(", "),
1813                self.roles.judges.len()
1814            );
1815            self.state.event("prep", note);
1816        }
1817
1818        self.state.event(
1819            "prep",
1820            format!(
1821                "{} candidates, {} judges, base {} ({})",
1822                self.state.candidates.len(),
1823                self.roles.judges.len(),
1824                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1825                self.state.base_branch
1826            ),
1827        );
1828        self.state.prep_complete = Some(true);
1829        self.state.status = RunStatus::Implementing;
1830        self.state.save()?;
1831        Ok(())
1832    }
1833
1834    // -------------------------------------------------------------- advise
1835
1836    /// The design-deliberation stage: independent, read-only advisor seats
1837    /// each sketch a design before any implementer touches the repository,
1838    /// and (when at least one produced a usable proposal) a synthesis seat
1839    /// blends them into a brief `implement` carries in every candidate's
1840    /// prompt.
1841    ///
1842    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1843    /// advisors` is the proposal count. Everything here is best-effort and
1844    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1845    /// that cannot reach quota, or a synthesis seat that produced nothing
1846    /// usable all leave `implement` exactly as it was before this stage
1847    /// existed — the task instruction alone — rather than failing the whole
1848    /// competition over an enrichment stage. Every outcome is still recorded
1849    /// as an event, so a run that got nothing from this stage says why.
1850    ///
1851    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1852    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1853    /// resumed run whose stage failed would re-run it, and re-spend the
1854    /// agent calls, on every reentry before `implement`.
1855    ///
1856    /// Also skipped once any candidate shows implementation progress — the
1857    /// exact predicate `implement` itself uses to decide a candidate is no
1858    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1859    /// is not enough: a run created by an older binary that predates this
1860    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1861    /// an already-`Implementing`-or-later run under this build would
1862    /// otherwise walk straight back through `prep` (a no-op once candidates
1863    /// exist) into this node and spawn every advisor seat against worktrees
1864    /// `prep` never recreated — after implementation has already started,
1865    /// which is exactly the invariant this stage exists to guarantee.
1866    async fn advise(&mut self) -> Result<()> {
1867        let implement_untouched = self
1868            .state
1869            .candidates
1870            .iter()
1871            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1872        if !self.state.config.graph.advise || self.state.advise_attempted {
1873            return Ok(());
1874        }
1875        if !implement_untouched {
1876            self.state.event(
1877                "advise",
1878                "skipping the design-deliberation stage: at least one \
1879                 candidate already shows implementation progress, so this \
1880                 run is past the point the stage exists to run before"
1881                    .to_owned(),
1882            );
1883            self.state.advise_attempted = true;
1884            self.state.save()?;
1885            return Ok(());
1886        }
1887        let run_id = self.state.id.clone();
1888        let prompts = self.state.config.prompts.clone();
1889        let instruction = self.state.instruction.clone();
1890        let language = self.state.config.graph.language.clone();
1891        let root = self.state.worktree_root();
1892        let n = self.state.config.graph.advisors;
1893        let where_recorded = self.state.dir().join("run.json");
1894
1895        let seats = match self.state.config.advisors() {
1896            Ok(seats) if !seats.is_empty() => seats,
1897            Ok(_) => {
1898                self.state.event(
1899                    "advise",
1900                    format!(
1901                        "[graph] advisors is 0; skipping the design-deliberation \
1902                         stage and continuing without a synthesis brief (see {})",
1903                        where_recorded.display()
1904                    ),
1905                );
1906                self.state.advise_attempted = true;
1907                self.state.save()?;
1908                return Ok(());
1909            }
1910            Err(e) => {
1911                self.state.event(
1912                    "advise",
1913                    format!(
1914                        "could not resolve advisor seats ({e:#}); continuing \
1915                         without a design-deliberation brief (see {})",
1916                        where_recorded.display()
1917                    ),
1918                );
1919                self.state.advise_attempted = true;
1920                self.state.save()?;
1921                return Ok(());
1922            }
1923        };
1924
1925        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1926        let artifacts = agent::artifacts_dir(&self.state.dir());
1927        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1928
1929        let mut jobs = Vec::new();
1930        for (i, spec) in seats.iter().cloned().enumerate() {
1931            let seat_key = format!("advisor-{}", i + 1);
1932            let seat = self.seat(&seat_key, &spec.id);
1933            jobs.push(SeatJob {
1934                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1935                spec,
1936                seat,
1937                cwd: worktrees[i % worktrees.len()].clone(),
1938                timeout,
1939                allow_write: false,
1940                sessions: false,
1941                artifacts: artifacts.clone(),
1942                stem: seat_key,
1943                handover: None,
1944            });
1945        }
1946
1947        self.state.event(
1948            "advise",
1949            format!(
1950                "{} advisor seat(s) sketching a design in parallel",
1951                jobs.len()
1952            ),
1953        );
1954        let mut quota_losses = Vec::new();
1955        let cache = self.state.config.cache_dir();
1956        let ctx = WaveCtx {
1957            carry_seats: false,
1958            pauses: Some((&self.pause, &self.interrupt)),
1959            run: &run_id,
1960            node: "advise",
1961            prompts: &prompts,
1962            cache: cache.as_deref(),
1963            round: None,
1964        };
1965        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1966        let results = ask_json_wave::<Proposal>(
1967            jobs,
1968            Arc::clone(&self.sem),
1969            self.state.config.graph.retries,
1970            &advisor_roster,
1971            &ctx,
1972            &mut quota_losses,
1973            &mut self.state,
1974            &|p: &Proposal| p.validate(),
1975        )
1976        .await;
1977        if self.state.resource_block.is_some() {
1978            return self.stop_for_resource();
1979        }
1980        self.state.quota.extend(quota_losses);
1981
1982        let mut records = Vec::with_capacity(results.len());
1983        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1984            let agent_id = seat.agent.clone();
1985            self.state.seats.insert(seat.key.clone(), seat);
1986            match res {
1987                Ok((proposal, out)) => {
1988                    self.state
1989                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1990                    records.push(advise::AdvisorRecord::proposed(
1991                        i + 1,
1992                        agent_id,
1993                        proposal,
1994                        out.duration_ms,
1995                    ));
1996                }
1997                Err(e) => {
1998                    self.state.event(
1999                        "advise",
2000                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
2001                    );
2002                    records.push(advise::AdvisorRecord::failed(
2003                        i + 1,
2004                        agent_id,
2005                        e.to_string(),
2006                    ));
2007                }
2008            }
2009        }
2010
2011        let mut advice = advise::Advice {
2012            records,
2013            synthesis: None,
2014        };
2015        if advice.proposals().is_empty() {
2016            self.state.event(
2017                "advise",
2018                "no advisor produced a usable proposal; continuing without a \
2019                 synthesis brief"
2020                    .to_owned(),
2021            );
2022        } else {
2023            match self
2024                .synthesize_brief(
2025                    &advice,
2026                    &instruction,
2027                    &language,
2028                    &worktrees[0],
2029                    &artifacts,
2030                    &run_id,
2031                    &prompts,
2032                    cache.as_deref(),
2033                )
2034                .await
2035            {
2036                Ok(Some(text)) => {
2037                    self.state.event(
2038                        "advise",
2039                        "synthesized a design brief for the implementer".to_owned(),
2040                    );
2041                    advice.synthesis = Some(text);
2042                }
2043                Ok(None) => {
2044                    self.state.event(
2045                        "advise",
2046                        "the synthesis seat produced nothing usable; continuing \
2047                         without a design brief"
2048                            .to_owned(),
2049                    );
2050                }
2051                Err(e) => {
2052                    if e.is::<crate::repo_lock::ResourceBlocked>() {
2053                        return Err(e);
2054                    }
2055                    self.state.event(
2056                        "advise",
2057                        format!("could not synthesize a design brief: {e:#}"),
2058                    );
2059                }
2060            }
2061        }
2062        advise::apply_reflection(&mut advice);
2063
2064        self.state.advice = Some(advice);
2065        self.state.advise_attempted = true;
2066        self.state.save()?;
2067        Ok(())
2068    }
2069
2070    /// The synthesis seat: reads every advisor's proposal and blends them
2071    /// into the design brief `advise` stores on [`RunState::advice`]. Split
2072    /// out of [`Runner::advise`] only for readability — it is not called
2073    /// anywhere else.
2074    ///
2075    /// Picked the same way [`crate::talk`]'s standing conversation and
2076    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
2077    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
2078    /// order (a claude seat, else the first runnable agent in roster order)
2079    /// used when that field is unset — see `[roles] synthesizer`'s own doc
2080    /// in [`crate::config`] for why a dedicated field exists here at all.
2081    #[allow(clippy::too_many_arguments)]
2082    async fn synthesize_brief(
2083        &mut self,
2084        advice: &advise::Advice,
2085        instruction: &str,
2086        language: &str,
2087        cwd: &Path,
2088        artifacts: &Path,
2089        run_id: &str,
2090        prompts: &Prompts,
2091        cache: Option<&Path>,
2092    ) -> Result<Option<String>> {
2093        let chain = agent::pick_chain(
2094            &self.state.config.agents,
2095            self.state.config.roles.synthesizer.as_ref(),
2096            &agent::installed,
2097            "synthesizer",
2098        )?;
2099        let proposals = advice.proposals();
2100        let mut prompt = prompt::with_overlay(
2101            prompt::synthesize_brief(instruction, &proposals, language),
2102            prompts.overlay("advise"),
2103        );
2104        if cache.is_some() {
2105            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
2106            // below — see `prompt::build_cache_note`'s doc for why telling a
2107            // read-only seat to build through the shared cache is exactly how
2108            // a sandbox's write refusal gets misread as a defect.
2109            prompt.push('\n');
2110            prompt.push_str(&prompt::build_cache_note("advise", false));
2111        }
2112        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2113        // Each id is tried once, in order; a quota hit, error or unusable
2114        // answer moves to the next. The seat is single-turn (`sessions:
2115        // false`) and the prompt is the whole context, so a fallback agent
2116        // needs nothing carried over.
2117        let mut last = None;
2118        for (n, spec) in chain.iter().enumerate() {
2119            if n > 0 {
2120                self.state
2121                    .event("advise", format!("synthesis falling back to {}", spec.id));
2122            }
2123            let mut seat = self.seat("advise-synthesis", &spec.id);
2124            let wait = self.repository_wait();
2125            let outcome = wait
2126                .scope(agent::invoke(
2127                    spec,
2128                    &mut seat,
2129                    &Invocation {
2130                        coordination: self.state.config.coordination.as_ref(),
2131                        cwd,
2132                        prompt: &prompt,
2133                        timeout,
2134                        allow_write: false,
2135                        unsandboxed: false,
2136                        sessions: false,
2137                        artifacts,
2138                        stem: &if n == 0 {
2139                            "advise-synthesis".to_owned()
2140                        } else {
2141                            format!("advise-synthesis-{}", spec.id)
2142                        },
2143                        run: run_id,
2144                        node: "advise",
2145                        cache_dir: None,
2146                        attachments: &[],
2147                        writable: &[],
2148                    },
2149                ))
2150                .await;
2151            if outcome.is_ok() {
2152                self.state.seats.insert(seat.key.clone(), seat);
2153            }
2154            let advance = agent::chain_advances(&outcome);
2155            last = Some(outcome);
2156            if !advance {
2157                break;
2158            }
2159        }
2160        // Exhausted: the last attempt's result is what a single failed seat
2161        // would have produced.
2162        let out = last.expect("a chain holds at least one agent")?;
2163        if !out.usable() {
2164            return Ok(None);
2165        }
2166        let text =
2167            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2168        Ok((!text.trim().is_empty()).then_some(text))
2169    }
2170
2171    // ----------------------------------------------------------- implement
2172
2173    async fn implement(&mut self) -> Result<()> {
2174        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2175        // agent files with `magi task add` name the run that paid for it. The
2176        // prompt overlay is cloned alongside it because the waves borrow it
2177        // while `self` is mutably borrowed by the node's own bookkeeping.
2178        let run_id = self.state.id.clone();
2179        let prompts = self.state.config.prompts.clone();
2180        let todo: Vec<usize> = self
2181            .state
2182            .candidates
2183            .iter()
2184            .enumerate()
2185            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2186            .map(|(i, _)| i)
2187            .collect();
2188        if todo.is_empty() {
2189            return self.after_implement();
2190        }
2191        self.state.status = RunStatus::Implementing;
2192
2193        let language = self.state.config.graph.language.clone();
2194        let github_language = self.state.config.graph.github_language.clone();
2195        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2196        let sessions = self.state.config.graph.sessions;
2197        let artifacts = agent::artifacts_dir(&self.state.dir());
2198        // The design-deliberation stage's blended brief, when `advise` found
2199        // one — carried into every implementer's prompt the same way
2200        // regardless of which candidate it is.
2201        let brief = self
2202            .state
2203            .advice
2204            .as_ref()
2205            .and_then(|a| a.synthesis.as_deref())
2206            .map(str::to_owned);
2207        let attachments = self.state.attachments.clone();
2208
2209        let mut jobs = Vec::new();
2210        for &i in &todo {
2211            let (index, label, worktree) = {
2212                let c = &self.state.candidates[i];
2213                (c.index, c.label, c.worktree.clone())
2214            };
2215            let spec = self.roles.implementers[index].clone();
2216            let seat_key = format!("impl-{label}");
2217            let seat = self.seat(&seat_key, &spec.id);
2218            let instruction = seeded_instruction(&self.state);
2219            jobs.push(SeatJob {
2220                spec,
2221                seat,
2222                prompt: prompt::implement_in(
2223                    &instruction,
2224                    &worktree.to_string_lossy(),
2225                    &language,
2226                    &github_language,
2227                    brief.as_deref(),
2228                    &attachments,
2229                ),
2230                cwd: worktree,
2231                timeout,
2232                allow_write: true,
2233                sessions,
2234                artifacts: artifacts.clone(),
2235                stem: format!("impl-{label}"),
2236                handover: None,
2237            });
2238        }
2239
2240        self.state.event(
2241            "implement",
2242            format!("{} candidates in parallel", jobs.len()),
2243        );
2244        // Kept so a seat whose CLI hung up can be asked again from the same
2245        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
2246        // can update a seat's own entry once a fallback agent takes it over —
2247        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
2248        // whichever agent actually answered, not the one that quota'd out.
2249        let mut sent = jobs.clone();
2250        let cache = self.state.config.cache_dir();
2251        let ctx = WaveCtx {
2252            carry_seats: false,
2253            pauses: Some((&self.pause, &self.interrupt)),
2254            run: &run_id,
2255            node: "implement",
2256            prompts: &prompts,
2257            cache: cache.as_deref(),
2258            round: None,
2259        };
2260        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2261        if !wave_blocked(&results) {
2262            self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2263                .await;
2264            self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2265                .await?;
2266            self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2267                .await;
2268        }
2269        let resource_blocked = self.state.resource_block.is_some() || wave_blocked(&results);
2270
2271        let selected_failed = self.state.config.graph.selected_implementer.is_some()
2272            && results
2273                .iter()
2274                .any(|(_, _, out)| FailClass::of(out).is_some());
2275        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2276            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
2277                continue;
2278            }
2279            let seat_key = seat.key.clone();
2280            // A quota fallback (`resume_seat_handovers`) may have handed this
2281            // seat to a different agent than the one `prep` recorded on the
2282            // candidate; the stats tables and any later fixer-defaults-to-
2283            // winner's-author lookup must credit whoever actually answered —
2284            // unless every fallback also quota'd out, in which case nobody
2285            // actually answered and crediting the last agent tried would
2286            // erase every earlier agent's own quota loss from the stats
2287            // tables instead of just this one seat's.
2288            let agent = seat.agent.clone();
2289            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2290            self.state.seats.insert(seat.key.clone(), seat);
2291            let label = self.state.candidates[i].label;
2292            let worktree = self.state.candidates[i].worktree.clone();
2293            let base = self.state.base_commit.clone();
2294
2295            let (summary, duration, failed, verified_claim) = match out {
2296                AgentOutcome::Ok(o) => {
2297                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2298                    let failed = (!o.usable()).then(|| {
2299                        if o.timed_out {
2300                            "agent timed out".to_owned()
2301                        } else {
2302                            format!("agent exited with {:?}", o.exit_code)
2303                        }
2304                    });
2305                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2306                    (text, o.duration_ms, failed, verified_claim)
2307                }
2308                // Left un-resumed by `resume_undelivered` (a dirty tree
2309                // already rescues the work, or there was no session left to
2310                // resume into) — reported like the ordinary failure it is,
2311                // never as if `o.text` (the CLI's raw error JSON) were an
2312                // answer.
2313                AgentOutcome::Dropped(o) => {
2314                    let why = o
2315                        .dropped
2316                        .as_ref()
2317                        .map(|d| d.why.as_str())
2318                        .unwrap_or("the CLI ended the stream without delivering its answer");
2319                    (
2320                        String::new(),
2321                        o.duration_ms,
2322                        Some(format!("the CLI dropped the stream ({why})")),
2323                        None,
2324                    )
2325                }
2326                AgentOutcome::Quota(o) => {
2327                    self.state.quota.push(QuotaLoss {
2328                        seat: seat_key,
2329                        node: "implement".to_owned(),
2330                        at: Timestamp::now(),
2331                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2332                    });
2333                    (
2334                        String::new(),
2335                        o.duration_ms,
2336                        Some("rate limited (quota); produced no change".to_owned()),
2337                        None,
2338                    )
2339                }
2340                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2341                    (String::new(), 0, Some(e), None)
2342                }
2343            };
2344
2345            // Rescue anything the agent edited but never committed: an
2346            // uncommitted candidate would silently be an empty one.
2347            // Parking stops pending resource waits, but completed seats must
2348            // persist their output before the node boundary observes it.
2349            let wait = crate::repo_lock::WaitPolicy::new(
2350                Duration::from_secs(self.state.config.graph.timeout_resource_wait),
2351                || false,
2352            );
2353            let _coordination = coordinate_operation(&mut self.state, &worktree, &wait).await?;
2354            let rescued = match git::rescue_commit(
2355                &worktree,
2356                &format!("magi: candidate {label} (uncommitted work)"),
2357            )
2358            .await
2359            {
2360                Ok(r) => {
2361                    self.state.note_withheld("implement", &r.withheld);
2362                    r.committed
2363                }
2364                Err(_) => false,
2365            };
2366            let commits = git::commits_ahead(&worktree, &base, "HEAD")
2367                .await
2368                .unwrap_or(0);
2369            let patch = git::diff(&worktree, &base, "HEAD")
2370                .await
2371                .unwrap_or_default();
2372            let stat = git::diff_stat(&worktree, &base, "HEAD")
2373                .await
2374                .unwrap_or_default();
2375            let files = git::changed_files(&worktree, &base, "HEAD")
2376                .await
2377                .map(|f| f.len())
2378                .unwrap_or(0);
2379            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2380
2381            let c = &mut self.state.candidates[i];
2382            if !exhausted_the_fallback_chain {
2383                c.agent = agent;
2384            }
2385            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2386            c.stat = stat;
2387            c.files = files;
2388            c.commits = commits;
2389            c.duration_ms = duration;
2390            c.empty = commits == 0 || patch.trim().is_empty();
2391            // An agent that failed but still produced a committed change stays
2392            // in the running: the patch is what gets judged, not the exit code.
2393            c.failed = match failed {
2394                Some(_) if c.empty => failed,
2395                _ => None,
2396            };
2397            // Only an empty candidate can be a verified no-op: a claim next
2398            // to a real patch is not what the marker is for, and `c.failed`
2399            // being `Some` here already implies `verified_claim` was never
2400            // set (see the guard above the match that produced it).
2401            c.verified_noop = if c.empty { verified_claim } else { None };
2402            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2403                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2404                (None, true, Some(_), _) => {
2405                    format!("candidate {label}: no change produced (agent-verified no-op)")
2406                }
2407                (None, true, None, _) => format!("candidate {label}: no change produced"),
2408                (None, false, _, true) => {
2409                    format!(
2410                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2411                    )
2412                }
2413                (None, false, _, false) => {
2414                    format!("candidate {label}: {files} files, {commits} commits")
2415                }
2416            };
2417            self.state.event("implement", note);
2418            self.state.save()?;
2419        }
2420
2421        if resource_blocked {
2422            return self.stop_for_resource();
2423        }
2424        if selected_failed {
2425            self.state.status = RunStatus::Blocked;
2426            self.state.event("implement", "selected implementer failed or hit quota; holding without fallback, review or publication");
2427            self.state.save()?;
2428            bail!(
2429                "selected implementer unavailable or quota-limited; restore availability and explicitly release the task"
2430            );
2431        }
2432        self.after_implement()
2433    }
2434
2435    /// Ask again, once, for work a CLI did and then failed to hand over.
2436    ///
2437    /// [`agent::dropped_stream`] recognises the one shape observed: an error
2438    /// status with an empty response and a usage report showing output tokens,
2439    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
2440    /// seven minutes and 14,267 output tokens that arrived as an empty
2441    /// candidate, because `agy`'s own subscriber fell behind and hung up.
2442    ///
2443    /// Two conditions, and both matter:
2444    ///
2445    /// - **Only when the tree is untouched.** Often the agent has already
2446    ///   written its files and only the closing message was lost; the rescue
2447    ///   commit below picks that up and there is nothing to ask for. Re-asking
2448    ///   then would pay for a second implementation of work already on disk.
2449    /// - **Once.** A CLI that drops one stream can drop the next, and this
2450    ///   node is the most expensive in the graph.
2451    ///
2452    /// The re-ask is a resume, not a re-run: `has_context` is true because the
2453    /// dropped reply still carried its `conversation_id`, so the seat is asked
2454    /// to finish what it was doing rather than sent the whole task again. It
2455    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2456    /// node's - for the same reason a re-ranked judge does: restating finished
2457    /// work is not the work.
2458    ///
2459    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2460    /// same way until it resets, while an abandoned conversation is still
2461    /// there to be picked up.
2462    async fn resume_undelivered(
2463        &mut self,
2464        results: &mut [(usize, SeatState, AgentOutcome)],
2465        sent: &[SeatJob],
2466        prompts: &Prompts,
2467        run_id: &str,
2468    ) {
2469        for (wi, seat, out) in results.iter_mut() {
2470            let Some(dropped) = (match &*out {
2471                AgentOutcome::Dropped(o) => o.dropped.clone(),
2472                _ => None,
2473            }) else {
2474                continue;
2475            };
2476            let Some(job) = sent.get(*wi) else { continue };
2477            // Already on disk? Then only the closing message was lost.
2478            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2479                self.state.event(
2480                    "implement",
2481                    format!(
2482                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2483                         work is in the tree",
2484                        seat.key, dropped.output_tokens, dropped.why
2485                    ),
2486                );
2487                continue;
2488            }
2489            // The re-ask only makes sense as a resume: `resume_after_drop`
2490            // says nothing about the task, trusting the seat to still hold it.
2491            // Without a session to resume — sessions disabled, or this CLI's
2492            // drop shape happened not to carry a session id — that prompt
2493            // would open a brand-new conversation with no context at all,
2494            // which is worse than leaving this as the ordinary failure it
2495            // already is.
2496            if !has_context(&job.spec, seat, job.sessions) {
2497                self.state.event(
2498                    "implement",
2499                    format!(
2500                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2501                         is no session left to resume",
2502                        seat.key, dropped.output_tokens, dropped.why
2503                    ),
2504                );
2505                continue;
2506            }
2507            self.state.event(
2508                "implement",
2509                format!(
2510                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2511                     conversation",
2512                    seat.key, dropped.output_tokens, dropped.why
2513                ),
2514            );
2515            let mut retry = job.clone();
2516            retry.seat = seat.clone();
2517            retry.prompt = prompt::resume_after_drop(&dropped.why);
2518            retry.timeout = retry_budget(job.timeout, true);
2519            retry.stem = format!("{}-resume", job.stem);
2520            let cache = self.state.config.cache_dir();
2521            let ctx = WaveCtx {
2522                carry_seats: false,
2523                pauses: Some((&self.pause, &self.interrupt)),
2524                run: run_id,
2525                node: "implement",
2526                prompts,
2527                cache: cache.as_deref(),
2528                round: None,
2529            };
2530            let (resumed_seat, resumed) =
2531                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2532            *seat = resumed_seat;
2533            *out = resumed;
2534        }
2535    }
2536
2537    /// Fall an implement seat through to the next untried agent in the
2538    /// implementer roster when it lost to quota — or, since the handover was
2539    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2540    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2541    /// quota path itself being unchanged — instead of leaving the
2542    /// seat's loss final the moment one agent's account runs dry.
2543    ///
2544    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2545    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2546    /// truncates to the single slot rotation picked, so a solo task whose one
2547    /// implementer hits quota mid-run used to have nothing else to try. This
2548    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2549    /// unrotated roster — which is the only place the *other* candidates in
2550    /// the machine's roster still exist once `implementers` has been cut down
2551    /// to size.
2552    ///
2553    /// Walks forward from just past the seat's own original position in the
2554    /// roster, never wrapping back to the front: a later candidate slot (say
2555    /// `beta`, the roster's second entry) must fall through to the *next*
2556    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2557    /// almost certainly a different candidate's own agent already — and once
2558    /// the roster's tail is exhausted there is nothing left to fall through
2559    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2560    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2561    /// let this retry that id forever. The loop keeps falling through until
2562    /// an attempt lands something other than `Quota` or the roster's tail
2563    /// runs out of untried ids, at which point the seat is left exactly as
2564    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2565    /// `QuotaLoss` recorded, the candidate failed/empty.
2566    ///
2567    /// `sent` is taken mutably and updated with the fallback agent's spec:
2568    /// `resume_unconfirmed_commands`, which runs after this and also reads
2569    /// `sent`, must see whichever agent actually ended up answering the seat
2570    /// — reading the stale, original spec there would check session
2571    /// eligibility against the wrong CLI and could hand a fallback agent's
2572    /// session id to the agent that just lost the seat to quota.
2573    ///
2574    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2575    /// — `self.seat` only reuses state when the agent id is unchanged, so
2576    /// handing it a different id already gets this for free. Reusing the old
2577    /// seat would resume a different CLI's session as if it were a
2578    /// continuation of this one.
2579    ///
2580    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2581    /// a quota loss cuts an agent off mid-turn, so anything already in the
2582    /// tree is unfinished work, not a completed candidate a re-ask would pay
2583    /// for twice. A dirty tree is rescued into a commit first (the same
2584    /// neutral-identity rescue `implement`'s own outcome loop gives every
2585    /// candidate) so the next agent starts clean.
2586    ///
2587    /// The new agent gets the implementer's full prompt and full
2588    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2589    /// it has no session and no context, and is implementing the task from
2590    /// nothing, unlike a resumed drop which is only restating work already
2591    /// done.
2592    ///
2593    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2594    /// `implement` event, never into `self.state.quota` — that is what
2595    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2596    /// go unspent, and a seat that ultimately recovered on its second or
2597    /// third agent is not the stalled panel that check exists to catch. Only
2598    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2599    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2600    /// outcome loop already has — this helper never pushes to it itself.
2601    async fn resume_seat_handovers(
2602        &mut self,
2603        results: &mut [(usize, SeatState, AgentOutcome)],
2604        sent: &mut [SeatJob],
2605        prompts: &Prompts,
2606        run_id: &str,
2607    ) -> Result<()> {
2608        let instruction = seeded_instruction(&self.state);
2609        let language = self.state.config.graph.language.clone();
2610        let github_language = self.state.config.graph.github_language.clone();
2611        let brief = self
2612            .state
2613            .advice
2614            .as_ref()
2615            .and_then(|a| a.synthesis.as_deref())
2616            .map(str::to_owned);
2617        let attachments = self.state.attachments.clone();
2618        for (wi, seat, out) in results.iter_mut() {
2619            // Who holds the other candidate seats of this wave right now
2620            // (earlier handovers already written back to `sent`).
2621            let others: BTreeSet<String> = sent
2622                .iter()
2623                .enumerate()
2624                .filter(|(j, _)| j != wi)
2625                .map(|(_, j)| j.spec.id.clone())
2626                .collect();
2627            let Some(job) = sent.get_mut(*wi) else {
2628                continue;
2629            };
2630            // Where the seat's own original agent sits in the roster — the
2631            // fallback walk starts just past here, never at the front, so a
2632            // later candidate slot's quota loss does not fall back onto an
2633            // earlier slot's own agent.
2634            let start = self
2635                .roles
2636                .implementer_roster
2637                .iter()
2638                .position(|s| s.id == job.spec.id)
2639                .unwrap_or(0);
2640            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2641            let mut fallback_attempt = 0usize;
2642            let mut prev: Option<FailClass> = None;
2643            while let Some(cur) = FailClass::of(&*out) {
2644                if !should_hand_over(prev.as_ref(), &cur) {
2645                    break;
2646                }
2647                let Some(next) =
2648                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2649                        .cloned()
2650                else {
2651                    break;
2652                };
2653                tried.insert(next.id.clone());
2654                fallback_attempt += 1;
2655
2656                let coordination = self.coordinate(&job.cwd).await?;
2657                if let Ok(r) = git::rescue_commit(
2658                    &job.cwd,
2659                    &format!(
2660                        "magi: candidate {} (uncommitted work before {} fallback)",
2661                        seat.key,
2662                        if cur == FailClass::Quota {
2663                            "quota"
2664                        } else {
2665                            "handover"
2666                        }
2667                    ),
2668                )
2669                .await
2670                {
2671                    self.state.note_withheld("implement", &r.withheld);
2672                }
2673                drop(coordination);
2674
2675                record_handover(
2676                    &mut self.state,
2677                    "implement",
2678                    &seat.key,
2679                    &seat.agent,
2680                    &next.id,
2681                    &cur,
2682                    &fail_reason(&*out),
2683                );
2684                prev = Some(cur.clone());
2685
2686                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2687                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2688                // Kept in sync on `sent` itself, not just the local retry: a
2689                // later helper (`resume_unconfirmed_commands`) reads `sent`
2690                // after this one returns and must see whichever agent is now
2691                // occupying the seat, not the one that just quota'd out —
2692                // otherwise it would judge session/continuation eligibility
2693                // by the wrong CLI and could resend a fallback's session id
2694                // to the agent that lost it the seat in the first place.
2695                job.spec = next.clone();
2696                let mut retry = job.clone();
2697                retry.seat = new_seat;
2698                retry.prompt = prompt::implement_in(
2699                    &instruction,
2700                    &job.cwd.to_string_lossy(),
2701                    &language,
2702                    &github_language,
2703                    brief.as_deref(),
2704                    &attachments,
2705                );
2706                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2707                let cache = self.state.config.cache_dir();
2708                let ctx = WaveCtx {
2709                    carry_seats: false,
2710                    pauses: Some((&self.pause, &self.interrupt)),
2711                    run: run_id,
2712                    node: "implement",
2713                    prompts,
2714                    cache: cache.as_deref(),
2715                    round: None,
2716                };
2717                let (fallback_seat, fallback_out) = run_one(
2718                    retry,
2719                    Arc::clone(&self.sem),
2720                    &ctx,
2721                    &mut self.state,
2722                    fallback_attempt,
2723                )
2724                .await;
2725                *seat = fallback_seat;
2726                *out = fallback_out;
2727            }
2728        }
2729        Ok(())
2730    }
2731
2732    /// Ask an implement seat's own CLI to confirm what it started, once, when
2733    /// its reply reported a command whose completion status it never
2734    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2735    /// that does and does not mean.
2736    ///
2737    /// The completion contract this task asks for, extended to `implement`
2738    /// with the same signal `continue_fix_report` reads for the fixer,
2739    /// rather than a keyword search over the reply or a hard requirement on
2740    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2741    /// a candidate's CLI turn ended cleanly while a test run it had started
2742    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2743    /// named in it at all is untouched by this: `commands` is empty, so
2744    /// there is nothing to be unconfirmed.
2745    ///
2746    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2747    /// this is not about recovering edits that might already be on disk, it
2748    /// is about a result the seat itself never vouched for, which resuming
2749    /// asks for regardless of what the tree already holds. Bounded to one
2750    /// attempt for the same reason `resume_undelivered` is — this is the
2751    /// most expensive node in the graph — and a seat that still cannot
2752    /// confirm on that attempt is left as whatever its (possibly still
2753    /// unconfirmed) reply says; this does not invent a new "failed" reason
2754    /// for a candidate that otherwise produced a real, committed change.
2755    async fn resume_unconfirmed_commands(
2756        &mut self,
2757        results: &mut [(usize, SeatState, AgentOutcome)],
2758        sent: &[SeatJob],
2759        prompts: &Prompts,
2760        run_id: &str,
2761    ) {
2762        for (wi, seat, out) in results.iter_mut() {
2763            let AgentOutcome::Ok(o) = &*out else {
2764                continue;
2765            };
2766            if !has_unconfirmed_command(&o.commands) {
2767                continue;
2768            }
2769            let Some(job) = sent.get(*wi) else { continue };
2770            if !has_context(&job.spec, seat, job.sessions) {
2771                self.state.event(
2772                    "implement",
2773                    format!(
2774                        "{}: the reply named a command whose own CLI never confirmed the exit \
2775                         status of, but there is no session left to resume",
2776                        seat.key
2777                    ),
2778                );
2779                continue;
2780            }
2781            self.state.event(
2782                "implement",
2783                format!(
2784                    "{}: the reply named a command whose own CLI never confirmed the exit \
2785                     status of; resuming the conversation",
2786                    seat.key
2787                ),
2788            );
2789            let mut retry = job.clone();
2790            retry.seat = seat.clone();
2791            retry.prompt = prompt::resume_incomplete(
2792                "a command in your last reply had no confirmed exit status",
2793            );
2794            retry.timeout = retry_budget(job.timeout, true);
2795            retry.stem = format!("{}-confirm", job.stem);
2796            let cache = self.state.config.cache_dir();
2797            let ctx = WaveCtx {
2798                carry_seats: false,
2799                pauses: Some((&self.pause, &self.interrupt)),
2800                run: run_id,
2801                node: "implement",
2802                prompts,
2803                cache: cache.as_deref(),
2804                round: None,
2805            };
2806            let (resumed_seat, resumed) =
2807                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2808            *seat = resumed_seat;
2809            *out = resumed;
2810        }
2811    }
2812
2813    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2814    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2815    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2816    /// that motivated this.
2817    ///
2818    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2819    /// own nudge loop already covers for judge/review/vote seats, and not a
2820    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2821    /// implement seats: here the CLI turn genuinely finished while the node's
2822    /// own work — the fixer's account of what it did — had not. Gated purely
2823    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2824    /// reply, never on any wording in it, so a fixer whose valid, first-try
2825    /// `FixReport` happens to mention having waited on a background test is
2826    /// never resumed — the `Ok(report)` branch at the call site returns
2827    /// before this is ever invoked.
2828    ///
2829    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2830    /// attempt ([`retry_budget`]), nothing attempted once the session is
2831    /// gone, and a quota hit ends the loop immediately rather than retrying a
2832    /// rate limit that fails the same way again.
2833    async fn continue_fix_report(
2834        &mut self,
2835        mut seat: SeatState,
2836        parse_err: String,
2837        job: &SeatJob,
2838        prompts: &Prompts,
2839        run_id: &str,
2840        round: usize,
2841    ) -> (
2842        SeatState,
2843        Option<FixReport>,
2844        Option<String>,
2845        ContinuationRecord,
2846    ) {
2847        let mut last_err = parse_err;
2848        let mut cumulative_wait_ms = 0u64;
2849        let mut attempts = 0usize;
2850        loop {
2851            if !has_context(&job.spec, &seat, job.sessions) {
2852                self.state.event(
2853                    "fix",
2854                    format!(
2855                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2856                         session left to resume into"
2857                    ),
2858                );
2859                let outcome = if attempts == 0 {
2860                    ContinuationOutcome::NoSession
2861                } else {
2862                    ContinuationOutcome::Exhausted
2863                };
2864                return (
2865                    seat,
2866                    None,
2867                    Some(format!("unparsable fix report: {last_err}")),
2868                    ContinuationRecord {
2869                        attempts,
2870                        cumulative_wait_ms,
2871                        outcome,
2872                    },
2873                );
2874            }
2875            if attempts >= MAX_FIX_CONTINUATIONS {
2876                self.state.event(
2877                    "fix",
2878                    format!(
2879                        "round {round}: fixer's reply still had no adoption report after \
2880                         {attempts} continuation(s) ({last_err}); giving up"
2881                    ),
2882                );
2883                return (
2884                    seat,
2885                    None,
2886                    Some(format!(
2887                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2888                    )),
2889                    ContinuationRecord {
2890                        attempts,
2891                        cumulative_wait_ms,
2892                        outcome: ContinuationOutcome::Exhausted,
2893                    },
2894                );
2895            }
2896            attempts += 1;
2897            self.state.event(
2898                "fix",
2899                format!(
2900                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2901                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2902                ),
2903            );
2904            let mut retry = job.clone();
2905            retry.seat = seat.clone();
2906            retry.prompt = prompt::resume_incomplete(&last_err);
2907            retry.timeout = retry_budget(job.timeout, true);
2908            retry.stem = format!("{}-continue{attempts}", job.stem);
2909            let cache = self.state.config.cache_dir();
2910            let ctx = WaveCtx {
2911                carry_seats: false,
2912                pauses: Some((&self.pause, &self.interrupt)),
2913                run: run_id,
2914                node: "fix",
2915                prompts,
2916                cache: cache.as_deref(),
2917                round: Some(round),
2918            };
2919            let (resumed_seat, resumed_out) = run_one(
2920                retry,
2921                Arc::clone(&self.sem),
2922                &ctx,
2923                &mut self.state,
2924                attempts,
2925            )
2926            .await;
2927            seat = resumed_seat;
2928            match resumed_out {
2929                AgentOutcome::Ok(o) => {
2930                    cumulative_wait_ms += o.duration_ms;
2931                    match verdict::extract_json::<FixReport>(&o.text) {
2932                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2933                            self.state.event(
2934                                "fix",
2935                                format!(
2936                                    "round {round}: fixer's adoption report recovered after \
2937                                     {attempts} continuation(s)"
2938                                ),
2939                            );
2940                            return (
2941                                seat,
2942                                Some(report),
2943                                None,
2944                                ContinuationRecord {
2945                                    attempts,
2946                                    cumulative_wait_ms,
2947                                    outcome: ContinuationOutcome::Resumed,
2948                                },
2949                            );
2950                        }
2951                        // The report parsed, but this same reply's own
2952                        // CommandEvidence — the identical record `state.jobs`
2953                        // renders — names a command whose CLI never
2954                        // confirmed an exit status. Read together, that is
2955                        // not a resolved answer: keep nudging rather than
2956                        // accept a report standing next to a command the
2957                        // seat's own CLI cannot vouch for.
2958                        Ok(_) => {
2959                            last_err = "the reply parsed, but it reported a command whose own CLI \
2960                                 never confirmed an exit status"
2961                                .to_owned();
2962                        }
2963                        Err(e) => last_err = e.to_string(),
2964                    }
2965                }
2966                AgentOutcome::Quota(o) => {
2967                    cumulative_wait_ms += o.duration_ms;
2968                    self.state.quota.push(QuotaLoss {
2969                        seat: seat.key.clone(),
2970                        node: "fix".to_owned(),
2971                        at: Timestamp::now(),
2972                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2973                    });
2974                    self.state.event(
2975                        "fix",
2976                        format!(
2977                            "round {round}: continuation rate limited (quota); not retrying now"
2978                        ),
2979                    );
2980                    return (
2981                        seat,
2982                        None,
2983                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2984                        ContinuationRecord {
2985                            attempts,
2986                            cumulative_wait_ms,
2987                            outcome: ContinuationOutcome::QuotaLost,
2988                        },
2989                    );
2990                }
2991                AgentOutcome::Dropped(o) => {
2992                    cumulative_wait_ms += o.duration_ms;
2993                    let why = o
2994                        .dropped
2995                        .as_ref()
2996                        .map(|d| d.why.as_str())
2997                        .unwrap_or("the CLI ended the stream without delivering its answer");
2998                    last_err = format!("the CLI dropped the stream ({why})");
2999                }
3000                AgentOutcome::Failed(e) => last_err = e,
3001                // The continuation was never started; the report stays lost
3002                // and the caller records that, without any handover.
3003                AgentOutcome::ResourceBlocked(e) => {
3004                    return (
3005                        seat,
3006                        None,
3007                        Some(format!("the continuation was not started: {e}")),
3008                        ContinuationRecord {
3009                            attempts: attempts.saturating_sub(1),
3010                            cumulative_wait_ms,
3011                            outcome: ContinuationOutcome::NoSession,
3012                        },
3013                    );
3014                }
3015            }
3016        }
3017    }
3018
3019    fn after_implement(&mut self) -> Result<()> {
3020        // Scan every candidate patch once the set is complete.
3021        if self.state.leaks.is_empty() {
3022            let cfg = self.state.config.blind.clone();
3023            let mut leaks = Vec::new();
3024            for c in &self.state.candidates {
3025                let Some(patch) =
3026                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
3027                else {
3028                    continue;
3029                };
3030                leaks.extend(blind::scan(
3031                    &format!("candidate {} patch", c.label),
3032                    &patch,
3033                    &cfg.vendor_tokens,
3034                ));
3035            }
3036            if !leaks.is_empty() {
3037                let summary = leaks
3038                    .iter()
3039                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
3040                    .collect::<Vec<_>>()
3041                    .join(", ");
3042                match cfg.on_leak {
3043                    LeakPolicy::Fail => {
3044                        self.state.status = RunStatus::Failed;
3045                        self.state
3046                            .event("blind", format!("vendor text in a patch: {summary}"));
3047                        self.state.leaks = leaks;
3048                        self.state.save()?;
3049                        self.settle_questions();
3050                        bail!(
3051                            "blind.on_leak = \"fail\" and vendor text reached a \
3052                             judged patch: {summary}"
3053                        );
3054                    }
3055                    LeakPolicy::Redact => self.state.event(
3056                        "blind",
3057                        format!("redacting vendor text for judging: {summary}"),
3058                    ),
3059                    LeakPolicy::Warn => self.state.event(
3060                        "blind",
3061                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
3062                    ),
3063                }
3064                self.state.leaks = leaks;
3065            }
3066        }
3067
3068        if self.state.viable().is_empty() {
3069            if self.state.all_candidates_verified_noop() {
3070                // Every candidate agreed, with evidence the adoption guard
3071                // accepted, that nothing belongs in this worktree. That is
3072                // not the same fact as a candidate that simply failed to
3073                // write anything, and settling it as an ordinary `Failed`
3074                // (see `SCHEMA`'s doc for schema 10) is what let two of
3075                // task 391f's attempts burn a retry each re-discovering the
3076                // same already-landed fix. Terminal either way, so `judge`
3077                // must never run over an empty candidate set — unlike the
3078                // `Failed` branch below this returns `Ok`, not an error:
3079                // nothing here failed.
3080                self.state.status = RunStatus::VerifiedNoop;
3081                self.state.save()?;
3082                self.settle_questions();
3083                return Ok(());
3084            }
3085            self.state.status = RunStatus::Failed;
3086            self.state.save()?;
3087            self.settle_questions();
3088            bail!("no candidate produced a change; nothing to judge");
3089        }
3090        self.state.status = RunStatus::Judging;
3091        self.state.save()?;
3092        Ok(())
3093    }
3094
3095    // --------------------------------------------------------------- judge
3096
3097    async fn judge(&mut self) -> Result<()> {
3098        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3099        // agent files with `magi task add` name the run that paid for it. The
3100        // prompt overlay is cloned alongside it because the waves borrow it
3101        // while `self` is mutably borrowed by the node's own bookkeeping.
3102        let run_id = self.state.id.clone();
3103        let prompts = self.state.config.prompts.clone();
3104        if !self.state.judgements.is_empty() || self.state.judge_skipped {
3105            return Ok(());
3106        }
3107        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3108        if viable.len() == 1 {
3109            // Recorded so this is a one-time event: `judgements` stays empty
3110            // either way, which without this flag is indistinguishable from
3111            // "not yet judged" on the next reentry — and status is left
3112            // untouched, so a later node's conclusion (e.g. `Blocked` after
3113            // the review budget ran out) survives a resume instead of being
3114            // clobbered back to `Judging` by this node running again.
3115            self.state.judge_skipped = true;
3116            self.state.event(
3117                "judge",
3118                format!(
3119                    "only candidate {} produced a change; judging skipped",
3120                    viable[0].label
3121                ),
3122            );
3123            self.state.save()?;
3124            return Ok(());
3125        }
3126        self.state.status = RunStatus::Judging;
3127
3128        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3129        let language = self.state.config.graph.language.clone();
3130        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3131        let sessions = self.state.config.graph.sessions;
3132        let artifacts = agent::artifacts_dir(&self.state.dir());
3133        let root = self.state.worktree_root();
3134        let base_short = short(&self.state.base_commit);
3135
3136        let mut jobs = Vec::new();
3137        let mut orders = Vec::new();
3138        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3139            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3140            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3141            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3142            let seat_key = format!("judge-{}", j + 1);
3143            let seat = self.seat(&seat_key, &spec.id);
3144            jobs.push(SeatJob {
3145                prompt: prompt::judge(
3146                    &self.state.instruction,
3147                    &views,
3148                    self.roles.judges.len(),
3149                    &base_short,
3150                    &language,
3151                ),
3152                spec,
3153                seat,
3154                cwd: root.join(format!("judge-{}", j + 1)),
3155                timeout,
3156                allow_write: false,
3157                sessions,
3158                artifacts: artifacts.clone(),
3159                stem: format!("judge-{}", j + 1),
3160                handover: None,
3161            });
3162        }
3163
3164        self.state.event(
3165            "judge",
3166            format!(
3167                "{} judges ranking {} candidates blind",
3168                jobs.len(),
3169                viable.len()
3170            ),
3171        );
3172        let labels_for_check = labels.clone();
3173        let mut quota_losses = Vec::new();
3174        let cache = self.state.config.cache_dir();
3175        let ctx = WaveCtx {
3176            carry_seats: false,
3177            pauses: Some((&self.pause, &self.interrupt)),
3178            run: &run_id,
3179            node: "judge",
3180            prompts: &prompts,
3181            cache: cache.as_deref(),
3182            round: None,
3183        };
3184        let results = ask_json_wave::<Ranking>(
3185            jobs,
3186            Arc::clone(&self.sem),
3187            self.state.config.graph.retries,
3188            &self.roles.judge_roster,
3189            &ctx,
3190            &mut quota_losses,
3191            &mut self.state,
3192            &move |r: &Ranking| r.validate(&labels_for_check),
3193        )
3194        .await;
3195        if self.state.resource_block.is_some() {
3196            return self.stop_for_resource();
3197        }
3198        self.state.quota.extend(quota_losses);
3199
3200        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3201            let agent_id = seat.agent.clone();
3202            self.state.seats.insert(seat.key.clone(), seat);
3203            let mut record = Judgement {
3204                judge: j + 1,
3205                seat: format!("judge-{}", j + 1),
3206                agent: agent_id,
3207                ranking: Vec::new(),
3208                reasons: BTreeMap::new(),
3209                confidence: None,
3210                strengths: BTreeMap::new(),
3211                order: orders[j].clone(),
3212                failed: None,
3213                duration_ms: 0,
3214            };
3215            match res {
3216                Ok((ranking, out)) => {
3217                    record.ranking = ranking.normalized();
3218                    record.reasons = ranking.reasons;
3219                    record.strengths = ranking.strengths;
3220                    record.confidence = ranking.confidence;
3221                    record.duration_ms = out.duration_ms;
3222                    self.state.event(
3223                        "judge",
3224                        format!(
3225                            "judge {} ranked {}",
3226                            j + 1,
3227                            record.ranking.iter().collect::<String>()
3228                        ),
3229                    );
3230                }
3231                Err(e) => {
3232                    record.failed = Some(e.to_string());
3233                    self.state
3234                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3235                }
3236            }
3237            self.state.judgements.push(record);
3238            self.state.save()?;
3239        }
3240        Ok(())
3241    }
3242
3243    // ---------------------------------------------------------- deliberate
3244
3245    async fn deliberate(&mut self) -> Result<()> {
3246        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3247        // agent files with `magi task add` name the run that paid for it. The
3248        // prompt overlay is cloned alongside it because the waves borrow it
3249        // while `self` is mutably borrowed by the node's own bookkeeping.
3250        let run_id = self.state.id.clone();
3251        let prompts = self.state.config.prompts.clone();
3252        if !self.state.deliberation.is_empty() {
3253            return Ok(());
3254        }
3255        let tops: Vec<char> = self
3256            .state
3257            .judgements
3258            .iter()
3259            .filter_map(|j| j.ranking.first().copied())
3260            .collect();
3261        let rounds = self.state.config.graph.deliberate_rounds;
3262        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3263            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3264                self.state.event(
3265                    "deliberate",
3266                    format!("judges agreed on {} outright; no deliberation", tops[0]),
3267                );
3268            }
3269            self.state.status = RunStatus::Voting;
3270            self.state.save()?;
3271            return Ok(());
3272        }
3273
3274        self.state.status = RunStatus::Deliberating;
3275        self.state.event(
3276            "deliberate",
3277            format!(
3278                "split: first choices were {} — opening {rounds} round(s)",
3279                tops.iter().collect::<String>()
3280            ),
3281        );
3282
3283        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3284        let language = self.state.config.graph.language.clone();
3285        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3286        let sessions = self.state.config.graph.sessions;
3287        let artifacts = agent::artifacts_dir(&self.state.dir());
3288        let root = self.state.worktree_root();
3289        let base_short = short(&self.state.base_commit);
3290
3291        // Judges argue in sequence so that a turn can answer the one before it;
3292        // that is the difference between deliberation and three parallel
3293        // monologues.
3294        for round in 1..=rounds {
3295            let mut turns: Vec<DeliberationTurn> = Vec::new();
3296            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3297                if self.state.judgements[j].failed.is_some() {
3298                    continue;
3299                }
3300                let seat_key = format!("judge-{}", j + 1);
3301                let spec = self.occupant(&seat_key, spec);
3302                let mut seat = self.seat(&seat_key, &spec.id);
3303                let transcript = self.transcript(&turns, j);
3304                let build = |context: Option<&str>| {
3305                    prompt::deliberate(
3306                        &self.state.instruction,
3307                        context,
3308                        &transcript,
3309                        round,
3310                        rounds,
3311                        &language,
3312                    )
3313                };
3314                let block = self.candidate_block(&viable, &base_short);
3315                let full = build(Some(&block));
3316                let text = if has_context(&spec, &seat, sessions) {
3317                    build(None)
3318                } else {
3319                    full.clone()
3320                };
3321                let job = SeatJob {
3322                    spec,
3323                    seat: seat.clone(),
3324                    prompt: text,
3325                    cwd: root.join(format!("judge-{}", j + 1)),
3326                    timeout,
3327                    allow_write: false,
3328                    sessions,
3329                    artifacts: artifacts.clone(),
3330                    stem: format!("delib-{round}-judge-{}", j + 1),
3331                    handover: Some(full),
3332                };
3333                let cache = self.state.config.cache_dir();
3334                let ctx = WaveCtx {
3335                    carry_seats: false,
3336                    pauses: Some((&self.pause, &self.interrupt)),
3337                    run: &run_id,
3338                    node: "deliberate",
3339                    prompts: &prompts,
3340                    cache: cache.as_deref(),
3341                    round: None,
3342                };
3343                // A turn is never nudged (`retries` 0): a failed seat is
3344                // handed to the next roster agent, which gets the full
3345                // context. An empty answer is a turn, not a failure.
3346                let mut losses = Vec::new();
3347                let mut results = ask_wave_with::<String>(
3348                    vec![job],
3349                    Arc::clone(&self.sem),
3350                    0,
3351                    &self.roles.judge_roster,
3352                    &ctx,
3353                    &mut losses,
3354                    &mut self.state,
3355                    &|text: &str| {
3356                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3357                    },
3358                )
3359                .await;
3360                self.state.quota.extend(losses);
3361                let (updated, res, _) = results.pop().expect("one job in, one result out");
3362                seat = updated;
3363                let agent_id = seat.agent.clone();
3364                self.state.seats.insert(seat.key.clone(), seat);
3365                let body = match res {
3366                    Ok((body, _)) => body,
3367                    // Skip the seat; a CLI's raw error JSON is never read as
3368                    // this judge's position.
3369                    Err(e) => {
3370                        self.state
3371                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3372                        continue;
3373                    }
3374                };
3375                let tentative = verdict::extract_json::<Position>(&body)
3376                    .ok()
3377                    .and_then(|p| p.tentative)
3378                    .and_then(|s| s.trim().chars().next())
3379                    .map(|c| c.to_ascii_uppercase());
3380                self.state.event(
3381                    "deliberate",
3382                    format!(
3383                        "round {round}: judge {} now favours {}",
3384                        j + 1,
3385                        tentative.map_or("—".to_owned(), |c| c.to_string())
3386                    ),
3387                );
3388                turns.push(DeliberationTurn {
3389                    judge: j + 1,
3390                    agent: agent_id,
3391                    body: blind::sanitize_prose(&body, &self.state.config.blind),
3392                    tentative,
3393                });
3394            }
3395            self.state
3396                .deliberation
3397                .push(DeliberationRound { round, turns });
3398            self.state.save()?;
3399        }
3400
3401        self.state.status = RunStatus::Voting;
3402        self.state.save()?;
3403        Ok(())
3404    }
3405
3406    // ---------------------------------------------------------------- vote
3407
3408    async fn vote(&mut self) -> Result<()> {
3409        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3410        // agent files with `magi task add` name the run that paid for it. The
3411        // prompt overlay is cloned alongside it because the waves borrow it
3412        // while `self` is mutably borrowed by the node's own bookkeeping.
3413        let run_id = self.state.id.clone();
3414        let prompts = self.state.config.prompts.clone();
3415        if !self.state.votes.is_empty() {
3416            return Ok(());
3417        }
3418        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3419        if viable.len() == 1 {
3420            return Ok(());
3421        }
3422        self.state.status = RunStatus::Voting;
3423
3424        let language = self.state.config.graph.language.clone();
3425        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3426        let sessions = self.state.config.graph.sessions;
3427        let artifacts = agent::artifacts_dir(&self.state.dir());
3428        let root = self.state.worktree_root();
3429        let base_short = short(&self.state.base_commit);
3430        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3431
3432        let mut jobs = Vec::new();
3433        let mut seats_at = Vec::new();
3434        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3435            if self
3436                .state
3437                .judgements
3438                .get(j)
3439                .is_some_and(|r| r.failed.is_some())
3440            {
3441                continue;
3442            }
3443            let seat_key = format!("judge-{}", j + 1);
3444            let spec = self.occupant(&seat_key, spec);
3445            let seat = self.seat(&seat_key, &spec.id);
3446            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3447            let text = if has_context(&spec, &seat, sessions) {
3448                prompt::final_vote(&viable, &language)
3449            } else {
3450                full.clone()
3451            };
3452            jobs.push(SeatJob {
3453                spec,
3454                seat,
3455                prompt: text,
3456                cwd: root.join(format!("judge-{}", j + 1)),
3457                timeout,
3458                allow_write: false,
3459                sessions,
3460                artifacts: artifacts.clone(),
3461                stem: format!("vote-judge-{}", j + 1),
3462                handover: Some(full),
3463            });
3464            seats_at.push(j);
3465        }
3466
3467        self.state.event(
3468            "vote",
3469            format!(
3470                "collecting {} final votes one by one, privately",
3471                jobs.len()
3472            ),
3473        );
3474        let allowed = viable.clone();
3475        let mut quota_losses = Vec::new();
3476        let cache = self.state.config.cache_dir();
3477        let ctx = WaveCtx {
3478            carry_seats: false,
3479            pauses: Some((&self.pause, &self.interrupt)),
3480            run: &run_id,
3481            node: "vote",
3482            prompts: &prompts,
3483            cache: cache.as_deref(),
3484            round: None,
3485        };
3486        let results = ask_json_wave::<FinalVote>(
3487            jobs,
3488            Arc::clone(&self.sem),
3489            self.state.config.graph.retries,
3490            &self.roles.judge_roster,
3491            &ctx,
3492            &mut quota_losses,
3493            &mut self.state,
3494            &move |v: &FinalVote| match v.label() {
3495                Some(c) if allowed.contains(&c) => Ok(()),
3496                other => bail!("vote {other:?} is not one of {allowed:?}"),
3497            },
3498        )
3499        .await;
3500        if self.state.resource_block.is_some() {
3501            return self.stop_for_resource();
3502        }
3503        self.state.quota.extend(quota_losses);
3504
3505        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3506            let agent_id = seat.agent.clone();
3507            self.state.seats.insert(seat.key.clone(), seat);
3508            let initial = self
3509                .state
3510                .judgements
3511                .get(j)
3512                .and_then(|r| r.ranking.first().copied());
3513            let mut record = VoteRecord {
3514                judge: j + 1,
3515                agent: agent_id,
3516                vote: None,
3517                reason: String::new(),
3518                changed: false,
3519            };
3520            match res {
3521                Ok((v, _)) => {
3522                    record.vote = v.label();
3523                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3524                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3525                    self.state.event(
3526                        "vote",
3527                        format!(
3528                            "judge {} voted {}{}",
3529                            j + 1,
3530                            record.vote.unwrap_or('?'),
3531                            if record.changed { " (changed)" } else { "" }
3532                        ),
3533                    );
3534                }
3535                Err(e) => {
3536                    self.state
3537                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3538                }
3539            }
3540            self.state.votes.push(record);
3541            self.state.save()?;
3542        }
3543        Ok(())
3544    }
3545
3546    // --------------------------------------------------------------- tally
3547
3548    fn tally(&mut self) -> Result<()> {
3549        if self.state.tally.is_some() {
3550            return Ok(());
3551        }
3552        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3553        let tops: Vec<char> = self
3554            .state
3555            .judgements
3556            .iter()
3557            .filter_map(|j| j.ranking.first().copied())
3558            .collect();
3559        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3560
3561        // A judge whose private vote failed still counted once, in the initial
3562        // ranking; using it beats discarding a whole seat.
3563        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3564        let mut cast: Vec<char> = Vec::new();
3565        for (i, j) in self.state.judgements.iter().enumerate() {
3566            let vote = self
3567                .state
3568                .votes
3569                .iter()
3570                .find(|v| v.judge == i + 1)
3571                .and_then(|v| v.vote)
3572                .or_else(|| j.ranking.first().copied());
3573            if let Some(v) = vote {
3574                *first_choice.entry(v).or_insert(0) += 1;
3575                cast.push(v);
3576            }
3577        }
3578
3579        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3580        for j in &self.state.judgements {
3581            let n = j.ranking.len();
3582            for (pos, label) in j.ranking.iter().enumerate() {
3583                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3584            }
3585        }
3586
3587        let best = first_choice.values().copied().max().unwrap_or(0);
3588        let mut leaders: Vec<char> = first_choice
3589            .iter()
3590            .filter(|(_, v)| **v == best)
3591            .map(|(k, _)| *k)
3592            .collect();
3593        let mut tie_break = None;
3594        if leaders.len() > 1 {
3595            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3596            let borda_leaders: Vec<char> = leaders
3597                .iter()
3598                .copied()
3599                .filter(|l| borda[l] == top_borda)
3600                .collect();
3601            tie_break = Some(if borda_leaders.len() == 1 {
3602                format!(
3603                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3604                    leaders.len()
3605                )
3606            } else {
3607                format!(
3608                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3609                    leaders.len()
3610                )
3611            });
3612            leaders = borda_leaders;
3613            leaders.sort_unstable();
3614        }
3615        let winner = *leaders
3616            .first()
3617            .or(viable.first())
3618            .context("no candidate to declare a winner from")?;
3619
3620        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3621        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3622        let deliberated = !self.state.deliberation.is_empty();
3623
3624        // Whose verdict is this? A rate-limited seat is absent even if it
3625        // ranked before the limit hit, so presence is measured against the
3626        // recorded losses, not just "did a ranking ever appear".
3627        let quota_seats: std::collections::BTreeSet<&str> =
3628            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3629        let mut present = 0usize;
3630        for (i, j) in self.state.judgements.iter().enumerate() {
3631            if quota_seats.contains(j.seat.as_str()) {
3632                continue;
3633            }
3634            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3635            let voted = self
3636                .state
3637                .votes
3638                .iter()
3639                .any(|v| v.judge == i + 1 && v.vote.is_some());
3640            if ranked || voted {
3641                present += 1;
3642            }
3643        }
3644        // Strict majority of the configured panel. A bare majority is real
3645        // signal we can act on, while a minority verdict must never stand in
3646        // for a healthy one. A one-candidate run needs no panel at all, and
3647        // `judges` stays `0` rather than the roster size a panel that never
3648        // sat would otherwise be credited with.
3649        let needs_quorum = viable.len() > 1;
3650        let judges_total = if needs_quorum {
3651            self.roles.judges.len()
3652        } else {
3653            0
3654        };
3655        let quorum = if needs_quorum {
3656            judges_total / 2 + 1
3657        } else {
3658            0
3659        };
3660        let met_quorum = !needs_quorum || present >= quorum;
3661        let uncontested = (!needs_quorum).then(|| {
3662            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3663        });
3664
3665        self.state.event(
3666            "tally",
3667            match &uncontested {
3668                Some(reason) => format!("winner {winner} — {reason}"),
3669                None => format!(
3670                    "winner {winner} — votes {} | initial {} | {} changed | \
3671                     {present}/{judges_total} judges{}",
3672                    first_choice
3673                        .iter()
3674                        .map(|(k, v)| format!("{k}:{v}"))
3675                        .collect::<Vec<_>>()
3676                        .join(" "),
3677                    if unanimous_initial {
3678                        "unanimous"
3679                    } else {
3680                        "split"
3681                    },
3682                    changed_votes,
3683                    if met_quorum {
3684                        String::new()
3685                    } else {
3686                        format!(" — below quorum ({quorum} required)")
3687                    },
3688                ),
3689            },
3690        );
3691        if !met_quorum {
3692            self.state.event(
3693                "stall",
3694                format!(
3695                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3696                     the run stops here, resumable"
3697                ),
3698            );
3699        }
3700        self.state.tally = Some(Tally {
3701            first_choice,
3702            borda,
3703            winner,
3704            rankings: tops.len(),
3705            unanimous_initial,
3706            deliberated,
3707            changed_votes,
3708            unanimous_final,
3709            tie_break,
3710            judges: judges_total,
3711            present,
3712            quorum,
3713            met_quorum,
3714            uncontested,
3715        });
3716        self.state.status = if met_quorum {
3717            RunStatus::Reviewing
3718        } else {
3719            RunStatus::Stalled
3720        };
3721        self.state.save()?;
3722        Ok(())
3723    }
3724
3725    // ------------------------------------------------------------- recover
3726
3727    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3728    /// resumed toward completion once the transient cause clears.
3729    ///
3730    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3731    /// it toward the quorum, which is exactly the set of seats whose absence
3732    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3733    /// not depend on which node happened to hit the limit), or an ordinary
3734    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3735    /// seat is never disturbed.
3736    ///
3737    /// A seat that now answers with a usable ranking is "recovered": its
3738    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3739    /// `tally` counts it present again), and its vote re-collected. A seat that
3740    /// still fails keeps its loss and stays absent.
3741    ///
3742    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3743    /// to review/gate/merge), `false` when it is still below quorum (the run
3744    /// stays `Stalled`, still resumable for a later retry).
3745    #[allow(clippy::too_many_lines)]
3746    async fn recover_stall(&mut self) -> Result<bool> {
3747        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3748        // agent files with `magi task add` name the run that paid for it. The
3749        // prompt overlay is cloned alongside it because the waves borrow it
3750        // while `self` is mutably borrowed by the node's own bookkeeping.
3751        let run_id = self.state.id.clone();
3752        let prompts = self.state.config.prompts.clone();
3753        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3754        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3755        // is what keeps a non-quota collapse — or a quota loss recorded at the
3756        // deliberate node — from being a permanent dead-end on `--resume`.
3757        let quota_seats: BTreeSet<&str> =
3758            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3759        let absent: Vec<String> = self
3760            .state
3761            .judgements
3762            .iter()
3763            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3764            .map(|j| j.seat.clone())
3765            .collect();
3766        if absent.is_empty() {
3767            return Ok(false);
3768        }
3769        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3770        if viable.len() <= 1 {
3771            return Ok(false);
3772        }
3773        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3774        let language = self.state.config.graph.language.clone();
3775        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3776        let sessions = self.state.config.graph.sessions;
3777        let artifacts = agent::artifacts_dir(&self.state.dir());
3778        let root = self.state.worktree_root();
3779        let base_short = short(&self.state.base_commit);
3780        let candidates: Vec<Candidate> = viable.clone();
3781
3782        // Map each absent seat key to its 0-based position in `roles.judges`.
3783        let mut positions: Vec<usize> = absent
3784            .iter()
3785            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3786            .collect();
3787        if positions.is_empty() {
3788            return Ok(false);
3789        }
3790        positions.sort_unstable();
3791        positions.dedup();
3792
3793        // Re-rank the lost seats, one blind prompt each.
3794        let mut judge_jobs = Vec::new();
3795        for &j in &positions {
3796            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3797            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3798            let seat_key = format!("judge-{}", j + 1);
3799            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3800            let seat = self.seat(&seat_key, &spec.id);
3801            judge_jobs.push(SeatJob {
3802                spec,
3803                seat,
3804                prompt: prompt::judge(
3805                    &self.state.instruction,
3806                    &views,
3807                    self.roles.judges.len(),
3808                    &base_short,
3809                    &language,
3810                ),
3811                cwd: root.join(seat_key),
3812                timeout,
3813                allow_write: false,
3814                sessions,
3815                artifacts: artifacts.clone(),
3816                stem: format!("judge-{}-recover", j + 1),
3817                handover: None,
3818            });
3819        }
3820
3821        let labels_for_check = labels.clone();
3822        let mut judge_losses = Vec::new();
3823        let retries = self.state.config.graph.retries;
3824        let cache = self.state.config.cache_dir();
3825        let ctx = WaveCtx {
3826            carry_seats: false,
3827            pauses: Some((&self.pause, &self.interrupt)),
3828            run: &run_id,
3829            node: "judge",
3830            prompts: &prompts,
3831            cache: cache.as_deref(),
3832            round: None,
3833        };
3834        let results = ask_json_wave::<Ranking>(
3835            judge_jobs,
3836            Arc::clone(&self.sem),
3837            retries,
3838            &self.roles.judge_roster,
3839            &ctx,
3840            &mut judge_losses,
3841            &mut self.state,
3842            &move |r: &Ranking| r.validate(&labels_for_check),
3843        )
3844        .await;
3845        if self.state.resource_block.is_some() {
3846            self.stop_for_resource()?;
3847            return Ok(false);
3848        }
3849
3850        // Refresh the judgement of every seat that ranked again.
3851        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3852        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3853            let agent_id = seat.agent.clone();
3854            self.state.seats.insert(seat.key.clone(), seat);
3855            let record = &mut self.state.judgements[j];
3856            match res {
3857                Ok((ranking, out)) => {
3858                    record.agent = agent_id;
3859                    record.ranking = ranking.normalized();
3860                    record.reasons = ranking.reasons;
3861                    record.strengths = ranking.strengths;
3862                    record.confidence = ranking.confidence;
3863                    record.failed = None;
3864                    record.duration_ms = out.duration_ms;
3865                    recovered.insert(j);
3866                    self.state.event(
3867                        "recover",
3868                        format!("judge {} ranked again after the limit", j + 1),
3869                    );
3870                }
3871                Err(e) => {
3872                    self.state
3873                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3874                }
3875            }
3876        }
3877
3878        // Re-ask the votes of the seats that recovered a ranking.
3879        let mut vote_jobs = Vec::new();
3880        let mut vote_pos: Vec<usize> = Vec::new();
3881        for &j in &recovered {
3882            let seat_key = format!("judge-{}", j + 1);
3883            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3884            let seat = self.seat(&seat_key, &spec.id);
3885            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3886            let text = if has_context(&spec, &seat, sessions) {
3887                prompt::final_vote(&labels, &language)
3888            } else {
3889                full.clone()
3890            };
3891            vote_jobs.push(SeatJob {
3892                spec,
3893                seat,
3894                prompt: text,
3895                cwd: root.join(seat_key),
3896                timeout,
3897                allow_write: false,
3898                sessions,
3899                artifacts: artifacts.clone(),
3900                stem: format!("vote-judge-{}-recover", j + 1),
3901                handover: Some(full),
3902            });
3903            vote_pos.push(j);
3904        }
3905        let allowed = labels.clone();
3906        let mut vote_losses = Vec::new();
3907        let vote_retries = self.state.config.graph.retries;
3908        let vote_cache = self.state.config.cache_dir();
3909        let ctx = WaveCtx {
3910            carry_seats: false,
3911            pauses: Some((&self.pause, &self.interrupt)),
3912            run: &run_id,
3913            node: "vote",
3914            prompts: &prompts,
3915            cache: vote_cache.as_deref(),
3916            round: None,
3917        };
3918        let votes = ask_json_wave::<FinalVote>(
3919            vote_jobs,
3920            Arc::clone(&self.sem),
3921            vote_retries,
3922            &self.roles.judge_roster,
3923            &ctx,
3924            &mut vote_losses,
3925            &mut self.state,
3926            &move |v: &FinalVote| match v.label() {
3927                Some(c) if allowed.contains(&c) => Ok(()),
3928                other => bail!("vote {other:?} is not one of {allowed:?}"),
3929            },
3930        )
3931        .await;
3932        if self.state.resource_block.is_some() {
3933            self.stop_for_resource()?;
3934            return Ok(false);
3935        }
3936        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3937            let agent_id = seat.agent.clone();
3938            self.state.seats.insert(seat.key.clone(), seat);
3939            match res {
3940                Ok((v, _)) => {
3941                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3942                        rec.vote = v.label();
3943                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3944                    } else {
3945                        self.state.votes.push(VoteRecord {
3946                            judge: j + 1,
3947                            agent: agent_id,
3948                            vote: v.label(),
3949                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3950                            changed: false,
3951                        });
3952                    }
3953                    self.state.event(
3954                        "recover",
3955                        format!("judge {} voted again after the limit", j + 1),
3956                    );
3957                }
3958                Err(e) => {
3959                    self.state
3960                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3961                }
3962            }
3963        }
3964
3965        // A seat that ranked again is present even if its re-vote failed —
3966        // `tally` falls back to the initial ranking's first choice — so clear
3967        // its quota loss. Seats that still fail keep theirs and stay absent.
3968        let recovered_keys: BTreeSet<String> = recovered
3969            .iter()
3970            .map(|&j| format!("judge-{}", j + 1))
3971            .collect();
3972        self.state
3973            .quota
3974            .retain(|q| !recovered_keys.contains(&q.seat));
3975        // A seat that hit the limit again is a fresh loss, not the old one:
3976        // replace the stale entry so the history stays one-per-seat and the
3977        // daemon can tell this attempt's loss from a previous session's.
3978        for loss in judge_losses.into_iter().chain(vote_losses) {
3979            if recovered_keys.contains(&loss.seat) {
3980                continue;
3981            }
3982            self.state.quota.retain(|q| q.seat != loss.seat);
3983            self.state.quota.push(loss);
3984        }
3985
3986        // Recompute the verdict from the refreshed panel.
3987        self.state.tally = None;
3988        self.tally()?;
3989        Ok(self
3990            .state
3991            .tally
3992            .as_ref()
3993            .map(|t| t.met_quorum)
3994            .unwrap_or(false))
3995    }
3996
3997    // ---------------------------------------------------------------- graft
3998
3999    /// Port the strengths the judges attributed to the losing candidates onto
4000    /// the winner, once, between the tally and the review loop.
4001    ///
4002    /// Best-effort by construction: it returns nothing and fails nothing. A
4003    /// skip, a fixer that failed or hit quota, a round that changed nothing
4004    /// and a result over the size limit ([`graft::limit`]) all leave the
4005    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
4006    /// run carries on as if the phase were off. See [`graft`] for the
4007    /// decisions and `AGENTS.md` for the invariants.
4008    ///
4009    /// The one exception is a rollback that could not be confirmed: the
4010    /// rejected commit may still be the winner's tip, so the run is blocked
4011    /// (resumable; the record stays `Running`, which makes a resume retry the
4012    /// restore) instead of reviewing, gating and merging it.
4013    async fn graft(&mut self) -> Result<()> {
4014        let Err(e) = self.graft_inner().await else {
4015            return Ok(());
4016        };
4017        if e.is::<crate::repo_lock::ResourceBlocked>() {
4018            return Err(e);
4019        }
4020        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
4021            self.state.status = RunStatus::Blocked;
4022            self.state.event("graft", format!("blocked: {e:#}"));
4023            self.state.save()?;
4024            return Err(e);
4025        }
4026        // An error after the `Running` record was saved may have left the
4027        // fixer's work in the tree: the record must not stay unsettled
4028        // (a later resume would reset past the review fixes), so confirm the
4029        // restore now, or block.
4030        if let Some(rec) = self.state.graft.clone()
4031            && !rec.outcome.settled()
4032        {
4033            if let Some(winner) = self.state.winner().cloned() {
4034                if let Err(re) = self.graft_restore(&winner.worktree, &rec.pre).await {
4035                    if re.is::<crate::repo_lock::ResourceBlocked>() {
4036                        return Err(re);
4037                    }
4038                    self.state.status = RunStatus::Blocked;
4039                    self.state.event("graft", format!("blocked: {re:#}"));
4040                    self.state.save()?;
4041                    return Err(re);
4042                }
4043            }
4044            if let Some(g) = self.state.graft.as_mut() {
4045                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
4046            }
4047        }
4048        self.state.event("graft", format!("skipped: {e:#}"));
4049        if let Err(e) = self.state.save() {
4050            tracing::warn!("save after graft: {e:#}");
4051        }
4052        Ok(())
4053    }
4054
4055    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
4056    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
4057    async fn graft_restore(&mut self, wt: &Path, pre: &str) -> Result<()> {
4058        let _coordination = self.coordinate(wt).await?;
4059        crate::recovery::restore_repair(wt, pre)
4060            .await
4061            .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
4062    }
4063
4064    /// A skip is a run event, recorded once however often the node is re-entered.
4065    fn graft_skipped(&mut self, why: &str) {
4066        let message = format!("skipped: {why}");
4067        if !self
4068            .state
4069            .events
4070            .iter()
4071            .any(|e| e.node == "graft" && e.message == message)
4072        {
4073            self.state.event("graft", message);
4074        }
4075    }
4076
4077    async fn graft_inner(&mut self) -> Result<()> {
4078        // A round that was started and never finished (crash, kill): the
4079        // fixer may have left anything in the tree, so put the winner back.
4080        if let Some(rec) = self.state.graft.clone()
4081            && !rec.outcome.settled()
4082        {
4083            // Reviews exist only if the graft ended without its record being
4084            // settled (a swallowed error): the tree has moved on since, and
4085            // a reset to `pre` would discard the review fixes.
4086            if self.state.reviews.is_empty()
4087                && let Some(winner) = self.state.winner().cloned()
4088            {
4089                self.graft_restore(&winner.worktree, &rec.pre).await?;
4090            }
4091            if rec.outcome == GraftOutcome::WaitingForRestore {
4092                self.state.graft = None;
4093                self.state.save()?;
4094            } else {
4095                if let Some(g) = self.state.graft.as_mut() {
4096                    g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
4097                }
4098                self.state.event(
4099                    "graft",
4100                    "rolled back: the previous round was interrupted; the winner is untouched",
4101                );
4102                self.state.save()?;
4103                return Ok(());
4104            }
4105        }
4106        let plan = match graft::plan(&self.state) {
4107            Ok(plan) => plan,
4108            Err(skip) => {
4109                if skip != graft::Skip::AlreadyDone {
4110                    self.graft_skipped(skip.reason());
4111                    self.state.save()?;
4112                }
4113                return Ok(());
4114            }
4115        };
4116        let Some(winner) = self.state.winner().cloned() else {
4117            return Ok(());
4118        };
4119        let wt = winner.worktree.clone();
4120        if !git::is_clean(&wt).await? {
4121            self.graft_skipped("the winner's worktree has uncommitted changes");
4122            self.state.save()?;
4123            return Ok(());
4124        }
4125        let pre = git::rev_parse(&wt, "HEAD").await?;
4126        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
4127        else {
4128            self.graft_skipped("the winner's diff could not be measured");
4129            self.state.save()?;
4130            return Ok(());
4131        };
4132        let limit = graft::limit(winner_lines);
4133        // Saved before the fixer is called: a stop from here on is repaired
4134        // by the branch above on the next entry.
4135        self.state.graft = Some(GraftRecord {
4136            pre: pre.clone(),
4137            post: None,
4138            lines: None,
4139            limit: Some(limit),
4140            agent: None,
4141            outcome: GraftOutcome::Running,
4142            requested: graft::requested(&plan),
4143            adoption: None,
4144            adoption_error: None,
4145        });
4146        self.state.event(
4147            "graft",
4148            format!(
4149                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4150                plan.strengths
4151                    .iter()
4152                    .map(|(l, _, _)| l.to_string())
4153                    .collect::<Vec<_>>()
4154                    .join(", "),
4155                plan.winner
4156            ),
4157        );
4158        self.state.save()?;
4159
4160        let fix_prompt = prompt::graft(
4161            &self.state.instruction,
4162            plan.winner,
4163            &plan.strengths,
4164            limit,
4165            &self.state.config.graph.language,
4166        );
4167        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4168        let sessions = self.state.config.graph.sessions;
4169        let artifacts = agent::artifacts_dir(&self.state.dir());
4170        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4171        let (_, seat, out) = self
4172            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4173                SeatJob {
4174                    prompt: fix_prompt.clone(),
4175                    spec,
4176                    seat,
4177                    cwd: wt.clone(),
4178                    timeout,
4179                    allow_write: true,
4180                    sessions,
4181                    artifacts: artifacts.clone(),
4182                    stem: "graft".to_owned(),
4183                    handover: None,
4184                }
4185            })
4186            .await;
4187        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4188            // Preserve the need to restore earlier fallback edits without
4189            // confusing an unstarted seat with an interrupted invocation.
4190            self.state.graft.as_mut().expect("graft record").outcome =
4191                GraftOutcome::WaitingForRestore;
4192            return self.stop_for_resource();
4193        }
4194        let mut why: Option<String> = None;
4195        match &out {
4196            AgentOutcome::Ok(o) if agent::output_advances(o) => {
4197                why = Some("the fixer produced nothing usable".to_owned());
4198            }
4199            AgentOutcome::Ok(_) => {}
4200            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4201            AgentOutcome::Quota(o) => {
4202                self.state.quota.push(QuotaLoss {
4203                    seat: seat.key.clone(),
4204                    node: "graft".to_owned(),
4205                    at: Timestamp::now(),
4206                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4207                });
4208                why = Some("rate limited (quota)".to_owned());
4209            }
4210            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4211                why = Some(format!(
4212                    "the fixer failed: {}",
4213                    e.lines()
4214                        .next()
4215                        .unwrap_or("")
4216                        .chars()
4217                        .take(160)
4218                        .collect::<String>()
4219                ));
4220            }
4221        }
4222        let agent_id = seat.agent.clone();
4223        self.state.seats.insert(seat.key.clone(), seat);
4224
4225        let mut post = None;
4226        let mut lines = None;
4227        let _coordination = self.coordinate(&wt).await?;
4228        if why.is_none() {
4229            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4230            if let Ok(r) = &rescued {
4231                self.state.note_withheld("graft", &r.withheld);
4232            }
4233            let head = git::rev_parse(&wt, "HEAD").await?;
4234            // What is measured must be what gets reviewed and gated: a failed
4235            // rescue, or anything still uncommitted (withheld lockfiles
4236            // aside, which are never part of the tree), rejects the graft.
4237            let dirty = match &rescued {
4238                Ok(r) => git::status_porcelain(&wt)
4239                    .await
4240                    .map(|st| {
4241                        st.lines().any(|l| {
4242                            !r.withheld
4243                                .iter()
4244                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
4245                        })
4246                    })
4247                    .unwrap_or(true),
4248                Err(_) => true,
4249            };
4250            if dirty {
4251                why = Some("the fixer left changes that could not be committed".to_owned());
4252            } else if head == pre {
4253                why = Some("the fixer changed nothing".to_owned());
4254            } else {
4255                lines = git::changed_lines(&wt, &pre, &head).await;
4256                if graft::within_budget(lines, limit) {
4257                    post = Some(head);
4258                } else {
4259                    why = Some(format!(
4260                        "{} changed lines against a limit of {limit}",
4261                        lines.map_or_else(
4262                            || "an unmeasurable number of".to_owned(),
4263                            |l| l.to_string()
4264                        )
4265                    ));
4266                }
4267            }
4268        }
4269        let (adoption, adoption_error) = match &out {
4270            AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4271            _ => (None, None),
4272        };
4273        let requested = graft::requested(&plan);
4274        let outcome = match why {
4275            None => {
4276                self.state.event(
4277                    "graft",
4278                    format!(
4279                        "applied: {} changed lines (limit {limit})",
4280                        lines.unwrap_or(0)
4281                    ),
4282                );
4283                GraftOutcome::Applied
4284            }
4285            Some(why) => {
4286                // Not settled until the restore is confirmed: the record
4287                // stays `Running`, so a resume retries it.
4288                crate::recovery::restore_repair(&wt, &pre)
4289                    .await
4290                    .map_err(|e| GraftRestoreFailed(format!("{e:#}")))?;
4291                self.state.event(
4292                    "graft",
4293                    format!("rolled back: {why}; the winner is untouched"),
4294                );
4295                GraftOutcome::RolledBack(why)
4296            }
4297        };
4298        self.state.graft = Some(GraftRecord {
4299            pre,
4300            post,
4301            lines,
4302            limit: Some(limit),
4303            agent: Some(agent_id),
4304            outcome,
4305            requested,
4306            adoption,
4307            adoption_error,
4308        });
4309        self.state.save()?;
4310        Ok(())
4311    }
4312
4313    // ----------------------------------------------------------------- fold
4314
4315    async fn fold_losers(&mut self) -> Result<()> {
4316        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4317            return Ok(());
4318        };
4319        let repo = self.state.repo.clone();
4320        let _coordination = self.coordinate(&repo).await?;
4321        let mut folded = Vec::new();
4322        for i in 0..self.state.candidates.len() {
4323            let c = &self.state.candidates[i];
4324            if c.label == winner || c.folded {
4325                continue;
4326            }
4327            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4328            git::worktree_remove(&repo, &wt).await.ok();
4329            git::branch_delete(&repo, &branch).await.ok();
4330            self.state.candidates[i].folded = true;
4331            folded.push(label.to_string());
4332        }
4333        // The judges are finished; their checkouts are pure cost from here.
4334        let root = self.state.worktree_root();
4335        for j in 1..=self.roles.judges.len() {
4336            let wt = root.join(format!("judge-{j}"));
4337            if wt.exists() {
4338                git::worktree_remove(&repo, &wt).await.ok();
4339            }
4340        }
4341        // The design-deliberation stage is finished by the time a tally
4342        // exists — same reasoning as the judges above.
4343        if self.state.config.graph.advise {
4344            for k in 1..=self.state.config.graph.advisors {
4345                let wt = root.join(format!("advisor-{k}"));
4346                if wt.exists() {
4347                    git::worktree_remove(&repo, &wt).await.ok();
4348                }
4349            }
4350        }
4351        if !folded.is_empty() {
4352            self.state
4353                .event("fold", format!("folded candidates {}", folded.join(", ")));
4354            self.state.save()?;
4355        }
4356        Ok(())
4357    }
4358
4359    // ------------------------------------------------------------ base sync
4360
4361    /// Land the winner's tree on the current tip of `<remote>/<base>` before
4362    /// anything verifies it.
4363    ///
4364    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
4365    /// read whatever is checked out in the winner's worktree. Left alone that
4366    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
4367    /// it when the run *branched* - and a run takes long enough that the base
4368    /// has usually moved by the time it gets here. A gate that ran there
4369    /// answers "green on the commit this run started from", not "green on
4370    /// what is about to land", and the difference showed up three times in
4371    /// one day as a green run whose merge would have reverted a file another
4372    /// pull request had already landed.
4373    ///
4374    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
4375    /// `land::Step::Rebase` calls, rather than a second implementation of the
4376    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
4377    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
4378    /// doc warns about ("two answers to a question nobody notices until a
4379    /// diff is wrong").
4380    ///
4381    /// A conflict is not the end of the road: the standing rebase is handed
4382    /// to the fixer seat, at most `graph.review_rounds` times, counted in
4383    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
4384    /// land). Once it finishes, review and the gate run as usual on the
4385    /// rebased tree, which is where a breakage the new base caused is caught
4386    /// by the ordinary gate-fix round. magi resolves nothing itself.
4387    ///
4388    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
4389    /// `state.base_sync.attempts`, is how many times the base is *rebased
4390    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
4391    /// *conflict* was given to a fixer. When the fixer cannot finish the
4392    /// rebase the branch is restored, `state.base_sync.conflict` is set with
4393    /// what was tried (rounds spent, paths still conflicted) and the branch
4394    /// and worktree stay exactly as they were - untouched, for a person to
4395    /// look at - which is also what makes re-entering this function
4396    /// afterwards a no-op instead of a second attempt at the same wall. A
4397    /// push failure ends the same way.
4398    async fn sync_to_base(&mut self) -> Result<()> {
4399        if self.state.status == RunStatus::AlreadyInBase {
4400            return Ok(());
4401        }
4402        let conflicted = self
4403            .state
4404            .base_sync
4405            .as_ref()
4406            .is_some_and(|s| s.conflict.is_some());
4407        let Some(winner) = self.state.winner().cloned() else {
4408            return Ok(());
4409        };
4410
4411        let repo = self.state.repo.clone();
4412        let remote = self.state.config.merge.remote.clone();
4413        let base_branch = self.state.base_branch.clone();
4414        let tracking = format!("{remote}/{base_branch}");
4415
4416        git::fetch(&repo, &remote, &base_branch).await.ok();
4417        // No network, or the remote never had this branch: the run already
4418        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
4419        // otherwise), and one that got this far is not blocked by a fetch
4420        // that fails now. It just has no newer tip to compare against.
4421        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4422            return Ok(());
4423        };
4424
4425        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4426        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4427        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4428
4429        // Before any rebase, and before a recorded conflict is honoured: a
4430        // branch whose change reached the base under other commit ids has
4431        // nothing to rebase and nothing to conflict with, and a run that
4432        // already stopped on that phantom conflict recovers here on resume.
4433        // `behind == 0` with head == tip is a branch the base has since taken
4434        // in whole, whether or not a conflict was ever recorded: the ancestry
4435        // proof must still run (`classify` ignores a head still on the start
4436        // commit).
4437        if (behind > 0 || conflicted || head == tip)
4438            && self
4439                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4440                .await?
4441        {
4442            return Ok(());
4443        }
4444        if conflicted {
4445            return Ok(());
4446        }
4447
4448        if behind == 0 {
4449            // A fixer-finished rebase moves the branch ref before the
4450            // winner's worktree is told (`sync_to_head` below). A run that
4451            // died in between resumes here with `behind == 0` and a tree still
4452            // holding the pre-rebase files, which review and the gate would
4453            // then read. That state is exactly: HEAD moved off the tip the
4454            // rebase started from, yet the tree is still identical to that
4455            // tip. A tree with edits of its own differs from it, so nothing
4456            // is thrown away.
4457            if let Some(from) = self
4458                .state
4459                .rebase_fixes
4460                .iter()
4461                .rev()
4462                .find_map(|r| r.from.clone())
4463                && from != head
4464                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4465                    .await
4466                    .is_ok_and(|o| o.ok())
4467            {
4468                git::sync_to_head(&winner.worktree).await?;
4469            }
4470            // An earlier attempt may have rebased the branch locally and died
4471            // before pushing it (only the fresh-rebase arm below pushes).
4472            // Publish it now, so the plain push at PR time is not refused as
4473            // a non-fast-forward. A run already holding a recorded conflict
4474            // never reaches here; that case is out of scope.
4475            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4476            if let Some(why) = &conflict {
4477                self.state.status = RunStatus::Blocked;
4478                self.state.event("land", why.clone());
4479            }
4480            self.state.base_sync = Some(BaseSync {
4481                tip,
4482                behind: 0,
4483                attempts,
4484                conflict,
4485                already_in: None,
4486            });
4487            self.state.save()?;
4488            return Ok(());
4489        }
4490
4491        if attempts >= BASE_SYNC_ROUNDS {
4492            let why = format!(
4493                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4494                 rebase(s); rebasing again would only race it",
4495                winner.branch
4496            );
4497            self.state.status = RunStatus::Blocked;
4498            self.state.base_sync = Some(BaseSync {
4499                tip,
4500                behind,
4501                attempts,
4502                conflict: Some(why.clone()),
4503                already_in: None,
4504            });
4505            self.state.event("land", why);
4506            self.state.save()?;
4507            return Ok(());
4508        }
4509
4510        self.state.event(
4511            "land",
4512            format!(
4513                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4514                winner.branch
4515            ),
4516        );
4517        self.state.save()?;
4518
4519        // The remote's copy of the branch, read now and only if the fetch
4520        // really succeeded (a stale tracking ref must never pin a lease). It is
4521        // pushed over after a rebase only when it is a commit this branch
4522        // already contains, by ancestry or by patch (an earlier rebase of ours
4523        // that never reached the remote): anything else is somebody else's work.
4524        let branch_tracking = format!("{remote}/{}", winner.branch);
4525        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4526        let remote_tip = if self.state.task_pr_branch().is_none()
4527            && matches!(&fetched_branch, Ok(o) if o.ok())
4528        {
4529            git::rev_parse(&repo, &branch_tracking).await.ok()
4530        } else {
4531            None
4532        };
4533        // A remote tip this branch does not contain is somebody else's work:
4534        // rebasing would leave a local tip that can never be pushed. Stop
4535        // before touching anything and say so.
4536        if let Some(theirs) = &remote_tip
4537            && !git::is_ancestor(&repo, theirs, &head).await
4538            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4539                .await
4540                .is_ok_and(|missing| missing.is_empty())
4541        {
4542            let why = format!(
4543                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4544                 them",
4545                short(theirs),
4546                winner.branch
4547            );
4548            self.state.status = RunStatus::Blocked;
4549            self.state.base_sync = Some(BaseSync {
4550                tip,
4551                behind,
4552                attempts,
4553                conflict: Some(why.clone()),
4554                already_in: None,
4555            });
4556            self.state.event("land", why);
4557            self.state.save()?;
4558            return Ok(());
4559        }
4560
4561        let scratch = self.state.dir().join("base-sync");
4562        let rebased = match crate::rebase::rebase_with_fixer(
4563            &mut self.state,
4564            &scratch,
4565            &winner.branch,
4566            &tracking,
4567        )
4568        .await
4569        {
4570            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4571            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4572            Err(e) => Err(e),
4573        };
4574        let attempts = attempts + 1;
4575        match rebased {
4576            Ok(None) => {
4577                // The branch ref moved, but a worktree that already had it
4578                // checked out (the winner's) was not told; sync its index and
4579                // files before anything reads them.
4580                git::sync_to_head(&winner.worktree).await?;
4581                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4582                let mut conflict = None;
4583                if let Some(pinned) = &remote_tip {
4584                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4585                    match pushed {
4586                        Ok(o) if o.ok() => self.state.event(
4587                            "land",
4588                            format!("pushed rebased {} to {remote}", winner.branch),
4589                        ),
4590                        Ok(o) => {
4591                            conflict = Some(format!(
4592                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4593                                winner.branch,
4594                                short(pinned),
4595                                o.stderr.chars().take(600).collect::<String>()
4596                            ));
4597                        }
4598                        Err(e) => {
4599                            conflict = Some(format!(
4600                                "rebased {} locally but could not push it: {e:#}",
4601                                winner.branch
4602                            ));
4603                        }
4604                    }
4605                }
4606                if let Some(why) = &conflict {
4607                    self.state.status = RunStatus::Blocked;
4608                    self.state.event("land", why.clone());
4609                }
4610                self.state.base_sync = Some(BaseSync {
4611                    tip: tip.clone(),
4612                    behind: 0,
4613                    attempts,
4614                    conflict,
4615                    already_in: None,
4616                });
4617                self.state
4618                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4619            }
4620            Ok(Some(conflict)) => {
4621                let why = format!(
4622                    "{} conflicts with {tracking} and did not rebase: {}",
4623                    winner.branch,
4624                    conflict.chars().take(600).collect::<String>()
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            }
4636            Err(e) => {
4637                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4638                self.state.status = RunStatus::Blocked;
4639                self.state.base_sync = Some(BaseSync {
4640                    tip,
4641                    behind,
4642                    attempts,
4643                    conflict: Some(why.clone()),
4644                    already_in: None,
4645                });
4646                self.state.event("land", why);
4647            }
4648        }
4649        self.state.save()?;
4650        Ok(())
4651    }
4652
4653    /// Push a branch an earlier attempt rebased locally but never published,
4654    /// pinned to the remote tip read right after a successful fetch. Returns
4655    /// the reason when the run must stop; `None` when there was nothing to do
4656    /// (no remote copy, the same tip, or a remote copy this branch already
4657    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4658    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4659        // Task competitions publish only after the PR text gate, to their
4660        // public head; never resurrect a candidate branch on the remote.
4661        if self.state.task_pr_branch().is_some() {
4662            return None;
4663        }
4664        let repo = self.state.repo.clone();
4665        let remote = self.state.config.merge.remote.clone();
4666        let fetched = git::fetch(&repo, &remote, branch).await;
4667        if !matches!(&fetched, Ok(o) if o.ok()) {
4668            return None;
4669        }
4670        let branch_tracking = format!("{remote}/{branch}");
4671        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4672        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4673            return None;
4674        }
4675        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4676            .await
4677            .is_ok_and(|missing| missing.is_empty())
4678        {
4679            return Some(format!(
4680                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4681                 them",
4682                short(&theirs)
4683            ));
4684        }
4685        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4686            Ok(o) if o.ok() => {
4687                self.state
4688                    .event("land", format!("pushed rebased {branch} to {remote}"));
4689                None
4690            }
4691            Ok(o) => Some(format!(
4692                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4693                 someone may have pushed): {}",
4694                short(&theirs),
4695                o.stderr.chars().take(600).collect::<String>()
4696            )),
4697            Err(e) => Some(format!(
4698                "{branch} is rebased locally but could not be pushed: {e:#}"
4699            )),
4700        }
4701    }
4702
4703    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4704    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4705    /// whether it did.
4706    ///
4707    /// Checked only when the base is ahead of the branch. A failing check is
4708    /// "not proven" - the ordinary rebase path then decides - never a reason to
4709    /// stop the run.
4710    ///
4711    /// The remote copy of the branch is held to the same standard as the local
4712    /// one: if it carries a tip this worktree does not, that tip must itself be
4713    /// proven in the base, or nothing is settled (a pull request would
4714    /// otherwise be closed over commits nobody checked). The pull request is
4715    /// closed *before* the terminal status is saved; if that fails for a
4716    /// reason other than a refusal (no network, a `gh` error) the run is left
4717    /// `Blocked` with the reason as its conflict, which a resume retries -
4718    /// the same recovery a phantom conflict gets.
4719    async fn settle_already_in(
4720        &mut self,
4721        branch: &str,
4722        tip: &str,
4723        head: &str,
4724        attempts: usize,
4725        behind: usize,
4726    ) -> Result<bool> {
4727        let repo = self.state.repo.clone();
4728        let remote = self.state.config.merge.remote.clone();
4729        let start = self.state.base_commit.clone();
4730        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4731            Ok(Some(e)) => e,
4732            Ok(None) => return Ok(false),
4733            Err(e) => {
4734                tracing::warn!("already-in-base check for {branch}: {e:#}");
4735                return Ok(false);
4736            }
4737        };
4738        let mut verified = vec![head.to_owned()];
4739        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4740        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4741        if matches!(&fetched, Ok(o) if o.ok())
4742            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4743            && theirs != head
4744        {
4745            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4746                Ok(Some(_)) => verified.push(theirs),
4747                _ => return Ok(false),
4748            }
4749        }
4750        let base_branch = self.state.base_branch.clone();
4751        let message = format!(
4752            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4753             land",
4754            evidence.names(),
4755            evidence.proof.as_str()
4756        );
4757        let closed =
4758            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4759                .await;
4760        match closed {
4761            Ok(Ok(url)) => self
4762                .state
4763                .event("land", format!("closed {url}: superseded on {base_branch}")),
4764            Ok(Err(why)) => self
4765                .state
4766                .event("land", format!("did not close a pull request: {why}")),
4767            Err(e) => {
4768                let why = format!(
4769                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4770                     not be closed ({e:#}); resume to retry"
4771                );
4772                self.state.status = RunStatus::Blocked;
4773                self.state.base_sync = Some(BaseSync {
4774                    tip: tip.to_owned(),
4775                    behind,
4776                    attempts,
4777                    conflict: Some(why.clone()),
4778                    already_in: None,
4779                });
4780                self.state.event("land", why);
4781                self.state.save()?;
4782                return Ok(true);
4783            }
4784        }
4785        self.state.status = RunStatus::AlreadyInBase;
4786        self.state.base_sync = Some(BaseSync {
4787            tip: tip.to_owned(),
4788            behind,
4789            attempts,
4790            conflict: None,
4791            already_in: Some(evidence),
4792        });
4793        self.state.event("land", message);
4794        self.state.save()?;
4795        self.settle_questions();
4796        Ok(true)
4797    }
4798
4799    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4800    /// last landed the winner on, once it has run, else the commit the run
4801    /// branched from.
4802    ///
4803    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4804    /// and `vote` all happen before there is a winner to rebase, so they
4805    /// compare every candidate against the branch point on purpose, and a
4806    /// base that moves after they are already done cannot change an answer
4807    /// they already gave.
4808    fn landing_base(&self) -> String {
4809        self.state
4810            .base_sync
4811            .as_ref()
4812            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4813    }
4814
4815    // ------------------------------------------------------- operator fix
4816
4817    /// Route specific, already-recorded review findings to a fixer for a
4818    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4819    /// entry point.
4820    ///
4821    /// Distinct from `review_loop`'s own fix step in three ways: it never
4822    /// runs a reviewer wave, it never spends review-round budget, and what
4823    /// happened is recorded as an [`OperatorFixRequest`] appended to
4824    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4825    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4826    /// and vote must never be rewritten to look like a manufactured blocking
4827    /// verdict.
4828    ///
4829    /// Only meaningful once review has actually concluded: `Ready` (handed
4830    /// off with findings still open, or simply concluded clean while minor
4831    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4832    /// gate failed). Everything else is refused: a run still in progress
4833    /// should simply be resumed, and a `Merged` run's branch has already
4834    /// landed — reopening *this* run's own record cannot change that, so the
4835    /// answer there is a fresh `magi review <branch>`.
4836    ///
4837    /// A real commit here re-verifies through a fresh, ordinary review-only
4838    /// run on the same branch ([`Self::review`]) rather than reopening this
4839    /// run's own `review_loop`: once any round in this run's history went
4840    /// clean, `review_conclusion` treats that as permanent by design (the
4841    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4842    /// way to force one more genuine reviewer wave out of *this* run without
4843    /// either rewriting history or weakening that guarantee for every other
4844    /// caller. A review-only run costs nothing extra — no implementation, no
4845    /// judging, no vote — and exercises the exact same review → verify →
4846    /// gate → (human) merge path, unmodified.
4847    pub async fn fix_selected(
4848        &mut self,
4849        ids: &[String],
4850        reason: &str,
4851        allow_stale: bool,
4852    ) -> Result<()> {
4853        let reason = reason.trim();
4854        if reason.is_empty() {
4855            bail!("a fix request needs a reason — that is the operator's own record of why");
4856        }
4857        if ids.is_empty() {
4858            bail!("no finding id given");
4859        }
4860        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4861            bail!(
4862                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4863                 has already concluded — can be given a targeted fix. A run still \
4864                 in progress should simply be resumed; a `merged` run's branch has \
4865                 already landed, so its answer is a fresh `magi review <branch>`, \
4866                 not reopening this run's own record",
4867                self.state.id,
4868                self.state.status.as_str()
4869            );
4870        }
4871        let Some(winner) = self.state.winner().cloned() else {
4872            bail!("run {} has no winning candidate to fix", self.state.id);
4873        };
4874        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4875            bail!(
4876                "branch `{}` no longer exists; this run cannot be extended",
4877                winner.branch
4878            );
4879        }
4880        let home = crate::run::home();
4881        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4882            bail!(
4883                "run {} is currently being worked on by another magi process",
4884                self.state.id
4885            );
4886        }
4887        // Held for the rest of this call, including the follow-up review
4888        // below: two `magi fix` invocations against the same run must not
4889        // both reach the worktree manipulation further down, which would
4890        // otherwise race to remove and recreate the same directory — see
4891        // [`FixClaim`]'s own doc.
4892        let _claim = FixClaim::acquire(&self.state.dir())?;
4893
4894        // Resolve every id before spending anything — an unknown id refuses
4895        // the whole request rather than silently dropping it — and dedup
4896        // while keeping the operator's own order.
4897        let mut seen = BTreeSet::new();
4898        let mut findings = Vec::new();
4899        let mut missing = Vec::new();
4900        for id in ids {
4901            if !seen.insert(id.clone()) {
4902                continue;
4903            }
4904            match self.state.finding(id) {
4905                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4906                    id: f.id.clone(),
4907                    severity: f.severity,
4908                    reviewer_vote: rec.vote,
4909                    round: round.round,
4910                    round_head: round.head.clone(),
4911                    reviewer: rec.reviewer,
4912                    agent: rec.agent.clone(),
4913                    file: f.file.clone(),
4914                    line: f.line,
4915                    title: f.title.clone(),
4916                    detail: f.detail.clone(),
4917                    outcome: OperatorFixOutcome::Pending,
4918                }),
4919                None => missing.push(id.clone()),
4920            }
4921        }
4922        if !missing.is_empty() {
4923            bail!(
4924                "unknown finding id(s): {}; nothing was changed",
4925                missing.join(", ")
4926            );
4927        }
4928
4929        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4930        let stale_details: Vec<(String, String)> = findings
4931            .iter()
4932            .filter(|f| f.round_head != head_at_request)
4933            .map(|f| (f.id.clone(), f.round_head.clone()))
4934            .collect();
4935        let stale = !stale_details.is_empty();
4936        if stale && !allow_stale {
4937            bail!(
4938                "the branch has moved since some finding(s) were raised — {} — now \
4939                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4940                stale_details
4941                    .iter()
4942                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4943                    .collect::<Vec<_>>()
4944                    .join(", "),
4945                short(&head_at_request)
4946            );
4947        }
4948
4949        let request = OperatorFixRequest {
4950            requested_at: Timestamp::now(),
4951            reason: reason.to_owned(),
4952            findings,
4953            head_at_request: head_at_request.clone(),
4954            allow_stale,
4955            stale,
4956            fix: None,
4957            result_head: None,
4958            follow_up_review_run: None,
4959        };
4960        self.state.event(
4961            "fix",
4962            format!(
4963                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4964                request.findings.len(),
4965                request
4966                    .findings
4967                    .iter()
4968                    .map(|f| f.id.as_str())
4969                    .collect::<Vec<_>>()
4970                    .join(", "),
4971            ),
4972        );
4973        // Recorded now, before any worktree work or the fixer call itself —
4974        // and re-saved at each checkpoint below: a crash at any point after
4975        // this (mid fixer call, mid follow-up review) must not lose the fact
4976        // that this was requested, for which findings, and why. Everything
4977        // past this point reads and writes through `request_index` rather
4978        // than a local variable, since `request` itself is moved here.
4979        self.state.operator_fixes.push(request);
4980        self.state.save()?;
4981        let request_index = self.state.operator_fixes.len() - 1;
4982
4983        // A fresh, dedicated worktree for this one call, never the winner's
4984        // own worktree in place: that one may already be gone (folded away),
4985        // and reusing it in place would leave the branch checked out there
4986        // when the follow-up review below tries to check it out again. Freed
4987        // immediately after, either way — but only once confirmed clean:
4988        // `worktree_remove` is a `git worktree remove --force`, which would
4989        // otherwise discard uncommitted work left there by the operator or
4990        // another process before this had a chance to even look at it.
4991        let repo = self.state.repo.clone();
4992        let coordination = self.coordinate(&repo).await?;
4993        if winner.worktree.exists() {
4994            // Lockfiles a rescue commit withheld stay untracked on purpose and
4995            // are already recorded; they are not the operator's work to protect.
4996            let dirty = git::git(
4997                &winner.worktree,
4998                &["status", "--porcelain", "--untracked-files=all"],
4999            )
5000            .await?;
5001            let only_withheld = dirty.lines().all(|l| {
5002                l.strip_prefix("?? ")
5003                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
5004            });
5005            if !only_withheld {
5006                bail!(
5007                    "`{}` has uncommitted changes; refusing to touch it — commit or \
5008                     discard them first",
5009                    winner.worktree.display()
5010                );
5011            }
5012            git::worktree_remove(&self.state.repo, &winner.worktree)
5013                .await
5014                .ok();
5015        }
5016        let fix_worktree = self.state.worktree_root().join("operator-fix");
5017        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
5018        git::git(
5019            &self.state.repo,
5020            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
5021        )
5022        .await
5023        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
5024        if !git::is_clean(&fix_worktree).await? {
5025            git::worktree_remove(&self.state.repo, &fix_worktree)
5026                .await
5027                .ok();
5028            bail!(
5029                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
5030                winner.branch
5031            );
5032        }
5033        let repo_for_setup = self.state.repo.clone();
5034        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
5035            Ok(()) => {
5036                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
5037            }
5038            Err(e) => Err(e),
5039        };
5040        if let Err(e) = setup {
5041            git::worktree_remove(&self.state.repo, &fix_worktree)
5042                .await
5043                .ok();
5044            return Err(e);
5045        }
5046
5047        let run_id = self.state.id.clone();
5048        let prompts = self.state.config.prompts.clone();
5049        let language = self.state.config.graph.language.clone();
5050        let sessions = self.state.config.graph.sessions;
5051        let artifacts = agent::artifacts_dir(&self.state.dir());
5052        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
5053            .findings
5054            .iter()
5055            .map(|f| Finding {
5056                id: f.id.clone(),
5057                severity: f.severity,
5058                file: f.file.clone(),
5059                line: f.line,
5060                title: f.title.clone(),
5061                detail: f.detail.clone(),
5062            })
5063            .collect();
5064        let fix_prompt = prompt::operator_fix_in(
5065            &self.state.instruction,
5066            &finding_list,
5067            reason,
5068            &stale_details,
5069            &head_at_request,
5070            &language,
5071            &self.state.config.graph.github_language,
5072        );
5073        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5074        drop(coordination);
5075        let (job, seat, out) = self
5076            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
5077                prompt: fix_prompt.clone(),
5078                spec,
5079                seat,
5080                cwd: fix_worktree.clone(),
5081                timeout,
5082                allow_write: true,
5083                sessions,
5084                artifacts: artifacts.clone(),
5085                stem: "operator-fix".to_owned(),
5086                handover: None,
5087            })
5088            .await;
5089        let agent_id = seat.agent.clone();
5090
5091        let mut fix = FixRecord {
5092            agent: agent_id,
5093            addressed: Vec::new(),
5094            rejected: Vec::new(),
5095            notes: String::new(),
5096            committed: false,
5097            failed: None,
5098            duration_ms: 0,
5099            continuation: None,
5100        };
5101        let mut final_seat = seat.clone();
5102        match out {
5103            AgentOutcome::Ok(o) => {
5104                fix.duration_ms = o.duration_ms;
5105                let parsed = verdict::extract_json::<FixReport>(&o.text);
5106                let incomplete_reason = match &parsed {
5107                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5108                        "the reply parsed, but it reported a command whose own CLI \
5109                         never confirmed an exit status"
5110                            .to_owned(),
5111                    ),
5112                    Ok(_) => None,
5113                    Err(e) => Some(e.to_string()),
5114                };
5115                match incomplete_reason {
5116                    None => {
5117                        let report = parsed.expect("checked Ok above");
5118                        fix.addressed = report.addressed;
5119                        fix.rejected = report.rejected;
5120                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5121                    }
5122                    Some(reason) => {
5123                        let (resumed_seat, resolved, failure, cont) = self
5124                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
5125                            .await;
5126                        fix.duration_ms += cont.cumulative_wait_ms;
5127                        fix.continuation = Some(cont);
5128                        final_seat = resumed_seat;
5129                        match resolved {
5130                            Some(report) => {
5131                                fix.addressed = report.addressed;
5132                                fix.rejected = report.rejected;
5133                                fix.notes =
5134                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
5135                            }
5136                            None => fix.failed = failure,
5137                        }
5138                    }
5139                }
5140            }
5141            AgentOutcome::Dropped(o) => {
5142                fix.duration_ms = o.duration_ms;
5143                let why = o
5144                    .dropped
5145                    .as_ref()
5146                    .map(|d| d.why.as_str())
5147                    .unwrap_or("the CLI ended the stream without delivering its answer");
5148                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5149            }
5150            AgentOutcome::Quota(o) => {
5151                self.state.quota.push(QuotaLoss {
5152                    seat: final_seat.key.clone(),
5153                    node: "fix".to_owned(),
5154                    at: Timestamp::now(),
5155                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5156                });
5157                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5158            }
5159            AgentOutcome::Failed(e) => fix.failed = Some(e),
5160            AgentOutcome::ResourceBlocked(e) => {
5161                fix.failed = Some(format!("the fixer was not started: {e}"));
5162            }
5163        }
5164        if fix.continuation.is_none() {
5165            fix.continuation = Some(ContinuationRecord::not_needed());
5166        }
5167        self.state.seats.insert(final_seat.key.clone(), final_seat);
5168
5169        let rescue_message = format!(
5170            "magi: operator-selected fix ({}) (uncommitted work)",
5171            self.state.operator_fixes[request_index]
5172                .findings
5173                .iter()
5174                .map(|f| f.id.as_str())
5175                .collect::<Vec<_>>()
5176                .join(", ")
5177        );
5178        let repo = self.state.repo.clone();
5179        let coordination = self.coordinate(&repo).await?;
5180        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5181            self.state.note_withheld("fix", &r.withheld);
5182        }
5183        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5184        fix.committed = after != head_at_request;
5185        git::worktree_remove(&self.state.repo, &fix_worktree)
5186            .await
5187            .ok();
5188        drop(coordination);
5189
5190        self.state.event(
5191            "fix",
5192            match &fix.failed {
5193                Some(reason) => format!(
5194                    "operator fix: adoption report was lost ({reason}); {}",
5195                    if fix.committed {
5196                        "committed"
5197                    } else {
5198                        "NO new commit"
5199                    }
5200                ),
5201                None => format!(
5202                    "operator fix: {} addressed, {} rejected, {}",
5203                    fix.addressed.len(),
5204                    fix.rejected.len(),
5205                    if fix.committed {
5206                        "committed"
5207                    } else {
5208                        "NO new commit"
5209                    }
5210                ),
5211            },
5212        );
5213
5214        // Every selected finding gets an outcome — never left `Pending` once
5215        // the fixer's own turn is over. A report that never came back at all
5216        // marks every one of them `Unreported`, not silently "not addressed":
5217        // quota, a dropped stream, or an exhausted continuation are gaps in
5218        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
5219        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
5220        for f in &mut self.state.operator_fixes[request_index].findings {
5221            f.outcome = if fix.failed.is_some() {
5222                OperatorFixOutcome::Unreported
5223            } else if fix.addressed.contains(&f.id) {
5224                OperatorFixOutcome::Addressed
5225            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5226                OperatorFixOutcome::Rejected { why: r.why.clone() }
5227            } else {
5228                OperatorFixOutcome::Unreported
5229            };
5230        }
5231
5232        let committed = fix.committed;
5233        if committed {
5234            self.state.operator_fixes[request_index].result_head = Some(after.clone());
5235        }
5236        self.state.operator_fixes[request_index].fix = Some(fix);
5237        // Saved again now that the fixer's own outcome is final, on top of
5238        // the save right after the request was first pushed above.
5239        self.state.save()?;
5240
5241        if committed {
5242            self.state.event(
5243                "fix",
5244                format!(
5245                    "operator fix committed {}; opening a follow-up review-only run",
5246                    short(&after)
5247                ),
5248            );
5249            // The operator asked for the fix, and the follow-up serves whatever
5250            // task the run it follows served.
5251            let origin =
5252                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5253            match Self::review(
5254                &self.state.repo,
5255                &winner.branch,
5256                self.state.config.clone(),
5257                origin,
5258            )
5259            .await
5260            {
5261                Ok(mut follow_up) => {
5262                    follow_up.state.event(
5263                        "start",
5264                        format!(
5265                            "requested by an operator fix on run {} for finding(s) {}",
5266                            self.state.id,
5267                            self.state.operator_fixes[request_index]
5268                                .findings
5269                                .iter()
5270                                .map(|f| f.id.as_str())
5271                                .collect::<Vec<_>>()
5272                                .join(", "),
5273                        ),
5274                    );
5275                    follow_up.state.save()?;
5276                    let follow_up_id = follow_up.state.id.clone();
5277                    // The follow-up is a run like any other: it belongs to the
5278                    // task of the run it follows, or to one filed for it.
5279                    let adopted = match crate::direct::adopt(
5280                        &follow_up.state,
5281                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5282                    ) {
5283                        Ok(a) => a,
5284                        Err(e) => {
5285                            // An ownerless run must not spend agent calls; it
5286                            // stays saved, and `magi run --resume` adopts it.
5287                            self.state.event(
5288                                "fix",
5289                                format!(
5290                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5291                                ),
5292                            );
5293                            self.state.save()?;
5294                            return Ok(());
5295                        }
5296                    };
5297                    let executed = follow_up.execute().await;
5298                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5299                    if let Some(a) = adopted {
5300                        a.finish(&follow_up.state, executed);
5301                    }
5302                    if let Some(e) = failure {
5303                        self.state.event(
5304                            "fix",
5305                            format!(
5306                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5307                            ),
5308                        );
5309                    }
5310                    self.state.operator_fixes[request_index].follow_up_review_run =
5311                        Some(follow_up_id);
5312                }
5313                Err(e) => {
5314                    self.state.event(
5315                        "fix",
5316                        format!("committed the fix but could not open a follow-up review: {e:#}"),
5317                    );
5318                }
5319            }
5320            self.state.save()?;
5321        }
5322
5323        Ok(())
5324    }
5325
5326    // --------------------------------------------------------------- review
5327
5328    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
5329    /// names, in order, each at most once, moving on only when the call
5330    /// advances (an error, a quota hit or nothing usable - the same decision
5331    /// point `agent::chain_advances` is for the other chained roles).
5332    ///
5333    /// Each agent gets its own seat from `Runner::seat`: the same agent
5334    /// continues its conversation (the winner's own implementer seat when it
5335    /// is the winner's author, now that the competition is over), another
5336    /// takes a fresh one so the full prompt is sent again. A handover is
5337    /// recorded under `node`, which is what makes the fallback stick for the
5338    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
5339    /// is returned, so an exhausted chain reads exactly like a single failed
5340    /// fixer: one quota loss, the same wording, the same refund. An earlier
5341    /// attempt's edits are left in the tree and judged, with the final
5342    /// attempt's, by what git says afterwards.
5343    ///
5344    /// Returns the job that produced the outcome, for `continue_fix_report`.
5345    async fn ask_fixer(
5346        &mut self,
5347        winner: &Candidate,
5348        node: &'static str,
5349        round: Option<usize>,
5350        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5351    ) -> (SeatJob, SeatState, AgentOutcome) {
5352        let attempts = fixer::attempts(&self.state, &self.roles, winner);
5353        self.ask_fixer_among(attempts, node, round, None, build)
5354            .await
5355    }
5356
5357    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
5358    /// worktree and a commit: before the next agent is asked after one that
5359    /// did not satisfy the call, the worktree is put back on that commit, so
5360    /// a failed agent's partial edits are not carried into the next one.
5361    async fn ask_fixer_among(
5362        &mut self,
5363        attempts: Vec<(AgentSpec, String)>,
5364        node: &'static str,
5365        round: Option<usize>,
5366        restore: Option<(&Path, &str)>,
5367        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5368    ) -> (SeatJob, SeatState, AgentOutcome) {
5369        let run_id = self.state.id.clone();
5370        let prompts = self.state.config.prompts.clone();
5371        let cache = self.state.config.cache_dir();
5372        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5373        let mut last = None;
5374        for (i, (spec, key)) in attempts.into_iter().enumerate() {
5375            let seat = self.seat(&key, &spec.id);
5376            let mut job = build(spec.clone(), seat);
5377            if i > 0 {
5378                job.stem = format!("{}-{}", job.stem, spec.id);
5379            }
5380            let ctx = WaveCtx {
5381                carry_seats: false,
5382                pauses: Some((&self.pause, &self.interrupt)),
5383                run: &run_id,
5384                node,
5385                prompts: &prompts,
5386                cache: cache.as_deref(),
5387                round,
5388            };
5389            let (seat, out) =
5390                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5391            // A resource block never advances the chain: the chosen agent
5392            // was not asked, so it must be the one asked again on resume.
5393            let advances = match &out {
5394                AgentOutcome::Ok(o) => agent::output_advances(o),
5395                AgentOutcome::ResourceBlocked(_) => false,
5396                _ => true,
5397            };
5398            if let Some(next) = ids.get(i + 1)
5399                && advances
5400            {
5401                self.state.seats.insert(seat.key.clone(), seat.clone());
5402                let class =
5403                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5404                let reason = match &out {
5405                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5406                    other => fail_reason(other),
5407                };
5408                // A failed agent's edits must not reach the next one, and a
5409                // restore that did not work ends the chain: the call fails
5410                // and the caller's own (confirmed) restore decides.
5411                if let Some((wt, rev)) = restore
5412                    && let Err(e) = self.graft_restore(wt, rev).await
5413                {
5414                    let why =
5415                        format!("could not restore the worktree before the next fixer: {e:#}");
5416                    tracing::warn!("{why}");
5417                    let out = if e.is::<crate::repo_lock::ResourceBlocked>() {
5418                        AgentOutcome::ResourceBlocked(why)
5419                    } else {
5420                        AgentOutcome::Failed(why)
5421                    };
5422                    last = Some((job, seat, out));
5423                    break;
5424                }
5425                record_handover(
5426                    &mut self.state,
5427                    node,
5428                    &seat.key,
5429                    &spec.id,
5430                    next,
5431                    &class,
5432                    &reason,
5433                );
5434                continue;
5435            }
5436            last = Some((job, seat, out));
5437            break;
5438        }
5439        last.expect("the fixer chain always has an entry")
5440    }
5441
5442    /// The one repair a verification-only failure earns (see
5443    /// [`crate::recovery`]), before the first review round of a run that took
5444    /// a branch over.
5445    ///
5446    /// Saved as started *before* the fixer is called, so a resume never
5447    /// repairs twice; a start found on re-entry is rolled back to the commit
5448    /// it began from. A failed or unusable fixer leaves the tree as it was and
5449    /// the review goes on over it, so the first round's own verification
5450    /// reports the failure again. The failure is never carried into a round as
5451    /// a result: verification is re-run on whatever commit this leaves.
5452    async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5453        use crate::recovery::{Mode, Repair};
5454        if !self.state.reviews.is_empty() {
5455            return Ok(());
5456        }
5457        let Some(mut rec) = self.state.recovery.clone() else {
5458            return Ok(());
5459        };
5460        if rec.mode != Mode::VerifyRepair {
5461            return Ok(());
5462        }
5463        let wt = winner.worktree.clone();
5464        let mut head = git::rev_parse(&wt, "HEAD").await?;
5465        match rec.repair.clone() {
5466            Repair::Done { .. } => return Ok(()),
5467            Repair::WaitingForRestore { before } => {
5468                self.graft_restore(&wt, &before).await?;
5469                head = before;
5470                rec.repair = Repair::NotStarted;
5471                self.state.recovery = Some(rec.clone());
5472                self.state.save()?;
5473            }
5474            Repair::Started { before } => {
5475                // Interrupted: the unfinished edits are not trusted.
5476                // An unconfirmed rollback blocks the run with `Started` kept,
5477                // so a resume retries it; the tree is never reviewed as is.
5478                if let Err(e) = self.graft_restore(&wt, &before).await {
5479                    if e.is::<crate::repo_lock::ResourceBlocked>() {
5480                        return Err(e);
5481                    }
5482                    return self
5483                        .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5484                        .await;
5485                }
5486                rec.repair = Repair::Done {
5487                    before: before.clone(),
5488                    after: before,
5489                    committed: false,
5490                    failed: Some("interrupted; rolled back".to_owned()),
5491                };
5492                self.state
5493                    .event("recovery", "the repair was interrupted and rolled back");
5494                self.state.recovery = Some(rec);
5495                self.state.save()?;
5496                return Ok(());
5497            }
5498            Repair::NotStarted => {}
5499        }
5500        if crate::recovery::recheck(&mut rec, &head) {
5501            self.state.event("recovery", rec.summary());
5502            self.state.recovery = Some(rec);
5503            self.state.save()?;
5504            return Ok(());
5505        }
5506        rec.repair = Repair::Started {
5507            before: head.clone(),
5508        };
5509        self.state.recovery = Some(rec.clone());
5510        self.state.save()?;
5511
5512        let language = self.state.config.graph.language.clone();
5513        let sessions = self.state.config.graph.sessions;
5514        let artifacts = agent::artifacts_dir(&self.state.dir());
5515        let fix_prompt = prompt::recovery_fix(
5516            &self.state.instruction,
5517            &rec,
5518            self.state.config.graph.review_rounds,
5519            &language,
5520        );
5521        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5522        self.state.event(
5523            "recovery",
5524            format!(
5525                "repair: the fixer starts from the failed verification on {}",
5526                short(&head)
5527            ),
5528        );
5529        let (_job, seat, out) = self
5530            .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5531                prompt: fix_prompt.clone(),
5532                spec,
5533                seat,
5534                cwd: wt.clone(),
5535                timeout,
5536                allow_write: true,
5537                sessions,
5538                artifacts: artifacts.clone(),
5539                stem: "fix-recovery".to_owned(),
5540                handover: None,
5541            })
5542            .await;
5543        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5544            rec.repair = Repair::WaitingForRestore { before: head };
5545            self.state.recovery = Some(rec);
5546            return self.stop_for_resource();
5547        }
5548        let failed = match &out {
5549            AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5550            AgentOutcome::Quota(o) => {
5551                self.state.quota.push(QuotaLoss {
5552                    seat: seat.key.clone(),
5553                    node: "fix".to_owned(),
5554                    at: Timestamp::now(),
5555                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5556                });
5557                Some("rate limited (quota); the fixer could not run".to_owned())
5558            }
5559            AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5560            AgentOutcome::Failed(e) => Some(e.clone()),
5561        };
5562        self.state.seats.insert(seat.key.clone(), seat);
5563        let coordination = self.coordinate(&wt).await?;
5564        let failed = match failed {
5565            // The agent's partial edits are not kept.
5566            Some(why) => {
5567                if let Err(e) = git::restore_to(&wt, &head).await {
5568                    return self
5569                        .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5570                        .await;
5571                }
5572                Some(why)
5573            }
5574            None => {
5575                if let Ok(r) = git::rescue_commit(
5576                    &wt,
5577                    "magi: recovery repair of the failed verification (uncommitted work)",
5578                )
5579                .await
5580                {
5581                    self.state.note_withheld("fix", &r.withheld);
5582                }
5583                None
5584            }
5585        };
5586        let after = git::rev_parse(&wt, "HEAD").await?;
5587        drop(coordination);
5588        self.state.event(
5589            "recovery",
5590            match &failed {
5591                Some(why) => format!("repair: nothing kept ({why})"),
5592                None if after != head => {
5593                    format!("repair: committed {}", short(&after))
5594                }
5595                None => "repair: the fixer made no commit".to_owned(),
5596            },
5597        );
5598        rec.repair = Repair::Done {
5599            before: head.clone(),
5600            committed: after != head,
5601            after,
5602            failed,
5603        };
5604        self.state.recovery = Some(rec);
5605        self.state.save()?;
5606        Ok(())
5607    }
5608
5609    /// A repair whose rollback could not be confirmed: the run is blocked and
5610    /// resumable, and the unconfirmed state is never reviewed.
5611    async fn stop_recovery(
5612        &mut self,
5613        mut rec: crate::recovery::RecoveryRecord,
5614        head: &str,
5615        why: String,
5616    ) -> Result<()> {
5617        // `Started` stays recorded: a resume retries the restore.
5618        rec.repair = crate::recovery::Repair::Started {
5619            before: head.to_owned(),
5620        };
5621        self.state.recovery = Some(rec);
5622        self.state
5623            .event("recovery", format!("repair could not be undone: {why}"));
5624        self.state.status = RunStatus::Blocked;
5625        self.state.save()?;
5626        bail!("recovery repair could not be rolled back: {why}")
5627    }
5628
5629    async fn review_loop(&mut self) -> Result<()> {
5630        // A base that would not rebase is a person's decision, not a review
5631        // round: nothing here would change the answer, and reviewers and a
5632        // fixer would be spending real budget on a tree that cannot land
5633        // regardless of what they find.
5634        if self
5635            .state
5636            .base_sync
5637            .as_ref()
5638            .is_some_and(|s| s.conflict.is_some())
5639        {
5640            return Ok(());
5641        }
5642        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
5643        // agent files with `magi task add` name the run that paid for it. The
5644        // prompt overlay is cloned alongside it because the waves borrow it
5645        // while `self` is mutably borrowed by the node's own bookkeeping.
5646        let run_id = self.state.id.clone();
5647        let prompts = self.state.config.prompts.clone();
5648        let Some(winner) = self.state.winner().cloned() else {
5649            return Ok(());
5650        };
5651        let max_rounds = self.state.config.graph.review_rounds;
5652        // A clean round, an exhausted round budget, or a stalled tree (see
5653        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
5654        // they are recorded — recomputed here, not read off `status`, so a
5655        // reentry into a run that already stopped restates the identical
5656        // verdict instead of silently handing back whatever an earlier node
5657        // in this same walk clobbered `status` to (a solo-candidate
5658        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
5659        // below runs an empty range once the budget is spent, and would
5660        // otherwise fall through without touching `status` at all.
5661        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5662            // A reentry after a crash between the last round's save and
5663            // `stop_reviewing` reaches the hand-off here, not there.
5664            if status == RunStatus::Gating {
5665                self.record_contested_handoff();
5666            }
5667            self.state.status = status;
5668            self.state.save()?;
5669            return Ok(());
5670        }
5671        self.state.status = RunStatus::Reviewing;
5672        // A last recorded round whose own verification never resolved
5673        // (`ResourceBlocked` — the shared build cache, not the patch) is
5674        // never a concluded round, whatever the round budget says: starting
5675        // a fresh round on top of it would spend a whole new reviewer wave
5676        // re-reading an unchanged patch instead of just retrying the one
5677        // check that actually needs it, and once the budget is spent the
5678        // loop below has nothing left to do at all (its range is empty).
5679        // Retry that check directly instead, exactly the same retry
5680        // `stop_reviewing` already does for its own catch-up case.
5681        if self
5682            .state
5683            .reviews
5684            .last()
5685            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5686        {
5687            let shell = self.state.config.shell();
5688            return self
5689                .stop_reviewing(
5690                    "the last round's own verification never resolved",
5691                    &shell,
5692                    &winner.worktree,
5693                )
5694                .await;
5695        }
5696
5697        self.recovery_repair(&winner).await?;
5698        if self.resource_halt()? {
5699            return Ok(());
5700        }
5701
5702        let repo = self.state.repo.clone();
5703        let root = self.state.worktree_root();
5704        let language = self.state.config.graph.language.clone();
5705        let sessions = self.state.config.graph.sessions;
5706        let artifacts = self.merge_repair_artifacts();
5707        let base = self.landing_base();
5708        let base_short = short(&base);
5709        let reviewers = self.roles.reviewers.clone();
5710        let shell = self.state.config.shell();
5711
5712        for round in (self.state.reviews.len() + 1)..=max_rounds {
5713            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5714            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5715            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5716            // The prior round's own record, already persisted — never a
5717            // hand-carried variable of just its failing output: that is
5718            // exactly what let a round's e2e result drift out of sync with
5719            // which commit it was actually about (see `SCHEMA`'s doc for
5720            // schema 8). Judged against `head`, the commit reviewers are
5721            // about to look at now, so the summary always reads as "an
5722            // earlier head" here — this round's own patch has not been
5723            // checked yet.
5724            let prev_verification = self
5725                .state
5726                .reviews
5727                .last()
5728                .and_then(|r| r.verification_summary(&head));
5729            // Only the first round of a run that took a branch over: later
5730            // rounds have this run's own history.
5731            let prior_section = match (&self.state.recovery, round) {
5732                (Some(rec), 1) if self.state.reviews.is_empty() => {
5733                    let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5734                        (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5735                            Some(crate::recovery::head_of(
5736                                &git::diff(&winner.worktree, reviewed, "HEAD")
5737                                    .await
5738                                    .unwrap_or_default(),
5739                                crate::recovery::SINCE_DIFF_MAX,
5740                            ))
5741                        }
5742                        _ => None,
5743                    };
5744                    Some(prompt::recovery_review(rec, since.as_deref()))
5745                }
5746                _ => None,
5747            };
5748
5749            // Each reviewer gets its own detached checkout of exactly this
5750            // commit: nobody can perturb the winner's tree, and the fixer can
5751            // keep working without racing a reviewer.
5752            let mut jobs = Vec::new();
5753            let coordination = self.coordinate(&repo).await?;
5754            for (r, spec) in reviewers.iter().cloned().enumerate() {
5755                let wt = root.join(format!("review-{}", r + 1));
5756                // `clean -fdx` in a reset wipes setup's products, so every
5757                // round sets the seat up again.
5758                if wt.exists() {
5759                    git::reset_detached(&wt, &head).await?;
5760                } else {
5761                    git::worktree_add_detached(&repo, &wt, &head).await?;
5762                }
5763                self.setup_seat_worktree(&repo, &wt, false).await?;
5764                let seat_key = format!("review-{}", r + 1);
5765                // The seat starts the round on whoever answered it last, not
5766                // on the agent the spec names, so a failure is not re-paid.
5767                let spec = pick_start_spec(
5768                    &self.roles.reviewer_roster,
5769                    spec,
5770                    self.state.seat_history.get(&seat_key),
5771                );
5772                let seat = self.seat(&seat_key, &spec.id);
5773                jobs.push(SeatJob {
5774                    prompt: prompt::review(&prompt::ReviewCtx {
5775                        instruction: &self.state.instruction,
5776                        branch: &winner.branch,
5777                        base_short: &base_short,
5778                        stat: &stat,
5779                        patch: &patch,
5780                        verification: prev_verification.as_ref(),
5781                        prior: prior_section.as_deref(),
5782                        reviewers: reviewers.len(),
5783                        round,
5784                        rounds: max_rounds,
5785                        // A review-only run has no rankings, so nothing
5786                        // competed for this patch and the reviewer is told so.
5787                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5788                        lens: Lens::for_seat(r),
5789                        language: &language,
5790                        github_language: &self.state.config.graph.github_language,
5791                    }),
5792                    spec,
5793                    seat,
5794                    cwd: wt,
5795                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5796                    allow_write: false,
5797                    sessions,
5798                    artifacts: artifacts.clone(),
5799                    stem: format!("review-{round}-{}", r + 1),
5800                    handover: None,
5801                });
5802            }
5803
5804            drop(coordination);
5805            self.state.event(
5806                "review",
5807                format!(
5808                    "round {round}: {} reviewers on {}",
5809                    jobs.len(),
5810                    short(&head)
5811                ),
5812            );
5813            let mut quota_losses = Vec::new();
5814            let review_retries = self.state.config.graph.retries;
5815            let review_cache = self.state.config.cache_dir();
5816            let ctx = WaveCtx {
5817                carry_seats: true,
5818                pauses: Some((&self.pause, &self.interrupt)),
5819                run: &run_id,
5820                node: "review",
5821                prompts: &prompts,
5822                cache: review_cache.as_deref(),
5823                round: Some(round),
5824            };
5825            let results = ask_json_wave::<Review>(
5826                jobs,
5827                Arc::clone(&self.sem),
5828                review_retries,
5829                &self.roles.reviewer_roster,
5830                &ctx,
5831                &mut quota_losses,
5832                &mut self.state,
5833                &|_: &Review| Ok(()),
5834            )
5835            .await;
5836            if self.state.resource_block.is_some() {
5837                return self.stop_for_resource();
5838            }
5839            // Counted before the move below: how many of *this* round's
5840            // reviewer seats were lost to their own rate limit, as opposed to
5841            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5842            let round_quota_missing = quota_losses.len();
5843            self.state.quota.extend(quota_losses);
5844
5845            let mut records = Vec::new();
5846            let mut all_findings = Vec::new();
5847            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5848                let agent_id = seat.agent.clone();
5849                self.state.seats.insert(seat.key.clone(), seat);
5850                let mut record = ReviewRecord {
5851                    reviewer: r + 1,
5852                    agent: agent_id,
5853                    summary: String::new(),
5854                    findings: Vec::new(),
5855                    vote: None,
5856                    failed: None,
5857                    duration_ms: 0,
5858                    // Set for both outcomes: `failed: Some(_)` with
5859                    // `attempts > 0` is a seat every retry still lost, not a
5860                    // recovered one — only `failed: None` with `attempts > 0`
5861                    // reads as "answered after a nudge" (see this field's own
5862                    // doc).
5863                    attempts,
5864                };
5865                match res {
5866                    Ok((review, out)) => {
5867                        // Sanitized here, at the point every other piece of
5868                        // agent prose in this file is (candidate summaries,
5869                        // deliberation turns, vote reasons): a reviewer's own
5870                        // words are the one thing about it that could name
5871                        // it, and reconsideration below broadcasts this same
5872                        // summary and these same findings to every other
5873                        // seat on the panel.
5874                        record.summary =
5875                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5876                        record.vote = Some(review.vote);
5877                        record.duration_ms = out.duration_ms;
5878                        for (n, mut f) in review.findings.into_iter().enumerate() {
5879                            // ids are magi's, never the agent's: the fixer's
5880                            // adoption report is keyed by them.
5881                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5882                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5883                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5884                            // `file` is agent-supplied prose too, never
5885                            // checked against the real tree — the same
5886                            // exposure `title`/`detail` above have, just in
5887                            // a field easy to forget because it looks like a
5888                            // path rather than free text.
5889                            f.file = f
5890                                .file
5891                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5892                            all_findings.push(f.clone());
5893                            record.findings.push(f);
5894                        }
5895                        self.state.event(
5896                            "review",
5897                            format!(
5898                                "round {round}: reviewer {} voted {} with {} finding(s)",
5899                                r + 1,
5900                                review.vote.label(),
5901                                record.findings.len()
5902                            ),
5903                        );
5904                    }
5905                    Err(e) => {
5906                        record.failed = Some(e.to_string());
5907                        self.state.event(
5908                            "review",
5909                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5910                        );
5911                    }
5912                }
5913                records.push(record);
5914            }
5915
5916            // Tally the round's votes and, if they split, spend the one
5917            // round of reconsideration the split -> deliberate -> revote
5918            // shape `judge`/`vote` use for the panel, sized down to what a
5919            // read-only review round can afford: one round, and a revote
5920            // rather than an argument, because the panel already wrote its
5921            // reasoning down as findings the first time around.
5922            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5923            let vote_split =
5924                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5925            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5926            if vote_split {
5927                self.state.event(
5928                    "review",
5929                    format!(
5930                        "round {round}: votes split ({}) — one round of reconsideration",
5931                        initial_votes
5932                            .iter()
5933                            .map(|v| v.label())
5934                            .collect::<Vec<_>>()
5935                            .join(", ")
5936                    ),
5937                );
5938                // Seats read every seat's findings and votes, still numbered
5939                // and never named — the same anonymity `review` itself keeps.
5940                let panel: Vec<ReviewSeatReport<'_>> = records
5941                    .iter()
5942                    .filter_map(|r| {
5943                        r.vote.map(|vote| ReviewSeatReport {
5944                            reviewer: r.reviewer,
5945                            vote,
5946                            summary: &r.summary,
5947                            findings: &r.findings,
5948                        })
5949                    })
5950                    .collect();
5951
5952                let mut jobs = Vec::new();
5953                let mut seats_at = Vec::new();
5954                for (r, spec) in reviewers.iter().cloned().enumerate() {
5955                    // A seat with no initial vote has nothing to reconsider
5956                    // from and stays absent, the same as it stayed absent
5957                    // from `panel` above.
5958                    if records[r].vote.is_none() {
5959                        continue;
5960                    }
5961                    let wt = root.join(format!("review-{}", r + 1));
5962                    let seat_key = format!("review-{}", r + 1);
5963                    let spec = self.occupant(&seat_key, spec);
5964                    let seat = self.seat(&seat_key, &spec.id);
5965                    // A seat with no live session has already forgotten the
5966                    // initial review's prompt — restate the patch it is
5967                    // voting on, the same as `deliberate`/`vote` do for a
5968                    // judge in the same position.
5969                    // The panel already carries this seat's own review and
5970                    // vote, so restating the patch makes the prompt whole for
5971                    // a seat handed to another agent.
5972                    let build = |with_patch: bool| {
5973                        prompt::review_reconsider(&ReviewReconsiderCtx {
5974                            instruction: &self.state.instruction,
5975                            reviewer: r + 1,
5976                            lens: Lens::for_seat(r),
5977                            panel: &panel,
5978                            patch: with_patch.then_some(ReviewPatch {
5979                                branch: &winner.branch,
5980                                base_short: &base_short,
5981                                stat: &stat,
5982                                patch: &patch,
5983                            }),
5984                            round,
5985                            rounds: max_rounds,
5986                            language: &language,
5987                        })
5988                    };
5989                    let full = build(true);
5990                    let prompt = if has_context(&spec, &seat, sessions) {
5991                        build(false)
5992                    } else {
5993                        full.clone()
5994                    };
5995                    jobs.push(SeatJob {
5996                        prompt,
5997                        spec,
5998                        seat,
5999                        cwd: wt,
6000                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
6001                        allow_write: false,
6002                        sessions,
6003                        artifacts: artifacts.clone(),
6004                        stem: format!("review-{round}-reconsider-{}", r + 1),
6005                        handover: Some(full),
6006                    });
6007                    seats_at.push(r);
6008                }
6009
6010                let mut recon_quota_losses = Vec::new();
6011                let recon_cache = self.state.config.cache_dir();
6012                let recon_ctx = WaveCtx {
6013                    carry_seats: true,
6014                    pauses: Some((&self.pause, &self.interrupt)),
6015                    run: &run_id,
6016                    node: "review",
6017                    prompts: &prompts,
6018                    cache: recon_cache.as_deref(),
6019                    round: Some(round),
6020                };
6021                let recon_results = ask_json_wave::<ReviewRevote>(
6022                    jobs,
6023                    Arc::clone(&self.sem),
6024                    review_retries,
6025                    &self.roles.reviewer_roster,
6026                    &recon_ctx,
6027                    &mut recon_quota_losses,
6028                    &mut self.state,
6029                    &|_: &ReviewRevote| Ok(()),
6030                )
6031                .await;
6032                if self.state.resource_block.is_some() {
6033                    return self.stop_for_resource();
6034                }
6035                self.state.quota.extend(recon_quota_losses);
6036
6037                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
6038                    let agent_id = seat.agent.clone();
6039                    self.state.seats.insert(seat.key.clone(), seat);
6040                    let mut rec = ReviewRevoteRecord {
6041                        reviewer: r + 1,
6042                        agent: agent_id,
6043                        vote: None,
6044                        reason: String::new(),
6045                        failed: None,
6046                    };
6047                    match res {
6048                        Ok((rv, _)) => {
6049                            rec.vote = Some(rv.vote);
6050                            rec.reason =
6051                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
6052                            self.state.event(
6053                                "review",
6054                                format!(
6055                                    "round {round}: reviewer {} revoted {}",
6056                                    r + 1,
6057                                    rv.vote.label()
6058                                ),
6059                            );
6060                        }
6061                        Err(e) => {
6062                            rec.failed = Some(e.to_string());
6063                            self.state.event(
6064                                "review",
6065                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
6066                            );
6067                        }
6068                    }
6069                    reconsideration.push(rec);
6070                }
6071            } else if initial_votes.len() > 1 {
6072                self.state.event(
6073                    "review",
6074                    format!(
6075                        "round {round}: votes agreed ({}) — no reconsideration",
6076                        initial_votes[0].label()
6077                    ),
6078                );
6079            }
6080
6081            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
6082            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6083            // A round that already has a blocking finding and a round left to
6084            // try is going back to the fixer no matter what `verify.e2e`
6085            // says, so running it first only spends the loop's slowest step
6086            // (minutes, for a Rust repo's full test suite) on a head about
6087            // to be rewritten. Deferred, never skipped: `verify.e2e` still
6088            // runs once a round has no blocking findings left (see
6089            // `round_is_clean`, which a deferred — empty — `e2e` can never
6090            // satisfy since `blocking` is nonzero whenever this branch is
6091            // taken), and `stop_reviewing` forces a real run before it will
6092            // ever read a deferred round as green.
6093            let defer_e2e =
6094                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
6095            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
6096                let reason =
6097                    format!("{blocking} blocking finding(s) already required a fix this round");
6098                self.state.event(
6099                    "verify",
6100                    format!(
6101                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
6102                         {}); it will run once a round has none left",
6103                        short(&head)
6104                    ),
6105                );
6106                (Vec::new(), false, true, Some(reason))
6107            } else {
6108                let e2e_commands = self.state.config.verify.e2e.clone();
6109                let cache_dir = self.state.config.cache_dir();
6110                let context = format!("round {round}");
6111                let wait = self.repository_wait();
6112                let (e2e, verify_retried) = with_cache_lease(
6113                    &mut self.state,
6114                    cache_dir.as_deref(),
6115                    "e2e",
6116                    "e2e",
6117                    &winner.worktree,
6118                    &head,
6119                    verify_timeout,
6120                    &context,
6121                    &wait,
6122                    |state, budget| {
6123                        let shell = shell.clone();
6124                        let e2e_commands = e2e_commands.clone();
6125                        let worktree = winner.worktree.clone();
6126                        let context = context.clone();
6127                        async move {
6128                            run_e2e_with_retry(
6129                                state,
6130                                &shell,
6131                                &e2e_commands,
6132                                &worktree,
6133                                budget,
6134                                &context,
6135                            )
6136                            .await
6137                        }
6138                    },
6139                )
6140                .await?;
6141                (e2e, verify_retried, false, None)
6142            };
6143
6144            let expected = records.len();
6145            let answered = records.iter().filter(|r| r.failed.is_none()).count();
6146            let incomplete = answered < expected;
6147            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
6148            let policy = self.state.config.graph.incomplete_review;
6149            let clean = round_is_clean(
6150                blocking,
6151                e2e_ok,
6152                answered,
6153                expected,
6154                round_quota_missing,
6155                policy,
6156            );
6157
6158            let mut round_record = ReviewRound {
6159                round,
6160                head: head.clone(),
6161                verified_head: None,
6162                verified_at: None,
6163                reviews: records,
6164                e2e,
6165                verify_retried,
6166                e2e_deferred,
6167                e2e_defer_reason,
6168                fix: None,
6169                blocking,
6170                answered,
6171                expected,
6172                clean,
6173                progressed: false,
6174                vote_split,
6175                reconsideration,
6176                verdict: None,
6177            };
6178            // The final vote per seat is its revote where reconsideration
6179            // ran and answered, its initial vote otherwise — the same
6180            // fallback `tally` uses for a judge whose private vote failed.
6181            round_record.verdict = ReviewVote::worst(
6182                round_record
6183                    .final_votes()
6184                    .into_iter()
6185                    .map(|(_, _, vote)| vote),
6186            );
6187            // Which commit and when magi actually attempted to check —
6188            // known the moment a command was dispatched against `head`,
6189            // whether or not it finished: a resource-blocked attempt still
6190            // targeted a specific commit at a specific time, and leaving
6191            // that unrecorded is exactly what made `verification_summary`
6192            // report a fresh attempt as "commit unknown ... recorded before
6193            // this was tracked", indistinguishable from a genuinely old,
6194            // untracked record. Only a deferred or unconfigured round never
6195            // ran at all and has nothing to record — see
6196            // `ReviewRound::verified_head`'s own doc.
6197            if !matches!(
6198                round_record.e2e_status(),
6199                E2eStatus::Deferred | E2eStatus::NotConfigured
6200            ) {
6201                round_record.verified_head = Some(head.clone());
6202                round_record.verified_at = Some(Timestamp::now());
6203            }
6204            let this_round_verification = round_record.verification_summary(&head);
6205
6206            if incomplete {
6207                let missing: Vec<String> = round_record
6208                    .reviews
6209                    .iter()
6210                    .filter(|r| r.failed.is_some())
6211                    .map(|r| format!("review-{}", r.reviewer))
6212                    .collect();
6213                self.state.event(
6214                    "review",
6215                    format!(
6216                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6217                        missing.join(", ")
6218                    ),
6219                );
6220            }
6221
6222            if clean {
6223                self.state.event(
6224                    "review",
6225                    if incomplete && policy == IncompleteReviewPolicy::Warn {
6226                        format!(
6227                            "round {round}: clean (warn policy, incomplete panel) — no \
6228                             blocking findings from the seats that answered, verification green"
6229                        )
6230                    } else if incomplete {
6231                        format!(
6232                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6233                             quorum) — no blocking findings from the seats that answered, \
6234                             verification green",
6235                            expected - answered
6236                        )
6237                    } else {
6238                        format!("round {round}: clean — no blocking findings, verification green")
6239                    },
6240                );
6241                self.state.reviews.push(round_record);
6242                self.state.status = RunStatus::Gating;
6243                self.state.save()?;
6244                return Ok(());
6245            }
6246
6247            // Nothing was raised and verification passed, but not every seat
6248            // answered and `round_is_clean` still refused to call it clean —
6249            // either a seat is missing for a reason other than its own quota
6250            // (a crash, a timeout, unparsable output — worth another try), or
6251            // every seat that could have answered lost its quota and nobody
6252            // is left to decide on: re-review rather than send the fixer
6253            // after a round with nothing to fix.
6254            if incomplete && blocking == 0 && e2e_ok {
6255                self.state.reviews.push(round_record);
6256                self.state.save()?;
6257                if round == max_rounds {
6258                    self.state.status = RunStatus::Blocked;
6259                    self.state.event(
6260                        "review",
6261                        format!(
6262                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6263                             refusing to call it clean",
6264                            expected - answered
6265                        ),
6266                    );
6267                    return Ok(());
6268                }
6269                continue;
6270            }
6271
6272            // Nothing for the fixer to act on (`blocking == 0`) and the only
6273            // reason this round is not clean is that magi itself never got
6274            // a command to run — the shared build cache, not the patch (see
6275            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
6276            // the fixer would invite a change to appease contention that has
6277            // nothing to do with the diff, and would leave this attempt
6278            // sitting in the next round's prompt as if it were about an
6279            // earlier, superseded commit rather than what it actually is:
6280            // the same head, still waiting to be checked. Wait for it the
6281            // same way the final round's own contention is already handled,
6282            // whatever round this happens to be.
6283            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6284                self.state.reviews.push(round_record);
6285                return self
6286                    .stop_reviewing(
6287                        "the round's own verification could not run",
6288                        &shell,
6289                        &winner.worktree,
6290                    )
6291                    .await;
6292            }
6293
6294            if round == max_rounds {
6295                self.state.reviews.push(round_record);
6296                return self
6297                    .stop_reviewing(
6298                        &format!(
6299                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6300                        ),
6301                        &shell,
6302                        &winner.worktree,
6303                    )
6304                    .await;
6305            }
6306
6307            // Fix. The winner's own implementer seat continues its conversation:
6308            // the competition is over, so context is pure benefit now.
6309            let blocking_findings: Vec<_> = all_findings
6310                .iter()
6311                .filter(|f| f.severity.blocks())
6312                .cloned()
6313                .collect();
6314            let fix_prompt = prompt::fix_in(
6315                &self.state.instruction,
6316                &blocking_findings,
6317                this_round_verification.as_ref(),
6318                round,
6319                max_rounds,
6320                &language,
6321                &self.state.config.graph.github_language,
6322            );
6323            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6324            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6325            let (job, seat, out) = self
6326                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6327                    prompt: fix_prompt.clone(),
6328                    spec,
6329                    seat,
6330                    cwd: winner.worktree.clone(),
6331                    timeout,
6332                    allow_write: true,
6333                    sessions,
6334                    artifacts: artifacts.clone(),
6335                    stem: format!("fix-{round}"),
6336                    handover: None,
6337                })
6338                .await;
6339            // Not asked: the round is not recorded (so a resume repeats it),
6340            // no fixer was handed over to, and the seat is left as it was.
6341            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6342                return self.stop_for_resource();
6343            }
6344            let agent_id = seat.agent.clone();
6345
6346            let mut fix = FixRecord {
6347                agent: agent_id,
6348                addressed: Vec::new(),
6349                rejected: Vec::new(),
6350                notes: String::new(),
6351                committed: false,
6352                failed: None,
6353                duration_ms: 0,
6354                continuation: None,
6355            };
6356            let mut continuation = ContinuationRecord::not_needed();
6357            let mut final_seat = seat.clone();
6358            match out {
6359                AgentOutcome::Ok(o) => {
6360                    fix.duration_ms = o.duration_ms;
6361                    let parsed = verdict::extract_json::<FixReport>(&o.text);
6362                    // A parsed report standing next to a command this same
6363                    // reply's own CLI never confirmed the exit status of is
6364                    // not a resolved answer — the identical `CommandEvidence`
6365                    // `state.jobs` renders, read here instead of only on
6366                    // display, per the completion judgment and the shown
6367                    // record needing to agree.
6368                    let incomplete_reason = match &parsed {
6369                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6370                            "the reply parsed, but it reported a command whose own CLI never \
6371                             confirmed an exit status"
6372                                .to_owned(),
6373                        ),
6374                        Ok(_) => None,
6375                        Err(e) => Some(e.to_string()),
6376                    };
6377                    match incomplete_reason {
6378                        None => {
6379                            let report = parsed.expect("checked Ok above");
6380                            fix.addressed = report.addressed;
6381                            fix.rejected = report.rejected;
6382                            fix.notes =
6383                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
6384                        }
6385                        Some(reason) => {
6386                            let (resumed_seat, resolved, failure, cont) = self
6387                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6388                                .await;
6389                            fix.duration_ms += cont.cumulative_wait_ms;
6390                            continuation = cont;
6391                            final_seat = resumed_seat;
6392                            match resolved {
6393                                Some(report) => {
6394                                    fix.addressed = report.addressed;
6395                                    fix.rejected = report.rejected;
6396                                    fix.notes = blind::sanitize_prose(
6397                                        &report.notes,
6398                                        &self.state.config.blind,
6399                                    );
6400                                }
6401                                None => fix.failed = failure,
6402                            }
6403                        }
6404                    }
6405                }
6406                // The CLI's raw error JSON is not a fix report to parse.
6407                AgentOutcome::Dropped(o) => {
6408                    fix.duration_ms = o.duration_ms;
6409                    let why = o
6410                        .dropped
6411                        .as_ref()
6412                        .map(|d| d.why.as_str())
6413                        .unwrap_or("the CLI ended the stream without delivering its answer");
6414                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6415                }
6416                AgentOutcome::Quota(o) => {
6417                    self.state.quota.push(QuotaLoss {
6418                        seat: final_seat.key.clone(),
6419                        node: "fix".to_owned(),
6420                        at: Timestamp::now(),
6421                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6422                    });
6423                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6424                }
6425                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6426            }
6427            fix.continuation = Some(continuation);
6428            self.state.seats.insert(final_seat.key.clone(), final_seat);
6429            let coordination = self.coordinate(&winner.worktree).await?;
6430            if let Ok(r) = git::rescue_commit(
6431                &winner.worktree,
6432                &format!("magi: review round {round} fixes (uncommitted work)"),
6433            )
6434            .await
6435            {
6436                self.state.note_withheld("fix", &r.withheld);
6437            }
6438            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6439            fix.committed = after != before;
6440            // Judged by what `git` says moved against base, never by the
6441            // fixer's own `addressed`/`rejected` count — see
6442            // `ReviewRound::progressed`. Propagated with `?`, the same as the
6443            // `patch` snapshot above: swallowing this error would default
6444            // `diff_after` to empty, which almost always differs from a
6445            // non-empty `patch` and reads as "progressed" — exactly backwards
6446            // for a `git` failure the stagnation check cannot see through.
6447            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6448            drop(coordination);
6449            let progressed = diff_after != patch;
6450            let commit_note = if fix.committed {
6451                "committed"
6452            } else {
6453                "NO new commit"
6454            };
6455            let tree_note = if progressed {
6456                "changed vs base"
6457            } else {
6458                "unchanged vs base"
6459            };
6460            self.state.event(
6461                "fix",
6462                match &fix.failed {
6463                    // Distinct on purpose from "0 addressed, 0 rejected": the
6464                    // fixer's own diff still landed (blocking counts do keep
6465                    // falling round over round), only its adoption report did
6466                    // not come back, so this must never read like every
6467                    // finding was reviewed and declined.
6468                    Some(reason) => {
6469                        format!(
6470                            "round {round}: fixer's adoption report was lost ({reason}); \
6471                             {commit_note}, tree {tree_note}"
6472                        )
6473                    }
6474                    None => format!(
6475                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6476                         {tree_note}{}",
6477                        fix.addressed.len(),
6478                        fix.rejected.len(),
6479                        if continuation.outcome == ContinuationOutcome::Resumed {
6480                            format!(
6481                                " (adoption report recovered after {} continuation(s))",
6482                                continuation.attempts
6483                            )
6484                        } else {
6485                            String::new()
6486                        },
6487                    ),
6488                },
6489            );
6490            round_record.fix = Some(fix);
6491            round_record.progressed = progressed;
6492            self.state.reviews.push(round_record);
6493            self.state.save()?;
6494
6495            // The fixer's own report never came back this round, even after
6496            // `continue_fix_report`'s own budget was spent on it — not an
6497            // ordinary "no report" (dropped stream, quota, plain failure),
6498            // which already reads that way and is left to the existing round
6499            // budget. Stopping here, rather than opening another round, is
6500            // what keeps a next reviewer/fixer wave from ever being
6501            // dispatched onto `winner.worktree` while whatever the seat's
6502            // last call may still have running there is unaccounted for: no
6503            // process liveness check exists (and none is being added — see
6504            // AGENTS.md/this task's own scope), so the only way to honour
6505            // "nothing starts before a valid report returns" is to not start
6506            // anything further on this worktree from this run at all.
6507            if matches!(
6508                continuation.outcome,
6509                ContinuationOutcome::Exhausted
6510                    | ContinuationOutcome::QuotaLost
6511                    | ContinuationOutcome::NoSession
6512            ) {
6513                return self
6514                    .stop_reviewing(
6515                        "the fixer's adoption report never came back, even after resuming its \
6516                         own seat; refusing to start another round against the same worktree \
6517                         while that is unresolved",
6518                        &shell,
6519                        &winner.worktree,
6520                    )
6521                    .await;
6522            }
6523
6524            let streak = self
6525                .state
6526                .reviews
6527                .iter()
6528                .rev()
6529                .take_while(|r| !r.progressed)
6530                .count();
6531            if streak >= STAGNANT_LIMIT {
6532                return self
6533                    .stop_reviewing(
6534                        &format!(
6535                            "the tree has not moved against base for {streak} round(s) in a row"
6536                        ),
6537                        &shell,
6538                        &winner.worktree,
6539                    )
6540                    .await;
6541            }
6542        }
6543        Ok(())
6544    }
6545
6546    /// Decide, from the last recorded round's own verification, whether
6547    /// stopping the review loop is a hand-off or a genuine block.
6548    ///
6549    /// Called once the loop has given up trying — the round budget is spent,
6550    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
6551    /// findings still open, never while a round is still clean or the
6552    /// incomplete-panel case handled inline above. Gate and e2e are facts
6553    /// about the tree; a lingering review finding is an opinion, and this
6554    /// workload's own `magi stats` puts reviewer precision low enough
6555    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
6556    /// must not by itself stand between a green, verified change and the
6557    /// human who decides what to do with it. A red e2e is not an opinion, so
6558    /// that case still blocks, with the failing command and a tail of its
6559    /// output recorded here rather than left in `run.json` for someone to go
6560    /// find.
6561    ///
6562    /// A round that deferred its own e2e (see [`Config::graph`]'s
6563    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
6564    /// only because nothing ran, and treating an empty list as a passing one
6565    /// here is exactly the "deferred painted green" bug this function exists
6566    /// to not have. When the last round's own verification never resolved —
6567    /// deferred on purpose, or a real attempt the shared build cache blocked
6568    /// — this makes (or retries) the real run, on the actual worktree this
6569    /// loop is about to stop touching, before deciding anything. A
6570    /// resource-blocked attempt is likewise never read as either green or
6571    /// red: it is evidence about the machine, not the patch (see
6572    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
6573    /// blocked cache leaves this call without deciding rather than guessing
6574    /// — the caller retries on a later reentry.
6575    /// Record, once, that the review loop handed off over a blocking finding
6576    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
6577    /// `land` asks the owner even with `land_approval` off. Called from every
6578    /// path that concludes `Gating`; a reentry keeps the first record.
6579    fn record_contested_handoff(&mut self) {
6580        if self.state.contested_handoff.is_some() {
6581            return;
6582        }
6583        let Some(contested) = self
6584            .state
6585            .reviews
6586            .last()
6587            .and_then(ReviewRound::contested_handoff)
6588        else {
6589            return;
6590        };
6591        self.state.event(
6592            "review",
6593            format!(
6594                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6595                 wait for the owner's approval",
6596                contested.findings.len(),
6597                contested.rejecters.len()
6598            ),
6599        );
6600        self.state.contested_handoff = Some(contested);
6601    }
6602
6603    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6604        let round_idx = self.state.reviews.len() - 1;
6605        // A deferred round and a resource-blocked one are the same shape
6606        // here: neither has a real result yet, and both get one more
6607        // attempt. Read off `e2e_status` — the single source for this —
6608        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
6609        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
6610        // still retries instead of being read as a settled result the
6611        // instant it stops being empty.
6612        let needs_catchup_run = matches!(
6613            self.state.reviews[round_idx].e2e_status(),
6614            E2eStatus::Deferred | E2eStatus::ResourceBlocked
6615        );
6616        if needs_catchup_run {
6617            let round = self.state.reviews[round_idx].round;
6618            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6619            let commands = self.state.config.verify.e2e.clone();
6620            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6621            let cache_dir = self.state.config.cache_dir();
6622            let context = format!(
6623                "round {round}: verification unresolved, catching up before the final decision"
6624            );
6625            let wait = self.repository_wait();
6626            let (outcomes, verify_retried) = with_cache_lease(
6627                &mut self.state,
6628                cache_dir.as_deref(),
6629                "e2e",
6630                "e2e",
6631                worktree,
6632                &attempted_head,
6633                timeout,
6634                &context,
6635                &wait,
6636                |state, budget| {
6637                    let shell = shell.to_vec();
6638                    let commands = commands.clone();
6639                    let context = context.clone();
6640                    async move {
6641                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6642                            .await
6643                    }
6644                },
6645            )
6646            .await?;
6647            let last = &mut self.state.reviews[round_idx];
6648            last.e2e = outcomes;
6649            last.verify_retried = verify_retried;
6650            // Always the commit and time this attempt actually targeted,
6651            // whether or not it happens to equal the reviewed `head` and
6652            // whether or not a command finished — see
6653            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
6654            // attempt is recorded too, so a later reader sees "attempted
6655            // again at T2" rather than silence.
6656            last.verified_head = Some(attempted_head);
6657            last.verified_at = Some(Timestamp::now());
6658            if verify_inconclusive(&last.e2e) {
6659                // Still not a real result: `e2e_deferred` is left exactly
6660                // as it was, so `needs_catchup_run` above reads
6661                // `ResourceBlocked` (via `e2e_status`, which checks
6662                // `resource_blocked` before `e2e_deferred`) and retries
6663                // again on the next reentry, rather than recording
6664                // contention as a red e2e and blocking the run on it.
6665                self.state.save()?;
6666                return Ok(());
6667            }
6668            last.e2e_deferred = false;
6669        }
6670        let last = &self.state.reviews[round_idx];
6671        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6672
6673        match last.e2e_status() {
6674            E2eStatus::Failed => {
6675                let red: Vec<String> = last
6676                    .e2e
6677                    .iter()
6678                    .filter(|o| !o.ok())
6679                    .map(|o| {
6680                        format!(
6681                            "`{}` -> {:?}\n{}",
6682                            o.command,
6683                            o.code,
6684                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6685                        )
6686                    })
6687                    .collect();
6688                self.state
6689                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6690                self.state.status = RunStatus::Blocked;
6691            }
6692            // `needs_catchup_run` above already retried once this call; if
6693            // it is still blocked, this is magi's own admission it could
6694            // not get a command to run, never a verdict on the patch — the
6695            // run is left exactly where a later reentry can retry again.
6696            E2eStatus::ResourceBlocked => {
6697                self.state.event(
6698                    "review",
6699                    format!(
6700                        "{why}; e2e could not run (shared build cache unavailable); not \
6701                         deciding yet"
6702                    ),
6703                );
6704            }
6705            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6706                self.state.event(
6707                    "review",
6708                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6709                );
6710                self.record_contested_handoff();
6711                self.state.status = RunStatus::Gating;
6712            }
6713        }
6714        self.state.save()?;
6715        Ok(())
6716    }
6717
6718    // ----------------------------------------------------------------- gate
6719
6720    async fn gate(&mut self) -> Result<()> {
6721        // Judged by the review record itself, not by `status`: a solo
6722        // candidate's `judge`/`deliberate` skip rewrites `status` on every
6723        // reentry (see `judge`), and trusting it here is exactly how a run
6724        // that exhausted its review budget got gated and merged a second
6725        // time around. `review_conclusion` recomputes the review loop's own
6726        // verdict from the round records themselves — `Gating` for a clean
6727        // round or a hand-off (see `stop_reviewing`), anything else means the
6728        // loop is still going or genuinely blocked.
6729        // A base the winner could not be replayed onto is a decision, not a
6730        // round: there is no landing tree to gate. Read as its own record for
6731        // the same reason the review verdict is.
6732        if self.state.status == RunStatus::Failed
6733            || self
6734                .state
6735                .base_sync
6736                .as_ref()
6737                .is_some_and(|s| s.conflict.is_some())
6738            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6739                != Some(RunStatus::Gating)
6740        {
6741            return Ok(());
6742        }
6743        if self.state.gate_ran {
6744            // `review_loop` derives its conclusion from the clean review
6745            // record on every reentry and therefore puts a completed run back
6746            // in `Gating`. A recorded gate is a stronger, terminal fact:
6747            // retain its original command output (or lack of any, for a repo
6748            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
6749            // doc) and restore `Blocked` on a real failure rather than
6750            // pretending the command is still running or running it a second
6751            // time. `gate_ran == false` remains the only shape — unattempted,
6752            // or a resource-blocked retry — that may still need to execute a
6753            // command.
6754            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6755                self.state.status = RunStatus::Blocked;
6756                self.state.save()?;
6757            }
6758            return Ok(());
6759        }
6760        let Some(winner) = self.state.winner().cloned() else {
6761            return Ok(());
6762        };
6763        self.state.status = RunStatus::Gating;
6764        let mut outcomes = self.run_gate(&winner).await?;
6765        loop {
6766            // A resource-blocked outcome means the gate command never actually
6767            // ran - the shared build cache could not be acquired or confirmed
6768            // fresh in time - which is evidence about the machine, not about
6769            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
6770            // Recording it as a red gate would mark a run `Blocked` on nothing
6771            // but contention magi has already logged; leaving `self.state.gate`
6772            // empty and `self.state.gate_ran` false instead keeps the shape
6773            // this function already treats as "still needs to run" (see the
6774            // early-return above), so the next call retries the command
6775            // rather than concluding anything.
6776            if verify_inconclusive(&outcomes) {
6777                self.state.save()?;
6778                return Ok(());
6779            }
6780            if outcomes.iter().all(CommandOutcome::ok) {
6781                break;
6782            }
6783            match self.gate_fix_round(&winner, &outcomes).await? {
6784                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6785                GateFix::Stop => break,
6786                GateFix::Defer => {
6787                    self.state.save()?;
6788                    return Ok(());
6789                }
6790            }
6791        }
6792        let passed = outcomes.iter().all(CommandOutcome::ok);
6793        self.state.gate = outcomes;
6794        self.state.gate_ran = true;
6795        self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6796        if !passed {
6797            self.state.status = RunStatus::Blocked;
6798            let spent = self.state.gate_fixes.len();
6799            self.state.event(
6800                "gate",
6801                if spent == 0 {
6802                    "gate failed; not merging".to_owned()
6803                } else {
6804                    format!("gate failed after {spent} gate-fix round(s); not merging")
6805                },
6806            );
6807        }
6808        self.state.save()?;
6809        Ok(())
6810    }
6811
6812    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6813    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6814    /// after review is clean and never on a candidate awaiting judging.
6815    ///
6816    /// Never fails the run: a non-zero exit or timeout is a warning and a
6817    /// recorded outcome, and the gate remains the single arbiter. Nothing
6818    /// configured means nothing happens - no event, no commit. `commit_all`
6819    /// commits any leftover change under the neutral identity and returns
6820    /// `false` when the tree is clean, so no empty commit is ever made.
6821    async fn run_pre_gate(state: &mut RunState, winner: &Candidate) {
6822        let commands = state.config.verify.pre_gate.clone();
6823        if commands.is_empty() {
6824            return;
6825        }
6826        let shell = state.config.shell();
6827        let timeout = Duration::from_secs(state.config.graph.verify_timeout());
6828        let (outcomes, _) = run_commands(
6829            state,
6830            "pre_gate",
6831            "pre_gate",
6832            0,
6833            &shell,
6834            &commands,
6835            &winner.worktree,
6836            timeout,
6837        )
6838        .await;
6839        for o in &outcomes {
6840            if !o.ok() {
6841                tracing::warn!(
6842                    "pre_gate `{}` failed ({:?}); the gate decides",
6843                    o.command,
6844                    o.code
6845                );
6846            }
6847            state.event(
6848                "pre_gate",
6849                format!(
6850                    "`{}` -> {}",
6851                    o.command,
6852                    if o.ok() {
6853                        "pass".to_owned()
6854                    } else {
6855                        format!(
6856                            "FAIL ({:?})\n{}",
6857                            o.code,
6858                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6859                        )
6860                    }
6861                ),
6862            );
6863        }
6864        state.pre_gate = outcomes;
6865        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6866            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6867                Ok(head) => {
6868                    state.event("pre_gate", format!("committed mechanical fixes ({head})"));
6869                    state.pre_gate_commit = Some(head);
6870                }
6871                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6872            },
6873            Ok(false) => {}
6874            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6875        }
6876        if let Err(e) = state.save() {
6877            tracing::warn!("could not persist the pre_gate record: {e:#}");
6878        }
6879    }
6880
6881    /// Run `verify.gate` once against the winner's current tree, logging one
6882    /// event per command. Empty when nothing is configured.
6883    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6884        let shell = self.state.config.shell();
6885        let gate_commands = self.state.config.verify.gate.clone();
6886        let has_pre_gate = !self.state.config.verify.pre_gate.is_empty();
6887        let outcomes = if gate_commands.is_empty() && !has_pre_gate {
6888            Vec::new()
6889        } else {
6890            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6891            let cache_dir = self.state.config.cache_dir();
6892            let wait = self.repository_wait();
6893            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6894            let (outcomes, _) = with_cache_lease(
6895                &mut self.state,
6896                cache_dir.as_deref(),
6897                "gate",
6898                "gate",
6899                &winner.worktree,
6900                &head,
6901                timeout,
6902                "final gate",
6903                &wait,
6904                |state, budget| {
6905                    let shell = shell.clone();
6906                    let gate_commands = gate_commands.clone();
6907                    let winner = winner.clone();
6908                    async move {
6909                        Self::run_pre_gate(state, &winner).await;
6910                        let (outcomes, timed_out_pids) = run_commands(
6911                            state,
6912                            "gate",
6913                            "gate",
6914                            0,
6915                            &shell,
6916                            &gate_commands,
6917                            &winner.worktree,
6918                            budget,
6919                        )
6920                        .await;
6921                        (outcomes, false, timed_out_pids)
6922                    }
6923                },
6924            )
6925            .await?;
6926            outcomes
6927        };
6928        if outcomes.is_empty() {
6929            // Nothing configured to check — distinct from every other
6930            // silence in this run's event log, since an empty `gate` alone
6931            // no longer says whether the gate ran at all (see
6932            // `RunState::gate_ran`'s own doc).
6933            self.state.event(
6934                "gate",
6935                "no gate commands configured; nothing to check, passing",
6936            );
6937        }
6938        for o in &outcomes {
6939            self.state.event(
6940                "gate",
6941                format!(
6942                    "`{}` -> {}",
6943                    o.command,
6944                    if o.ok() {
6945                        "pass".to_owned()
6946                    } else {
6947                        format!(
6948                            "FAIL ({:?})\n{}",
6949                            o.code,
6950                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6951                        )
6952                    }
6953                ),
6954            );
6955        }
6956        Ok(outcomes)
6957    }
6958
6959    /// One bounded fix round for a failing gate.
6960    ///
6961    /// The fixer is told the failure came from the gate itself, not from a
6962    /// reviewer, and is shown the failed commands, their exit codes and a tail
6963    /// of their output - whatever `[verify].gate` holds, nothing here knows
6964    /// what those commands run. Only a normal non-zero exit that printed
6965    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6966    /// command or a full disk says nothing about the code, and a fixer sent
6967    /// after it can only appease the machine. The round is judged by what git
6968    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6969    /// again before the gate does, so a fix cannot trade a green gate for a
6970    /// red e2e unnoticed.
6971    async fn gate_fix_round(
6972        &mut self,
6973        winner: &Candidate,
6974        outcomes: &[CommandOutcome],
6975    ) -> Result<GateFix> {
6976        let cap = self.state.config.graph.gate_fix_rounds;
6977        let spent = self.state.gate_fixes.len();
6978        if spent >= cap {
6979            if cap > 0 {
6980                self.state.event(
6981                    "gate",
6982                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6983                );
6984            }
6985            return Ok(GateFix::Stop);
6986        }
6987        if !gate_fixable(outcomes) {
6988            self.state.event(
6989                "gate",
6990                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6991                 command or similar); not spending a fix round on it",
6992            );
6993            return Ok(GateFix::Stop);
6994        }
6995        let min_free = self.state.config.disk.min_free_bytes;
6996        if min_free > 0 {
6997            match crate::disk::free_bytes(&winner.worktree) {
6998                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6999                Ok(free) => {
7000                    self.state.event(
7001                        "gate",
7002                        format!(
7003                            "only {free} bytes free ({min_free} required by `[disk] \
7004                             min_free_bytes`); not spending a fix round on a failure the disk \
7005                             may explain"
7006                        ),
7007                    );
7008                    return Ok(GateFix::Stop);
7009                }
7010                Err(e) => {
7011                    self.state.event(
7012                        "gate",
7013                        format!("free disk space could not be measured ({e:#}); no fix round"),
7014                    );
7015                    return Ok(GateFix::Stop);
7016                }
7017            }
7018        }
7019
7020        let attempt = spent + 1;
7021        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
7022        let base = self.landing_base();
7023        let fix_prompt = prompt::gate_fix_in(
7024            &self.state.instruction,
7025            &failed,
7026            attempt,
7027            cap,
7028            &self.state.config.graph.language,
7029            &self.state.config.graph.github_language,
7030        );
7031        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
7032        let sessions = self.state.config.graph.sessions;
7033        let artifacts = self.merge_repair_artifacts();
7034        self.state.event(
7035            "gate",
7036            format!("gate failed; gate-fix round {attempt} of {cap}"),
7037        );
7038        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
7039        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
7040        let (_, seat, out) = self
7041            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
7042                prompt: fix_prompt.clone(),
7043                spec,
7044                seat,
7045                cwd: winner.worktree.clone(),
7046                timeout,
7047                allow_write: true,
7048                sessions,
7049                artifacts: artifacts.clone(),
7050                stem: format!("gate-fix-{attempt}"),
7051                handover: None,
7052            })
7053            .await;
7054        // The fixer was never asked: no round is spent (nothing is recorded)
7055        // and a later reentry asks the same agent again.
7056        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
7057            return Ok(GateFix::Defer);
7058        }
7059        let mut record = GateFixRecord {
7060            agent: seat.agent.clone(),
7061            failed,
7062            notes: String::new(),
7063            committed: false,
7064            error: None,
7065        };
7066        match out {
7067            AgentOutcome::Ok(o) => {
7068                // A missing report is not a failed fix: the round is judged
7069                // by the tree below, and the report only carries prose.
7070                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
7071                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
7072                }
7073            }
7074            AgentOutcome::Dropped(_) => {
7075                record.error = Some("the CLI dropped the stream".to_owned());
7076            }
7077            AgentOutcome::Quota(o) => {
7078                self.state.quota.push(QuotaLoss {
7079                    seat: seat.key.clone(),
7080                    node: "gate-fix".to_owned(),
7081                    at: Timestamp::now(),
7082                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
7083                });
7084                record.error = Some("rate limited (quota); fixer could not run".to_owned());
7085            }
7086            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
7087        }
7088        self.state.seats.insert(seat.key.clone(), seat);
7089        let coordination = self.coordinate(&winner.worktree).await?;
7090        if let Ok(r) = git::rescue_commit(
7091            &winner.worktree,
7092            &format!("magi: gate fix {attempt} (uncommitted work)"),
7093        )
7094        .await
7095        {
7096            self.state.note_withheld("gate-fix", &r.withheld);
7097        }
7098        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
7099        record.committed = after != before;
7100        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
7101        let note = record.error.clone();
7102        self.state.gate_fixes.push(record);
7103        self.state.save()?;
7104        if !changed {
7105            self.state.event(
7106                "gate",
7107                match note {
7108                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
7109                    None => format!("gate-fix round {attempt}: the tree did not change"),
7110                },
7111            );
7112            return Ok(GateFix::Stop);
7113        }
7114        self.state.event(
7115            "gate",
7116            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
7117        );
7118
7119        drop(coordination);
7120        let commands = self.state.config.verify.e2e.clone();
7121        if !commands.is_empty() {
7122            let shell = self.state.config.shell();
7123            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
7124            let cache_dir = self.state.config.cache_dir();
7125            let context = format!("gate-fix round {attempt}");
7126            let wait = self.repository_wait();
7127            let (e2e, _) = with_cache_lease(
7128                &mut self.state,
7129                cache_dir.as_deref(),
7130                "e2e",
7131                "e2e",
7132                &winner.worktree,
7133                &after,
7134                timeout,
7135                &context,
7136                &wait,
7137                |state, budget| {
7138                    let shell = shell.clone();
7139                    let commands = commands.clone();
7140                    let context = context.clone();
7141                    let worktree = winner.worktree.clone();
7142                    async move {
7143                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
7144                            .await
7145                    }
7146                },
7147            )
7148            .await?;
7149            if verify_inconclusive(&e2e) {
7150                return Ok(GateFix::Defer);
7151            }
7152            if e2e.iter().any(|o| !o.ok()) {
7153                self.state.event(
7154                    "gate",
7155                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7156                );
7157                return Ok(GateFix::Stop);
7158            }
7159        }
7160        Ok(GateFix::Retry)
7161    }
7162
7163    // ---------------------------------------------------------------- merge
7164
7165    /// One read-only rewrite by the summary's author; a text the gate still
7166    /// withholds goes to the owner (see `github_text`) and the fixed fallback
7167    /// applies on their word or on silence. `None` means the run is parked on
7168    /// that question and nothing may be pushed or opened yet.
7169    async fn guarded_pr_message(
7170        &mut self,
7171        winner: &Candidate,
7172        facts: Option<&BranchFacts>,
7173        posting: bool,
7174    ) -> Result<Option<PrMessage>> {
7175        let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7176        let head = match final_head.as_deref() {
7177            Some(h) => crate::pr_outcome::FinalHead::At(h),
7178            None => crate::pr_outcome::FinalHead::Unknown,
7179        };
7180        // Only a pull request that is about to be posted pays for a summary.
7181        let summarized = if posting {
7182            Some(self.summarize_change(winner, final_head.as_deref()).await)
7183        } else {
7184            None
7185        };
7186        // A busy shared resource is not a permanent cause: publishing an
7187        // "unavailable" description would stick (a later head move never
7188        // edits the body). Stop before anything is pushed or opened; the
7189        // run is `Blocked` and a resume asks the summary again.
7190        if self.state.resource_block.is_some() {
7191            self.state.event(
7192                "pr-summary",
7193                "a shared resource was busy; nothing was published, resume to summarize again",
7194            );
7195            return Ok(None);
7196        }
7197        if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7198            anyhow::bail!(
7199                "the final commit moved during public description generation; retry before publishing"
7200            );
7201        }
7202        let note = match (&summarized, &self.state.change_summary) {
7203            (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7204            (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7205            (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7206            (None, _) => ChangeNote::NotAsked,
7207        };
7208        let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7209        if !posting {
7210            let identity = crate::scrub::Identity::current();
7211            return Ok(Some(PrMessage {
7212                title: crate::scrub::scrub(&pr.title, &identity),
7213                body: crate::scrub::scrub(&pr.body, &identity),
7214            }));
7215        }
7216        // This very text was already rejected and asked about: no second
7217        // judge call, rewrite or question, just carry the record on.
7218        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7219        if let Some(known) = self
7220            .state
7221            .github_text
7222            .clone()
7223            .filter(|g| g.fingerprint == raw_fp)
7224        {
7225            return self.settle_withheld(winner, &pr, known).await;
7226        }
7227        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7228        let mut violations =
7229            crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7230        let identity = crate::scrub::Identity::current();
7231        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7232            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7233            && !violations.contains(&crate::github_text::Violation::SensitiveData)
7234        {
7235            violations.push(crate::github_text::Violation::SensitiveData);
7236        }
7237        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
7238        // only a language violation starts one; sensitive data alone goes
7239        // straight to the owner, with the redacted text as the fallback.
7240        let has_language = violations
7241            .iter()
7242            .any(|v| *v != crate::github_text::Violation::SensitiveData);
7243        if self.state.config.graph.github_text_guard && !violations.is_empty() {
7244            self.state.event(
7245                "github-text",
7246                if has_language {
7247                    format!("description rejected: {violations:?}; requesting one rewrite")
7248                } else {
7249                    format!("description rejected: {violations:?}; asking the owner")
7250                },
7251            );
7252            let mut rewritten = false;
7253            let mut rejected_rewrite = None;
7254            if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7255            {
7256                let output = text;
7257                if let Some(title) = summary_title(&output) {
7258                    // Judge the redacted candidate that will actually be posted.
7259                    // Sensitive data alone must not discard a useful rewrite.
7260                    let raw_body = summary_without_title(&output);
7261                    let clean_title = crate::scrub::scrub(&title, &identity);
7262                    let mut body = crate::scrub::scrub(&raw_body, &identity);
7263                    let redacted = clean_title != title || body != raw_body;
7264                    let title = clean_title;
7265                    let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7266                    if !complete {
7267                        self.state.event(
7268                            "github-text",
7269                            "description rewrite omitted required findings",
7270                        );
7271                    }
7272                    if !body.trim().is_empty() {
7273                        body.push_str("\n\n");
7274                        body.push_str(&public_pr_evidence(&self.state, head));
7275                    }
7276                    let marker = format!("magi:run/{}", self.state.id);
7277                    if !body.trim().is_empty() && !body.contains(&marker) {
7278                        body.push_str(&format!("\n\n{marker}"));
7279                    }
7280                    let rewrite = PrMessage {
7281                        title: title.clone(),
7282                        body: body.clone(),
7283                    };
7284                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7285                    let mut failed = crate::github_text::check_for(
7286                        &self.state.config.graph,
7287                        &title,
7288                        &body,
7289                        redecision,
7290                    );
7291                    if (!crate::github_text::shareable(&title)
7292                        || !crate::github_text::shareable(&body))
7293                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
7294                    {
7295                        failed.push(crate::github_text::Violation::SensitiveData);
7296                    }
7297                    if complete
7298                        && !title.trim().is_empty()
7299                        && !body.trim().is_empty()
7300                        && !failed.is_empty()
7301                    {
7302                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
7303                            title: title.clone(),
7304                            body: body.clone(),
7305                            failed: failed
7306                                .iter()
7307                                .map(|v| crate::github_text::category(*v).to_owned())
7308                                .collect(),
7309                        });
7310                    }
7311                    if complete
7312                        && !title.trim().is_empty()
7313                        && !raw_body.trim().is_empty()
7314                        && failed.is_empty()
7315                    {
7316                        decision = redecision;
7317                        pr = PrMessage { title, body };
7318                        rewritten = true;
7319                        self.state
7320                            .event("github-text", "description rewrite passed");
7321                        if redacted {
7322                            self.state.event(
7323                                "github-text",
7324                                "sensitive data removed from description rewrite",
7325                            );
7326                        }
7327                    }
7328                }
7329            }
7330            if !rewritten {
7331                self.state.event(
7332                    "github-text",
7333                    "description rewrite unavailable or rejected; asking the owner",
7334                );
7335                if let Some(w) =
7336                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7337                {
7338                    // A different text than the one asked about before: that
7339                    // question no longer describes anything.
7340                    self.retire_github_text_question("the withheld text changed");
7341                    let record = crate::run::GithubTextAsk {
7342                        rewrite: rejected_rewrite,
7343                        fingerprint: raw_fp,
7344                        title: w.title,
7345                        body: w.body,
7346                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7347                        question: None,
7348                        asks: 0,
7349                        resolved: false,
7350                        chosen_title: None,
7351                        choice: None,
7352                    };
7353                    return self.settle_withheld(winner, &pr, record).await;
7354                }
7355            }
7356        }
7357        let (title, body) =
7358            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7359        Ok(Some(PrMessage { title, body }))
7360    }
7361
7362    /// Describe the whole change from its final diff in either run mode.
7363    ///
7364    /// The summary is tied to the final commit and to the merge base of the
7365    /// resolved remote base; a record for any other pair is dropped and the
7366    /// agent asked again, so a description never outlives the diff it was
7367    /// written from. The seat is read-only and the prompt tells it to read the
7368    /// diff and the code around it. `[roles] synthesizer` selects the agents
7369    /// (unset: the default pick), each asked at most once, advancing on an
7370    /// error, quota, unusable answer or a reply with no `TITLE:` line. `Err`
7371    /// carries the reason it is unavailable; nothing is invented then.
7372    async fn summarize_change(
7373        &mut self,
7374        winner: &Candidate,
7375        head: Option<&str>,
7376    ) -> std::result::Result<(), String> {
7377        let Some(head) = head else {
7378            return Err("the final commit could not be read".to_owned());
7379        };
7380        let remote = self.state.config.merge.remote.clone();
7381        let tracking = format!("{remote}/{}", self.state.base_branch);
7382        if !git::rev_exists(&winner.worktree, &tracking).await {
7383            return Err(format!("the remote base `{tracking}` could not be read"));
7384        }
7385        let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7386            Ok(b) if !b.is_empty() => b,
7387            _ => return Err("the merge base with the remote base could not be read".to_owned()),
7388        };
7389        let evidence = crate::pr_outcome::summary_evidence(
7390            &self.state,
7391            crate::pr_outcome::FinalHead::At(head),
7392        );
7393        let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7394        if self
7395            .state
7396            .change_summary
7397            .as_ref()
7398            .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7399        {
7400            return Ok(());
7401        }
7402        if self.state.change_summary.take().is_some() {
7403            self.state.event(
7404                "pr-summary",
7405                "the final diff or evidence changed; the earlier change summary was discarded",
7406            );
7407        }
7408        // Save the source snapshot even if no generation agent is available.
7409        if let Err(e) = write_artifact(
7410            &self.state,
7411            &format!("public-description-{fingerprint}.md"),
7412            &format!("Final diff: {base}..{head}\n\n{evidence}"),
7413        ) {
7414            tracing::warn!("could not save public description evidence: {e:#}");
7415        }
7416        let chain = match agent::pick_chain(
7417            &self.state.config.agents,
7418            self.state.config.roles.synthesizer.as_ref(),
7419            &agent::installed,
7420            "synthesizer",
7421        ) {
7422            Ok(chain) => chain,
7423            Err(e) => {
7424                tracing::warn!("no agent for the change summary: {e:#}");
7425                self.state.event("pr-summary", "no summary agent available");
7426                return Err("no agent was available to summarize it".to_owned());
7427            }
7428        };
7429        let subjects = git::subjects(&winner.worktree, &base, head)
7430            .await
7431            .unwrap_or_default();
7432        let stat = git::diff_stat(&winner.worktree, &base, head)
7433            .await
7434            .unwrap_or_default();
7435        let stat: String = stat.chars().take(6_000).collect();
7436        let mut prompt = crate::prompt::change_summary_in(
7437            &base,
7438            head,
7439            &subjects,
7440            stat.trim(),
7441            &self.state.config.graph.github_language,
7442        );
7443        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."));
7444        let sessions = self.state.config.graph.sessions;
7445        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7446        let prompts = self.state.config.prompts.clone();
7447        let cache = self.state.config.cache_dir();
7448        let run = self.state.id.clone();
7449        for (i, spec) in chain.into_iter().enumerate() {
7450            let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7451            let job = SeatJob {
7452                spec: spec.clone(),
7453                seat,
7454                cwd: winner.worktree.clone(),
7455                prompt: prompt.clone(),
7456                timeout,
7457                allow_write: false,
7458                sessions,
7459                artifacts: agent::artifacts_dir(&self.state.dir()),
7460                stem: if i == 0 {
7461                    "change-summary".to_owned()
7462                } else {
7463                    format!("change-summary-{}", spec.id)
7464                },
7465                handover: None,
7466            };
7467            let ctx = WaveCtx {
7468                run: &run,
7469                node: "pr-summary",
7470                prompts: &prompts,
7471                cache: cache.as_deref(),
7472                round: None,
7473                carry_seats: false,
7474                pauses: Some((&self.pause, &self.interrupt)),
7475            };
7476            let (seat, outcome) =
7477                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7478            self.state.seats.insert(seat.key.clone(), seat);
7479            let text = match outcome {
7480                AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7481                AgentOutcome::ResourceBlocked(why) => {
7482                    // No agent was asked: the run stops resumably before
7483                    // anything is published (see `guarded_pr_message`).
7484                    if self.state.resource_block.is_none() {
7485                        self.state.resource_block = Some(crate::run::ResourceBlock {
7486                            node: "pr-summary".to_owned(),
7487                            kind: "shared resource".to_owned(),
7488                            detail: why,
7489                            at: Timestamp::now(),
7490                        });
7491                    }
7492                    return Err("a shared resource was busy".to_owned());
7493                }
7494                _ => continue,
7495            };
7496            let blind = &self.state.config.blind;
7497            let Some(title) = summary_title(&text) else {
7498                continue;
7499            };
7500            let body: String = summary_without_title(&text)
7501                .lines()
7502                .filter(|l| !l.contains("magi:run/"))
7503                .collect::<Vec<_>>()
7504                .join("\n");
7505            let body = blind::sanitize_prose(body.trim(), blind);
7506            let title = blind::sanitize_prose(&title, blind);
7507            if body.trim().is_empty() || title.trim().is_empty() {
7508                continue;
7509            }
7510            if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7511                continue;
7512            }
7513            // The branch must still be the one the agent was shown.
7514            if git::rev_parse(&winner.worktree, "HEAD")
7515                .await
7516                .ok()
7517                .as_deref()
7518                != Some(head)
7519            {
7520                return Err("the branch moved while the summary was written".to_owned());
7521            }
7522            if git::merge_base(&winner.worktree, &tracking, head)
7523                .await
7524                .ok()
7525                .as_deref()
7526                != Some(base.as_str())
7527                || crate::pr_outcome::summary_evidence(
7528                    &self.state,
7529                    crate::pr_outcome::FinalHead::At(head),
7530                ) != evidence
7531            {
7532                return Err("the summary evidence changed while it was written".to_owned());
7533            }
7534            self.state.change_summary = Some(crate::run::ChangeSummary {
7535                head: head.to_owned(),
7536                base,
7537                evidence: fingerprint,
7538                title,
7539                body,
7540            });
7541            self.state.event(
7542                "pr-summary",
7543                format!("change summary written by {} for the final diff", spec.id),
7544            );
7545            if let Err(e) = self.state.save() {
7546                tracing::warn!("could not save the change summary: {e:#}");
7547            }
7548            return Ok(());
7549        }
7550        self.state
7551            .event("pr-summary", "no agent produced a change summary");
7552        Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7553    }
7554
7555    /// One read-only rewrite request for a withheld description. The
7556    /// implementer's own conversation is reused only when it exists and can be
7557    /// resumed; otherwise the `[roles] language_judge` chain (or the default
7558    /// pick) writes it in a separate conversation. The prompt carries the whole
7559    /// text, so no agent needs any memory. Each agent is asked at most once and
7560    /// the chain advances on an error, quota, unusable answer or a reply with
7561    /// no `TITLE:` line. Nothing is recorded as a handover.
7562    async fn rewrite_pr_text(
7563        &mut self,
7564        winner: &Candidate,
7565        pr: &PrMessage,
7566        violations: &[crate::github_text::Violation],
7567    ) -> Option<String> {
7568        let prompt = format!(
7569            "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{}",
7570            pr.title, pr.body
7571        );
7572        let sessions = self.state.config.graph.sessions;
7573        let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7574        // (spec, seat, resumed)
7575        let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7576        let key = format!("impl-{}", winner.label);
7577        if let Some(spec) = self
7578            .state
7579            .config
7580            .agents
7581            .iter()
7582            .find(|a| a.id == winner.agent)
7583            .cloned()
7584            && let Some(existing) = self.state.seats.get(&key)
7585            && existing.agent == spec.id
7586            && agent::has_session(spec.kind, existing, sessions)
7587        {
7588            attempts.push((spec, existing.clone(), true));
7589        } else {
7590            let chain = agent::pick_chain(
7591                &self.state.config.agents,
7592                self.state.config.roles.language_judge.as_ref(),
7593                &agent::installed,
7594                "language_judge",
7595            );
7596            match chain {
7597                Ok(chain) => {
7598                    for spec in chain {
7599                        let seat = crate::fixer::seat_for(
7600                            &mut self.state,
7601                            "github-text-rewrite",
7602                            &spec.id,
7603                        );
7604                        attempts.push((spec, seat, false));
7605                    }
7606                }
7607                Err(e) => {
7608                    tracing::warn!("no agent for the description rewrite: {e:#}");
7609                    self.state
7610                        .event("github-text", "no rewrite agent available");
7611                    return None;
7612                }
7613            }
7614        }
7615        let prompts = self.state.config.prompts.clone();
7616        let cache = self.state.config.cache_dir();
7617        let run = self.state.id.clone();
7618        for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7619            let job = SeatJob {
7620                spec: spec.clone(),
7621                seat,
7622                cwd: winner.worktree.clone(),
7623                prompt: prompt.clone(),
7624                timeout: retry_budget(timeout_full, resumed),
7625                allow_write: false,
7626                sessions,
7627                artifacts: agent::artifacts_dir(&self.state.dir()),
7628                stem: if i == 0 {
7629                    "github-text-rewrite".to_owned()
7630                } else {
7631                    format!("github-text-rewrite-{}", spec.id)
7632                },
7633                handover: None,
7634            };
7635            let ctx = WaveCtx {
7636                run: &run,
7637                node: "github-text",
7638                prompts: &prompts,
7639                cache: cache.as_deref(),
7640                round: None,
7641                carry_seats: false,
7642                pauses: Some((&self.pause, &self.interrupt)),
7643            };
7644            let (seat, outcome) =
7645                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7646            self.state.seats.insert(seat.key.clone(), seat);
7647            match outcome {
7648                AgentOutcome::Ok(o)
7649                    if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7650                {
7651                    return Some(o.text);
7652                }
7653                AgentOutcome::ResourceBlocked(_) => return None,
7654                _ => {}
7655            }
7656        }
7657        None
7658    }
7659
7660    /// Abandon the standing posting-gate question, if any.
7661    fn retire_github_text_question(&mut self, why: &str) {
7662        let Some(id) = self
7663            .state
7664            .github_text
7665            .as_ref()
7666            .and_then(|g| g.question.clone())
7667        else {
7668            return;
7669        };
7670        let _ = ask::Questions::open().update(&id, |q| {
7671            q.abandon(why);
7672            Ok(())
7673        });
7674    }
7675
7676    /// Carry a withheld text to its end: file the question, park while it is
7677    /// open, and on an answer, silence or timeout pick the text to post. The
7678    /// decision is saved before it is used, so it applies once.
7679    async fn settle_withheld(
7680        &mut self,
7681        winner: &Candidate,
7682        pr: &PrMessage,
7683        mut g: crate::run::GithubTextAsk,
7684    ) -> Result<Option<PrMessage>> {
7685        use crate::github_text as gt;
7686        let store = ask::Questions::open();
7687        let timeout = self.state.config.graph.answer_timeout;
7688        if !g.resolved {
7689            let mut standing = g
7690                .question
7691                .as_ref()
7692                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7693            if let Some(q) = standing.as_ref()
7694                && q.status.open()
7695                && gt::expired(q, timeout)
7696            {
7697                let why = format!("no answer within {}s of asking", timeout.max(1));
7698                standing = store
7699                    .update(&q.id, |q| {
7700                        q.abandon(&why);
7701                        Ok(())
7702                    })
7703                    .ok()
7704                    .map(|(q, ())| q);
7705            }
7706            let mut ask_again: Option<Option<Vec<String>>> = None;
7707            match standing {
7708                // Recorded but never written (a stop between the two saves),
7709                // or removed: ask again without spending the retry.
7710                None if g.asks <= 1 => {
7711                    g.asks = 0;
7712                    ask_again = Some(None);
7713                }
7714                None => {
7715                    g.resolved = true;
7716                    g.chosen_title = None;
7717                    g.choice = Some(GithubTextChoice::Fallback);
7718                }
7719                Some(q) if q.status.open() => {
7720                    self.park_on_github_text(&g)?;
7721                    return Ok(None);
7722                }
7723                Some(q) => match gt::read_reply(&q) {
7724                    gt::Reply::Fallback => {
7725                        g.resolved = true;
7726                        g.chosen_title = None;
7727                        g.choice = Some(GithubTextChoice::Fallback);
7728                    }
7729                    gt::Reply::Original => {
7730                        g.resolved = true;
7731                        g.chosen_title = None;
7732                        g.choice = Some(GithubTextChoice::Original);
7733                    }
7734                    gt::Reply::Rewrite => {
7735                        g.resolved = true;
7736                        g.chosen_title = None;
7737                        g.choice = Some(if g.rewrite.is_some() {
7738                            GithubTextChoice::Rewrite
7739                        } else {
7740                            GithubTextChoice::Fallback
7741                        });
7742                    }
7743                    gt::Reply::Title(text) => {
7744                        let first = text
7745                            .lines()
7746                            .map(str::trim)
7747                            .find(|l| !l.is_empty())
7748                            .unwrap_or("");
7749                        let decision = if first.is_empty() {
7750                            None
7751                        } else {
7752                            let candidate = PrMessage {
7753                                title: first.to_owned(),
7754                                body: String::new(),
7755                            };
7756                            self.judge_pr_language(&winner.worktree, &candidate).await
7757                        };
7758                        match gt::vet_title(
7759                            &text,
7760                            decision,
7761                            self.state.config.graph.github_english(),
7762                        ) {
7763                            Ok(title) => {
7764                                g.resolved = true;
7765                                g.chosen_title = Some(title);
7766                                g.choice = Some(GithubTextChoice::MyTitle);
7767                            }
7768                            Err(cats) if g.asks < 2 => {
7769                                ask_again = Some(Some(
7770                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7771                                ))
7772                            }
7773                            Err(_) => {
7774                                g.resolved = true;
7775                                g.chosen_title = None;
7776                                g.choice = Some(GithubTextChoice::Fallback);
7777                            }
7778                        }
7779                    }
7780                },
7781            }
7782            if let Some(rejected) = ask_again {
7783                // A retry (after a rejected replacement title) is still about
7784                // the original text: the same ORIGINAL / FALLBACK comparison.
7785                let w = gt::Withheld {
7786                    title: g.title,
7787                    body: g.body,
7788                    categories: g.categories.clone(),
7789                };
7790                // Named by the original text's fingerprint alone, so a resume
7791                // rewrites the same file and the detail stays identical.
7792                let artifact = match crate::run::write_artifact(
7793                    &self.state,
7794                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
7795                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7796                ) {
7797                    Ok(p) => Some(p),
7798                    Err(e) => {
7799                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
7800                        None
7801                    }
7802                };
7803                let (detail, comparison) = gt::question_content(
7804                    &w,
7805                    &pr.title,
7806                    &pr.body,
7807                    &self.state.id,
7808                    rejected.as_deref(),
7809                    artifact.as_deref(),
7810                    g.rewrite.as_ref(),
7811                );
7812                let mut q = ask::Question::new(
7813                    self.state.id.clone(),
7814                    gt::ASK_NODE.to_owned(),
7815                    gt::ASK_SEAT.to_owned(),
7816                    gt::question_summary(&self.state.config.graph.language, &w),
7817                    detail,
7818                    gt::question_choices(&w, g.rewrite.as_ref()),
7819                );
7820                q.posting_comparison = Some(comparison);
7821                q.answer_timeout = timeout;
7822                // Recorded before it is written: a stop in between leaves a
7823                // record naming a question the resume then files again.
7824                g.question = Some(q.id.clone());
7825                g.asks += 1;
7826                self.state.github_text = Some(g.clone());
7827                self.state.event(
7828                    "github-text",
7829                    format!("asking the owner about the withheld text ({})", q.short()),
7830                );
7831                self.state.save()?;
7832                store
7833                    .put(&mut q)
7834                    .context("file the posting-gate question")?;
7835                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7836                    tracing::warn!(
7837                        "could not notify about posting-gate question {}: {e:#}",
7838                        q.short()
7839                    );
7840                }
7841                self.park_on_github_text(&g)?;
7842                return Ok(None);
7843            }
7844        }
7845        let resolved = g.chosen_title.clone();
7846        let original = g.choice == Some(GithubTextChoice::Original);
7847        // Field names only: the event log never records the text.
7848        self.state.event(
7849            "github-text",
7850            if original {
7851                format!(
7852                    "posting the original text for: {}",
7853                    match (g.title, g.body) {
7854                        (true, true) => "title, description",
7855                        (true, false) => "title",
7856                        _ => "description",
7857                    }
7858                )
7859            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7860                "posting the saved rewrite title and description".to_owned()
7861            } else if resolved.is_some() {
7862                "posting the owner's replacement title".to_owned()
7863            } else {
7864                "posting the neutral text for the withheld fields".to_owned()
7865            },
7866        );
7867        self.state.github_text = Some(g.clone());
7868        self.state.save()?;
7869        if g.choice == Some(GithubTextChoice::Rewrite)
7870            && let Some(r) = g.rewrite.as_ref()
7871        {
7872            return Ok(Some(PrMessage {
7873                title: r.title.clone(),
7874                body: r.body.clone(),
7875            }));
7876        }
7877        let w = gt::Withheld {
7878            title: g.title,
7879            body: g.body,
7880            categories: g.categories.clone(),
7881        };
7882        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7883        if let Some(t) = resolved {
7884            title = t;
7885        }
7886        // Every withheld field is now neutral or vetted English.
7887        let english = gt::LanguageDecision {
7888            title_english: true,
7889            body_english: true,
7890            body_complete: true,
7891        };
7892        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7893        if original {
7894            // The owner's explicit choice overrides the gate for the fields it
7895            // was made for; the others keep what the gate produced.
7896            if g.title {
7897                title = pr.title.clone();
7898            }
7899            if g.body {
7900                body = pr.body.clone();
7901            }
7902        }
7903        Ok(Some(PrMessage { title, body }))
7904    }
7905
7906    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7907        self.state.github_text = Some(g.clone());
7908        self.state.end_pass_parked(ParkCause::Question);
7909        self.state.parked = true;
7910        self.state.event(
7911            "github-text",
7912            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7913        );
7914        self.state.save()
7915    }
7916
7917    /// Ask `[roles] language_judge` about this text and record which source
7918    /// decided; the text itself is never recorded.
7919    async fn judge_pr_language(
7920        &mut self,
7921        cwd: &Path,
7922        pr: &PrMessage,
7923    ) -> Option<crate::github_text::LanguageDecision> {
7924        self.state.config.roles.language_judge.as_ref()?;
7925        if !self.state.config.graph.language_gate() {
7926            return None;
7927        }
7928        let decision =
7929            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7930        self.state.event(
7931            "github-text",
7932            if decision.is_some() {
7933                "language decided by the language judge"
7934            } else {
7935                "language judge unavailable; using built-in heuristics"
7936            },
7937        );
7938        decision
7939    }
7940
7941    async fn merge(&mut self) -> Result<()> {
7942        // Same reasoning as `gate`: ask the review and gate records directly
7943        // rather than `status`, which a solo-candidate `judge`/`deliberate`
7944        // skip can rewrite on reentry to something that no longer says
7945        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
7946        // so a hand-off (open findings, green verification) reaches merge
7947        // exactly like a genuinely clean round does.
7948        //
7949        // A run resumed mid-`land` never reaches here at all: `execute`
7950        // recognises `RunStatus::Landing` before it even calls `prep`, and
7951        // routes straight to `run_land` instead. That has to happen a level
7952        // up from this function, not with a check in here, because
7953        // `review_loop`'s own status recomputation (see its doc) runs
7954        // *before* `merge` on every reentry and would otherwise overwrite
7955        // the `Landing` marker with `Gating` before this node ever saw it.
7956        if self
7957            .state
7958            .base_sync
7959            .as_ref()
7960            .is_some_and(|s| s.conflict.is_some())
7961            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7962                != Some(RunStatus::Gating)
7963            // `gate_ran == false` is not "passed" - `gate` leaves it false
7964            // both before it has ever run and when its last attempt was
7965            // resource-blocked (see `Runner::gate`'s own doc), and neither is
7966            // permission to merge on nothing but the review record. Only a
7967            // gate that actually ran - zero commands configured and
7968            // vacuously passed, or one or more that all exited 0 - may
7969            // proceed; `RunState::gate_status` is the single place that
7970            // reading is computed.
7971            || !self.state.gate_status().ok()
7972        {
7973            return Ok(());
7974        }
7975        // This node's own record, not `status`: `status == Ready` is not
7976        // unique to the harmless `MergeMode::None` path this line was
7977        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
7978        // run's PR was closed without merging — and on that run `mode` is
7979        // still `Pr`, so a reentry that fell through here would push and
7980        // open a second pull request. `self.state.merge` is set exactly once
7981        // this node (or `land`) has already produced a verdict, under every
7982        // mode, which is what "already done" actually means here.
7983        if self.state.merge.is_some() && !self.state.pr_publication_pending {
7984            return Ok(());
7985        }
7986        let Some(winner) = self.state.winner().cloned() else {
7987            return Ok(());
7988        };
7989        let repo = self.state.repo.clone();
7990        let base = self.state.base_branch.clone();
7991        let mode = self.state.config.merge.mode;
7992        let style = self.state.config.merge.style;
7993        let facts = if is_review_run(&self.state) {
7994            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7995            let start = review_base(
7996                &repo,
7997                &self.state.config.merge.remote,
7998                &base,
7999                &self.state.base_commit,
8000                &winner.branch,
8001            )
8002            .await;
8003            branch_facts(&repo, &start, &winner.branch).await
8004        } else {
8005            None
8006        };
8007        // `None` when the base could not be freshly read: then an adopted
8008        // pull request's title is left alone.
8009        let leaked = if is_review_run(&self.state) {
8010            leaked_subjects(&self.state, &winner.branch).await
8011        } else {
8012            Some(Vec::new())
8013        };
8014        // `None`: parked on the owner's word about a withheld title or body.
8015        // Nothing has been pushed or opened, and `merge` stays unrecorded so
8016        // the resume arrives here again.
8017        let Some(pr) = self
8018            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
8019            .await?
8020        else {
8021            return Ok(());
8022        };
8023        let message = pr.commit_message();
8024
8025        let outcome = match mode {
8026            MergeMode::None => MergeOutcome {
8027                mode,
8028                ok: true,
8029                detail: manual_merge_command(style, &repo, &winner.branch, &message),
8030                empty: false,
8031            },
8032            MergeMode::Pr | MergeMode::Local
8033                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
8034            {
8035                MergeOutcome {
8036                    mode,
8037                    ok: false,
8038                    detail: empty_candidate_detail(&self.state, &base),
8039                    empty: true,
8040                }
8041            }
8042            MergeMode::Local => {
8043                let on = git::current_branch(&repo).await?;
8044                if on.as_deref() != Some(base.as_str()) {
8045                    MergeOutcome {
8046                        mode,
8047                        ok: false,
8048                        detail: format!(
8049                            "{} has {} checked out, not the base branch {base}",
8050                            repo.display(),
8051                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
8052                        ),
8053                        empty: false,
8054                    }
8055                } else if !git::is_clean(&repo).await? {
8056                    MergeOutcome {
8057                        mode,
8058                        ok: false,
8059                        detail: format!("{} is dirty; refusing to merge", repo.display()),
8060                        empty: false,
8061                    }
8062                } else {
8063                    let out = match style {
8064                        MergeStyle::Merge => {
8065                            git::merge_no_ff(&repo, &winner.branch, &message).await?
8066                        }
8067                        MergeStyle::Squash => {
8068                            git::merge_squash(&repo, &winner.branch, &message).await?
8069                        }
8070                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
8071                    };
8072                    MergeOutcome {
8073                        mode,
8074                        ok: out.ok(),
8075                        detail: if out.ok() { out.stdout } else { out.stderr },
8076                        empty: false,
8077                    }
8078                }
8079            }
8080            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
8081                self.state.pr_publication_pending = true;
8082                self.state.save()?;
8083                let out =
8084                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
8085                self.state.pr_publication_pending = out.is_err();
8086                MergeOutcome {
8087                    mode,
8088                    ok: out.is_ok(),
8089                    detail: match out {
8090                        Ok(url) => url,
8091                        Err(e) => format!("{e:#}"),
8092                    },
8093                    empty: false,
8094                }
8095            }
8096            MergeMode::Pr => {
8097                let remote = self.state.config.merge.remote.clone();
8098                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
8099                if !pushed.ok() {
8100                    MergeOutcome {
8101                        mode,
8102                        ok: false,
8103                        detail: pushed.stderr,
8104                        empty: false,
8105                    }
8106                } else {
8107                    // A retry or resume of a run whose branch already has an
8108                    // open pull request adopts it rather than failing on a
8109                    // duplicate. Only this winner branch into this base:
8110                    // `branch_for` derives the name from the run id, so a
8111                    // different run's pull request never matches.
8112                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
8113                    let out = match pr_merge_plan(found) {
8114                        PrPlan::Create => {
8115                            gh_pr_create(
8116                                &winner.worktree,
8117                                &base,
8118                                &winner.branch,
8119                                &pr.title,
8120                                &pr.body,
8121                            )
8122                            .await
8123                        }
8124                        PrPlan::Adopt { url, title } => {
8125                            self.state
8126                                .event("merge", format!("Pr: adopted open pull request {url}"));
8127                            if self.state.github_text.as_ref().is_some_and(|g| {
8128                                g.resolved
8129                                    && g.choice == Some(GithubTextChoice::Rewrite)
8130                                    && g.rewrite.is_some()
8131                            }) {
8132                                // The owner chose the entire pair, even if the
8133                                // existing title already matches or was edited.
8134                                land::set_pr_text_verbatim(
8135                                    &winner.worktree,
8136                                    &url,
8137                                    &pr.title,
8138                                    &pr.body,
8139                                )
8140                                .await
8141                                .map(|()| url)
8142                            } else {
8143                                if title != pr.title
8144                                    && (!is_review_run(&self.state)
8145                                        || leaked
8146                                            .as_deref()
8147                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
8148                                    && let Err(e) =
8149                                        if self.state.github_text.as_ref().is_some_and(|g| {
8150                                            g.resolved
8151                                                && g.title
8152                                                && g.choice == Some(GithubTextChoice::Original)
8153                                        }) {
8154                                            // The owner chose to post this title unmodified.
8155                                            land::set_pr_title_verbatim(
8156                                                &winner.worktree,
8157                                                &url,
8158                                                &pr.title,
8159                                            )
8160                                            .await
8161                                        } else {
8162                                            land::set_pr_title(
8163                                                &mut self.state,
8164                                                &winner.worktree,
8165                                                &url,
8166                                                &pr.title,
8167                                            )
8168                                            .await
8169                                        }
8170                                {
8171                                    tracing::warn!("could not refresh title of {url}: {e:#}");
8172                                    self.state
8173                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
8174                                }
8175                                Ok(url)
8176                            }
8177                        }
8178                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8179                    };
8180                    match out {
8181                        Ok(url) => MergeOutcome {
8182                            mode,
8183                            ok: true,
8184                            detail: url,
8185                            empty: false,
8186                        },
8187                        Err(e) => MergeOutcome {
8188                            mode,
8189                            ok: false,
8190                            detail: e.to_string(),
8191                            empty: false,
8192                        },
8193                    }
8194                }
8195            }
8196        };
8197
8198        self.state.status = match (mode, outcome.ok) {
8199            (MergeMode::None, _) => RunStatus::Ready,
8200            (_, true) => RunStatus::Merged,
8201            (_, false) => RunStatus::Blocked,
8202        };
8203        self.state.event(
8204            "merge",
8205            format!(
8206                "{:?}: {}",
8207                mode,
8208                outcome.detail.lines().next().unwrap_or("")
8209            ),
8210        );
8211        self.state.merge = Some(outcome);
8212        self.state.save()?;
8213
8214        // The PR is open and the run would historically stop here, leaving the
8215        // operator to watch checks, feed review comments back to a fixer, and
8216        // merge. That was done by hand six times in one session before this
8217        // existed. Opt-in, because merging is the one irreversible thing magi
8218        // can do to a repository.
8219        if self.state.config.graph.land
8220            && mode == MergeMode::Pr
8221            && self.state.status == RunStatus::Merged
8222        {
8223            self.run_land().await?;
8224        }
8225        // `run_land` may have left `status` at `Landing` - still waiting on
8226        // CI or the owner's approval, not actually settled - so this has to
8227        // read whatever `status` ended up as here, not the `Merged` this
8228        // function set a few lines up.
8229        self.settle_questions();
8230        Ok(())
8231    }
8232
8233    /// Enter `land`.
8234    ///
8235    /// Shared between a fresh run's first pass through [`Runner::merge`] and
8236    /// a resumed run's re-entry. `land::land` itself is what serialises the
8237    /// two git-mutating moments inside the loop — the rebase push and
8238    /// `gh pr merge` — per repository (see its own doc); nothing here needs
8239    /// to hold a lock across the whole call, and doing so would serialise
8240    /// this run's CI wait against a *different* run's land-approval resume
8241    /// in the same repository, which is exactly the "must not wait on
8242    /// another task" property the daemon's slot-freeing exists to give.
8243    async fn run_land(&mut self) -> Result<()> {
8244        crate::pr_head::ensure_owner(&self.state)?;
8245        let url = self
8246            .state
8247            .merge
8248            .as_ref()
8249            .map(|m| m.detail.clone())
8250            .unwrap_or_default();
8251        let url = url.lines().next().unwrap_or("").trim().to_owned();
8252        if !url.starts_with("http") {
8253            return Ok(());
8254        }
8255        // A land failure is not a lost run: the work is on a branch and the
8256        // pull request is open, which is exactly where a human takes over.
8257        loop {
8258            match land::land(&mut self.state, &url).await {
8259                Ok(_)
8260                    if self
8261                        .state
8262                        .merge_repairs
8263                        .last()
8264                        .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8265                {
8266                    if self.repair_merge_review().await? {
8267                        continue;
8268                    }
8269                }
8270                Ok(pr) if self.state.parked => {
8271                    // `land` already saved the parked marker; nothing here
8272                    // overrides `status` back to a terminal value while an
8273                    // approval is still outstanding.
8274                    let _ = pr;
8275                }
8276                Ok(_) if self.state.merge_decision.as_ref().is_some_and(|d| d.closed) => {
8277                    self.state.status = RunStatus::Superseded;
8278                    self.state.save()?;
8279                }
8280                Ok(pr) => {
8281                    self.state.status = match pr.state {
8282                        land::PrLifecycle::Merged => RunStatus::Merged,
8283                        _ => RunStatus::Blocked,
8284                    };
8285                    // Downstream of a confirmed merge only - see
8286                    // `bump::should_release_bump`'s own doc for why this one
8287                    // check covers all three of `land`'s success paths.
8288                    // Best-effort: the run already landed, so a failure here
8289                    // (the decision call, `gh`, `cargo`) is recorded and never
8290                    // turns a landed run into a failed one.
8291                    if bump::should_release_bump(self.state.status)
8292                        && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8293                    {
8294                        // The event is the run's own record. Not-eligible cases
8295                        // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
8296                        // `Err` is a bump that was tried and failed:
8297                        // `after_merge` itself raises the operator notice for
8298                        // that, whether or not a release PR exists yet.
8299                        self.state
8300                            .event("bump", format!("release bump skipped: {e:#}"));
8301                    }
8302                    // Independent of the bump, and best-effort in the same way:
8303                    // findings the merge left open become follow-up tasks.
8304                    if self.state.status == RunStatus::Merged {
8305                        crate::followup::after_merge(&mut self.state, &pr.url).await;
8306                    }
8307                    self.state.save()?;
8308                }
8309                Err(e) => {
8310                    if e.is::<crate::repo_lock::ResourceBlocked>() {
8311                        return Err(e);
8312                    }
8313                    self.state.status = RunStatus::Blocked;
8314                    self.state.event("land", format!("gave up: {e}"));
8315                    self.state.save()?;
8316                }
8317            }
8318            break;
8319        }
8320        Ok(())
8321    }
8322
8323    /// One repair cycle stays under the existing driver/task claim. An
8324    /// interrupted fixer is rolled back and held rather than run twice.
8325    async fn repair_merge_review(&mut self) -> Result<bool> {
8326        use crate::run::MergeRepairPhase as Phase;
8327        crate::pr_head::ensure_owner(&self.state)?;
8328        let i = self.state.merge_repairs.len() - 1;
8329        let request = self.state.merge_repairs[i].clone();
8330        if request.phase == Phase::Fixing {
8331            let restored = match self.state.winner().cloned() {
8332                Some(winner) => self.graft_restore(&winner.worktree, &request.head).await,
8333                None => Err(anyhow::anyhow!("repair has no winning branch")),
8334            };
8335            let why = match restored {
8336                Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8337                Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
8338                Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8339            };
8340            self.hold_merge_repair(i, why)?;
8341            return Ok(false);
8342        }
8343        let failure = if i >= self.state.config.graph.land_rounds
8344            || self.state.config.graph.review_rounds == 0
8345        {
8346            Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8347        } else {
8348            None
8349        };
8350        if let Some(why) = failure {
8351            self.hold_merge_repair(i, why)?;
8352            return Ok(false);
8353        }
8354        if request.phase == Phase::Requested {
8355            let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8356                Ok(pr) => pr,
8357                Err(e) => {
8358                    self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8359                    return Ok(false);
8360                }
8361            };
8362            self.state.merge_repairs[i].phase = Phase::Fixing;
8363            self.state.land_approval = None;
8364            self.state.land_armed_head = None;
8365            self.state.save()?;
8366            // Serialize the exact records, including finding ids/details,
8367            // reconsideration reasons, final votes and verification output.
8368            let evidence = serde_json::to_string_pretty(&request)?;
8369            let reason = format!(
8370                "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}"
8371            );
8372            let budget = self.state.config.graph.land_rounds;
8373            let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8374            match result {
8375                Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8376                Ok(land::Fixed::Failed(why)) => {
8377                    self.hold_merge_repair(
8378                        i,
8379                        format!(
8380                            "repair failed: {why}; inspect the branch and run an explicit review"
8381                        ),
8382                    )?;
8383                    return Ok(false);
8384                }
8385                Err(e) => {
8386                    self.hold_merge_repair(
8387                        i,
8388                        format!(
8389                            "repair failed: {e:#}; inspect the branch and run an explicit review"
8390                        ),
8391                    )?;
8392                    return Ok(false);
8393                }
8394            }
8395            // Even no change gets a real new reviewer wave. The previous
8396            // evidence remains in the request; a former clean/concluded round
8397            // cannot short-circuit the new review or gate.
8398            self.begin_merge_repair_review(i)?;
8399        }
8400        self.review_loop().await?;
8401        self.gate().await?;
8402        if self.resource_halt()? || self.state.parked {
8403            return Ok(false);
8404        }
8405        if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8406            != Some(RunStatus::Gating)
8407            || !self.state.gate_status().ok()
8408        {
8409            self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8410            return Ok(false);
8411        }
8412        // Review and gate may have added commits after the initial repair.
8413        // Publish exactly their final head to the existing PR, with a lease.
8414        let winner = self
8415            .state
8416            .winner()
8417            .cloned()
8418            .context("repair has no winning branch")?;
8419        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8420        if self.state.gate_head.as_deref() != Some(after.as_str())
8421            || !git::is_clean(&winner.worktree).await?
8422        {
8423            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())?;
8424            return Ok(false);
8425        }
8426        let expected = self
8427            .state
8428            .pr_pushed_head
8429            .as_deref()
8430            .unwrap_or(&request.head)
8431            .to_owned();
8432        let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8433        // Push the checked commit itself: a local ref moving after the check
8434        // must not change which commit the forge receives.
8435        let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8436        let refspec = format!("{after}:refs/heads/{public}");
8437        let push = git::git_raw(
8438            &winner.worktree,
8439            &["push", &lease, &self.state.config.merge.remote, &refspec],
8440        )
8441        .await;
8442        match push {
8443            Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8444            Ok(out) => {
8445                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8446                return Ok(false);
8447            }
8448            Err(e) => {
8449                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8450                return Ok(false);
8451            }
8452        }
8453        self.state.merge_repairs[i].phase = Phase::Complete;
8454        self.state.status = RunStatus::Landing;
8455        self.state.event(
8456            "repair",
8457            "new review and gate completed; fresh owner merge decision required",
8458        );
8459        self.state.save()?;
8460        Ok(true)
8461    }
8462
8463    fn merge_repair_artifacts(&self) -> PathBuf {
8464        let artifacts = agent::artifacts_dir(&self.state.dir());
8465        match self
8466            .state
8467            .merge_repairs
8468            .last()
8469            .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8470        {
8471            Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8472            None => artifacts,
8473        }
8474    }
8475
8476    fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8477        self.state.reviews.clear();
8478        self.state.gate.clear();
8479        self.state.gate_ran = false;
8480        self.state.gate_head = None;
8481        self.state.gate_fixes.clear();
8482        self.state.pre_gate.clear();
8483        self.state.pre_gate_commit = None;
8484        self.state.contested_handoff = None;
8485        self.state.land_reruns = None;
8486        self.state.land_approval = None;
8487        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8488        self.state.status = RunStatus::Reviewing;
8489        self.state.save()
8490    }
8491
8492    fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8493        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8494        self.state.merge_repairs[index].failure = Some(why.clone());
8495        self.state.status = RunStatus::Blocked;
8496        self.state.event("repair", why);
8497        self.state.save()
8498    }
8499
8500    // -------------------------------------------------------------- helpers
8501
8502    /// Fetch or create a seat, keeping its conversation across nodes.
8503    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8504        if let Some(existing) = self.state.seats.get(key)
8505            && existing.agent == agent
8506        {
8507            return existing.clone();
8508        }
8509        // A seat that changes agent mints its session id from the agent too,
8510        // like a handover: the old agent's uuid is already taken by the CLI.
8511        let fresh = if self.state.seats.contains_key(key) {
8512            handover_seat(key, agent, self.state.next_seat_seed())
8513        } else {
8514            SeatState::new(key, agent, self.state.seed)
8515        };
8516        self.state.seats.insert(key.to_owned(), fresh.clone());
8517        fresh
8518    }
8519
8520    /// The agent now holding seat `key`: `spec`, unless a handover moved the
8521    /// seat to another roster agent, in which case that agent. Nodes that
8522    /// continue a seat's conversation (deliberation, the votes, a reviewer's
8523    /// reconsideration) must keep talking to whoever answered it, not slip
8524    /// back to the agent that failed it.
8525    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8526        match self.state.seats.get(key) {
8527            Some(s) if s.agent != spec.id => {
8528                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8529            }
8530            _ => spec,
8531        }
8532    }
8533
8534    /// A candidate rendered for judging, with the leak policy applied.
8535    fn view(&self, c: &Candidate) -> CandidateView {
8536        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8537            .unwrap_or_default();
8538        let (patch, _) = blind::sanitize_patch(
8539            &format!("candidate {} patch", c.label),
8540            &raw,
8541            &self.state.config.blind,
8542        );
8543        CandidateView {
8544            label: c.label,
8545            branch: c.branch.clone(),
8546            summary: c.summary.clone(),
8547            stat: c.stat.clone(),
8548            patch,
8549        }
8550    }
8551
8552    /// The full candidate set as prompt text, for seats with no live session.
8553    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8554        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8555        prompt::judge(
8556            "(see above)",
8557            &views,
8558            self.roles.judges.len(),
8559            base_short,
8560            "en",
8561        )
8562    }
8563
8564    /// The final-vote prompt with everything a seat that has no session of its
8565    /// own needs: the candidates, the seat's own ranking and reasons, and the
8566    /// anonymised deliberation it took part in (only when there was one, so a
8567    /// handed-over seat never sees more than the seat it replaces did). The
8568    /// `Final vote` heading stays first.
8569    fn vote_prompt_full(
8570        &self,
8571        j: usize,
8572        labels: &[char],
8573        language: &str,
8574        candidates: &[Candidate],
8575        base_short: &str,
8576    ) -> String {
8577        let mut text = format!(
8578            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8579            prompt::final_vote(labels, language),
8580            self.state.instruction,
8581            self.candidate_block(candidates, base_short)
8582        );
8583        if let Some(own) = self
8584            .state
8585            .judgements
8586            .get(j)
8587            .filter(|r| !r.ranking.is_empty())
8588        {
8589            let reasons = own
8590                .reasons
8591                .iter()
8592                .map(|(k, v)| format!("- {k}: {v}"))
8593                .collect::<Vec<_>>()
8594                .join("\n");
8595            text.push_str(&format!(
8596                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8597                own.ranking.iter().collect::<String>(),
8598                if reasons.is_empty() {
8599                    ""
8600                } else {
8601                    ", because:\n"
8602                }
8603            ));
8604        }
8605        if !self.state.deliberation.is_empty() {
8606            text.push_str("\n# What was argued before this vote\n");
8607            for t in self.transcript(&[], j) {
8608                text.push_str(&format!(
8609                    "\n## {}{}\n\n{}\n",
8610                    t.who,
8611                    if t.is_self { " (you)" } else { "" },
8612                    t.body.trim()
8613                ));
8614            }
8615        }
8616        text
8617    }
8618
8619    /// Anonymised transcript for judge `self_idx`.
8620    ///
8621    /// The initial rankings are always the opening statements. Seeding them
8622    /// only when no turn had been taken yet meant every judge after the first
8623    /// argued against a single voice instead of against the actual split — the
8624    /// disagreement is the information, so it is always on the table.
8625    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8626        let mut turns = Vec::new();
8627        for j in &self.state.judgements {
8628            if j.ranking.is_empty() {
8629                continue;
8630            }
8631            let reasons = j
8632                .reasons
8633                .iter()
8634                .map(|(k, v)| format!("- {k}: {v}"))
8635                .collect::<Vec<_>>()
8636                .join("\n");
8637            turns.push(Turn {
8638                who: format!("Judge {} (opening ranking)", j.judge),
8639                is_self: j.judge == self_idx + 1,
8640                body: format!(
8641                    "Ranked {}{}{reasons}",
8642                    j.ranking.iter().collect::<String>(),
8643                    if reasons.is_empty() {
8644                        ""
8645                    } else {
8646                        ", because:\n"
8647                    }
8648                ),
8649            });
8650        }
8651        for t in self
8652            .state
8653            .deliberation
8654            .iter()
8655            .flat_map(|r| r.turns.iter())
8656            .chain(current)
8657        {
8658            turns.push(Turn {
8659                who: format!("Judge {}", t.judge),
8660                is_self: t.judge == self_idx + 1,
8661                body: t.body.clone(),
8662            });
8663        }
8664        turns
8665    }
8666}
8667
8668/// Does this seat still hold the context a follow-up prompt would rely on?
8669fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8670    agent::has_session(spec.kind, seat, sessions)
8671}
8672
8673/// The next entry in `roster` after `start`, never wrapping back to the
8674/// front, whose id is not in `tried` yet.
8675///
8676/// Starts one past `start` rather than at the front of `roster`: `start` is
8677/// the seat's own original position, and a seat whose candidate slot already
8678/// sits on the roster's second entry must fall through to the third next, not
8679/// restart at the first — which is very likely a different candidate's own
8680/// agent already. Never wraps back past `start`, for the same reason: an
8681/// entry earlier in the roster than the seat's own position is almost
8682/// certainly some *other* candidate slot's own agent, and once the tail of
8683/// the roster is exhausted there are no more untried agents for *this* seat
8684/// to fall through to — the caller's fallback chain ends there, exactly as
8685/// "no further untried agents remain in the list for that seat" asks for.
8686///
8687/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
8688/// the same id twice (an operator's `roles.implementers` typo, or a
8689/// `[[agents]]` list reused across roles) must not let
8690/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
8691/// over `roster` either finds an untried id or runs out, so this always
8692/// terminates regardless of duplicates.
8693fn next_untried_in_roster<'a>(
8694    roster: &'a [AgentSpec],
8695    start: usize,
8696    tried: &BTreeSet<String>,
8697) -> Option<&'a AgentSpec> {
8698    roster
8699        .get(start + 1..)?
8700        .iter()
8701        .find(|s| !tried.contains(&s.id))
8702}
8703
8704/// The successor for a seat, shared by all four seat kinds (implement, judge,
8705/// review, advise). `others` holds the ids that *currently* occupy the other
8706/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
8707/// sees them), so roster entries beyond the seat count act as spares: a failed
8708/// seat goes to an agent no other seat holds whenever the roster permits.
8709///
8710/// `carried` is `Some` only for the review loop's carried failure history.
8711/// Order: (1) forward from `start`, never wrapping, untried, not a carried
8712/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
8713/// whole roster: untried and not another seat's occupant; (3) the plain walk
8714/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
8715/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
8716/// a better outcome than an empty seat, and it keeps the answer to "is there
8717/// a successor at all" exactly what it was before occupants were considered,
8718/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
8719/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
8720/// carried failure is a cheaper bet than doubling an agent on the panel, and
8721/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
8722/// the same round are handled one at a time, so a seat later in the batch
8723/// sees an earlier one's new occupant but not yet its own freed agent.
8724fn pick_successor<'a>(
8725    roster: &'a [AgentSpec],
8726    start: usize,
8727    tried: &BTreeSet<String>,
8728    carried: Option<&BTreeSet<String>>,
8729    others: &BTreeSet<String>,
8730) -> Option<&'a AgentSpec> {
8731    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8732    roster
8733        .get(start + 1..)
8734        .and_then(|tail| {
8735            tail.iter()
8736                .filter(free)
8737                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8738        })
8739        .or_else(|| {
8740            carried
8741                .is_some()
8742                .then(|| roster.iter().find(free))
8743                .flatten()
8744        })
8745        .or_else(|| match carried {
8746            Some(c) => next_for_seat(roster, start, tried, c),
8747            None => next_untried_in_roster(roster, start, tried),
8748        })
8749}
8750
8751/// The next agent for a seat that carries its failure history across rounds
8752/// (the review loop). `round_tried` is this round's own bound and starts
8753/// empty every round; `carried_failed` only decides priority.
8754///
8755/// First: an id walking forward from `start`, never wrapping, that is neither
8756/// tried this round nor failed in an earlier one. Only when that is exhausted
8757/// does it rescue: the first roster id (in roster order, so this one *does*
8758/// look before `start`) not yet tried this round, which is by then a carried
8759/// failure. Each id is rescued at most once per round, so it cannot loop.
8760fn next_for_seat<'a>(
8761    roster: &'a [AgentSpec],
8762    start: usize,
8763    round_tried: &BTreeSet<String>,
8764    carried_failed: &BTreeSet<String>,
8765) -> Option<&'a AgentSpec> {
8766    roster
8767        .get(start + 1..)?
8768        .iter()
8769        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8770        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8771}
8772
8773/// Where a reviewer seat starts a round: the agent that last answered it when
8774/// it is still on the roster and not marked failed, else the spec's own agent
8775/// unless it failed, else the next roster agent that has not failed, else the
8776/// spec's own agent again (the whole roster failed). Ids no longer on the
8777/// roster are ignored. An empty roster has no handover, so the spec stands.
8778fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8779    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8780        return spec;
8781    };
8782    let ok = |id: &str| !h.failed.contains(id);
8783    if let Some(last) = h.last_ok.as_deref()
8784        && ok(last)
8785        && let Some(s) = roster.iter().find(|s| s.id == last)
8786    {
8787        return s.clone();
8788    }
8789    if ok(&spec.id) {
8790        return spec;
8791    }
8792    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8793    roster
8794        .get(from + 1..)
8795        .into_iter()
8796        .flatten()
8797        .chain(roster.iter())
8798        .find(|s| ok(&s.id))
8799        .cloned()
8800        .unwrap_or(spec)
8801}
8802
8803/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
8804/// seed so a CLI that mints its session id up front (`--session-id`) never
8805/// reuses the uuid the previous agent already opened under the same seat key.
8806pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8807    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8808}
8809
8810/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
8811/// so the classifier below and the code that builds the message cannot drift.
8812const TIMED_OUT: &str = "timed out";
8813
8814impl FailClass {
8815    /// `None` for an answer; otherwise how the turn failed.
8816    fn of(out: &AgentOutcome) -> Option<Self> {
8817        match out {
8818            AgentOutcome::Ok(_) => None,
8819            AgentOutcome::Quota(_) => Some(Self::Quota),
8820            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8821            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8822            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8823            // Not a failure of the agent. Callers check for it first; `None`
8824            // here means "no class to hand over on".
8825            AgentOutcome::ResourceBlocked(_) => None,
8826        }
8827    }
8828
8829    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
8830    fn stem_word(&self) -> &'static str {
8831        match self {
8832            Self::Quota => "quota",
8833            _ => "handover",
8834        }
8835    }
8836}
8837
8838/// The message's first line with its variable parts removed — digit runs and
8839/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
8840/// as one kind of failure.
8841fn failure_signature(msg: &str) -> String {
8842    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8843    let mut out = Vec::new();
8844    for word in line.split_whitespace() {
8845        if word.contains('/') || word.contains('\\') {
8846            out.push("<path>".to_owned());
8847            continue;
8848        }
8849        let mut w = String::new();
8850        let mut in_digits = false;
8851        for c in word.chars() {
8852            if c.is_ascii_digit() {
8853                if !in_digits {
8854                    w.push('#');
8855                }
8856                in_digits = true;
8857            } else {
8858                in_digits = false;
8859                w.push(c);
8860            }
8861        }
8862        out.push(w);
8863    }
8864    out.join(" ").chars().take(120).collect()
8865}
8866
8867/// Whether a seat that just failed with `cur` may go to the next roster agent.
8868/// A quota or a timeout always may. Any other failure may not when the agent
8869/// before it failed the same way: an error the prompt causes would otherwise
8870/// walk the whole roster. `prev` is the class of the immediately preceding
8871/// agent's failure, so a quota or timeout in between breaks the run of
8872/// identical failures by itself.
8873fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8874    match cur {
8875        FailClass::Quota | FailClass::Timeout => true,
8876        FailClass::Other(_) => prev != Some(cur),
8877    }
8878}
8879
8880/// A short human reason for a failed outcome, for the handover record.
8881fn fail_reason(out: &AgentOutcome) -> String {
8882    match out {
8883        AgentOutcome::Ok(_) => String::new(),
8884        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8885        AgentOutcome::Dropped(o) => format!(
8886            "the CLI dropped the stream ({})",
8887            o.dropped
8888                .as_ref()
8889                .map(|d| d.why.as_str())
8890                .unwrap_or("it ended without delivering its answer")
8891        ),
8892        AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8893            e.lines().next().unwrap_or("").chars().take(160).collect()
8894        }
8895    }
8896}
8897
8898/// Note one handover in the run: the structured record and, in the timeline,
8899/// the sentence a person reads. A quota keeps the wording it always had.
8900pub(crate) fn record_handover(
8901    state: &mut RunState,
8902    node: &str,
8903    seat: &str,
8904    from: &str,
8905    to: &str,
8906    class: &FailClass,
8907    reason: &str,
8908) {
8909    let message = if *class == FailClass::Quota {
8910        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8911    } else {
8912        format!("{seat}: handed over {from} -> {to} ({reason})")
8913    };
8914    state.event(node, message);
8915    state.handovers.push(Handover {
8916        at: Timestamp::now(),
8917        node: node.to_owned(),
8918        seat: seat.to_owned(),
8919        from: from.to_owned(),
8920        to: to.to_owned(),
8921        reason: reason.to_owned(),
8922    });
8923}
8924
8925/// Did this reply report running a command whose own CLI never confirmed an
8926/// exit status?
8927///
8928/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
8929/// command *finished* (see that type's own doc), so this can only be `true`
8930/// for a command whose completion event carried no readable exit code — not
8931/// for one that simply is not mentioned at all. That is the one signal this
8932/// crate can read, from the same record `state.jobs` renders, about a reply
8933/// standing next to work its own CLI cannot vouch for finishing; it is
8934/// deliberately not a check on the exit code's *value* (a fixer legitimately
8935/// runs a command that fails mid-iteration before it succeeds) and not a
8936/// guess at a command still running in the background (which emits no event
8937/// at all, and so leaves no evidence here to find).
8938fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8939    commands.iter().any(|c| c.exit_code.is_none())
8940}
8941
8942/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
8943/// trusted as a verified no-op — the adoption guard's own text-level half.
8944///
8945/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
8946/// timed out): a marker only earns the benefit of the doubt from a turn the
8947/// CLI itself vouches for finishing properly, the same house style
8948/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
8949/// *fix* report to for `commands`. A candidate that timed out, exited
8950/// non-zero, or left a command unconfirmed is read as the ordinary loss it
8951/// is, whatever prose it wrote — this returns `None` before it ever looks at
8952/// `text`. The remaining guards (the tree really is empty, the evidence is
8953/// non-empty) are the caller's: this only reads what the reply *claimed*.
8954fn verified_noop_claim(
8955    usable: bool,
8956    commands: &[agent::CommandEvidence],
8957    text: &str,
8958) -> Option<String> {
8959    (usable && !has_unconfirmed_command(commands))
8960        .then(|| verdict::verified_noop(text))
8961        .flatten()
8962}
8963
8964fn short(commit: &str) -> String {
8965    commit.chars().take(7).collect()
8966}
8967
8968fn make_executable(path: &Path) -> Result<()> {
8969    #[cfg(unix)]
8970    {
8971        use std::os::unix::fs::PermissionsExt as _;
8972        let mut perms = std::fs::metadata(path)?.permissions();
8973        perms.set_mode(0o755);
8974        std::fs::set_permissions(path, perms)?;
8975    }
8976    #[cfg(not(unix))]
8977    {
8978        let _ = path;
8979    }
8980    Ok(())
8981}
8982
8983/// What every seat in one batch shares: where the answers are attributed, the
8984/// prompt overlay they inherit, and the build cache they are told to use.
8985///
8986/// A struct rather than four more parameters: `wave` also needs the run's
8987/// state (to record who is answering right now) and the attempt number, and
8988/// eight positional arguments is both unreadable and a clippy error.
8989struct WaveCtx<'a> {
8990    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
8991    /// paid for it.
8992    run: &'a str,
8993    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
8994    node: &'a str,
8995    prompts: &'a Prompts,
8996    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
8997    cache: Option<&'a Path>,
8998    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
8999    /// `JobRecord::round`. `None` for every other node.
9000    round: Option<usize>,
9001    /// Carry each seat's failed-agent history across waves (the review loop
9002    /// only): start-of-round priority and handover choice read
9003    /// [`RunState::seat_history`], and every answer or failure writes it.
9004    carry_seats: bool,
9005    /// The run's park / interrupt requests, so a wait for a shared resource
9006    /// can be abandoned the moment the operator asks to park.
9007    pauses: Option<(&'a Pause, &'a Pause)>,
9008}
9009
9010/// Shared scheduling policy for operations and spawned seat invocations.
9011fn repository_wait(
9012    state: &RunState,
9013    pauses: Option<(&Pause, &Pause)>,
9014) -> crate::repo_lock::WaitPolicy {
9015    let pauses = pauses.map(|(a, b)| (a.clone(), b.clone()));
9016    let inherited = crate::repo_lock::WaitPolicy::current();
9017    crate::repo_lock::WaitPolicy::new(
9018        Duration::from_secs(state.config.graph.timeout_resource_wait),
9019        move || {
9020            inherited.cancelled()
9021                || pauses
9022                    .as_ref()
9023                    .is_some_and(|(a, b)| a.parked() || b.parked())
9024        },
9025    )
9026}
9027
9028fn record_repository_block(state: &mut RunState, error: &anyhow::Error) {
9029    if error.is::<crate::repo_lock::ResourceBlocked>() {
9030        state.resource_block = Some(crate::run::ResourceBlock {
9031            node: "repository".into(),
9032            kind: "repository coordination".into(),
9033            detail: error.to_string(),
9034            at: Timestamp::now(),
9035        });
9036        state.event("resource", error.to_string());
9037    }
9038}
9039
9040async fn coordinate_operation(
9041    state: &mut RunState,
9042    cwd: &Path,
9043    wait: &crate::repo_lock::WaitPolicy,
9044) -> Result<Option<crate::repo_lock::SeatLease>> {
9045    let result =
9046        crate::repo_lock::SeatLease::operation_with(cwd, state.config.coordination.as_ref(), wait)
9047            .await;
9048    if let Err(e) = &result {
9049        record_repository_block(state, e);
9050    }
9051    result
9052}
9053
9054/// Run one job, honouring the parallelism budget.
9055async fn run_one(
9056    job: SeatJob,
9057    sem: Arc<Semaphore>,
9058    ctx: &WaveCtx<'_>,
9059    state: &mut RunState,
9060    attempt: usize,
9061) -> (SeatState, AgentOutcome) {
9062    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
9063        .await
9064        .pop()
9065        .expect("one job in, one result out");
9066    (seat, out)
9067}
9068
9069/// Run every job concurrently, capped by the semaphore, preserving order.
9070///
9071/// Every seat in the batch is recorded into [`RunState::active`] before the
9072/// wave starts and cleared as each answer lands, so the run's own record says
9073/// who is still being waited on rather than only who finished.
9074async fn wave(
9075    jobs: Vec<SeatJob>,
9076    sem: Arc<Semaphore>,
9077    ctx: &WaveCtx<'_>,
9078    state: &mut RunState,
9079    attempt: usize,
9080) -> Vec<(usize, SeatState, AgentOutcome)> {
9081    let WaveCtx {
9082        run,
9083        node,
9084        prompts,
9085        cache,
9086        round,
9087        carry_seats: _,
9088        pauses,
9089    } = *ctx;
9090    for job in &jobs {
9091        state.seat_started(node, &job.seat.key, job.timeout, attempt);
9092    }
9093    if let Err(e) = state.save() {
9094        // A failed persist of "who is answering right now" must not abort the
9095        // wave: the seats are already being asked, and the alternative is
9096        // losing the answers to save a status line nobody may even be
9097        // watching.
9098        tracing::warn!("could not persist in-progress seats: {e:#}");
9099    }
9100    // Hold the shared build cache's lease for the whole batch, not per job:
9101    // several candidates (an implement wave) or a fixer legitimately share
9102    // one cache concurrently within this run, and that stays untouched — a
9103    // single lease taken once for the whole wave and released once it is
9104    // done is what stops a *different* borrower (another run's own wave, its
9105    // e2e/gate, a human's `magi review`) from interleaving a build into the
9106    // same directory while this one is in flight. Best-effort, not
9107    // all-or-nothing: a wave that cannot get the lease within its own
9108    // longest job's budget still runs — an hour of paid implementer calls is
9109    // not thrown away over cache contention — but every write-allowed seat
9110    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
9111    // below), the same fallback a read-only seat always gets, rather than
9112    // building into a directory this run was never granted. The identity
9113    // record is still invalidated below either way, so the next tracked
9114    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
9115    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
9116    // The wait for the lease has its own bound, `timeout_resource_wait`; it is
9117    // never borrowed from a job's agent budget (and never charged to it either,
9118    // see below). The agent's clock starts in `agent::invoke`, at spawn.
9119    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
9120    let cache_guard = if let Some(cache_dir) = cache {
9121        if jobs_had_a_writer {
9122            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
9123            let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
9124            match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
9125            {
9126                Ok(Some(g)) => Some(g),
9127                // Parking: leave without spawning anything. The run parks at
9128                // the next boundary and the seats are asked afresh on resume.
9129                Ok(None) => {
9130                    state.resource_block = Some(crate::run::ResourceBlock {
9131                        node: node.to_owned(),
9132                        kind: "shared build cache".to_owned(),
9133                        detail: "the run is parking".to_owned(),
9134                        at: Timestamp::now(),
9135                    });
9136                    return block_wave(
9137                        jobs,
9138                        state,
9139                        node,
9140                        attempt,
9141                        "parked while waiting for the shared build cache",
9142                    );
9143                }
9144                // An unreadable lease is not contention: keep the old
9145                // fallback (no shared cache for this wave's seats).
9146                Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
9147                Err(e) => {
9148                    // Writing seats may not build into a cache this run does
9149                    // not hold, and an agent that cannot be given one is not
9150                    // asked at all: the run stops resumably instead.
9151                    let why = format!("{e:#}");
9152                    state.resource_block = Some(crate::run::ResourceBlock {
9153                        node: node.to_owned(),
9154                        kind: "shared build cache".to_owned(),
9155                        detail: why.clone(),
9156                        at: Timestamp::now(),
9157                    });
9158                    state.event(
9159                        "resource",
9160                        format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
9161                    );
9162                    return block_wave(jobs, state, node, attempt, &why);
9163                }
9164            }
9165        } else {
9166            None
9167        }
9168    } else {
9169        None
9170    };
9171    // Every job keeps its whole budget: the wait above was not the agent's.
9172    // Restart the seats' clocks so "started" means "being spawned now".
9173    state.seats_spawning(node, attempt);
9174    if let Err(e) = state.save() {
9175        tracing::warn!("could not persist the start of a wave: {e:#}");
9176    }
9177    // Queueing behind this wave's own seats for a parallelism slot is normal
9178    // (each of them is bounded by its own agent timeout); only a wait beyond
9179    // all of them plus the resource limit means the slots never came back.
9180    let slot_limit = resource_limit
9181        + jobs
9182            .iter()
9183            .map(|j| j.timeout)
9184            .max()
9185            .unwrap_or_default()
9186            .saturating_mul(jobs.len() as u32);
9187    let mut set = tokio::task::JoinSet::new();
9188    // Seats announce themselves here the moment they hold a parallelism
9189    // permit, i.e. just before their CLI is spawned.
9190    let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9191    let overlay = prompts.overlay(node);
9192    let repository_wait = repository_wait(state, pauses);
9193    for (i, mut job) in jobs.into_iter().enumerate() {
9194        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
9195        if cache.is_some() {
9196            job.prompt.push('\n');
9197            job.prompt
9198                .push_str(&prompt::build_cache_note(node, job.allow_write));
9199        }
9200        let sem = Arc::clone(&sem);
9201        let run = run.to_owned();
9202        let node = node.to_owned();
9203        // Only implementers were told about the task's attachments, so only
9204        // their seats get the directory widened for reading.
9205        let attachments = if node == "implement" {
9206            state.attachments.clone()
9207        } else {
9208            Vec::new()
9209        };
9210        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
9211        // `prompt::build_cache_note`'s doc for why setting it anyway is
9212        // exactly how a sandboxed reviewer's write refusal got reported as a
9213        // defect in the patch, not a property of its own seat. And a
9214        // write-allowed one is handed it only when the lease above was
9215        // actually acquired: a wave that could not get it (`cache_guard` is
9216        // `None`, see its own comment) must not send seats to build into a
9217        // directory this run does not hold - that is the exact concurrent,
9218        // unmanaged-write race this module exists to prevent, not something
9219        // "proceeding anyway" is allowed to reintroduce.
9220        let cache = cache
9221            .filter(|_| job.allow_write && cache_guard.is_some())
9222            .map(Path::to_path_buf);
9223        let spawned_tx = spawned_tx.clone();
9224        let repository_wait = repository_wait.clone();
9225        let coordination = state.config.coordination.clone();
9226        set.spawn(async move {
9227            // A wait for a parallelism slot is scheduling, not the agent: it
9228            // is bounded by its own limit and ends as a resource block, never
9229            // as a timeout of the agent that was not started.
9230            let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9231                Ok(p) => p,
9232                Err(_) => {
9233                    return (
9234                        i,
9235                        job.seat,
9236                        AgentOutcome::ResourceBlocked(format!(
9237                            "no parallel slot became free within {}s",
9238                            slot_limit.as_secs()
9239                        )),
9240                    );
9241                }
9242            };
9243            spawned_tx.send(job.seat.key.clone()).ok();
9244            let mut seat = job.seat;
9245            let out = repository_wait
9246                .scope(agent::invoke(
9247                    &job.spec,
9248                    &mut seat,
9249                    &Invocation {
9250                        coordination: coordination.as_ref(),
9251                        cwd: &job.cwd,
9252                        prompt: &job.prompt,
9253                        timeout: job.timeout,
9254                        allow_write: job.allow_write,
9255                        unsandboxed: false,
9256                        sessions: job.sessions,
9257                        artifacts: &job.artifacts,
9258                        stem: &job.stem,
9259                        run: &run,
9260                        node: &node,
9261                        cache_dir: cache.as_deref(),
9262                        attachments: &attachments,
9263                        writable: &[],
9264                    },
9265                ))
9266                .await;
9267            let out = match out {
9268                Ok(o) if o.usable() => AgentOutcome::Ok(o),
9269                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9270                // Billed work the CLI failed to hand over is not an ordinary
9271                // failure, but its text is the CLI's raw error JSON, not an
9272                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
9273                // read it as one by forgetting to check. `usable()` is always
9274                // false here (dropped implies an empty response), so this has
9275                // to be checked before the catch-all `Failed` below or the
9276                // one shape this exists for is lost with the rest.
9277                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9278                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9279                Ok(o) => AgentOutcome::Failed(format!(
9280                    "exited with {:?} and no usable output",
9281                    o.exit_code
9282                )),
9283                Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
9284                    AgentOutcome::ResourceBlocked(e.to_string())
9285                }
9286                Err(e) => AgentOutcome::Failed(e.to_string()),
9287            };
9288            (i, seat, out)
9289        });
9290    }
9291    drop(spawned_tx);
9292    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9293    loop {
9294        let joined = tokio::select! {
9295            Some(key) = spawned_rx.recv() => {
9296                // The clock of this seat starts now, not when it queued.
9297                state.seat_spawned(&key);
9298                if let Err(e) = state.save() {
9299                    tracing::warn!("could not persist a seat's start: {e:#}");
9300                }
9301                continue;
9302            }
9303            joined = set.join_next() => joined,
9304        };
9305        let Some(joined) = joined else {
9306            break;
9307        };
9308        let (i, seat, out) = match joined {
9309            Ok(v) => v,
9310            // No seat to clear: a panicked task never reported which one it
9311            // was. The defensive sweep below this loop is what stops that
9312            // seat's `active` entry from surviving forever.
9313            Err(e) => {
9314                tracing::error!("agent task panicked: {e}");
9315                continue;
9316            }
9317        };
9318        state.seat_finished(&seat.key);
9319        if let AgentOutcome::ResourceBlocked(why) = &out
9320            && state.resource_block.is_none()
9321        {
9322            state.resource_block = Some(crate::run::ResourceBlock {
9323                node: node.to_owned(),
9324                kind: if why.contains("repository coordination") {
9325                    "repository coordination"
9326                } else {
9327                    "parallel slot"
9328                }
9329                .to_owned(),
9330                detail: why.clone(),
9331                at: Timestamp::now(),
9332            });
9333            state.event("resource", format!("{node}: no agent was started - {why}"));
9334        }
9335        record_jobs(state, node, round, &seat.key, &out);
9336        if let Err(e) = state.save() {
9337            tracing::warn!("could not persist a seat's completion: {e:#}");
9338        }
9339        if collected.len() <= i {
9340            collected.resize_with(i + 1, || None);
9341        }
9342        collected[i] = Some((i, seat, out));
9343    }
9344    // Belt-and-braces for the panic branch above: every seat this exact batch
9345    // started shares this `(node, attempt)` pair, and every seat that finished
9346    // normally already cleared itself, so anything left tagged with it here
9347    // can only be a panicked task's leftover. Cleared unconditionally rather
9348    // than left to read as still answering forever.
9349    if state
9350        .active
9351        .values()
9352        .any(|a| a.node == node && a.attempt == attempt)
9353    {
9354        state
9355            .active
9356            .retain(|_, a| !(a.node == node && a.attempt == attempt));
9357        if let Err(e) = state.save() {
9358            tracing::warn!("could not persist the end of a wave: {e:#}");
9359        }
9360    }
9361    // Whether or not the lease above was actually held, several worktrees
9362    // may just have built into the cache with nothing here able to name one
9363    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
9364    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
9365    // clean it might not have strictly needed; trusting a stale match would
9366    // cost it a wrong answer.
9367    if let Some(cache_dir) = cache
9368        && jobs_had_a_writer
9369    {
9370        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9371    }
9372    if let Some(guard) = cache_guard {
9373        guard.release();
9374    }
9375    collected.into_iter().flatten().collect()
9376}
9377
9378/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
9379/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
9380/// this is data collection, not the fix-specific completion contract in
9381/// [`Runner::continue_fix_report`], and applies regardless of which node
9382/// asked.
9383///
9384/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
9385/// evidence from; `Failed` does not, and correctly contributes nothing — a
9386/// timeout or crash is not itself evidence about a command the seat may have
9387/// started.
9388fn record_jobs(
9389    state: &mut RunState,
9390    node: &str,
9391    round: Option<usize>,
9392    seat: &str,
9393    out: &AgentOutcome,
9394) {
9395    let commands: &[agent::CommandEvidence] = match out {
9396        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9397        AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9398    };
9399    let checked_at = Timestamp::now();
9400    for c in commands {
9401        state.jobs.push(JobRecord {
9402            node: node.to_owned(),
9403            round,
9404            seat: seat.to_owned(),
9405            id: c.id.clone(),
9406            description: c.description.clone(),
9407            checked_at,
9408            status: match c.exit_code {
9409                Some(0) => JobStatus::Completed,
9410                Some(_) => JobStatus::Failed,
9411                None => JobStatus::Unknown,
9412            },
9413            exit_code: c.exit_code,
9414            result_summary: c.result_summary.clone(),
9415            source: c.source.clone(),
9416        });
9417    }
9418}
9419
9420/// Is a review round clean, given how many reviewer seats answered against
9421/// how many the round expected?
9422///
9423/// A seat that never answered (timeout, crash, unparsable output) is not a
9424/// seat that read the patch and found nothing — treating it as such is
9425/// exactly the bug this function exists to close. Under the default `block`
9426/// policy a missing seat can never be clean; `warn` still requires the seats
9427/// that *did* answer to have found nothing blocking and verification to be
9428/// green.
9429///
9430/// `quota_missing` narrows that `block` default for exactly one cause of
9431/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
9432/// a session limit lifts by the very next round buys nothing — the seat is
9433/// asked again with the same quota — so once every missing seat is accounted
9434/// for by a quota loss (and at least one seat *did* answer, so a decision has
9435/// something to rest on) the round is decided on the panel that could answer,
9436/// same as `warn` would. A panel that lost every seat to quota is not
9437/// decided here: `answered == 0` falls through to the existing `block`
9438/// fallback so a fully collapsed panel still waits rather than landing on no
9439/// review at all.
9440fn round_is_clean(
9441    blocking: usize,
9442    e2e_ok: bool,
9443    answered: usize,
9444    expected: usize,
9445    quota_missing: usize,
9446    policy: IncompleteReviewPolicy,
9447) -> bool {
9448    if blocking != 0 || !e2e_ok {
9449        return false;
9450    }
9451    if answered == expected || policy == IncompleteReviewPolicy::Warn {
9452        return true;
9453    }
9454    answered > 0 && expected - answered <= quota_missing
9455}
9456
9457/// The review loop's own conclusion, derived entirely from its persisted
9458/// round records and the round budget that produced them — never from
9459/// `status`, so a reentry (or `gate`/`merge` reading it independently)
9460/// recomputes the identical answer regardless of what an earlier node in the
9461/// same walk, or a previous walk, did to `status`.
9462///
9463/// `None` while more rounds remain to try, including when review never ran
9464/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
9465/// gone clean, or the budget is spent, or the tree has stopped moving (see
9466/// [`STAGNANT_LIMIT`]), the answer is one of two things:
9467///
9468/// - An incomplete panel that raised nothing is missing input, not a
9469///   verified tree — never a hand-off candidate, whatever verification said
9470///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
9471/// - Otherwise, green e2e on the last round hands off (see
9472///   [`Runner::stop_reviewing`]); red e2e blocks.
9473///
9474/// A last round whose own verification is still `ResourceBlocked` — magi
9475/// itself never got a command to run, not evidence the patch is broken —
9476/// is neither: this returns `None` for it too, the same as "more rounds
9477/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
9478/// handling of that shape) instead of this cheap recomputation guessing a
9479/// verdict a real attempt never produced.
9480fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9481    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9482        return Some(RunStatus::Gating);
9483    }
9484    let last = reviews.last()?;
9485    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9486    if reviews.len() < max_rounds && !stagnant {
9487        return None;
9488    }
9489    if last.incomplete() && last.blocking == 0 {
9490        return Some(RunStatus::Blocked);
9491    }
9492    if last.e2e_status() == E2eStatus::ResourceBlocked {
9493        return None;
9494    }
9495    Some(if last.e2e.iter().all(CommandOutcome::ok) {
9496        RunStatus::Gating
9497    } else {
9498        RunStatus::Blocked
9499    })
9500}
9501
9502/// How long a re-ask may take, given the budget the first attempt had.
9503///
9504/// A `nudged` retry is a request to restate an answer the seat has already
9505/// worked out: it carries no new work, so it does not deserve the original
9506/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
9507/// 133 seconds while a third sat for over ten minutes on a resumed session
9508/// holding 410 KB of prior output - and because the retry had inherited the
9509/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
9510/// judging round whose other seats were long finished.
9511///
9512/// A quarter of the budget, with a floor so that a deliberately short timeout
9513/// does not collapse to nothing. A retry that re-sends the whole prompt
9514/// (because the seat kept no context) is the original job again, and keeps the
9515/// original budget.
9516pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9517    if nudged {
9518        (full / 4).max(Duration::from_secs(120)).min(full)
9519    } else {
9520        full
9521    }
9522}
9523
9524/// Run a wave and parse each reply, re-asking the seats whose reply was
9525/// unusable.
9526///
9527/// The re-ask is a nudge rather than the whole prompt again when the seat still
9528/// holds its conversation, which is the difference between a cheap retry and
9529/// paying for the entire candidate set twice.
9530///
9531/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
9532/// successor in `roster` is handed to it instead of being re-asked: the
9533/// handover is the retry. A seat with no successor left (a single-agent
9534/// roster, the roster's tail) is nudged as before, up to `retries` times. So
9535/// the asks to one seat in one node number at most
9536/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
9537/// is asked once (a dropped stream is nudged first), and only the last agent
9538/// of the chain gets the `retries` same-agent nudges. Each roster agent is
9539/// tried at most once per seat, walking forward from the seat's own position and never wrapping
9540/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
9541/// other failure stops the chain when the previous agent failed the same way
9542/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
9543/// [`has_context`] is false and the job's own full prompt and full budget are
9544/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
9545/// intermediate ones are not losses) and is returned as a failure like any
9546/// other absent seat — the caller decides whether the panel still has a
9547/// quorum. An empty `roster` disables handover: failures are nudged as they
9548/// always were, and a quota is simply lost. A reply that fails to parse or
9549/// validate is the prompt's doing and is only ever nudged, never handed over.
9550///
9551/// The returned [`SeatState`] names the agent that answered (or tried last).
9552#[allow(clippy::too_many_arguments)]
9553async fn ask_json_wave<T>(
9554    jobs: Vec<SeatJob>,
9555    sem: Arc<Semaphore>,
9556    retries: usize,
9557    roster: &[AgentSpec],
9558    ctx: &WaveCtx<'_>,
9559    losses: &mut Vec<QuotaLoss>,
9560    state: &mut RunState,
9561    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9562) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9563where
9564    T: serde::de::DeserializeOwned + Send + 'static,
9565{
9566    ask_wave_with(
9567        jobs,
9568        sem,
9569        retries,
9570        roster,
9571        ctx,
9572        losses,
9573        state,
9574        &|text: &str| {
9575            let v = verdict::extract_json::<T>(text)?;
9576            validate(&v)?;
9577            Ok(v)
9578        },
9579    )
9580    .await
9581}
9582
9583/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
9584/// a node whose answer is prose (deliberation) shares the same handover,
9585/// failure classification, quota bookkeeping and bounds instead of a copy.
9586///
9587/// A seat handed to another roster agent is sent the job's `handover` prompt
9588/// (when it has one) rather than `prompt`: the new agent has no session, so a
9589/// resume-style prompt would be incomplete. That holds for the handover ask
9590/// and for every nudge to that agent whose `has_context` is false.
9591#[allow(clippy::too_many_arguments)]
9592async fn ask_wave_with<T>(
9593    jobs: Vec<SeatJob>,
9594    sem: Arc<Semaphore>,
9595    retries: usize,
9596    roster: &[AgentSpec],
9597    ctx: &WaveCtx<'_>,
9598    losses: &mut Vec<QuotaLoss>,
9599    state: &mut RunState,
9600    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9601) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9602where
9603    T: Send + 'static,
9604{
9605    let n = jobs.len();
9606    let originals: Vec<SeatJob> = jobs;
9607    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9608    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9609    // Nudges each seat's *current* agent has taken — 0 for a first-ask
9610    // answer, N once it has gone through N nudges. Read back once this
9611    // returns, so a caller building a history record (`ReviewRecord`) can
9612    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
9613    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
9614    // field's own doc.
9615    let mut nudges: Vec<usize> = vec![0; n];
9616    // The agent now occupying each seat, the ids it has already been through,
9617    // where in the roster the walk began, the class of the last failure, and
9618    // the stem word of a handover not yet asked (full prompt, full budget).
9619    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9620    let mut tried: Vec<BTreeSet<String>> = specs
9621        .iter()
9622        .map(|s| BTreeSet::from([s.id.clone()]))
9623        .collect();
9624    let starts: Vec<usize> = specs
9625        .iter()
9626        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9627        .collect();
9628    let carry = ctx.carry_seats && !roster.is_empty();
9629    // Carried across rounds: ids that failed the seat earlier, and how the
9630    // last failure went (so a repeat of it is not handed over again).
9631    let carried: Vec<BTreeSet<String>> = originals
9632        .iter()
9633        .map(|j| {
9634            state
9635                .seat_history
9636                .get(&j.seat.key)
9637                .filter(|_| carry)
9638                .map(|h| h.failed.clone())
9639                .unwrap_or_default()
9640        })
9641        .collect();
9642    let mut prev: Vec<Option<FailClass>> = originals
9643        .iter()
9644        .map(|j| {
9645            state
9646                .seat_history
9647                .get(&j.seat.key)
9648                .filter(|_| carry)
9649                .and_then(|h| h.last_fail.clone())
9650        })
9651        .collect();
9652    let next_agent =
9653        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9654            let others: BTreeSet<String> = specs
9655                .iter()
9656                .enumerate()
9657                .filter(|(j, _)| *j != i)
9658                .map(|(_, s)| s.id.clone())
9659                .collect();
9660            pick_successor(
9661                roster,
9662                starts[i],
9663                tried,
9664                carry.then(|| &carried[i]),
9665                &others,
9666            )
9667            .cloned()
9668        };
9669    let mut fresh: Vec<Option<String>> = vec![None; n];
9670    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9671    let mut pending: Vec<usize> = (0..n).collect();
9672
9673    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
9674    // asks: an agent with a successor is asked once and handed over, and only
9675    // a seat with no successor spends `retries` nudges on the same agent. This
9676    // only guarantees the loop's own termination whatever those say.
9677    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9678    for round in 0..max_rounds {
9679        if pending.is_empty() {
9680            break;
9681        }
9682        let mut batch = Vec::with_capacity(pending.len());
9683        let mut renudged: Vec<&str> = Vec::new();
9684        for &i in &pending {
9685            let src = &originals[i];
9686            // A seat now held by another agent than the job named has no
9687            // session of its own: it gets the full-context prompt whenever
9688            // it is asked in full (the handover ask, a nudge it cannot
9689            // resume).
9690            let full: &str = match &src.handover {
9691                Some(h) if specs[i].id != src.spec.id => h,
9692                _ => &src.prompt,
9693            };
9694            // The prompt and the budget are one decision: a nudge restates
9695            // finished work, a re-sent prompt redoes it.
9696            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9697                (
9698                    full.to_owned(),
9699                    src.timeout,
9700                    format!("{}-{word}-{}", src.stem, specs[i].id),
9701                )
9702            } else if nudges[i] == 0 {
9703                (full.to_owned(), src.timeout, src.stem.clone())
9704            } else {
9705                renudged.push(src.seat.key.as_str());
9706                let why = done[i]
9707                    .as_ref()
9708                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
9709                    .unwrap_or_else(|| "no parsable answer".to_owned());
9710                let nudge = prompt::nudge(&why);
9711                let nudged = has_context(&specs[i], &seats[i], src.sessions);
9712                let prompt = if nudged {
9713                    nudge
9714                } else {
9715                    format!("{full}\n\n---\n\n{nudge}")
9716                };
9717                (
9718                    prompt,
9719                    retry_budget(src.timeout, nudged),
9720                    format!("{}-retry{}", src.stem, nudges[i]),
9721                )
9722            };
9723            batch.push(SeatJob {
9724                spec: specs[i].clone(),
9725                seat: seats[i].clone(),
9726                cwd: src.cwd.clone(),
9727                prompt,
9728                timeout,
9729                allow_write: src.allow_write,
9730                sessions: src.sessions,
9731                artifacts: src.artifacts.clone(),
9732                stem,
9733                handover: None,
9734            });
9735        }
9736
9737        if !renudged.is_empty() {
9738            state.event(
9739                ctx.node,
9740                format!("retry {round}: re-asking {}", renudged.join(", ")),
9741            );
9742        }
9743        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9744        let mut still = Vec::new();
9745        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9746            seats[i] = seat;
9747            // No agent was asked: nothing to hand over, nudge, remember or
9748            // charge. The caller sees `resource_block` and stops the node.
9749            if let AgentOutcome::ResourceBlocked(why) = &out {
9750                done[i] = Some(Err(anyhow::anyhow!("{why}")));
9751                continue;
9752            }
9753            let class = FailClass::of(&out);
9754            // A dropped stream is nudged first (the conversation is still
9755            // there to pick up); only a seat whose nudges are spent hands over.
9756            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9757                && nudges[i] < retries
9758                && !roster.is_empty();
9759            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9760                let next = should_hand_over(prev[i].as_ref(), &cur)
9761                    .then(|| next_agent(i, &tried[i], &specs))
9762                    .flatten();
9763                if carry {
9764                    let h = state
9765                        .seat_history
9766                        .entry(originals[i].seat.key.clone())
9767                        .or_default();
9768                    h.failed.insert(specs[i].id.clone());
9769                    h.last_fail = Some(cur.clone());
9770                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9771                        h.last_ok = None;
9772                    }
9773                    // Saved before the next agent is asked, so a restart in
9774                    // between does not forget who failed.
9775                    if let Err(e) = state.save() {
9776                        tracing::warn!("could not persist a seat's failure history: {e:#}");
9777                    }
9778                }
9779                if let Some(next) = next {
9780                    record_handover(
9781                        state,
9782                        ctx.node,
9783                        &originals[i].seat.key,
9784                        &specs[i].id,
9785                        &next.id,
9786                        &cur,
9787                        &fail_reason(&out),
9788                    );
9789                    tried[i].insert(next.id.clone());
9790                    prev[i] = Some(cur.clone());
9791                    seats[i] =
9792                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9793                    specs[i] = next;
9794                    fresh[i] = Some(cur.stem_word().to_owned());
9795                    nudges[i] = 0;
9796                    done[i] = Some(Err(anyhow::anyhow!(
9797                        "handed over after: {}",
9798                        fail_reason(&out)
9799                    )));
9800                    still.push(i);
9801                    continue;
9802                }
9803            }
9804            if carry
9805                && !nudge_first
9806                && let Some(cur) = class.clone()
9807            {
9808                // The chain ended here (no successor, or a repeated failure).
9809                let h = state
9810                    .seat_history
9811                    .entry(originals[i].seat.key.clone())
9812                    .or_default();
9813                h.failed.insert(specs[i].id.clone());
9814                h.last_fail = Some(cur);
9815            }
9816            let parsed = match out {
9817                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9818                AgentOutcome::Quota(o) => {
9819                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9820                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9821                }
9822                // Not a parseable answer: the nudge loop re-asks it, which is
9823                // exactly what a dropped stream needs. Just don't hand its raw
9824                // error JSON to `extract_json`.
9825                AgentOutcome::Dropped(o) => {
9826                    let why = o
9827                        .dropped
9828                        .as_ref()
9829                        .map(|d| d.why.as_str())
9830                        .unwrap_or("the CLI ended the stream without delivering its answer");
9831                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9832                }
9833                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9834                    Err(anyhow::anyhow!(e))
9835                }
9836            };
9837            let quota = class == Some(FailClass::Quota);
9838            let failed = parsed.is_err();
9839            done[i] = Some(parsed);
9840            if carry && !failed {
9841                let h = state
9842                    .seat_history
9843                    .entry(originals[i].seat.key.clone())
9844                    .or_default();
9845                h.failed.remove(&specs[i].id);
9846                h.last_ok = Some(specs[i].id.clone());
9847                h.last_fail = None;
9848            }
9849            // Do not re-ask a rate-limited seat (quota) — a retry is known to
9850            // fail the same way; and never re-ask a seat that already parsed.
9851            // A failed agent that still has a successor is not re-asked
9852            // either: the handover was its remedy and has just been refused
9853            // (the chain stops on a repeated failure class). A seat with no
9854            // successor left (a single-agent roster, the roster's tail, or an
9855            // empty roster) keeps the same-agent nudge, bounded by `retries`.
9856            let agent_failure = class.is_some()
9857                && !nudge_first
9858                && !roster.is_empty()
9859                && next_agent(i, &tried[i], &specs).is_some();
9860            if failed && !quota && !agent_failure && nudges[i] < retries {
9861                nudges[i] += 1;
9862                still.push(i);
9863            }
9864        }
9865        pending = still;
9866    }
9867
9868    // One loss per seat whose chain ended on a quota: the intermediate ones
9869    // were absorbed by a handover and are not losses.
9870    for (i, q) in last_quota.into_iter().enumerate() {
9871        if let Some(reset) = q {
9872            losses.push(QuotaLoss {
9873                seat: originals[i].seat.key.clone(),
9874                node: ctx.node.to_owned(),
9875                at: Timestamp::now(),
9876                reset,
9877            });
9878        }
9879    }
9880
9881    seats
9882        .into_iter()
9883        .zip(done)
9884        .zip(nudges)
9885        .map(|((seat, res), attempts)| {
9886            (
9887                seat,
9888                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9889                attempts,
9890            )
9891        })
9892        .collect()
9893}
9894
9895/// Did the wave end without asking any agent (see [`block_wave`])?
9896fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9897    results
9898        .iter()
9899        .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9900}
9901
9902/// Settle a wave that never spawned anything: every job answers
9903/// [`AgentOutcome::ResourceBlocked`], its seat leaves `active`, and nothing
9904/// else about the seat (session, history, quota) is touched.
9905fn block_wave(
9906    jobs: Vec<SeatJob>,
9907    state: &mut RunState,
9908    node: &str,
9909    attempt: usize,
9910    why: &str,
9911) -> Vec<(usize, SeatState, AgentOutcome)> {
9912    state.resource_wait = None;
9913    state
9914        .active
9915        .retain(|_, a| !(a.node == node && a.attempt == attempt));
9916    if let Err(e) = state.save() {
9917        tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9918    }
9919    jobs.into_iter()
9920        .enumerate()
9921        .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9922        .collect()
9923}
9924
9925/// Acquire the shared build cache's lease, waiting out contention within
9926/// `budget` (never past it — see AGENTS.md's build-cache section on why an
9927/// unbounded wait is never acceptable).
9928///
9929/// A first, non-blocking check happens before ever waiting; if it finds the
9930/// lease busy, that fact is logged as a `verify` event *and* flushed with
9931/// [`RunState::save`] immediately — not only once the wait finally succeeds
9932/// or gives up — so a `magi show` run by a different process while this one
9933/// is still waiting reads a `run.json` that says so, rather than whatever it
9934/// looked like before the wait started. The same applies to the terminal
9935/// failure: logged and saved before this returns `Err`, so a caller that
9936/// could not get the lease at all still leaves a legible record of why.
9937async fn acquire_cache_lease(
9938    state: &mut RunState,
9939    cache_dir: &Path,
9940    owner: &crate::cache::Owner,
9941    budget: Duration,
9942    context: &str,
9943    cancel: &(dyn Fn() -> bool + Sync),
9944) -> Result<Option<crate::cache::Guard>> {
9945    let home = crate::run::home();
9946    let started = Instant::now();
9947    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9948        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9949        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9950        Err(e) => {
9951            state.event(
9952                "verify",
9953                format!("{context}: could not check the shared build cache: {e:#}"),
9954            );
9955            if let Err(e2) = state.save() {
9956                tracing::warn!("could not persist a cache-check failure: {e2:#}");
9957            }
9958            return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9959        }
9960    };
9961    state.event(
9962        "verify",
9963        format!(
9964            "{context}: waiting for the shared build cache at {} ({})",
9965            cache_dir.display(),
9966            busy.describe()
9967        ),
9968    );
9969    // Seats of this node are waiting, not running: say so, so the countdown a
9970    // reader sees only ever measures an agent that has been spawned.
9971    state.resource_wait = Some(crate::run::ResourceWait {
9972        node: context.to_owned(),
9973        kind: "shared build cache".to_owned(),
9974        holder: busy.describe(),
9975        since: Timestamp::now(),
9976        limit_secs: budget.as_secs(),
9977    });
9978    if let Err(e) = state.save() {
9979        tracing::warn!("could not persist a cache wait: {e:#}");
9980    }
9981    let remaining = budget.saturating_sub(started.elapsed());
9982    let waited = crate::cache::wait_for_unless(
9983        &home,
9984        cache_dir,
9985        owner,
9986        remaining,
9987        Duration::from_secs(5),
9988        cancel,
9989    )
9990    .await;
9991    state.resource_wait = None;
9992    match waited {
9993        Ok(g) => Ok(g),
9994        Err(e) => {
9995            state.event("verify", format!("{context}: {e:#}"));
9996            if let Err(e2) = state.save() {
9997                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9998            }
9999            Err(e)
10000        }
10001    }
10002}
10003
10004/// The lease state could not be read at all (unreadable lease file, a
10005/// permission problem). Unlike a busy lease that outlasts its bound, nothing is
10006/// known to be contended, so a wave degrades to running without a shared cache
10007/// instead of stopping on a hold that [`crate::triage`] could never release.
10008#[derive(Debug)]
10009struct CacheCheckError(String);
10010
10011impl std::fmt::Display for CacheCheckError {
10012    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10013        f.write_str(&self.0)
10014    }
10015}
10016
10017impl std::error::Error for CacheCheckError {}
10018
10019/// Run `body` — a verify command batch — while holding the shared build
10020/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
10021/// never interleave with another borrower's build against the same
10022/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
10023/// timeout, or a human's own `magi review`. See the `cache` module doc for
10024/// why this matters more than Cargo's own per-target locking covers — two
10025/// *different* worktrees building the same package name/version into one
10026/// cache directory is a staleness bug, not a lock contention one.
10027///
10028/// Cache acquisition has its own bound, `timeout_resource_wait`, just as
10029/// agent waves do. Repository scheduling also has its own bounded,
10030/// cancellable resource-wait policy. Contention must not consume the
10031/// verification budget: `body` receives the full `budget` after both leases
10032/// are acquired and the cache is fresh. Without a shared cache, only
10033/// repository coordination is acquired before handing over the full budget.
10034///
10035/// A lease that cannot be acquired within the resource wait is reported as a single
10036/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
10037/// verification — the same shape a spawn failure already takes in
10038/// [`run_commands`], so a caller need not special-case it.
10039#[allow(clippy::too_many_arguments)]
10040async fn with_cache_lease<'s, F, Fut>(
10041    state: &'s mut RunState,
10042    cache_dir: Option<&Path>,
10043    node: &str,
10044    seat: &str,
10045    worktree: &Path,
10046    head: &str,
10047    budget: Duration,
10048    context: &str,
10049    wait: &crate::repo_lock::WaitPolicy,
10050    body: F,
10051) -> Result<(Vec<CommandOutcome>, bool)>
10052where
10053    F: FnOnce(&'s mut RunState, Duration) -> Fut,
10054    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
10055{
10056    // Match editing waves: cache first, repository second. In particular,
10057    // pre_gate must never hold the repository while waiting for a wave's cache.
10058    let started = Instant::now();
10059    let home = crate::run::home();
10060    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
10061    let guard = if let Some(cache_dir) = cache_dir {
10062        let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
10063        match acquire_cache_lease(state, cache_dir, &owner, resource_limit, context, &|| {
10064            wait.cancelled()
10065        })
10066        .await
10067        .and_then(|g| {
10068            g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
10069        }) {
10070            Ok(g) => Some(g),
10071            Err(e) => {
10072                return Ok((
10073                    vec![CommandOutcome {
10074                        command: "(waiting for the shared build cache)".into(),
10075                        code: None,
10076                        output_tail: e.to_string(),
10077                        duration_ms: started.elapsed().as_millis() as u64,
10078                        resource_blocked: true,
10079                    }],
10080                    false,
10081                ));
10082            }
10083        }
10084    } else {
10085        None
10086    };
10087    let _coordination = coordinate_operation(state, worktree, wait).await?;
10088    if let Some(cache_dir) = cache_dir {
10089        let identity = crate::cache::Identity::new(worktree, head);
10090        if let Err(e) = crate::cache::ensure_fresh(
10091            &home,
10092            cache_dir,
10093            &identity,
10094            &state.config.verify.cargo_manifests(),
10095        ) {
10096            state.event(
10097                "verify",
10098                format!(
10099                    "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
10100                    worktree.display(),
10101                    short(head)
10102                ),
10103            );
10104            if let Some(guard) = guard {
10105                guard.release();
10106            }
10107            return Ok((
10108                vec![CommandOutcome {
10109                    command: "(confirming the shared build cache is fresh)".into(),
10110                    code: None,
10111                    output_tail: e.to_string(),
10112                    duration_ms: started.elapsed().as_millis() as u64,
10113                    resource_blocked: true,
10114                }],
10115                false,
10116            ));
10117        }
10118    }
10119    let (outcomes, retried, timed_out_pids) = body(state, budget).await;
10120    if !timed_out_pids.is_empty() {
10121        wait_for_timed_out_children_to_die(&timed_out_pids).await;
10122    }
10123    // Drop repository coordination before releasing the cache, reversing the
10124    // acquisition order. A cancelled/error future also drops both guards.
10125    drop(_coordination);
10126    if let Some(guard) = guard {
10127        guard.release();
10128    }
10129    Ok((outcomes, retried))
10130}
10131
10132/// Poll `pids` — commands [`run_commands`] reports as still running when its
10133/// own timeout elapsed — until every one is confirmed gone, or
10134/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
10135///
10136/// Real confirmation where confirmation is possible, not a substitute for
10137/// full process-tree observation: a grandchild the timed-out process spawned
10138/// and that survives independently of it is invisible to a pid check the
10139/// same way it always was, and continuing to observe and collect *that*
10140/// stays a different piece of work with its own owner. This only narrows a
10141/// fixed blind wait into an actual check of the pids this process does know
10142/// about.
10143async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
10144    wait_for_pids_with(
10145        pids,
10146        crate::proc::pid_alive,
10147        LEASE_RELEASE_POLL,
10148        LEASE_RELEASE_MAX_WAIT,
10149    )
10150    .await;
10151}
10152
10153/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
10154/// interval and ceiling supplied by the caller, so the polling *logic* -
10155/// returns as soon as every pid reports dead, gives up at the ceiling
10156/// otherwise - is testable on millisecond durations without asking the real
10157/// OS about a pid at all.
10158async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10159    pids: &[u32],
10160    alive: F,
10161    poll: Duration,
10162    max_wait: Duration,
10163) {
10164    let deadline = Instant::now() + max_wait;
10165    loop {
10166        if pids.iter().all(|&pid| !alive(pid)) {
10167            return;
10168        }
10169        if Instant::now() >= deadline {
10170            return;
10171        }
10172        tokio::time::sleep(poll).await;
10173    }
10174}
10175
10176/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
10177/// admission that it could not even get a verify command to run, as opposed
10178/// to evidence the command actually produced? A caller that would otherwise
10179/// read a resource-blocked outcome as a red command must check this first:
10180/// see [`Runner::gate`], which retries rather than records `Blocked` when
10181/// this is true.
10182fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10183    outcomes.iter().any(|o| o.resource_blocked)
10184}
10185
10186/// What [`Runner::gate_fix_round`] decided.
10187enum GateFix {
10188    /// The tree changed and `verify.e2e` is still green: run the gate again.
10189    Retry,
10190    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
10191    /// last failure stands and the run ends blocked.
10192    Stop,
10193    /// `verify.e2e` could not run after the fix (magi's own contention):
10194    /// decide nothing now, a later reentry retries.
10195    Defer,
10196}
10197
10198/// Is every red command in `outcomes` an ordinary failure the code could
10199/// explain: it ran, exited non-zero, and said something?
10200///
10201/// A timeout, a spawn failure and a killed process all leave `code` `None`;
10202/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
10203/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
10204/// what the command is stays the gate's business.
10205fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10206    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10207    red.peek().is_some()
10208        && red.all(|o| {
10209            !o.resource_blocked
10210                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10211                && !o.output_tail.trim().is_empty()
10212        })
10213}
10214
10215/// Describe one verify command's outcome for the event log, distinguishing a
10216/// build/link failure — the toolchain never produced a binary to run — from
10217/// an actual test failure, since only the latter is a verdict on the patch.
10218fn e2e_outcome_label(o: &CommandOutcome) -> String {
10219    if o.ok() {
10220        return "pass".to_owned();
10221    }
10222    let reason = if o.build_failed() {
10223        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10224    } else {
10225        format!("FAIL ({:?})", o.code)
10226    };
10227    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10228}
10229
10230/// Run `verify.e2e`, retrying once if the first attempt could not build or
10231/// link — a build/link failure is frequently a race against a shared
10232/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
10233/// `verify` event per command, tagged with `context` (normally `"round N"`)
10234/// so the two call sites that need this — the ordinary per-round leg in
10235/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
10236/// it will ever call a round green — read identically in the event log.
10237async fn run_e2e_with_retry(
10238    state: &mut RunState,
10239    shell: &[String],
10240    commands: &[String],
10241    worktree: &Path,
10242    timeout: Duration,
10243    context: &str,
10244) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10245    let (mut e2e, mut timed_out_pids) = run_commands(
10246        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10247    )
10248    .await;
10249    for o in &e2e {
10250        state.event(
10251            "verify",
10252            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10253        );
10254    }
10255    // A build/link failure is not a verdict on the patch — it is frequently a
10256    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
10257    // one retry before letting a red like that decide the round.
10258    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10259    if verify_retried {
10260        state.event(
10261            "verify",
10262            format!(
10263                "{context}: verify could not build/link, not a test result — retrying once \
10264                 before concluding"
10265            ),
10266        );
10267        let retried = run_commands(
10268            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10269        )
10270        .await;
10271        e2e = retried.0;
10272        // Both attempts' timeouts matter, not just the last one: the first
10273        // attempt's descendants may still be alive alongside the retry's.
10274        timed_out_pids.extend(retried.1);
10275        for o in &e2e {
10276            state.event(
10277                "verify",
10278                format!(
10279                    "{context}: retry `{}` -> {}",
10280                    o.command,
10281                    e2e_outcome_label(o)
10282                ),
10283            );
10284        }
10285    }
10286    (e2e, verify_retried, timed_out_pids)
10287}
10288
10289/// Run configured shell commands in `cwd`, in order. The second element is
10290/// the pid of every command that hit `timeout` and was still running when
10291/// this stopped waiting on it (best-effort: `None` when the platform did not
10292/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
10293/// that releases a shared resource afterward needs to know.
10294///
10295/// Records `task` into [`RunState::active`] at every command boundary
10296/// (`RunState::task_command`) and clears it once the whole list has run
10297/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
10298/// for minutes with no seat and no output of its own to show for it (see
10299/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
10300/// apart from "not yet run" without this), and this is the only place that
10301/// knows which command is running right now and how many are left. Three
10302/// saves per command — start, not per second — matching the same "only at a
10303/// boundary" rule [`wave`] already follows for seats.
10304#[allow(clippy::too_many_arguments)]
10305async fn run_commands(
10306    state: &mut RunState,
10307    node: &str,
10308    task: &str,
10309    attempt: usize,
10310    shell: &[String],
10311    commands: &[String],
10312    cwd: &Path,
10313    timeout: Duration,
10314) -> (Vec<CommandOutcome>, Vec<u32>) {
10315    if commands.is_empty() {
10316        // Nothing to mark as running and nothing to clear — an empty list
10317        // means "not configured", and touching `active` (or the disk) over
10318        // that would be a write for every round of a repo with no
10319        // `verify.e2e` / `verify.gate` commands at all.
10320        return (Vec::new(), Vec::new());
10321    }
10322    let mut out = Vec::new();
10323    let mut timed_out_pids = Vec::new();
10324    let total = commands.len();
10325    for (idx, command) in commands.iter().enumerate() {
10326        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10327        if let Err(e) = state.save() {
10328            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10329        }
10330        let started = Instant::now();
10331        let mut cmd = tokio::process::Command::new(&shell[0]);
10332        cmd.quiet();
10333        cmd.args(&shell[1..])
10334            .arg(command)
10335            .current_dir(cwd)
10336            .stdin(std::process::Stdio::null())
10337            .stdout(std::process::Stdio::piped())
10338            .stderr(std::process::Stdio::piped())
10339            .kill_on_drop(true);
10340        let spawned = cmd.spawn();
10341        let (code, body) = match spawned {
10342            Ok(child) => {
10343                // Captured before the child is consumed below: `kill_on_drop`
10344                // only *asks* the process to die when the timeout branch
10345                // drops it, and the pid is the only way anyone downstream can
10346                // later check whether that request actually took.
10347                let pid = child.id();
10348                match tokio::time::timeout(timeout, child.wait_with_output()).await {
10349                    Ok(Ok(o)) => {
10350                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10351                        body.push_str(&String::from_utf8_lossy(&o.stderr));
10352                        (o.status.code(), body)
10353                    }
10354                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
10355                    Err(_) => {
10356                        if let Some(pid) = pid {
10357                            timed_out_pids.push(pid);
10358                        }
10359                        (None, format!("timed out after {}s", timeout.as_secs()))
10360                    }
10361                }
10362            }
10363            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10364        };
10365        out.push(CommandOutcome {
10366            command: command.clone(),
10367            code,
10368            output_tail: tail(&body, OUTPUT_TAIL),
10369            duration_ms: started.elapsed().as_millis() as u64,
10370            resource_blocked: false,
10371        });
10372    }
10373    state.task_finished(task);
10374    if let Err(e) = state.save() {
10375        tracing::warn!("could not persist the end of {task}: {e:#}");
10376    }
10377    (out, timed_out_pids)
10378}
10379
10380/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
10381/// section (`report::run`) and in the `merge` event this node records — for
10382/// the operator to run by hand.
10383///
10384/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
10385/// branch whose ruleset forbids merge commits (GitHub's "must not contain
10386/// merge commits", or "require linear history") rejects the push a `--no-ff`
10387/// merge would produce, which is exactly the guidance this function replaces.
10388/// `message`'s first line becomes the squash commit's subject, matching the
10389/// note `report::run` prints alongside this command — see that function for
10390/// why an explicit subject is not optional there.
10391fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10392    let repo = repo.display();
10393    match style {
10394        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10395        MergeStyle::Squash => {
10396            // The subject sits inside double quotes, and a title an agent
10397            // wrote may carry the characters that break out of them.
10398            let subject = message
10399                .lines()
10400                .next()
10401                .unwrap_or(branch)
10402                .replace(['\\', '"', '$', '`'], "");
10403            format!(
10404                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10405            )
10406        }
10407        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10408    }
10409}
10410
10411/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
10412/// under the hood, rejects a `title` over 256 characters and the whole
10413/// command fails — no PR at all, for a run whose body was otherwise fine
10414/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
10415/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
10416/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
10417/// this bound. It is a margin, not a guarantee — a title packed
10418/// with multi-unit characters could still in principle land close to the
10419/// edge, but a real task title's occasional emoji or accented letter fits
10420/// comfortably inside it.
10421const PR_TITLE_MAX: usize = 240;
10422
10423/// A pull request title from the opening line of `text`, or `None` when that
10424/// line is not English (GitHub text is) or has no letters.
10425///
10426/// A line within `max` is kept as is. A longer one is cut at the end of its
10427/// first sentence when that falls inside `max`, else at a word boundary with a
10428/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
10429/// make every merely-truncated title look non-English). The language check
10430/// runs on the kept text before any mark is added, so only what GitHub will
10431/// show is judged: an English opening followed by non-ASCII far past the cut
10432/// still passes.
10433fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10434    let line = queue::first_line(text)?;
10435    let chars: Vec<char> = line.chars().collect();
10436    let (kept, mark) = if chars.len() <= max {
10437        (line.to_owned(), "")
10438    } else if let Some(end) = sentence_end(&chars, max) {
10439        (chars[..end].iter().collect::<String>(), "")
10440    } else {
10441        let room = max.saturating_sub(3);
10442        // Cut at the last space inside the room; when the char just past the
10443        // room is a space the room already ends on a word.
10444        let cut = if chars[room].is_whitespace() {
10445            room
10446        } else {
10447            chars[..room]
10448                .iter()
10449                .rposition(|c| c.is_whitespace())
10450                .unwrap_or(room)
10451        };
10452        let head: String = chars[..cut].iter().collect();
10453        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10454        (head.to_owned(), "...")
10455    };
10456    if kept.is_empty() {
10457        return None;
10458    }
10459    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10460        return None;
10461    }
10462    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10463        return None;
10464    }
10465    Some(format!("{kept}{mark}"))
10466}
10467
10468/// The char length of the first sentence of `chars` when it ends within `max`
10469/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
10470/// abbreviations so `e.g. foo` does not end a title early.
10471fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10472    const MIN: usize = 20;
10473    for i in MIN..max.min(chars.len()) {
10474        if !matches!(chars[i], '.' | '!' | '?') {
10475            continue;
10476        }
10477        let Some(&next) = chars.get(i + 1) else {
10478            continue;
10479        };
10480        if !next.is_whitespace() {
10481            continue;
10482        }
10483        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10484        if after.is_some_and(|c| c.is_ascii_lowercase()) {
10485            continue;
10486        }
10487        let word: String = chars[..i]
10488            .iter()
10489            .rev()
10490            .take_while(|c| !c.is_whitespace())
10491            .collect::<Vec<_>>()
10492            .into_iter()
10493            .rev()
10494            .collect();
10495        let word = word.to_ascii_lowercase();
10496        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10497            continue;
10498        }
10499        let end = chars[..i]
10500            .iter()
10501            .rposition(|c| !c.is_whitespace())
10502            .map_or(i, |p| p + 1);
10503        return Some(end);
10504    }
10505    None
10506}
10507
10508/// What `merge = "pr"` (and the merge commit of the other modes) says about a
10509/// change: a title and a body describing what was *implemented*, not the task
10510/// that asked for it. A task reads as a request; a reader of the merged
10511/// history wants the change.
10512struct PrMessage {
10513    title: String,
10514    body: String,
10515}
10516
10517impl PrMessage {
10518    /// Title, blank line, body. The first line is the squash/merge commit
10519    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
10520    /// has to stay one sensible line.
10521    fn commit_message(&self) -> String {
10522        format!("{}\n\n{}", self.title, self.body)
10523    }
10524}
10525
10526/// The text after a leading `TITLE:` (any case) on `line`.
10527fn title_marker(line: &str) -> Option<&str> {
10528    let line = line.trim();
10529    let head = line.get(..6)?;
10530    head.eq_ignore_ascii_case("title:")
10531        .then(|| line[6..].trim())
10532}
10533
10534/// The implementer's own one-line title: the `TITLE:` line the implement
10535/// prompt asks for at the top of its SUMMARY. Candidate commits are all
10536/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
10537/// source, and a title that says as much is refused here too.
10538fn summary_title(summary: &str) -> Option<String> {
10539    let first = summary.lines().find(|l| !l.trim().is_empty())?;
10540    let raw = title_marker(first)?;
10541    if raw.is_empty() {
10542        return None;
10543    }
10544    let title = queue::title_from(raw, PR_TITLE_MAX);
10545    let lower = title.to_ascii_lowercase();
10546    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10547        return None;
10548    }
10549    Some(title)
10550}
10551
10552/// How `open_review`'s instruction begins; see [`landing_title`].
10553const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10554
10555/// Marker `open_review` gives a candidate that nothing in the roster wrote.
10556pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10557
10558/// Does this run review work that already existed, rather than implement a
10559/// task? Runs recorded before `reviewed_commits` existed carry only the
10560/// candidate marker.
10561fn is_review_run(state: &RunState) -> bool {
10562    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10563}
10564
10565/// The title of a review-only run: the subject of the oldest commit under
10566/// review. Later commits are usually fixups, and `instruction` is the review
10567/// prompt, which says nothing about the change. GitHub text is English, so a
10568/// non-ASCII or blank subject yields `None` and the caller's neutral title.
10569fn review_title(state: &RunState) -> Option<String> {
10570    english_subject(
10571        state.reviewed_commits.as_ref()?.first()?,
10572        state.config.graph.github_english(),
10573    )
10574}
10575
10576/// `raw` as a pull request title, or `None` when it is blank, not English
10577/// (GitHub text is), or one of magi's own candidate commit subjects.
10578fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10579    let raw = raw.trim();
10580    if raw.is_empty() {
10581        return None;
10582    }
10583    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10584        return None;
10585    }
10586    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10587        return None;
10588    }
10589    let title = queue::title_from(raw, PR_TITLE_MAX);
10590    let lower = title.to_ascii_lowercase();
10591    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10592        return None;
10593    }
10594    Some(title)
10595}
10596
10597/// What a review-only run's branch says about itself, read at the moment the
10598/// pull request is opened.
10599#[derive(Debug, Clone, PartialEq, Eq)]
10600struct BranchFacts {
10601    /// `(subject, body)` of each commit, oldest first.
10602    commits: Vec<(String, String)>,
10603    /// Trimmed `git diff --stat`.
10604    stat: String,
10605}
10606
10607/// Longest diff stat shown: this many file lines plus the summary line.
10608const STAT_FILE_LINES: usize = 25;
10609/// Cap on the commit list, well inside GitHub's 65536-character body limit.
10610const COMMITS_MAX_CHARS: usize = 20_000;
10611
10612/// Read the commits and diff stat of `base..branch`. `None` when git cannot
10613/// say or finds nothing, so the caller falls back to what the run recorded.
10614async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10615    let commits = git::commit_log(repo, base, branch).await.ok()?;
10616    if commits.is_empty() {
10617        return None;
10618    }
10619    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10620    let lines: Vec<&str> = stat.lines().collect();
10621    let stat = if lines.len() > STAT_FILE_LINES + 1 {
10622        let omitted = lines.len() - 1 - STAT_FILE_LINES;
10623        let more = format!(" ... {omitted} more file(s)");
10624        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10625        kept.push(&more);
10626        kept.push(lines[lines.len() - 1]);
10627        kept.join("\n")
10628    } else {
10629        lines.join("\n")
10630    };
10631    Some(BranchFacts { commits, stat })
10632}
10633
10634/// Defang what would break the surrounding markdown: a closing `</details>`
10635/// and a code fence.
10636fn markdown_safe(text: &str) -> String {
10637    text.replace("</details>", "&lt;/details&gt;")
10638        .replace("\x60\x60\x60", "~~~")
10639}
10640
10641fn neutral_title(state: &RunState, winner: char) -> String {
10642    format!(
10643        "chore: land candidate {} of run {}",
10644        winner.to_ascii_uppercase(),
10645        state.id
10646    )
10647}
10648
10649/// The pull request title to hand to `land::merge_subject`. A review-only run
10650/// opened by an earlier build titled its pull request with the review prompt;
10651/// that title is dropped (empty, so the fallback applies) rather than landed.
10652/// Any other title, including an operator's rename, passes through untouched,
10653/// and so does every title of a run that implements a task.
10654pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10655    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10656        ""
10657    } else {
10658        pr_title
10659    }
10660}
10661
10662/// What the squash subject falls back to when the pull request title is empty
10663/// or candidate-shaped: for a review-only run the derived title, never the
10664/// review prompt held in `instruction`.
10665pub fn landing_subject_source(state: &RunState) -> String {
10666    if is_review_run(state) {
10667        let winner = state.candidates.first().map_or('A', |c| c.label);
10668        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10669    }
10670    state.instruction.clone()
10671}
10672
10673/// `summary` without its `TITLE:` line, which the pull request title already
10674/// carries.
10675fn summary_without_title(summary: &str) -> String {
10676    let mut lines = summary.trim().lines().peekable();
10677    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10678        lines.next();
10679    }
10680    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10681}
10682
10683/// The pull request title and body for the winning candidate.
10684///
10685/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10686/// the task's own opening line via [`queue::title_from`] when there is none.
10687/// `state.instruction` can open with blank lines (`task_text` only rejects a
10688/// body that is blank *entirely*), which `title_from` skips.
10689///
10690/// Publishing uses an independent description generated from the final diff
10691/// and structured evidence. Internal summaries and reports remain local.
10692/// Non-publishing previews may display the original task and candidate summary.
10693/// The footer retains the run and candidate provenance.
10694#[cfg(test)]
10695fn pr_message(state: &RunState, winner: char) -> PrMessage {
10696    pr_message_with(state, winner, None)
10697}
10698
10699/// [`pr_message`] with what the branch of a review-only run says about itself.
10700/// `facts` is ignored for a run that implements a task.
10701#[cfg(test)]
10702fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10703    let raw = pr_message_raw(
10704        state,
10705        winner,
10706        facts,
10707        crate::pr_outcome::FinalHead::Unreported,
10708        ChangeNote::NotAsked,
10709    );
10710    let id = crate::scrub::Identity::current();
10711    PrMessage {
10712        title: crate::scrub::scrub(&raw.title, &id),
10713        body: crate::scrub::scrub(&raw.body, &id),
10714    }
10715}
10716
10717/// What the public description may say about the change itself.
10718#[derive(Debug, Clone, Copy)]
10719enum ChangeNote<'a> {
10720    /// A local preview or non-publishing merge mode: no public generation needed.
10721    NotAsked,
10722    /// A summary written from the final diff.
10723    Summary(&'a crate::run::ChangeSummary),
10724    /// A summary was asked for and could not be produced; the text says why.
10725    Unavailable(&'a str),
10726}
10727
10728fn pr_message_raw(
10729    state: &RunState,
10730    winner: char,
10731    facts: Option<&BranchFacts>,
10732    head: crate::pr_outcome::FinalHead<'_>,
10733    change: ChangeNote<'_>,
10734) -> PrMessage {
10735    if !matches!(change, ChangeNote::NotAsked) {
10736        let (title, mut body) = match change {
10737            ChangeNote::Summary(c) => (
10738                c.title.clone(),
10739                format!("## Summary\n\n{}\n\n", c.body.trim()),
10740            ),
10741            ChangeNote::Unavailable(why) => (
10742                neutral_title(state, winner),
10743                format!(
10744                    "## 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"
10745                ),
10746            ),
10747            ChangeNote::NotAsked => unreachable!(),
10748        };
10749        body.push_str(&public_pr_evidence(state, head));
10750        body.push_str(&format!(
10751            "\n---\nmagi:run/{} magi:candidate-{}\n",
10752            state.id,
10753            winner.to_ascii_lowercase()
10754        ));
10755        return PrMessage { title, body };
10756    }
10757    pr_preview(state, winner, facts, head)
10758}
10759
10760/// Append public facts from the run records after every prose generation,
10761/// including posting-gate rewrites. The agent cannot drop this provenance.
10762fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10763    let mut body = crate::pr_outcome::review_rounds(state);
10764    body.push_str(&crate::pr_outcome::final_verification(state, head));
10765    let open = state.last_round_findings();
10766    if !open.is_empty() {
10767        body.push_str("## Open review findings\n\n");
10768        for f in open {
10769            body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10770        }
10771        body.push('\n');
10772    }
10773    if let Some(c) = state.change_summary.as_ref()
10774        && head == crate::pr_outcome::FinalHead::At(&c.head)
10775    {
10776        body.push_str(&format!(
10777            "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10778            c.base, c.head
10779        ));
10780    }
10781    body
10782}
10783
10784/// Local-only preview of the operator's task and candidate summary.
10785fn pr_preview(
10786    state: &RunState,
10787    winner: char,
10788    facts: Option<&BranchFacts>,
10789    head: crate::pr_outcome::FinalHead<'_>,
10790) -> PrMessage {
10791    let summary = state
10792        .candidates
10793        .iter()
10794        .find(|c| c.label == winner)
10795        .map(|c| c.summary.as_str())
10796        .unwrap_or_default();
10797    // The fallback is the operator's own words and may not be English; GitHub
10798    // text always is, so a non-English task gets a neutral title instead.
10799    let review = is_review_run(state);
10800    let title = if review {
10801        facts
10802            .and_then(|f| {
10803                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10804            })
10805            .or_else(|| review_title(state))
10806            .or_else(|| {
10807                state
10808                    .candidates
10809                    .iter()
10810                    .find(|c| c.label == winner)
10811                    .filter(|c| !c.branch.starts_with("magi/"))
10812                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10813            })
10814            .unwrap_or_else(|| neutral_title(state, winner))
10815    } else {
10816        summary_title(summary).unwrap_or_else(|| {
10817            english_title(
10818                &state.instruction,
10819                PR_TITLE_MAX,
10820                state.config.graph.github_english(),
10821            )
10822            .unwrap_or_else(|| neutral_title(state, winner))
10823        })
10824    };
10825
10826    let mut body = String::new();
10827    let what = summary_without_title(summary);
10828    if !what.is_empty() {
10829        body.push_str("## Summary\n\n");
10830        body.push_str(&what);
10831        body.push_str("\n\n");
10832    }
10833
10834    // Review history uses structured facts across all rounds, never reports.
10835    body.push_str(&crate::pr_outcome::review_rounds(state));
10836    body.push_str(&crate::pr_outcome::final_verification(state, head));
10837    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10838
10839    let open = state.open_findings();
10840    if !open.is_empty() {
10841        body.push_str("## Open review findings\n\n");
10842        for f in &open {
10843            body.push_str(&format!(
10844                "- `{}` [{:?}] {}\n",
10845                f.id, f.severity, "Context retained in local review records."
10846            ));
10847        }
10848        body.push('\n');
10849    }
10850
10851    if let Some(fix) = fix
10852        && !fix.rejected.is_empty()
10853    {
10854        body.push_str("## Declined by the fixer\n\n");
10855        for r in &fix.rejected {
10856            body.push_str(&format!(
10857                "- `{}`: {}\n",
10858                r.id, "Reason retained in local review records."
10859            ));
10860        }
10861        body.push('\n');
10862    }
10863
10864    if review {
10865        // The review prompt is not the task; list what the branch carries.
10866        body.push_str("## Commits under review\n\n");
10867        if let Some(facts) = facts {
10868            let mut left = COMMITS_MAX_CHARS;
10869            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10870                let mut entry = format!("- {}\n", markdown_safe(subject));
10871                for l in markdown_safe(text).lines() {
10872                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10873                }
10874                if left == 0 {
10875                    body.push_str(&format!(
10876                        "- ... {} more commit(s)\n",
10877                        facts.commits.len() - i
10878                    ));
10879                    break;
10880                }
10881                if entry.len() > left {
10882                    // Even the first commit is cut: one huge body must not
10883                    // push the whole description past GitHub's limit.
10884                    let mut end = left;
10885                    while !entry.is_char_boundary(end) {
10886                        end -= 1;
10887                    }
10888                    entry.truncate(end);
10889                    entry.push_str("\n  ... (truncated)\n");
10890                    left = 0;
10891                } else {
10892                    left -= entry.len();
10893                }
10894                body.push_str(&entry);
10895            }
10896            if !facts.stat.trim().is_empty() {
10897                body.push_str(&format!(
10898                    "\n## Diff stat\n\n```\n{}\n```\n",
10899                    markdown_safe(facts.stat.trim())
10900                ));
10901            }
10902        } else {
10903            match &state.reviewed_commits {
10904                Some(subjects) => {
10905                    for s in subjects {
10906                        body.push_str(&format!("- {}\n", s.trim()));
10907                    }
10908                }
10909                None => {
10910                    // An older run kept only the prompt, with the commit list
10911                    // after its first paragraph.
10912                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10913                    body.push_str(rest.trim());
10914                    body.push('\n');
10915                }
10916            }
10917        }
10918    } else {
10919        let task = state.instruction.trim();
10920        let task = if task.is_empty() {
10921            "(empty task)"
10922        } else {
10923            task
10924        };
10925        body.push_str(&format!(
10926            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10927            task.replace("</details>", "&lt;/details&gt;")
10928        ));
10929    }
10930
10931    body.push_str(&format!(
10932        "\n---\nmagi:run/{} magi:candidate-{}\n",
10933        state.id,
10934        winner.to_ascii_lowercase()
10935    ));
10936
10937    PrMessage { title, body }
10938}
10939
10940/// The task with what the repository says about the existing work it names
10941/// appended, so an implementer knows what it started from and what it must
10942/// not redo. Unchanged when the task names nothing.
10943fn seeded_instruction(state: &RunState) -> String {
10944    match refs::describe(&state.seeds) {
10945        Some(facts) => format!(
10946            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10947             Candidates start from the unmerged branch named above, when there \
10948             is one, and carry any unmerged commit named by sha as a \
10949             cherry-pick. Check that this is what the task meant before \
10950             building on it.",
10951            state.instruction
10952        ),
10953        None => state.instruction.clone(),
10954    }
10955}
10956
10957/// Does the winner have no commits ahead of the base it would land on?
10958/// Any failure to find out reads as "not empty": the merge then behaves as it
10959/// always did rather than refusing on a guess.
10960async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10961    let base = &state.base_branch;
10962    let mut against = base.clone();
10963    if mode == MergeMode::Pr {
10964        // A pull request lands on the remote's base, never the local branch
10965        // of the same name: if that cannot be read, "not empty" is the safe
10966        // answer.
10967        let remote = &state.config.merge.remote;
10968        let tracking = format!("{remote}/{base}");
10969        let fetched = git::fetch(repo, remote, base).await;
10970        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10971            against = tracking;
10972        } else {
10973            return false;
10974        }
10975    }
10976    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10977}
10978
10979/// Why nothing was opened for an empty winner, with what the task's own
10980/// references resolved to.
10981fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10982    let mut detail = format!(
10983        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10984         nothing to open a pull request for"
10985    );
10986    match refs::describe(&state.seeds) {
10987        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10988        None => detail.push_str(
10989            "\nThe task names no existing branch or commit; if it means to land work \
10990             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10991        ),
10992    }
10993    detail
10994}
10995
10996/// What the `Pr` merge does once it knows whether the branch already has an
10997/// open pull request.
10998#[derive(Debug, PartialEq, Eq)]
10999enum PrPlan {
11000    Create,
11001    Adopt { url: String, title: String },
11002    Stop(String),
11003}
11004
11005/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
11006/// a failed lookup -> stop with the real reason. Never guesses.
11007fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
11008    match found {
11009        Ok(land::OpenPr::None) => PrPlan::Create,
11010        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
11011        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
11012            "several open pull requests exist for this branch, not picking one: {}",
11013            urls.join(" ")
11014        )),
11015        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
11016    }
11017}
11018
11019/// `gh pr create`, returning the PR url.
11020pub(crate) async fn gh_pr_create(
11021    cwd: &Path,
11022    base: &str,
11023    head: &str,
11024    title: &str,
11025    body: &str,
11026) -> Result<String> {
11027    let out = tokio::process::Command::new("gh")
11028        .args([
11029            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
11030        ])
11031        .current_dir(cwd)
11032        .quiet()
11033        .stdin(std::process::Stdio::null())
11034        .output()
11035        .await
11036        .context("spawn gh")?;
11037    if out.status.success() {
11038        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
11039    } else {
11040        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
11041    }
11042}
11043
11044/// Tear a run's worktrees and branches down.
11045///
11046/// `home` is where the updated `run.json` is saved (via
11047/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
11048/// a housekeeping pass already has its own honest `home` handed to it, and
11049/// falling through to the global here would write back through whichever
11050/// directory some other process or test pinned into that `OnceLock` first,
11051/// not the one the caller actually resolved its `runs` and `state` from.
11052pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
11053    if crate::pr_recovery::sources_retained(home, &state.id) {
11054        return Ok(Vec::new());
11055    }
11056    let repo = state.repo.clone();
11057    // CLI, Web and automatic cleanup all enter here, independently of
11058    // Runner::fold_losers. Protect deletion and shared config release too.
11059    let wait = repository_wait(state, None);
11060    let _coordination = match coordinate_operation(state, &repo, &wait).await {
11061        Ok(lease) => lease,
11062        Err(e) => {
11063            state.save_under(home)?;
11064            return Err(e);
11065        }
11066    };
11067    let root = state.worktree_root();
11068    let winner = state.tally.as_ref().map(|t| t.winner);
11069    let mut removed = Vec::new();
11070
11071    for i in 0..state.candidates.len() {
11072        let c = state.candidates[i].clone();
11073        let is_winner = Some(c.label) == winner;
11074        if is_winner && !drop_winner {
11075            continue;
11076        }
11077        if c.worktree.exists() {
11078            git::worktree_remove(&repo, &c.worktree).await.ok();
11079            removed.push(c.worktree.to_string_lossy().into_owned());
11080        }
11081        // A branch handed to a later run (and its pull request) is not this
11082        // run's to delete.
11083        let handed_over = state.released_branches.contains(&c.branch);
11084        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
11085            git::branch_delete(&repo, &c.branch).await.ok();
11086            removed.push(c.branch.clone());
11087        }
11088        state.candidates[i].folded = true;
11089    }
11090
11091    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
11092        let path = name.path();
11093        let keep = !drop_winner
11094            && winner.is_some_and(|w| {
11095                path.file_name()
11096                    .is_some_and(|n| n == format!("cand-{w}").as_str())
11097            });
11098        if keep {
11099            continue;
11100        }
11101        git::worktree_remove(&repo, &path).await.ok();
11102        removed.push(path.to_string_lossy().into_owned());
11103    }
11104
11105    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
11106    // judge worktrees, so once the loop above has cleared all of them out,
11107    // the parent is a bare directory nobody else was ever going to remove -
11108    // git only ever managed what was inside it. Left alone, one of these
11109    // accumulates per fully-folded run; the operator's own machine had 74.
11110    // `remove_if_empty` re-checks rather than assuming: a run whose winner
11111    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
11112    // so does anything a run never claimed that happens to share the bay.
11113    remove_if_empty(&root);
11114
11115    if state.enabled_worktree_config && drop_winner {
11116        // A release, not a raw disable: some sibling run in this repository
11117        // may still hold its own reference (see `git::acquire_worktree_config`),
11118        // and only the last release actually turns the setting back off.
11119        git::release_worktree_config(&repo).await.ok();
11120        state.enabled_worktree_config = false;
11121    }
11122    state.save_under(home)?;
11123    Ok(removed)
11124}
11125
11126/// Remove `dir` if it exists and has nothing in it.
11127///
11128/// Best-effort and silent by design: a directory that is not empty (a run
11129/// whose winner is still parked there, a stray file some other process left)
11130/// is exactly the case this must refuse, and a directory that is already gone
11131/// is not a failure worth reporting either. `std::fs::remove_dir` itself
11132/// already refuses a non-empty directory, so the emptiness check below is
11133/// belt, not suspenders - it is what keeps this from ever attempting the
11134/// removal in the case that matters, rather than trusting `remove_dir`'s
11135/// error path to have no side effects if it ever changed.
11136fn remove_if_empty(dir: &Path) {
11137    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
11138        std::fs::remove_dir(dir).ok();
11139    }
11140}
11141
11142/// Severity of the worst open finding in the last review round, for reporting.
11143pub fn worst_open(state: &RunState) -> Option<Severity> {
11144    state
11145        .reviews
11146        .last()?
11147        .reviews
11148        .iter()
11149        .flat_map(|r| r.findings.iter())
11150        .map(|f| f.severity)
11151        .max()
11152}
11153
11154/// `[worktree] setup` hides its products per worktree, which needs
11155/// `extensions.worktreeConfig`. Taken once per run and released with the run's
11156/// worktrees, like the hook's; every place that runs setup calls this first.
11157async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
11158    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
11159        git::acquire_worktree_config(repo).await?;
11160        state.enabled_worktree_config = true;
11161    }
11162    Ok(())
11163}
11164
11165#[cfg(test)]
11166mod tests {
11167    #[test]
11168    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11169        let leaked = vec!["chore(deps): update a crate".to_owned()];
11170        let own = "fix(daemon): apply a chosen action";
11171        assert!(should_retitle("", own, &leaked));
11172        assert!(should_retitle(
11173            &format!("{REVIEW_PROMPT_OPENING} `x`"),
11174            own,
11175            &leaked
11176        ));
11177        assert!(should_retitle(
11178            "chore: land candidate A of run 1",
11179            own,
11180            &leaked
11181        ));
11182        assert!(should_retitle(
11183            "magi: candidate A (uncommitted work)",
11184            own,
11185            &leaked
11186        ));
11187        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11188        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11189        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11190    }
11191
11192    #[test]
11193    fn pr_merge_plan_creates_adopts_or_stops() {
11194        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11195        assert_eq!(
11196            pr_merge_plan(Ok(land::OpenPr::One {
11197                url: "u".into(),
11198                title: "t".into()
11199            })),
11200            PrPlan::Adopt {
11201                url: "u".into(),
11202                title: "t".into()
11203            }
11204        );
11205        let PrPlan::Stop(many) =
11206            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11207        else {
11208            panic!("many must stop");
11209        };
11210        assert!(many.contains('a') && many.contains('b'));
11211        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11212            panic!("a failed lookup must stop");
11213        };
11214        assert!(err.contains("bad token"));
11215    }
11216
11217    use super::*;
11218    use crate::run::GateStatus;
11219    use std::collections::BTreeMap;
11220    use std::time::Duration;
11221
11222    fn conductor() -> AgentSpec {
11223        AgentSpec {
11224            id: "conductor".to_owned(),
11225            kind: crate::config::AgentKind::Command,
11226            model: None,
11227            command: vec!["true".to_owned()],
11228            extra_args: Vec::new(),
11229            env: BTreeMap::new(),
11230            prompt_delivery: None,
11231        }
11232    }
11233
11234    fn spec(id: &str) -> AgentSpec {
11235        AgentSpec {
11236            id: id.to_owned(),
11237            kind: crate::config::AgentKind::Command,
11238            model: None,
11239            command: vec!["true".to_owned()],
11240            extra_args: Vec::new(),
11241            env: BTreeMap::new(),
11242            prompt_delivery: None,
11243        }
11244    }
11245
11246    fn ids(xs: &[&str]) -> BTreeSet<String> {
11247        xs.iter().map(|s| (*s).to_owned()).collect()
11248    }
11249
11250    #[test]
11251    fn pick_successor_skips_an_agent_another_seat_holds() {
11252        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
11253        let roster = [spec("a"), spec("b"), spec("c")];
11254        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11255        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11256    }
11257
11258    #[test]
11259    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11260        // The other seat started on c but was handed to d; c is free again.
11261        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11262        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11263        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11264        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11265        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11266    }
11267
11268    #[test]
11269    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11270        let roster = [spec("a"), spec("b")];
11271        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11272        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11273        let carried = ids(&["b"]);
11274        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11275        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11276    }
11277
11278    #[test]
11279    fn pick_successor_returns_none_without_any_untried_successor() {
11280        let roster = [spec("a"), spec("b")];
11281        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11282        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11283        let carried = ids(&["a"]);
11284        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11285    }
11286
11287    #[test]
11288    fn pick_successor_rescue_avoids_another_seats_occupant() {
11289        // b is a carried failure and free; a is held by the other seat.
11290        let roster = [spec("a"), spec("b"), spec("c")];
11291        let carried = ids(&["b", "c"]);
11292        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11293        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11294    }
11295
11296    #[test]
11297    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11298        let roster = [spec("a"), spec("b"), spec("c")];
11299        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11300        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11301    }
11302
11303    #[test]
11304    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11305        let roster = [spec("a"), spec("b"), spec("c")];
11306        let failed = ids(&["a", "b", "c"]);
11307        // Rescue looks at the whole roster, once per id, then runs out.
11308        let mut tried = ids(&["b"]);
11309        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11310        assert_eq!(first.id, "a");
11311        tried.insert(first.id.clone());
11312        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11313        assert_eq!(second.id, "c");
11314        tried.insert(second.id.clone());
11315        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11316    }
11317
11318    #[test]
11319    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11320        let roster = [spec("a"), spec("b")];
11321        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11322        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11323    }
11324
11325    #[test]
11326    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11327        let roster = [spec("a"), spec("b"), spec("c")];
11328        let h = SeatHistory {
11329            failed: ids(&["a"]),
11330            last_ok: Some("b".to_owned()),
11331            last_fail: None,
11332        };
11333        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11334        // A last answerer that left the roster, or later failed, is ignored.
11335        let gone = SeatHistory {
11336            last_ok: Some("zzz".to_owned()),
11337            ..h.clone()
11338        };
11339        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11340        let failed = SeatHistory {
11341            failed: ids(&["a", "b"]),
11342            last_ok: Some("b".to_owned()),
11343            last_fail: None,
11344        };
11345        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11346    }
11347
11348    #[test]
11349    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11350        let first = SeatState::new("review-1", "alpha", 7);
11351        let next = handover_seat("review-1", "gamma", 7);
11352        assert_ne!(first.claude_session, next.claude_session);
11353    }
11354
11355    #[test]
11356    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11357        let mut state = state_with_summary("x", "y");
11358        let a = handover_seat("review-1", "beta", state.next_seat_seed());
11359        let b = handover_seat("review-1", "beta", state.next_seat_seed());
11360        assert_ne!(a.claude_session, b.claude_session);
11361    }
11362
11363    #[test]
11364    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11365        let roster = [spec("a"), spec("b")];
11366        let h = SeatHistory {
11367            failed: ids(&["a", "b"]),
11368            ..SeatHistory::default()
11369        };
11370        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11371        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11372        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11373    }
11374
11375    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
11376    // fallback loop depends on to terminate: it must walk forward from the
11377    // seat's own position, never restart at the front of the roster, and it
11378    // must never hand back an id already tried, however many times that id
11379    // happens to appear.
11380
11381    #[test]
11382    fn failure_signature_ignores_numbers_and_paths() {
11383        assert_eq!(
11384            failure_signature("exited with Some(2) and no usable output"),
11385            failure_signature("exited with Some(137) and no usable output")
11386        );
11387        assert_eq!(
11388            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11389            failure_signature("cannot open /var/x.txt: denied")
11390        );
11391        assert_ne!(failure_signature("boom"), failure_signature("bang"));
11392    }
11393
11394    #[test]
11395    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11396        let other = FailClass::Other("x".into());
11397        assert!(should_hand_over(None, &FailClass::Quota));
11398        assert!(should_hand_over(Some(&other), &FailClass::Quota));
11399        assert!(should_hand_over(
11400            Some(&FailClass::Timeout),
11401            &FailClass::Timeout
11402        ));
11403        assert!(should_hand_over(None, &other));
11404        assert!(!should_hand_over(Some(&other), &other));
11405        assert!(should_hand_over(
11406            Some(&other),
11407            &FailClass::Other("y".into())
11408        ));
11409        // A quota or timeout in between ends the run of identical failures.
11410        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11411        assert!(should_hand_over(Some(&FailClass::Quota), &other));
11412    }
11413
11414    #[test]
11415    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11416        let a = SeatState::new("judge-1", "alpha", 7);
11417        let b = handover_seat("judge-1", "beta", 7);
11418        assert_ne!(a.claude_session, b.claude_session);
11419        assert_eq!(b.turns, 0);
11420    }
11421
11422    #[test]
11423    fn a_timeout_is_classified_apart_from_other_failures() {
11424        assert_eq!(
11425            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11426            Some(FailClass::Timeout)
11427        );
11428        assert!(matches!(
11429            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11430            Some(FailClass::Other(_))
11431        ));
11432    }
11433
11434    #[test]
11435    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11436        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11437        let tried = BTreeSet::from(["beta".to_owned()]);
11438        // beta sits at index 1; the next candidate is gamma, never alpha —
11439        // which is very likely a different candidate slot's own agent.
11440        let next = next_untried_in_roster(&roster, 1, &tried);
11441        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11442    }
11443
11444    #[test]
11445    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11446        let roster = vec![spec("alpha"), spec("beta")];
11447        let tried = BTreeSet::from(["beta".to_owned()]);
11448        // beta is the roster's last entry: nothing follows it, and alpha —
11449        // earlier in the roster, almost certainly a different candidate
11450        // slot's own agent — must not be reached by wrapping back to it.
11451        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11452    }
11453
11454    #[test]
11455    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11456        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11457        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11458        // beta (index 1) and gamma (index 2, the only entry after it) have
11459        // both been tried; alpha (index 0) never has, but it comes before
11460        // beta's own position, so there is nothing further for this seat.
11461        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11462    }
11463
11464    #[test]
11465    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11466        let roster = vec![spec("a"), spec("a"), spec("b")];
11467        let tried = BTreeSet::from(["a".to_owned()]);
11468        let next = next_untried_in_roster(&roster, 0, &tried);
11469        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11470    }
11471
11472    #[test]
11473    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11474        let roster = vec![spec("a"), spec("b")];
11475        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11476        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11477    }
11478
11479    #[test]
11480    fn remove_if_empty_only_ever_takes_a_bare_directory() {
11481        let dir = tempfile::tempdir().unwrap();
11482        let bay = dir.path().join("ffff");
11483
11484        // Not there yet: nothing to do, nothing to panic on.
11485        remove_if_empty(&bay);
11486        assert!(!bay.exists());
11487
11488        // Something still inside - the winner's worktree, or a stray file -
11489        // keeps the directory standing.
11490        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11491        remove_if_empty(&bay);
11492        assert!(bay.exists(), "non-empty directory must survive");
11493
11494        // Once the last entry is gone, so is the directory itself.
11495        std::fs::remove_dir(bay.join("cand-A")).unwrap();
11496        remove_if_empty(&bay);
11497        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11498    }
11499
11500    // `round_is_clean` is the exact decision this task fixed: a round with a
11501    // seat that never answered must not read the same as a round every seat
11502    // actually reviewed. These are deterministic and process-free by design —
11503    // the equivalent end-to-end check (a real reviewer timing out under a
11504    // live graph run) is a genuine race against wall-clock contention, and a
11505    // spawn slow enough to blow even a generous budget under a loaded test
11506    // run must not turn this specific regression check flaky.
11507
11508    #[test]
11509    fn a_full_panel_that_found_nothing_is_clean() {
11510        assert!(round_is_clean(
11511            0,
11512            true,
11513            2,
11514            2,
11515            0,
11516            IncompleteReviewPolicy::Block
11517        ));
11518    }
11519
11520    #[test]
11521    fn a_missing_seat_is_never_clean_under_the_default_policy() {
11522        assert!(!round_is_clean(
11523            0,
11524            true,
11525            1,
11526            2,
11527            0,
11528            IncompleteReviewPolicy::Block
11529        ));
11530    }
11531
11532    #[test]
11533    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11534        assert!(!round_is_clean(
11535            1,
11536            true,
11537            1,
11538            2,
11539            0,
11540            IncompleteReviewPolicy::Warn
11541        ));
11542    }
11543
11544    #[test]
11545    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11546        assert!(round_is_clean(
11547            0,
11548            true,
11549            1,
11550            2,
11551            0,
11552            IncompleteReviewPolicy::Warn
11553        ));
11554    }
11555
11556    #[test]
11557    fn a_full_panel_with_an_open_finding_is_not_clean() {
11558        assert!(!round_is_clean(
11559            1,
11560            true,
11561            2,
11562            2,
11563            0,
11564            IncompleteReviewPolicy::Block
11565        ));
11566    }
11567
11568    #[test]
11569    fn a_full_panel_with_a_red_e2e_is_not_clean() {
11570        assert!(!round_is_clean(
11571            0,
11572            false,
11573            2,
11574            2,
11575            0,
11576            IncompleteReviewPolicy::Block
11577        ));
11578    }
11579
11580    // The stall this task closes: under the default `block` policy, a seat
11581    // missing only because it was rate limited must not force a wait for a
11582    // session limit that will not lift by the next round. `round_is_clean`
11583    // is where that quorum carve-out lives; the review loop around it never
11584    // changes what a reviewer's vote or a finding's severity means.
11585
11586    #[test]
11587    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11588        // 1 of 2 answered, and the one missing was quota'd — the exact
11589        // "review-2 rate limited (quota)" shape from the field report.
11590        assert!(round_is_clean(
11591            0,
11592            true,
11593            1,
11594            2,
11595            1,
11596            IncompleteReviewPolicy::Block
11597        ));
11598    }
11599
11600    #[test]
11601    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11602        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
11603        // not a quota loss (`quota_missing` stays 0) — worth another try.
11604        assert!(!round_is_clean(
11605            0,
11606            true,
11607            1,
11608            2,
11609            0,
11610            IncompleteReviewPolicy::Block
11611        ));
11612    }
11613
11614    #[test]
11615    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11616        assert!(!round_is_clean(
11617            1,
11618            true,
11619            1,
11620            2,
11621            1,
11622            IncompleteReviewPolicy::Block
11623        ));
11624        assert!(!round_is_clean(
11625            0,
11626            false,
11627            1,
11628            2,
11629            1,
11630            IncompleteReviewPolicy::Block
11631        ));
11632    }
11633
11634    #[test]
11635    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11636        // Every seat quota'd, nobody answered: there is no panel to decide
11637        // on, so this must fall through to the existing block-and-retry
11638        // fallback rather than call an unreviewed patch clean.
11639        assert!(!round_is_clean(
11640            0,
11641            true,
11642            0,
11643            2,
11644            2,
11645            IncompleteReviewPolicy::Block
11646        ));
11647    }
11648
11649    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11650        CommandOutcome {
11651            command: "test".to_owned(),
11652            code,
11653            output_tail: String::new(),
11654            duration_ms: 0,
11655            resource_blocked,
11656        }
11657    }
11658
11659    #[test]
11660    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11661        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11662        assert!(
11663            !verify_inconclusive(&[outcome(Some(1), false)]),
11664            "an ordinary failure is still evidence about the patch"
11665        );
11666        assert!(verify_inconclusive(&[outcome(None, true)]));
11667        assert!(
11668            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11669            "one inconclusive outcome taints the whole batch"
11670        );
11671        assert!(!verify_inconclusive(&[]));
11672    }
11673
11674    #[tokio::test]
11675    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11676        // Alive for the first two checks, then dead - confirms the loop
11677        // actually re-polls rather than deciding once and sleeping out the
11678        // ceiling regardless.
11679        let calls = std::sync::atomic::AtomicUsize::new(0);
11680        let started = Instant::now();
11681        wait_for_pids_with(
11682            &[123],
11683            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11684            Duration::from_millis(5),
11685            Duration::from_secs(5),
11686        )
11687        .await;
11688        assert!(
11689            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11690            "must keep checking rather than deciding on the first answer"
11691        );
11692        assert!(
11693            started.elapsed() < Duration::from_secs(1),
11694            "must return the moment it is confirmed dead, not wait out the ceiling"
11695        );
11696    }
11697
11698    #[tokio::test]
11699    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11700        let started = Instant::now();
11701        wait_for_pids_with(
11702            &[123],
11703            |_| true, // never reports dead
11704            Duration::from_millis(5),
11705            Duration::from_millis(30),
11706        )
11707        .await;
11708        let elapsed = started.elapsed();
11709        assert!(
11710            elapsed >= Duration::from_millis(30),
11711            "must not give up before its own ceiling: {elapsed:?}"
11712        );
11713        assert!(
11714            elapsed < Duration::from_secs(1),
11715            "must not wait past its own ceiling either: {elapsed:?}"
11716        );
11717    }
11718
11719    #[tokio::test]
11720    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11721        let started = Instant::now();
11722        wait_for_pids_with(
11723            &[],
11724            |_| true,
11725            Duration::from_secs(5),
11726            Duration::from_secs(5),
11727        )
11728        .await;
11729        assert!(
11730            started.elapsed() < Duration::from_millis(200),
11731            "an empty pid list has nothing to confirm"
11732        );
11733    }
11734
11735    // `review_conclusion` is the exact decision the review hand-off task
11736    // fixed: a round budget spent (or a tree that stopped moving) must not
11737    // collapse into `Blocked` regardless of what verification actually
11738    // said. Deterministic and process-free for the same reason the
11739    // `round_is_clean` family above is.
11740    fn review_round(
11741        clean: bool,
11742        blocking: usize,
11743        answered: usize,
11744        expected: usize,
11745        progressed: bool,
11746        e2e_ok: bool,
11747    ) -> ReviewRound {
11748        ReviewRound {
11749            round: 1,
11750            head: "h".to_owned(),
11751            verified_head: None,
11752            verified_at: None,
11753            reviews: Vec::new(),
11754            e2e: vec![CommandOutcome {
11755                command: "test".to_owned(),
11756                code: Some(if e2e_ok { 0 } else { 1 }),
11757                output_tail: String::new(),
11758                duration_ms: 0,
11759                resource_blocked: false,
11760            }],
11761            verify_retried: false,
11762            e2e_deferred: false,
11763            e2e_defer_reason: None,
11764            fix: None,
11765            blocking,
11766            answered,
11767            expected,
11768            clean,
11769            progressed,
11770            vote_split: false,
11771            reconsideration: Vec::new(),
11772            verdict: None,
11773        }
11774    }
11775
11776    #[test]
11777    fn review_conclusion_is_none_when_nothing_has_run() {
11778        assert_eq!(review_conclusion(&[], 3), None);
11779    }
11780
11781    #[test]
11782    fn review_conclusion_is_none_while_rounds_remain() {
11783        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11784        assert_eq!(review_conclusion(&rounds, 3), None);
11785    }
11786
11787    #[test]
11788    fn review_conclusion_is_gating_once_a_round_is_clean() {
11789        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11790        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11791    }
11792
11793    #[test]
11794    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11795        let rounds = vec![
11796            review_round(false, 1, 2, 2, true, true),
11797            review_round(false, 1, 2, 2, true, true),
11798        ];
11799        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11800    }
11801
11802    #[test]
11803    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11804        let rounds = vec![
11805            review_round(false, 1, 2, 2, true, true),
11806            review_round(false, 1, 2, 2, true, false),
11807        ];
11808        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11809    }
11810
11811    #[test]
11812    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11813        // Magi never got a command to run against this round's own head — a
11814        // resource-blocked attempt, not a red one — so this must never
11815        // settle on `Blocked` the way a genuine e2e failure would. `None`
11816        // here is what tells `Runner::review_loop` to retry the check
11817        // itself rather than trust this cheap recomputation with a verdict
11818        // it cannot actually produce.
11819        let mut blocked = review_round(false, 1, 2, 2, true, false);
11820        blocked.e2e[0].resource_blocked = true;
11821        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11822        assert_eq!(review_conclusion(&rounds, 2), None);
11823    }
11824
11825    #[test]
11826    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11827        // Missing input, not a verified tree — never a hand-off candidate.
11828        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11829        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11830    }
11831
11832    #[test]
11833    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11834        let rounds = vec![
11835            review_round(false, 1, 2, 2, false, true),
11836            review_round(false, 1, 2, 2, false, true),
11837        ];
11838        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11839    }
11840
11841    fn secs(n: u64) -> Duration {
11842        Duration::from_secs(n)
11843    }
11844
11845    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11846    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11847    #[tokio::test]
11848    async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11849        ask_test_home();
11850        let tmp = tempfile::tempdir().unwrap();
11851        let repo = tmp.path().join("repo");
11852        std::fs::create_dir_all(&repo).unwrap();
11853        init_repo(&repo);
11854        let g = |args: &[&str]| {
11855            let out = std::process::Command::new("git")
11856                .args(args)
11857                .current_dir(&repo)
11858                .quiet()
11859                .output()
11860                .unwrap();
11861            assert!(
11862                out.status.success(),
11863                "{}",
11864                String::from_utf8_lossy(&out.stderr)
11865            );
11866        };
11867        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11868        g(&["checkout", "-b", "historical"]);
11869        std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11870        g(&["add", "."]);
11871        g(&["commit", "-m", "old alternative"]);
11872        let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11873        g(&["checkout", "-b", "magi/task-selected", &base]);
11874        std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11875        g(&["add", "."]);
11876        g(&["commit", "-m", "measurement infrastructure"]);
11877        let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11878        std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11879        g(&["add", "."]);
11880        g(&["commit", "-m", "dependent implementation"]);
11881        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11882        g(&["checkout", "main"]);
11883        std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11884        g(&["add", "."]);
11885        g(&["commit", "-m", "unrelated base change"]);
11886        let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11887        let origin = tmp.path().join("origin.git");
11888        g(&[
11889            "clone",
11890            "--bare",
11891            repo.to_str().unwrap(),
11892            origin.to_str().unwrap(),
11893        ]);
11894        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11895        g(&["fetch", "origin"]);
11896        let selection = crate::pr_recovery::Selection {
11897            url: "https://example.test/project/pull/1".into(),
11898            branch: "magi/task-selected".into(),
11899            head: head.clone(),
11900            instruction: "preserve measured behavior".into(),
11901            approved_at: jiff::Timestamp::now(),
11902        };
11903        let mut template = runner_at(RunStatus::Reviewing);
11904        template.state.repo = repo.clone();
11905        template.state.base_commit = current.clone();
11906        template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11907        template.state.origin = Some(crate::run::Origin::queue("selected"));
11908        template.state.pr_head = Some(selection.branch.clone());
11909        template.state.pr_recovery = Some(selection.clone());
11910        let mut runner =
11911            Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11912                .await
11913                .unwrap();
11914        let wt = runner.state.winner().unwrap().worktree.clone();
11915        assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11916        assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11917        assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11918        assert!(runner.state.seeds.is_empty());
11919        assert_eq!(runner.state.instruction, "preserve measured behavior");
11920        runner.sync_to_base().await.unwrap();
11921        assert_eq!(
11922            std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11923            "prerequisite"
11924        );
11925        assert_eq!(
11926            std::fs::read_to_string(wt.join("implementation")).unwrap(),
11927            "measured implementation"
11928        );
11929        assert_eq!(
11930            std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11931            "current main change"
11932        );
11933        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11934        assert_eq!(
11935            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11936            head
11937        );
11938
11939        // A failed selected-source setup stays inspectable, never resumable as a new competition.
11940        let mut partial = runner_at(RunStatus::Reviewing);
11941        partial.state.repo = repo.clone();
11942        partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11943        partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11944            run: Some("exit 1".into()),
11945            ..Default::default()
11946        }];
11947        let selection = runner.state.pr_recovery.clone().unwrap();
11948        partial.state.pr_recovery = Some(selection.clone());
11949        partial.state.pr_head = Some(selection.branch.clone());
11950        partial.state.origin = Some(crate::run::Origin::queue("selected"));
11951        let id = partial.state.id.clone();
11952        let error = match Runner::open_selected(
11953            &repo,
11954            partial.state,
11955            partial.roles,
11956            1,
11957            runner.state.base_commit.clone(),
11958            selection,
11959        )
11960        .await
11961        {
11962            Ok(_) => panic!("setup must fail"),
11963            Err(e) => e,
11964        };
11965        assert_eq!(
11966            error
11967                .downcast_ref::<crate::pr_recovery::StartupFailed>()
11968                .unwrap()
11969                .run,
11970            id
11971        );
11972        let failed = RunState::load(&id).unwrap();
11973        assert_eq!(failed.status, RunStatus::Blocked);
11974        assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11975        assert!(Runner::resume(&id).is_err());
11976        runner.state.status = RunStatus::Ready;
11977        runner.state.driver_exited = true;
11978        runner.state.save().unwrap();
11979        let mut retry = runner_at(RunStatus::Reviewing);
11980        retry.state.repo = repo.clone();
11981        retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11982        let mut reselected = runner.state.pr_recovery.clone().unwrap();
11983        reselected.approved_at = jiff::Timestamp::now();
11984        retry.state.origin = Some(crate::run::Origin::queue("selected"));
11985        retry.state.pr_head = Some(reselected.branch.clone());
11986        retry.state.pr_recovery = Some(reselected.clone());
11987        crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11988            .await
11989            .unwrap();
11990        let restarted = Runner::open_selected(
11991            &repo,
11992            retry.state,
11993            retry.roles,
11994            1,
11995            runner.state.base_commit.clone(),
11996            reselected,
11997        )
11998        .await
11999        .unwrap();
12000        assert_eq!(
12001            git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
12002                .await
12003                .unwrap(),
12004            head
12005        );
12006        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
12007        assert_eq!(
12008            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
12009            head
12010        );
12011    }
12012
12013    #[tokio::test]
12014    #[cfg(unix)]
12015    async fn fallback_and_interrupted_restoration_require_coordination() {
12016        use std::os::fd::AsRawFd as _;
12017        ask_test_home();
12018        let tmp = tempfile::tempdir().unwrap();
12019        let mut runner = registered_runner(tmp.path()).await;
12020        runner.state.config.graph.timeout_resource_wait = 1;
12021        let winner = runner.state.candidates[0].clone();
12022        let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12023        let leftover = winner.worktree.join("leftover");
12024        std::fs::write(&leftover, "untrusted edits").unwrap();
12025        let next = tmp.path().join("next-fixer");
12026        let mut failed = spec("first");
12027        failed.command = vec!["sh".into(), "-c".into(), "exit 1".into()];
12028        let mut successor = spec("second");
12029        successor.command = vec![
12030            "sh".into(),
12031            "-c".into(),
12032            format!("touch '{}'; echo done", next.display()),
12033        ];
12034        let attempts = vec![
12035            (failed.clone(), "first".into()),
12036            (successor.clone(), "second".into()),
12037        ];
12038        let lock_path = runner.state.repo.join(".git/updater.lock");
12039        let external = std::fs::File::create(&lock_path).unwrap();
12040        assert_eq!(
12041            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12042            0
12043        );
12044        let artifacts = tmp.path().join("artifacts");
12045        let build = |spec, seat| SeatJob {
12046            spec,
12047            seat,
12048            cwd: winner.worktree.clone(),
12049            prompt: "fixture".into(),
12050            timeout: Duration::from_secs(10),
12051            allow_write: true,
12052            sessions: false,
12053            artifacts: artifacts.clone(),
12054            stem: "fix".into(),
12055            handover: None,
12056        };
12057        let (_, _, out) = runner
12058            .ask_fixer_among(
12059                attempts.clone(),
12060                "graft",
12061                None,
12062                Some((&winner.worktree, &head)),
12063                build,
12064            )
12065            .await;
12066        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
12067        assert!(leftover.exists(), "contention must prevent clean");
12068        assert!(!next.exists());
12069        assert!(runner.state.handovers.is_empty());
12070        // Early interrupted-repair rollback uses the same protected route.
12071        runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12072            from_run: "earlier".into(),
12073            generation: 1,
12074            mode: crate::recovery::Mode::VerifyRepair,
12075            reasons: Vec::new(),
12076            reviewed_head: None,
12077            rounds: Vec::new(),
12078            open_findings: Vec::new(),
12079            evidence: None,
12080            criteria_inherited: true,
12081            repair: crate::recovery::Repair::Started {
12082                before: head.clone(),
12083            },
12084        });
12085        assert!(
12086            runner
12087                .recovery_repair(&winner)
12088                .await
12089                .unwrap_err()
12090                .is::<crate::repo_lock::ResourceBlocked>()
12091        );
12092        assert!(matches!(
12093            runner.state.recovery.as_ref().unwrap().repair,
12094            crate::recovery::Repair::Started { .. }
12095        ));
12096        assert!(leftover.exists());
12097        drop(external);
12098        runner.recovery_repair(&winner).await.unwrap();
12099        assert!(!leftover.exists());
12100        assert!(matches!(
12101            runner.state.recovery.as_ref().unwrap().repair,
12102            crate::recovery::Repair::Done { .. }
12103        ));
12104        // A hard lock-open failure also leaves HEAD and untracked work intact.
12105        std::fs::remove_file(&lock_path).unwrap();
12106        std::fs::create_dir(&lock_path).unwrap();
12107        std::fs::write(&leftover, "untrusted edits").unwrap();
12108        let (_, _, out) = runner
12109            .ask_fixer_among(
12110                attempts,
12111                "graft",
12112                None,
12113                Some((&winner.worktree, &head)),
12114                build,
12115            )
12116            .await;
12117        assert!(matches!(out, AgentOutcome::Failed(_)));
12118        assert!(leftover.exists());
12119        assert!(!next.exists());
12120        assert_eq!(
12121            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12122            head
12123        );
12124    }
12125
12126    #[cfg(unix)]
12127    async fn registered_runner(root: &Path) -> Runner {
12128        ask_test_home();
12129        let repo = root.join("repo");
12130        std::fs::create_dir_all(&repo).unwrap();
12131        init_repo(&repo);
12132        std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12133        let mut runner = runner_at(RunStatus::Prep);
12134        runner.state.repo = repo.clone();
12135        runner.state.config.coordination = crate::config::test_coordination();
12136        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12137        runner.state.config.graph.worktree_root = Some(root.join("wt"));
12138        runner.state.config.graph.advise = false;
12139        runner.roles.implementers = vec![spec("implement")];
12140        runner.roles.judges = vec![spec("judge")];
12141        runner.prep().await.unwrap();
12142        runner
12143    }
12144
12145    #[tokio::test]
12146    #[cfg(unix)]
12147    async fn parked_implementer_persists_completed_work() {
12148        for registered in [false, true] {
12149            let tmp = tempfile::tempdir().unwrap();
12150            let mut runner = registered_runner(tmp.path()).await;
12151            if !registered {
12152                std::fs::remove_file(runner.state.repo.join(".git/updater.contract")).unwrap();
12153            }
12154            let marker = tmp.path().join("started");
12155            let release = tmp.path().join("release");
12156            let mut implementer = spec("implement");
12157            implementer.command = vec![
12158                "sh".into(),
12159                "-c".into(),
12160                format!(
12161                    "touch '{}'; while [ ! -f '{}' ]; do sleep 0.01; done; echo completed > completed; echo saved-result",
12162                    marker.display(),
12163                    release.display()
12164                ),
12165            ];
12166            runner.roles.implementers = vec![implementer.clone()];
12167            runner.state.config.agents = vec![implementer];
12168            let pause = runner.pause.clone();
12169            let (result, ()) = tokio::join!(runner.implement(), async {
12170                tokio::time::timeout(Duration::from_secs(10), async {
12171                    while !marker.exists() {
12172                        tokio::time::sleep(Duration::from_millis(10)).await;
12173                    }
12174                })
12175                .await
12176                .unwrap();
12177                pause.park();
12178                std::fs::write(&release, "go").unwrap();
12179            });
12180            result.unwrap();
12181            let candidate = &runner.state.candidates[0];
12182            assert!(candidate.commits > 0, "{candidate:?}");
12183            assert!(candidate.summary.contains("saved-result"));
12184            assert_eq!(candidate.files, 1);
12185            let before = candidate.commits;
12186            runner.implement().await.unwrap();
12187            assert_eq!(runner.state.candidates[0].commits, before);
12188        }
12189    }
12190
12191    #[tokio::test]
12192    #[cfg(unix)]
12193    async fn resource_blocked_recovery_retries_the_unstarted_fixer() {
12194        let tmp = tempfile::tempdir().unwrap();
12195        let mut runner = registered_runner(tmp.path()).await;
12196        let winner = runner.state.candidates[0].clone();
12197        let cache = tmp.path().join("cache");
12198        runner.state.config.verify.e2e = vec![format!("CARGO_TARGET_DIR={} true", cache.display())];
12199        runner.state.config.graph.timeout_resource_wait = 0;
12200        let owner = crate::cache::Owner::here("other", "e2e", "e2e", &cache, "abc");
12201        let crate::cache::AcquireOutcome::Acquired(held) =
12202            crate::cache::try_acquire(&crate::run::home(), &cache, &owner).unwrap()
12203        else {
12204            panic!("cache must be free")
12205        };
12206        runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12207            from_run: "earlier".into(),
12208            generation: 1,
12209            mode: crate::recovery::Mode::VerifyRepair,
12210            reasons: Vec::new(),
12211            reviewed_head: None,
12212            rounds: Vec::new(),
12213            open_findings: Vec::new(),
12214            evidence: None,
12215            criteria_inherited: true,
12216            repair: crate::recovery::Repair::NotStarted,
12217        });
12218        runner.recovery_repair(&winner).await.unwrap();
12219        assert!(matches!(
12220            runner.state.recovery.as_ref().unwrap().repair,
12221            crate::recovery::Repair::WaitingForRestore { .. }
12222        ));
12223        drop(held);
12224        runner.state.resource_block = None;
12225        runner.state.config.graph.timeout_resource_wait = 1;
12226        runner.recovery_repair(&winner).await.unwrap();
12227        assert!(matches!(
12228            runner.state.recovery.as_ref().unwrap().repair,
12229            crate::recovery::Repair::Done { .. }
12230        ));
12231        assert!(runner.state.seats.values().any(|seat| seat.turns > 0));
12232    }
12233
12234    #[tokio::test]
12235    #[cfg(unix)]
12236    async fn repository_blocked_graft_and_recovery_retry_after_deferred_restore() {
12237        use std::os::fd::AsRawFd as _;
12238        for graft in [false, true] {
12239            let tmp = tempfile::tempdir().unwrap();
12240            let mut runner = registered_runner(tmp.path()).await;
12241            runner.state.config.graph.timeout_resource_wait = 1;
12242            let winner = runner.state.candidates[0].clone();
12243            let marker = tmp.path().join("fixer-started");
12244            let mut fixer = spec("fixer");
12245            fixer.command = vec![
12246                "sh".into(),
12247                "-c".into(),
12248                format!("echo started >> '{}'; echo done", marker.display()),
12249            ];
12250            runner.roles.fixer = Some(vec![fixer]);
12251            if graft {
12252                let mut loser = winner.clone();
12253                loser.label = 'B';
12254                loser.index = 1;
12255                runner.state.candidates.push(loser);
12256                runner.state.config.graph.graft_runner_up = true;
12257                runner.state.tally = Some(Tally {
12258                    first_choice: BTreeMap::new(),
12259                    borda: BTreeMap::new(),
12260                    winner: winner.label,
12261                    rankings: 1,
12262                    unanimous_initial: true,
12263                    deliberated: false,
12264                    changed_votes: 0,
12265                    unanimous_final: true,
12266                    tie_break: None,
12267                    judges: 1,
12268                    present: 1,
12269                    quorum: 1,
12270                    met_quorum: true,
12271                    uncontested: None,
12272                });
12273                runner.state.judgements.push(crate::run::Judgement {
12274                    judge: 1,
12275                    seat: "judge-1".into(),
12276                    agent: "judge".into(),
12277                    ranking: vec![winner.label, 'B'],
12278                    reasons: BTreeMap::new(),
12279                    confidence: None,
12280                    strengths: BTreeMap::from([("B".into(), vec!["handles empty input".into()])]),
12281                    order: vec![0, 1],
12282                    failed: None,
12283                    duration_ms: 0,
12284                });
12285            } else {
12286                runner.state.recovery = Some(crate::recovery::RecoveryRecord {
12287                    from_run: "earlier".into(),
12288                    generation: 1,
12289                    mode: crate::recovery::Mode::VerifyRepair,
12290                    reasons: Vec::new(),
12291                    reviewed_head: None,
12292                    rounds: Vec::new(),
12293                    open_findings: Vec::new(),
12294                    evidence: None,
12295                    criteria_inherited: true,
12296                    repair: crate::recovery::Repair::NotStarted,
12297                });
12298            }
12299            let reader = crate::repo_lock::SeatLease::for_checkout(
12300                &winner.worktree,
12301                false,
12302                crate::config::test_coordination().as_ref(),
12303            )
12304            .await
12305            .unwrap();
12306            let pause = runner.pause.clone();
12307            let (result, ()) = tokio::join!(
12308                async {
12309                    if graft {
12310                        runner.graft().await
12311                    } else {
12312                        runner.recovery_repair(&winner).await
12313                    }
12314                },
12315                async {
12316                    tokio::time::sleep(Duration::from_millis(100)).await;
12317                    pause.park();
12318                }
12319            );
12320            result.unwrap();
12321            assert!(!marker.exists(), "a blocked seat must not spawn");
12322            // Exercise the persisted state, as a binary replacement/resume does.
12323            runner.state = RunState::load(&runner.state.id).unwrap();
12324            if graft {
12325                assert_eq!(
12326                    runner.state.graft.as_ref().unwrap().outcome,
12327                    GraftOutcome::WaitingForRestore
12328                );
12329            } else {
12330                assert!(matches!(
12331                    runner.state.recovery.as_ref().unwrap().repair,
12332                    crate::recovery::Repair::WaitingForRestore { .. }
12333                ));
12334            }
12335            drop(reader);
12336            runner.pause = Pause::new();
12337            runner.state.resource_block = None;
12338            // Deferred restoration must still respect an external lock owner.
12339            let external =
12340                std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12341            assert_eq!(
12342                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12343                0
12344            );
12345            let leftover = winner.worktree.join("leftover");
12346            std::fs::write(&leftover, "earlier fallback edits").unwrap();
12347            let result = if graft {
12348                runner.graft().await
12349            } else {
12350                runner.recovery_repair(&winner).await
12351            };
12352            assert!(
12353                result
12354                    .unwrap_err()
12355                    .is::<crate::repo_lock::ResourceBlocked>()
12356            );
12357            assert!(leftover.exists());
12358            assert!(!marker.exists());
12359            drop(external);
12360            runner.state.resource_block = None;
12361            let result = if graft {
12362                runner.graft().await
12363            } else {
12364                runner.recovery_repair(&winner).await
12365            };
12366            result.unwrap();
12367            assert!(!leftover.exists(), "restore must precede retry");
12368            assert_eq!(std::fs::read_to_string(&marker).unwrap(), "started\n");
12369            if graft {
12370                assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12371            } else {
12372                assert!(matches!(
12373                    runner.state.recovery.as_ref().unwrap().repair,
12374                    crate::recovery::Repair::Done { .. }
12375                ));
12376            }
12377        }
12378    }
12379
12380    #[tokio::test]
12381    #[cfg(unix)]
12382    async fn interrupted_graft_restore_waits_and_preserves_its_record() {
12383        use std::os::fd::AsRawFd as _;
12384        let tmp = tempfile::tempdir().unwrap();
12385        let mut runner = registered_runner(tmp.path()).await;
12386        runner.state.config.graph.timeout_resource_wait = 1;
12387        let winner = runner.state.candidates[0].clone();
12388        let pre = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12389        runner.state.tally = Some(Tally {
12390            first_choice: BTreeMap::new(),
12391            borda: BTreeMap::new(),
12392            winner: winner.label,
12393            rankings: 0,
12394            unanimous_initial: false,
12395            deliberated: false,
12396            changed_votes: 0,
12397            unanimous_final: false,
12398            tie_break: None,
12399            judges: 0,
12400            present: 0,
12401            quorum: 0,
12402            met_quorum: true,
12403            uncontested: Some("fixture".into()),
12404        });
12405        runner.state.graft = Some(GraftRecord {
12406            pre: pre.clone(),
12407            post: None,
12408            lines: None,
12409            limit: None,
12410            agent: None,
12411            outcome: GraftOutcome::Running,
12412            requested: Vec::new(),
12413            adoption: None,
12414            adoption_error: None,
12415        });
12416        std::fs::write(winner.worktree.join("README.md"), "unfinished graft\n").unwrap();
12417        git::commit_all(&winner.worktree, "unfinished graft")
12418            .await
12419            .unwrap();
12420        let unfinished = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12421        let leftover = winner.worktree.join("leftover");
12422        std::fs::write(&leftover, "untrusted edits").unwrap();
12423        let path = runner.state.repo.join(".git/updater.lock");
12424        let external = std::fs::File::create(&path).unwrap();
12425        assert_eq!(
12426            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12427            0
12428        );
12429        assert!(
12430            runner
12431                .graft()
12432                .await
12433                .unwrap_err()
12434                .is::<crate::repo_lock::ResourceBlocked>()
12435        );
12436        assert!(matches!(
12437            runner.state.graft.as_ref().unwrap().outcome,
12438            GraftOutcome::Running
12439        ));
12440        assert_eq!(
12441            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12442            unfinished
12443        );
12444        assert!(leftover.exists());
12445        drop(external);
12446        std::fs::remove_file(&path).unwrap();
12447        std::fs::create_dir(&path).unwrap();
12448        assert!(runner.graft().await.is_err());
12449        assert!(matches!(
12450            runner.state.graft.as_ref().unwrap().outcome,
12451            GraftOutcome::Running
12452        ));
12453        assert_eq!(
12454            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12455            unfinished
12456        );
12457        assert!(leftover.exists());
12458        std::fs::remove_dir(&path).unwrap();
12459        runner.graft().await.unwrap();
12460        assert!(runner.state.graft.as_ref().unwrap().outcome.settled());
12461        assert_eq!(git::rev_parse(&winner.worktree, "HEAD").await.unwrap(), pre);
12462        assert!(!leftover.exists());
12463    }
12464
12465    #[tokio::test]
12466    #[cfg(unix)]
12467    async fn repository_contention_blocks_and_parks_without_poisoning_preparation() {
12468        use std::os::fd::AsRawFd as _;
12469        let tmp = tempfile::tempdir().unwrap();
12470        let mut runner = registered_runner(tmp.path()).await;
12471        runner.state.prep_complete = Some(false);
12472        runner.state.config.graph.timeout_resource_wait = 1;
12473        runner.state.save().unwrap();
12474        let external = std::fs::File::create(runner.state.repo.join(".git/updater.lock")).unwrap();
12475        assert_eq!(
12476            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12477            0
12478        );
12479        runner.execute().await.unwrap();
12480        assert_eq!(runner.state.status, RunStatus::Blocked);
12481        assert_eq!(
12482            runner.state.resource_block.as_ref().unwrap().kind,
12483            "repository coordination"
12484        );
12485        assert!(runner.state.prep_failure.is_none());
12486        assert!(runner.state.seats.is_empty());
12487        runner.pause.park();
12488        tokio::time::timeout(Duration::from_secs(1), runner.execute())
12489            .await
12490            .unwrap()
12491            .unwrap();
12492        assert!(runner.state.parked);
12493        assert!(runner.state.prep_failure.is_none());
12494    }
12495
12496    #[tokio::test]
12497    #[cfg(unix)]
12498    async fn gate_takes_cache_before_repository_so_an_editing_wave_can_finish() {
12499        let tmp = tempfile::tempdir().unwrap();
12500        let mut runner = registered_runner(tmp.path()).await;
12501        let winner = runner.state.candidates[0].clone();
12502        let cache = tmp.path().join("cache");
12503        let started = tmp.path().join("pre-gate-started");
12504        runner.state.config.verify.pre_gate = vec![format!("touch '{}'", started.display())];
12505        runner.state.config.verify.gate =
12506            vec![format!("CARGO_TARGET_DIR='{}' true", cache.display())];
12507        let home = crate::run::home();
12508        let owner =
12509            crate::cache::Owner::here("editing-wave", "implement", "*", &winner.worktree, "HEAD");
12510        let guard = match crate::cache::try_acquire(&home, &cache, &owner).unwrap() {
12511            crate::cache::AcquireOutcome::Acquired(g) => g,
12512            crate::cache::AcquireOutcome::Busy(busy) => panic!("cache must be free: {busy:?}"),
12513        };
12514        let mut gate = Box::pin(runner.run_gate(&winner));
12515        assert!(
12516            tokio::time::timeout(Duration::from_millis(150), &mut gate)
12517                .await
12518                .is_err()
12519        );
12520        assert!(!started.exists(), "pre-gate must wait for the cache too");
12521        // Simulate the wave that already owns the cache entering invoke.
12522        // It must obtain the editor reservation and finish without waiting
12523        // for the gate that is queued behind its cache.
12524        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_millis(200), || false);
12525        let editor = crate::repo_lock::SeatLease::checkout_with(
12526            &winner.worktree,
12527            true,
12528            crate::config::test_coordination().as_ref(),
12529            &wait,
12530        )
12531        .await
12532        .unwrap();
12533        drop(editor);
12534        guard.release();
12535        let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12536            .await
12537            .unwrap()
12538            .unwrap();
12539        drop(gate);
12540        assert!(outcomes.iter().all(CommandOutcome::ok));
12541        assert!(started.exists());
12542    }
12543
12544    #[tokio::test]
12545    #[cfg(unix)]
12546    async fn post_seat_rescue_and_gate_wait_for_shared_lock() {
12547        use std::os::fd::AsRawFd as _;
12548
12549        ask_test_home();
12550        let tmp = tempfile::tempdir().unwrap();
12551        let repo = tmp.path().join("repo");
12552        std::fs::create_dir_all(&repo).unwrap();
12553        init_repo(&repo);
12554        let common = repo.join(".git");
12555        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12556        let mut runner = runner_at(RunStatus::Prep);
12557        runner.state.repo = repo.clone();
12558        runner.state.config.coordination = crate::config::test_coordination();
12559        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12560        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12561        runner.state.config.graph.advise = false;
12562        runner.roles.implementers = vec![spec("implement")];
12563        runner.roles.judges = vec![spec("judge")];
12564        runner.prep().await.unwrap();
12565        let winner = runner.state.candidates[0].clone();
12566        // Emulate edits already left by a completed seat. The fixture CLI
12567        // only reports completion; it performs no repository operations.
12568        std::fs::write(winner.worktree.join("leftover"), "seat work\n").unwrap();
12569        let completed = tmp.path().join("seat-completed");
12570        runner.roles.implementers[0].command = vec![
12571            "sh".into(),
12572            "-c".into(),
12573            format!("touch '{}'; echo completed", completed.display()),
12574        ];
12575        let outsider = std::fs::File::create(common.join("updater.lock")).unwrap();
12576        assert_eq!(
12577            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12578            0
12579        );
12580        let before = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12581        let mut implement = Box::pin(runner.implement());
12582        tokio::select! {
12583            result = &mut implement => panic!("rescue must wait: {result:?}"),
12584            result = tokio::time::timeout(Duration::from_secs(10), async {
12585                while !completed.exists() {
12586                    tokio::time::sleep(Duration::from_millis(20)).await;
12587                }
12588            }) => result.unwrap(),
12589        }
12590        assert!(
12591            tokio::time::timeout(Duration::from_millis(200), &mut implement)
12592                .await
12593                .is_err()
12594        );
12595        assert_eq!(
12596            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12597            before
12598        );
12599        assert_eq!(
12600            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12601            0
12602        );
12603        tokio::time::timeout(Duration::from_secs(10), &mut implement)
12604            .await
12605            .unwrap()
12606            .unwrap();
12607        drop(implement);
12608        assert_ne!(
12609            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12610            before
12611        );
12612        assert!(git::is_clean(&winner.worktree).await.unwrap());
12613
12614        let started = tmp.path().join("pre-gate-started");
12615        let proceed = tmp.path().join("pre-gate-proceed");
12616        let checked = tmp.path().join("gate-ran");
12617        runner.state.config.verify.pre_gate = vec![format!(
12618            "echo mechanical >> README.md; touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12619            started.display(),
12620            proceed.display()
12621        )];
12622        runner.state.config.verify.gate = vec![format!("touch '{}'", checked.display())];
12623        assert_eq!(
12624            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12625            0
12626        );
12627        let mut gate = Box::pin(runner.run_gate(&winner));
12628        assert!(
12629            tokio::time::timeout(Duration::from_millis(200), &mut gate)
12630                .await
12631                .is_err()
12632        );
12633        assert!(!started.exists());
12634        assert!(!checked.exists());
12635        assert!(git::is_clean(&winner.worktree).await.unwrap());
12636        assert_eq!(
12637            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_UN) },
12638            0
12639        );
12640        tokio::select! {
12641            result = &mut gate => panic!("pre-gate must wait for signal: {result:?}"),
12642            result = tokio::time::timeout(Duration::from_secs(10), async {
12643                while !started.exists() {
12644                    tokio::time::sleep(Duration::from_millis(20)).await;
12645                }
12646            }) => result.unwrap(),
12647        }
12648        assert_eq!(
12649            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12650            -1,
12651            "mechanical edits must hold the real lock"
12652        );
12653        std::fs::write(proceed, "ready").unwrap();
12654        let outcomes = tokio::time::timeout(Duration::from_secs(10), &mut gate)
12655            .await
12656            .unwrap()
12657            .unwrap();
12658        drop(gate);
12659        assert!(outcomes.iter().all(CommandOutcome::ok));
12660        assert!(checked.exists());
12661        assert!(runner.state.pre_gate_commit.is_some());
12662        assert!(git::is_clean(&winner.worktree).await.unwrap());
12663        assert_eq!(
12664            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12665            0,
12666            "gate completion must release coordination"
12667        );
12668        drop(outsider);
12669        std::fs::remove_file(common.join("updater.lock")).unwrap();
12670        std::fs::create_dir(common.join("updater.lock")).unwrap();
12671        std::fs::remove_file(&started).unwrap();
12672        std::fs::remove_file(&checked).unwrap();
12673        let head = git::rev_parse(&winner.worktree, "HEAD").await.unwrap();
12674        let error = runner.run_gate(&winner).await.unwrap_err();
12675        assert!(
12676            error
12677                .to_string()
12678                .contains("acquire mandatory repository lock")
12679        );
12680        assert!(
12681            !started.exists(),
12682            "failed acquisition must prevent pre-gate"
12683        );
12684        assert!(!checked.exists(), "failed acquisition must prevent gate");
12685        assert!(git::is_clean(&winner.worktree).await.unwrap());
12686        assert_eq!(
12687            git::rev_parse(&winner.worktree, "HEAD").await.unwrap(),
12688            head
12689        );
12690    }
12691
12692    #[tokio::test]
12693    #[cfg(unix)]
12694    async fn preparation_waits_for_shared_lock_before_checkout_and_holds_it_through_setup() {
12695        use std::os::fd::AsRawFd as _;
12696        use std::time::Duration;
12697
12698        ask_test_home();
12699        let tmp = tempfile::tempdir().unwrap();
12700        let repo = tmp.path().join("repo");
12701        std::fs::create_dir_all(&repo).unwrap();
12702        init_repo(&repo);
12703        let common = repo.join(".git");
12704        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
12705        let lock = std::fs::File::create(common.join("updater.lock")).unwrap();
12706        assert_eq!(
12707            unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12708            0
12709        );
12710        let mut runner = runner_at(RunStatus::Prep);
12711        runner.state.repo = repo.clone();
12712        runner.state.config.coordination = crate::config::test_coordination();
12713        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12714        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12715        runner.state.config.graph.advise = true;
12716        runner.state.config.graph.advisors = 1;
12717        runner.roles.implementers = vec![spec("implement")];
12718        runner.roles.judges = vec![spec("judge")];
12719        let root = runner.state.worktree_root();
12720        let started = tmp.path().join("setup-started");
12721        let proceed = tmp.path().join("setup-proceed");
12722        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
12723            run: Some(format!(
12724                "touch '{}'; while ! test -f '{}'; do sleep 0.02; done",
12725                started.display(),
12726                proceed.display()
12727            )),
12728            ..Default::default()
12729        }];
12730        let branches = git::git(&repo, &["branch", "--list"]).await.unwrap();
12731        let config = git::git(&repo, &["config", "--local", "--list"])
12732            .await
12733            .unwrap();
12734        let mut prep = Box::pin(runner.prep());
12735        assert!(
12736            tokio::time::timeout(Duration::from_millis(200), &mut prep)
12737                .await
12738                .is_err()
12739        );
12740        assert!(
12741            !root.exists(),
12742            "contention must prevent even checkout creation"
12743        );
12744        assert!(!started.exists(), "contention must prevent setup");
12745        assert_eq!(
12746            git::git(&repo, &["branch", "--list"]).await.unwrap(),
12747            branches
12748        );
12749        assert_eq!(
12750            git::git(&repo, &["config", "--local", "--list"])
12751                .await
12752                .unwrap(),
12753            config
12754        );
12755        drop(lock);
12756        tokio::select! {
12757            result = &mut prep => panic!("setup must wait for its signal: {result:?}"),
12758            result = tokio::time::timeout(Duration::from_secs(10), async {
12759                while !started.exists() {
12760                    tokio::time::sleep(Duration::from_millis(20)).await;
12761                }
12762            }) => result.unwrap(),
12763        }
12764        let outsider = std::fs::File::open(common.join("updater.lock")).unwrap();
12765        assert_eq!(
12766            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12767            -1
12768        );
12769        assert!(
12770            tokio::time::timeout(
12771                Duration::from_millis(100),
12772                crate::repo_lock::SeatLease::for_checkout(
12773                    &repo,
12774                    true,
12775                    crate::config::test_coordination().as_ref()
12776                )
12777            )
12778            .await
12779            .is_err(),
12780            "preparation must also exclude local editors"
12781        );
12782        std::fs::write(proceed, "ready").unwrap();
12783        tokio::time::timeout(Duration::from_secs(10), &mut prep)
12784            .await
12785            .unwrap()
12786            .unwrap();
12787        drop(prep);
12788        assert_eq!(runner.state.prep_complete, Some(true));
12789        assert!(root.join("judge-1").exists());
12790        assert!(root.join("advisor-1").exists());
12791        assert_eq!(
12792            unsafe { libc::flock(outsider.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
12793            0
12794        );
12795    }
12796
12797    #[tokio::test]
12798    #[cfg(unix)]
12799    async fn preparation_lock_failure_prevents_repository_changes() {
12800        ask_test_home();
12801        let tmp = tempfile::tempdir().unwrap();
12802        let repo = tmp.path().join("repo");
12803        std::fs::create_dir_all(&repo).unwrap();
12804        init_repo(&repo);
12805        std::fs::write(repo.join(".git/updater.contract"), "test-protocol-v1\n").unwrap();
12806        // A directory at the required lock path makes opening the real lock
12807        // fail, rather than merely exercising an invalid registration.
12808        std::fs::create_dir(repo.join(".git/updater.lock")).unwrap();
12809        let mut runner = runner_at(RunStatus::Prep);
12810        runner.state.repo = repo.clone();
12811        runner.state.config.coordination = crate::config::test_coordination();
12812        runner.state.base_commit = git::rev_parse(&repo, "HEAD").await.unwrap();
12813        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12814        runner.roles.implementers = vec![spec("implement")];
12815        let error = runner.prep().await.unwrap_err();
12816        assert!(
12817            error
12818                .to_string()
12819                .contains("acquire mandatory repository lock")
12820        );
12821        assert!(!runner.state.worktree_root().exists());
12822        assert!(runner.state.candidates.is_empty());
12823        assert!(!repo.join(".git/config.worktree").exists());
12824        assert_eq!(
12825            git::git(&repo, &["branch", "--list"]).await.unwrap().trim(),
12826            "* main"
12827        );
12828    }
12829
12830    #[tokio::test]
12831    async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
12832        ask_test_home();
12833        let tmp = tempfile::tempdir().unwrap();
12834        let repo = tmp.path().join("repo");
12835        std::fs::create_dir_all(&repo).unwrap();
12836        init_repo(&repo);
12837        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
12838        git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
12839        std::fs::write(repo.join("referenced"), "required commit").unwrap();
12840        git::git(&repo, &["add", "."]).await.unwrap();
12841        git::git(&repo, &["commit", "-m", "required seed"])
12842            .await
12843            .unwrap();
12844        let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
12845        git::git(&repo, &["checkout", "main"]).await.unwrap();
12846        let mut runner = runner_at(RunStatus::Prep);
12847        runner.state.repo = repo.clone();
12848        runner.state.base_commit = base.clone();
12849        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
12850        runner.state.config.graph.advise = false;
12851        runner.state.config.blind.commit_msg_hook = false;
12852        runner.state.seeds = vec![refs::Seed {
12853            token: seed.clone(),
12854            sha: seed.clone(),
12855            kind: refs::SeedKind::Unmerged,
12856            branch: false,
12857            detail: String::new(),
12858        }];
12859        runner.roles.implementers = vec![spec("one"), spec("two")];
12860        runner.roles.judges = vec![spec("judge")];
12861        let label = blind::assign_labels(2, runner.state.seed)[0];
12862        let branch = runner.state.branch_for(label);
12863        let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
12864        git::worktree_add_branch(&repo, &worktree, &branch, &base)
12865            .await
12866            .unwrap();
12867        runner.state.candidates.push(Candidate {
12868            index: 0,
12869            label,
12870            agent: "one".into(),
12871            branch,
12872            worktree,
12873            summary: String::new(),
12874            stat: String::new(),
12875            files: 0,
12876            commits: 0,
12877            empty: false,
12878            failed: None,
12879            verified_noop: None,
12880            duration_ms: 0,
12881            folded: false,
12882        });
12883        runner.state.save().unwrap();
12884        // Reload the checkpoint immediately before its first cherry-pick.
12885        runner.state = RunState::load(&runner.state.id).unwrap();
12886        assert_eq!(runner.state.prep_complete, Some(false));
12887        runner.prep().await.unwrap();
12888        assert_eq!(runner.state.prep_complete, Some(true));
12889        assert_eq!(runner.state.candidates.len(), 2);
12890        for candidate in &runner.state.candidates {
12891            assert_eq!(
12892                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
12893                git::tree_of(&repo, &seed).await.unwrap()
12894            );
12895            assert_eq!(
12896                std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
12897                "required commit"
12898            );
12899        }
12900        assert!(runner.state.worktree_root().join("judge-1").exists());
12901        let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
12902        runner.prep().await.unwrap();
12903        assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
12904
12905        runner.state.prep_complete = Some(false);
12906        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
12907            run: Some(format!(
12908                "test -f '{}'",
12909                tmp.path().join("dependency-ready").display()
12910            )),
12911            ..Default::default()
12912        }];
12913        assert!(runner.prep().await.is_err());
12914        assert!(runner.state.prep_failure.is_some());
12915        std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
12916        runner.state = RunState::load(&runner.state.id).unwrap();
12917        assert!(
12918            runner.prep().await.is_err(),
12919            "correcting the cause alone must not auto-retry"
12920        );
12921        let failed_at = runner.state.events.last().unwrap().at;
12922        assert!(
12923            runner.retry_preparation(failed_at).is_err(),
12924            "old approvals cannot unlock the hold"
12925        );
12926        let mut task = crate::queue::Task::new(
12927            "retry".into(),
12928            "retry setup".into(),
12929            repo.clone(),
12930            crate::queue::Source::Human,
12931        );
12932        task.start(runner.state.id.clone());
12933        task.hold_machine(Some("setup failed".into()));
12934        task.release_operator("cli", None);
12935        runner
12936            .retry_preparation(task.resumption.as_ref().unwrap().at)
12937            .unwrap();
12938        runner.prep().await.unwrap();
12939        assert!(runner.state.prep_failure.is_none());
12940        assert_eq!(runner.state.prep_complete, Some(true));
12941        assert_eq!(runner.state.candidates.len(), 2);
12942        assert!(runner.state.worktree_root().join("judge-1").exists());
12943        for candidate in &runner.state.candidates {
12944            assert_eq!(
12945                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
12946                git::tree_of(&repo, &seed).await.unwrap()
12947            );
12948        }
12949    }
12950
12951    async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
12952        let mut heads = Vec::new();
12953        for candidate in candidates {
12954            heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
12955        }
12956        heads
12957    }
12958
12959    fn init_repo(dir: &Path) {
12960        let run = |args: &[&str]| {
12961            let out = std::process::Command::new("git")
12962                .args(args)
12963                .current_dir(dir)
12964                .quiet()
12965                .output()
12966                .expect("spawn git");
12967            assert!(
12968                out.status.success(),
12969                "git {args:?} failed: {}",
12970                String::from_utf8_lossy(&out.stderr)
12971            );
12972        };
12973        run(&["init", "-b", "main"]);
12974        run(&["config", "user.name", "magi test"]);
12975        run(&["config", "user.email", "magi@example.com"]);
12976        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
12977        run(&["add", "-A"]);
12978        run(&["commit", "-m", "init"]);
12979    }
12980
12981    // `settle_questions` is what closes the ghost the phone showed: a run's
12982    // seat asked something, the run then ended, and nothing was left to
12983    // abandon the question it left `open`. `HOME` is a process-wide
12984    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
12985    // first time it runs in the binary — every test below still reaches the
12986    // same directory whichever call won, and each gets its own run id from
12987    // `RunState::new`, so they never collide there.
12988    fn ask_test_home() {
12989        crate::run::pin_test_home();
12990    }
12991
12992    /// A minimal, git-free `Runner` at a given status — `settle_questions`
12993    /// reads nothing else off it.
12994    fn runner_at(status: RunStatus) -> Runner {
12995        let mut state = RunState::new(
12996            PathBuf::from("/nonexistent/repo"),
12997            "main".to_owned(),
12998            "deadbeef".to_owned(),
12999            "task".to_owned(),
13000            Config::default(),
13001        );
13002        // These fixtures share one home and save concurrently. Short clock
13003        // IDs can collide, making an ownership test read another test's run.
13004        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13005        state.id = format!(
13006            "20000101-000000-{:04x}",
13007            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
13008        );
13009        state.status = status;
13010        Runner {
13011            state,
13012            roles: ResolvedRoles {
13013                implementers: Vec::new(),
13014                judges: Vec::new(),
13015                reviewers: Vec::new(),
13016                fixer: None,
13017                conductor: conductor(),
13018                implementer_roster: Vec::new(),
13019                judge_roster: Vec::new(),
13020                reviewer_roster: Vec::new(),
13021            },
13022            sem: Arc::new(Semaphore::new(1)),
13023            pause: Pause::new(),
13024            interrupt: Pause::new(),
13025        }
13026    }
13027
13028    fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
13029        crate::run::MergeRepair {
13030            question: "repair-question".into(),
13031            head: "old-head".into(),
13032            pr: "https://github.com/o/r/pull/42".into(),
13033            reviews: vec![review_round(false, 1, 1, 1, false, true)],
13034            gate: vec![outcome(Some(0), false)],
13035            gate_head: Some("old-head".into()),
13036            gate_fixes: Vec::new(),
13037            pre_gate: Vec::new(),
13038            phase,
13039            failure: None,
13040        }
13041    }
13042
13043    #[tokio::test]
13044    async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
13045        crate::run::pin_test_home();
13046        let mut runner = runner_at(RunStatus::Landing);
13047        runner.state.merge_repairs.push(merge_repair_request(
13048            crate::run::MergeRepairPhase::Requested,
13049        ));
13050        runner.state.save().unwrap();
13051        let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
13052        assert!(
13053            runner
13054                .execute()
13055                .await
13056                .unwrap_err()
13057                .to_string()
13058                .contains("already running")
13059        );
13060        assert_eq!(
13061            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13062            crate::run::MergeRepairPhase::Requested
13063        );
13064        drop(claim);
13065        let mut disk = runner.state.clone();
13066        disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
13067        disk.save().unwrap();
13068        assert!(
13069            runner
13070                .execute()
13071                .await
13072                .unwrap_err()
13073                .to_string()
13074                .contains("decision changed")
13075        );
13076        assert_eq!(
13077            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
13078            crate::run::MergeRepairPhase::Complete
13079        );
13080    }
13081
13082    #[test]
13083    fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
13084        crate::run::pin_test_home();
13085        let mut runner = runner_at(RunStatus::Landing);
13086        let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
13087        runner.state.reviews = request.reviews.clone();
13088        runner.state.gate = request.gate.clone();
13089        runner.state.gate_ran = true;
13090        runner.state.land_approval = Some(crate::run::LandApproval {
13091            question: "old".into(),
13092            head: "old-head".into(),
13093        });
13094        runner.state.merge_repairs.push(request);
13095        runner.begin_merge_repair_review(0).unwrap();
13096        assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
13097        assert!(!runner.state.gate_status().ok());
13098        assert!(runner.state.land_approval.is_none());
13099        let disk = RunState::load(&runner.state.id).unwrap();
13100        assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
13101        assert!(disk.merge_repairs[0].gate[0].ok());
13102        assert_eq!(
13103            disk.merge_repairs[0].phase,
13104            crate::run::MergeRepairPhase::Reviewing
13105        );
13106        assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
13107        assert!(disk.reviews.is_empty());
13108    }
13109
13110    #[tokio::test]
13111    async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
13112        crate::run::pin_test_home();
13113        for phase in [
13114            crate::run::MergeRepairPhase::Fixing,
13115            crate::run::MergeRepairPhase::Requested,
13116        ] {
13117            let mut runner = runner_at(RunStatus::Landing);
13118            runner.state.config.graph.land_rounds = 0;
13119            runner.state.merge_repairs.push(merge_repair_request(phase));
13120            runner.state.save().unwrap();
13121            assert!(!runner.repair_merge_review().await.unwrap());
13122            assert_eq!(runner.state.status, RunStatus::Blocked);
13123            assert_eq!(
13124                runner.state.merge_repairs[0].phase,
13125                crate::run::MergeRepairPhase::Failed
13126            );
13127            let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
13128            assert!(
13129                reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
13130                    "interrupted"
13131                } else {
13132                    "budget exhausted"
13133                })
13134            );
13135            assert!(runner.state.merge.is_none());
13136        }
13137    }
13138
13139    /// `park_here` folding in the reason `Pause::park_because` recorded -
13140    /// this is what lets an operator reading a run's events tell an
13141    /// interrupt-driven park from an ordinary shutdown park.
13142    #[test]
13143    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
13144        crate::run::pin_test_home();
13145        let mut runner = runner_at(RunStatus::Implementing);
13146        let interrupt = Pause::new();
13147        runner.watch_interrupt(interrupt.clone());
13148
13149        interrupt.park_because("task a1b2 asked to run first");
13150
13151        assert!(runner.park_here().expect("park_here"));
13152        assert!(runner.state.parked);
13153        let last = runner.state.events.last().expect("a park event");
13154        assert_eq!(last.node, "park");
13155        assert!(
13156            last.message.contains("task a1b2 asked to run first"),
13157            "expected the interrupt reason in {:?}",
13158            last.message
13159        );
13160    }
13161
13162    /// The cause is stored on the pass when it parks, and the first cause a
13163    /// shared pause was given is the one that stays.
13164    #[test]
13165    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
13166        crate::run::pin_test_home();
13167        let mut runner = runner_at(RunStatus::Implementing);
13168        runner.state.begin_pass();
13169        let shutdown = Pause::new();
13170        runner.on_pause(shutdown.clone());
13171        let upgrade = ParkCause::Upgrade {
13172            from: "0.121.0".to_owned(),
13173            to: Some("0.121.1".to_owned()),
13174        };
13175        shutdown.park_for(upgrade.clone());
13176        shutdown.park_for(ParkCause::Operator);
13177
13178        assert!(runner.park_here().expect("park_here"));
13179        assert_eq!(
13180            runner.state.passes[0].end,
13181            Some(crate::run::PassEnd::Parked { cause: upgrade })
13182        );
13183        let last = runner.state.events.last().expect("a park event");
13184        assert!(last.message.contains("0.121.0"), "{}", last.message);
13185    }
13186
13187    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
13188    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
13189    /// cleared) must not make a *different* run - one only watching its own,
13190    /// unshared interrupt `Pause` - see itself as parked. If a future change
13191    /// ever collapsed these back into one handle, the interrupt scheduler
13192    /// would park every run for the rest of the daemon's life, not just the
13193    /// one it meant to interrupt.
13194    #[test]
13195    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
13196        crate::run::pin_test_home();
13197        let mut runner = runner_at(RunStatus::Implementing);
13198        let shutdown = Pause::new();
13199        runner.on_pause(shutdown.clone());
13200        let interrupt = Pause::new();
13201        runner.watch_interrupt(interrupt.clone());
13202
13203        // Nobody has asked for anything yet.
13204        assert!(!runner.park_here().expect("park_here"));
13205        assert!(!runner.state.parked);
13206
13207        // Only the interrupt handle fires; the shutdown handle stays clear.
13208        interrupt.park_because("test");
13209        assert!(!shutdown.parked());
13210        assert!(runner.park_here().expect("park_here"));
13211    }
13212
13213    /// The property every prior attempt at this feature failed to pin down:
13214    /// asking a run to park while one of its nodes has a real, in-flight
13215    /// async operation running (an agent call, in production) must not cut
13216    /// that operation short. `park_here` is only ever consulted *between*
13217    /// `execute`'s node calls - see its own doc - so nothing inside a node
13218    /// can observe a park request until the node itself returns. This proves
13219    /// that structurally, with real `tokio` concurrency and a channel
13220    /// handshake (never a sleep, which would only prove "usually", not
13221    /// "cannot"): the "node" below reports that it has genuinely started,
13222    /// and only then is the park requested; the node still has to be told to
13223    /// finish before `park_here` is ever called, exactly mirroring every
13224    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
13225    /// in `execute`.
13226    #[tokio::test]
13227    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
13228        crate::run::pin_test_home();
13229        let mut runner = runner_at(RunStatus::Implementing);
13230        let interrupt = Pause::new();
13231        runner.watch_interrupt(interrupt.clone());
13232
13233        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
13234        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
13235
13236        // Stands in for one node's in-flight agent call: it proves it has
13237        // genuinely started, then blocks - exactly as a spawned CLI process
13238        // does - until told to finish.
13239        let node = async move {
13240            started_tx.send(()).expect("send started");
13241            finish_rx.await.expect("recv finish");
13242            "node finished"
13243        };
13244
13245        let interrupter = async move {
13246            started_rx.await.expect("recv started");
13247            // The call is now genuinely in flight. Ask it to park.
13248            interrupt.park_because("higher-priority task waiting");
13249            // Nothing the node does can observe this yet - there is no
13250            // check inside it, by construction - so let the executor run
13251            // anything pending and then let the node finish on its own.
13252            tokio::task::yield_now().await;
13253            finish_tx.send(()).expect("send finish");
13254        };
13255
13256        let (node_result, ()) = tokio::join!(node, interrupter);
13257        assert_eq!(
13258            node_result, "node finished",
13259            "the in-flight call ran to completion"
13260        );
13261
13262        // Only now, at the boundary the real `execute` would check right
13263        // after this node, does the park take effect.
13264        assert!(runner.park_here().expect("park_here"));
13265        assert!(runner.state.parked);
13266    }
13267
13268    /// A run parked mid-competition carries every field it had accumulated
13269    /// through the exact same disk round-trip an ordinary resume uses -
13270    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
13271    /// Nothing about parking for an interrupt is a special case of that path;
13272    /// this is what proves it rather than assuming it.
13273    #[test]
13274    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
13275        crate::run::pin_test_home();
13276        let mut runner = runner_at(RunStatus::Judging);
13277        // `Runner::resume` re-resolves roles from the saved config, which
13278        // refuses an empty roster - give it the same minimal one `conductor`
13279        // itself uses.
13280        runner.state.config.agents = vec![conductor()];
13281        runner.state.candidates = vec![Candidate {
13282            index: 0,
13283            label: 'A',
13284            agent: "alpha".to_owned(),
13285            branch: "magi/x/A".to_owned(),
13286            worktree: PathBuf::from("/nonexistent/worktree"),
13287            summary: "did the thing".to_owned(),
13288            stat: "1 file changed".to_owned(),
13289            files: 1,
13290            commits: 1,
13291            empty: false,
13292            failed: None,
13293            verified_noop: None,
13294            duration_ms: 1234,
13295            folded: false,
13296        }];
13297        let run_id = runner.state.id.clone();
13298
13299        let interrupt = Pause::new();
13300        runner.watch_interrupt(interrupt.clone());
13301        interrupt.park_because("task c3d4 asked to run first");
13302        assert!(runner.park_here().expect("park_here"));
13303
13304        let resumed = Runner::resume(&run_id).expect("resume");
13305        assert_eq!(resumed.state.candidates.len(), 1);
13306        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
13307        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
13308        assert_eq!(resumed.state.status, runner.state.status);
13309        assert!(
13310            resumed.state.parked,
13311            "still parked until `execute` actually walks the graph again"
13312        );
13313        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
13314    }
13315
13316    /// A fresh open question on `run`, stored and handed back for assertions.
13317    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
13318        let mut q = ask::Question::new(
13319            run.to_owned(),
13320            "implement".to_owned(),
13321            "impl-A".to_owned(),
13322            "Which storage backend should the cache use?".to_owned(),
13323            String::new(),
13324            vec!["SQLite".to_owned(), "Redis".to_owned()],
13325        );
13326        store.put(&mut q).unwrap();
13327        q
13328    }
13329
13330    #[test]
13331    fn a_failed_runs_open_question_is_abandoned() {
13332        ask_test_home();
13333        let store = ask::Questions::open();
13334        let mut runner = runner_at(RunStatus::Failed);
13335        let run = runner.state.id.clone();
13336        let q = ask_open_question(&store, &run);
13337
13338        runner.settle_questions();
13339
13340        let back = store.get(&q.id).unwrap();
13341        assert!(
13342            !back.status.open(),
13343            "the seat that asked died with the run; nobody is left to read an answer"
13344        );
13345        assert!(
13346            back.detail.contains(&run) && back.detail.contains("failed"),
13347            "the reason names what the run became, not just that it is gone: {}",
13348            back.detail
13349        );
13350    }
13351
13352    #[test]
13353    fn a_merged_runs_open_question_is_abandoned_too() {
13354        ask_test_home();
13355        let store = ask::Questions::open();
13356        // A run that finishes cleanly still leaves nobody to read an answer -
13357        // this is not only a failure-path cleanup.
13358        for status in [RunStatus::Merged, RunStatus::Ready] {
13359            let mut runner = runner_at(status);
13360            let run = runner.state.id.clone();
13361            let q = ask_open_question(&store, &run);
13362
13363            runner.settle_questions();
13364
13365            let back = store.get(&q.id).unwrap();
13366            assert!(
13367                !back.status.open(),
13368                "{status:?} run's question must not outlive the run"
13369            );
13370        }
13371    }
13372
13373    #[test]
13374    fn a_still_resumable_runs_open_question_is_left_alone() {
13375        ask_test_home();
13376        let store = ask::Questions::open();
13377        // `Blocked` and `Stalled` can still be resumed — the candidates, the
13378        // review round and the seat sessions are all still on disk — so a
13379        // question asked mid-round may yet get a real answer from a real
13380        // resume. Sweeping it here would be exactly the failure mode this
13381        // whole feature exists to avoid on the other side.
13382        for status in [RunStatus::Blocked, RunStatus::Stalled] {
13383            let mut runner = runner_at(status);
13384            let run = runner.state.id.clone();
13385            let q = ask_open_question(&store, &run);
13386
13387            runner.settle_questions();
13388
13389            let back = store.get(&q.id).unwrap();
13390            assert!(
13391                back.status.open(),
13392                "{status:?} is still alive; the question must still be waiting"
13393            );
13394        }
13395    }
13396
13397    #[test]
13398    fn settle_questions_never_touches_an_already_answered_question() {
13399        ask_test_home();
13400        let store = ask::Questions::open();
13401        let mut runner = runner_at(RunStatus::Failed);
13402        let run = runner.state.id.clone();
13403        let mut q = ask_open_question(&store, &run);
13404        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
13405            .unwrap();
13406        store.put(&mut q).unwrap();
13407
13408        // Called twice, the way a crash-recovered daemon reclaim and the
13409        // graph's own cleanup both can for the same run — `abandon_for_run`
13410        // only ever touches what is still open, so this must be inert both
13411        // times, not merely the second.
13412        runner.settle_questions();
13413        runner.settle_questions();
13414
13415        let back = store.get(&q.id).unwrap();
13416        assert_eq!(
13417            back.status,
13418            ask::QuestionStatus::Answered,
13419            "a real answer is a decision on record, never overwritten by a sweep"
13420        );
13421    }
13422
13423    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
13424    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
13425    /// without merging, whose winner is still the operator's answer to read.
13426    /// Nothing previously called `fold_run` itself with a real `tally`, so
13427    /// this is the first test to pin down the one distinction the whole
13428    /// automatic-fold feature depends on: the winner's worktree and branch
13429    /// must survive, everything else sharing the run's worktree bay - a
13430    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
13431    /// second sweep treats every non-winner directory under the bay alike -
13432    /// must not.
13433    #[tokio::test]
13434    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
13435        crate::run::pin_test_home();
13436        let tmp = tempfile::tempdir().expect("tempdir");
13437        let repo = tmp.path().join("repo");
13438        std::fs::create_dir_all(&repo).unwrap();
13439        init_repo(&repo);
13440
13441        let mut config = Config::default();
13442        config.graph.worktree_root = Some(tmp.path().join("wt"));
13443
13444        let mut state = RunState::new(
13445            repo.clone(),
13446            "main".to_owned(),
13447            "deadbeef".to_owned(),
13448            "task".to_owned(),
13449            config,
13450        );
13451        let root = state.worktree_root();
13452        let wt_a = root.join("cand-A");
13453        let wt_b = root.join("cand-B");
13454        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
13455            .await
13456            .expect("worktree A");
13457        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
13458            .await
13459            .expect("worktree B");
13460
13461        state.candidates = vec![
13462            Candidate {
13463                index: 0,
13464                label: 'A',
13465                agent: "alpha".to_owned(),
13466                branch: "magi/x/A".to_owned(),
13467                worktree: wt_a.clone(),
13468                summary: String::new(),
13469                stat: String::new(),
13470                files: 0,
13471                commits: 0,
13472                empty: false,
13473                failed: None,
13474                verified_noop: None,
13475                duration_ms: 0,
13476                folded: false,
13477            },
13478            Candidate {
13479                index: 1,
13480                label: 'B',
13481                agent: "beta".to_owned(),
13482                branch: "magi/x/B".to_owned(),
13483                worktree: wt_b.clone(),
13484                summary: String::new(),
13485                stat: String::new(),
13486                files: 0,
13487                commits: 0,
13488                empty: false,
13489                failed: None,
13490                verified_noop: None,
13491                duration_ms: 0,
13492                folded: false,
13493            },
13494        ];
13495        state.tally = Some(Tally {
13496            first_choice: BTreeMap::from([('A', 1)]),
13497            borda: BTreeMap::new(),
13498            winner: 'A',
13499            rankings: 1,
13500            unanimous_initial: true,
13501            deliberated: false,
13502            changed_votes: 0,
13503            unanimous_final: true,
13504            tie_break: None,
13505            judges: 1,
13506            present: 1,
13507            quorum: 1,
13508            met_quorum: true,
13509            uncontested: None,
13510        });
13511        state.status = RunStatus::Ready;
13512
13513        fold_run(&mut state, false, &crate::run::home())
13514            .await
13515            .expect("fold_run");
13516
13517        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
13518        assert!(
13519            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
13520            "the unmerged winner's branch survives"
13521        );
13522        assert!(
13523            !state.candidates[0].folded,
13524            "the winner is not marked folded"
13525        );
13526
13527        assert!(!wt_b.exists(), "the loser's worktree is removed");
13528        assert!(
13529            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
13530            "the loser's branch is removed"
13531        );
13532        assert!(state.candidates[1].folded, "the loser is marked folded");
13533    }
13534
13535    #[tokio::test]
13536    #[cfg(unix)]
13537    async fn fold_run_waits_for_shared_lock_before_removing_worktrees_branches_or_config() {
13538        use std::os::fd::AsRawFd as _;
13539        use std::time::Duration;
13540
13541        let tmp = tempfile::tempdir().unwrap();
13542        let repo = tmp.path().join("repo");
13543        std::fs::create_dir_all(&repo).unwrap();
13544        init_repo(&repo);
13545        let common = repo.join(".git");
13546        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
13547        let home = tmp.path().join("home");
13548        let mut config = Config::default();
13549        config.coordination = crate::config::test_coordination();
13550        config.graph.worktree_root = Some(tmp.path().join("wt"));
13551        config.graph.timeout_resource_wait = 1;
13552        let mut state = RunState::new(
13553            repo.clone(),
13554            "main".into(),
13555            "deadbeef".into(),
13556            "task".into(),
13557            config,
13558        );
13559        let root = state.worktree_root();
13560        let candidate = root.join("cand-A");
13561        let reviewer = root.join("review-1");
13562        let branch = state.branch_for('A');
13563        git::worktree_add_branch(&repo, &candidate, &branch, "main")
13564            .await
13565            .unwrap();
13566        git::worktree_add_detached(&repo, &reviewer, "main")
13567            .await
13568            .unwrap();
13569        git::acquire_worktree_config(&repo).await.unwrap();
13570        state.enabled_worktree_config = true;
13571        state.status = RunStatus::Ready;
13572        state.candidates.push(Candidate {
13573            index: 0,
13574            label: 'A',
13575            agent: "implement".into(),
13576            branch: branch.clone(),
13577            worktree: candidate.clone(),
13578            summary: String::new(),
13579            stat: String::new(),
13580            files: 0,
13581            commits: 0,
13582            empty: false,
13583            failed: None,
13584            verified_noop: None,
13585            duration_ms: 0,
13586            folded: false,
13587        });
13588        state.save_under(&home).unwrap();
13589        let config_before = std::fs::read(common.join("config")).unwrap();
13590        let worktrees_before = git::git(&repo, &["worktree", "list", "--porcelain"])
13591            .await
13592            .unwrap();
13593        // A separate OS handle bypasses the launcher's local gate, exactly as
13594        // an external updater does, and must still prevent all folding work.
13595        let external = std::fs::File::create(common.join("updater.lock")).unwrap();
13596        assert_eq!(
13597            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
13598            0
13599        );
13600        let mut fold = Box::pin(fold_run(&mut state, true, &home));
13601        assert!(
13602            tokio::time::timeout(Duration::from_millis(200), &mut fold)
13603                .await
13604                .is_err()
13605        );
13606        assert!(candidate.exists());
13607        assert!(reviewer.exists());
13608        assert!(git::branch_exists(&repo, &branch).await.unwrap());
13609        assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
13610        assert_eq!(
13611            git::git(&repo, &["worktree", "list", "--porcelain"])
13612                .await
13613                .unwrap(),
13614            worktrees_before
13615        );
13616        let error = tokio::time::timeout(Duration::from_secs(3), &mut fold)
13617            .await
13618            .unwrap()
13619            .unwrap_err();
13620        assert!(error.is::<crate::repo_lock::ResourceBlocked>());
13621        drop(fold);
13622        assert!(candidate.exists() && reviewer.exists());
13623        assert!(!state.candidates[0].folded && state.enabled_worktree_config);
13624        assert_eq!(
13625            state.resource_block.as_ref().unwrap().kind,
13626            "repository coordination"
13627        );
13628        assert_eq!(std::fs::read(common.join("config")).unwrap(), config_before);
13629        drop(external);
13630        let mut fold = Box::pin(fold_run(&mut state, true, &home));
13631        let removed = tokio::time::timeout(Duration::from_secs(10), &mut fold)
13632            .await
13633            .unwrap()
13634            .unwrap();
13635        drop(fold);
13636        assert!(removed.contains(&branch));
13637        assert!(!root.exists());
13638        assert!(!git::branch_exists(&repo, &branch).await.unwrap());
13639        assert!(state.candidates[0].folded);
13640        assert!(!state.enabled_worktree_config);
13641        assert_ne!(std::fs::read(common.join("config")).unwrap(), config_before);
13642        let enabled = git::git_raw(
13643            &repo,
13644            &["config", "--local", "--get", "extensions.worktreeConfig"],
13645        )
13646        .await
13647        .unwrap();
13648        assert!(!enabled.ok(), "the last config reference was released");
13649    }
13650
13651    /// A branch handed to a later run is that run's (and its pull request's):
13652    /// folding the run that released it must not delete it.
13653    #[tokio::test]
13654    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
13655        let tmp = tempfile::tempdir().expect("tempdir");
13656        let repo = tmp.path().join("repo");
13657        std::fs::create_dir_all(&repo).unwrap();
13658        init_repo(&repo);
13659        let home = tmp.path().join("home");
13660
13661        let mut config = Config::default();
13662        config.graph.worktree_root = Some(tmp.path().join("wt"));
13663        let mut state = RunState::new(
13664            repo.clone(),
13665            "main".to_owned(),
13666            "deadbeef".to_owned(),
13667            "task".to_owned(),
13668            config,
13669        );
13670        // The worktree is already gone (released); the branch survives.
13671        git::git(&repo, &["branch", "magi/x/A", "main"])
13672            .await
13673            .expect("branch");
13674        state.candidates = vec![Candidate {
13675            index: 0,
13676            label: 'A',
13677            agent: "alpha".to_owned(),
13678            branch: "magi/x/A".to_owned(),
13679            worktree: state.worktree_root().join("cand-A"),
13680            summary: String::new(),
13681            stat: String::new(),
13682            files: 0,
13683            commits: 0,
13684            empty: false,
13685            failed: None,
13686            verified_noop: None,
13687            duration_ms: 0,
13688            folded: true,
13689        }];
13690        state.released_to = Some("20260901-000000-new1".to_owned());
13691        state.released_branches = vec!["magi/x/A".to_owned()];
13692
13693        fold_run(&mut state, true, &home).await.expect("fold_run");
13694
13695        assert!(
13696            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
13697            "the handed-over branch survives a fold"
13698        );
13699    }
13700
13701    /// A winner with nothing ahead of the base is caught before `gh` is ever
13702    /// asked for a pull request, and the message carries what the task's
13703    /// references resolved to.
13704    #[tokio::test]
13705    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
13706        let tmp = tempfile::tempdir().expect("tempdir");
13707        let repo = tmp.path().join("repo");
13708        std::fs::create_dir_all(&repo).unwrap();
13709        init_repo(&repo);
13710        let run = |args: &[&str]| {
13711            let out = std::process::Command::new("git")
13712                .quiet()
13713                .args(args)
13714                .current_dir(&repo)
13715                .output()
13716                .expect("spawn git");
13717            assert!(out.status.success(), "git {args:?}");
13718        };
13719        run(&["branch", "magi/x/A"]);
13720        run(&["checkout", "-q", "-b", "magi/x/B"]);
13721        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
13722        run(&["add", "-A"]);
13723        run(&["commit", "-q", "-m", "work"]);
13724        run(&["checkout", "-q", "main"]);
13725
13726        // A pull request is compared against the remote's base, so the
13727        // fixture needs one. Before it exists nothing can be read, and the
13728        // answer must be "not empty".
13729        let probe = RunState::new(
13730            repo.clone(),
13731            "main".to_owned(),
13732            "deadbeef".to_owned(),
13733            "task".to_owned(),
13734            Config::default(),
13735        );
13736        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
13737        let bare = tmp.path().join("origin.git");
13738        let out = std::process::Command::new("git")
13739            .quiet()
13740            .args(["init", "-q", "--bare"])
13741            .arg(&bare)
13742            .output()
13743            .expect("spawn git");
13744        assert!(out.status.success(), "git init --bare");
13745        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
13746        run(&["push", "-q", "origin", "main"]);
13747
13748        let mut state = RunState::new(
13749            repo.clone(),
13750            "main".to_owned(),
13751            "deadbeef".to_owned(),
13752            "task".to_owned(),
13753            Config::default(),
13754        );
13755        state.seeds = vec![refs::Seed {
13756            token: "magi/27b2/A".to_owned(),
13757            kind: refs::SeedKind::Unresolved,
13758            sha: String::new(),
13759            branch: true,
13760            detail: "no branch or commit named magi/27b2/A".to_owned(),
13761        }];
13762
13763        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
13764        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
13765        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
13766        let detail = empty_candidate_detail(&state, "main");
13767        assert!(detail.starts_with("empty candidate"), "{detail}");
13768        assert!(detail.contains("magi/27b2/A"), "{detail}");
13769    }
13770
13771    /// `status == Ready` used to be read as "this is the harmless
13772    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
13773    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
13774    /// run's PR was closed without merging — and reentering `merge` with
13775    /// `mode` still `Pr` does not know the difference, so it pushed and
13776    /// opened a second pull request. `mode == Local` reproduces the same
13777    /// blind spot without a network call: reentry must not attempt another
13778    /// git merge once this node has already recorded an outcome.
13779    #[tokio::test]
13780    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
13781        let tmp = tempfile::tempdir().expect("tempdir");
13782        let repo = tmp.path().join("repo");
13783        std::fs::create_dir_all(&repo).unwrap();
13784        init_repo(&repo);
13785
13786        let mut config = Config::default();
13787        config.merge.mode = MergeMode::Local;
13788
13789        let mut state = RunState::new(
13790            repo.clone(),
13791            "main".to_owned(),
13792            "deadbeef".to_owned(),
13793            "task".to_owned(),
13794            config,
13795        );
13796        state.candidates = vec![Candidate {
13797            index: 0,
13798            label: 'A',
13799            agent: "alpha".to_owned(),
13800            branch: "does-not-exist".to_owned(),
13801            worktree: repo.clone(),
13802            summary: String::new(),
13803            stat: String::new(),
13804            files: 0,
13805            commits: 0,
13806            empty: false,
13807            failed: None,
13808            verified_noop: None,
13809            duration_ms: 0,
13810            folded: false,
13811        }];
13812        state.tally = Some(Tally {
13813            first_choice: BTreeMap::from([('A', 1)]),
13814            borda: BTreeMap::new(),
13815            winner: 'A',
13816            rankings: 1,
13817            unanimous_initial: true,
13818            deliberated: false,
13819            changed_votes: 0,
13820            unanimous_final: true,
13821            tie_break: None,
13822            judges: 0,
13823            present: 0,
13824            quorum: 0,
13825            met_quorum: true,
13826            uncontested: Some("only candidate A produced a change".to_owned()),
13827        });
13828        state.reviews = vec![ReviewRound {
13829            round: 1,
13830            head: "deadbeef".to_owned(),
13831            verified_head: None,
13832            verified_at: None,
13833            reviews: Vec::new(),
13834            e2e: Vec::new(),
13835            fix: None,
13836            blocking: 0,
13837            answered: 0,
13838            expected: 0,
13839            clean: true,
13840            verify_retried: false,
13841            e2e_deferred: false,
13842            e2e_defer_reason: None,
13843            progressed: false,
13844            vote_split: false,
13845            reconsideration: Vec::new(),
13846            verdict: None,
13847        }];
13848        state.gate = vec![CommandOutcome {
13849            command: "test".to_owned(),
13850            code: Some(0),
13851            output_tail: String::new(),
13852            duration_ms: 0,
13853            resource_blocked: false,
13854        }];
13855        state.gate_ran = true;
13856        // Reached its conclusion already — e.g. `land` closing the PR without
13857        // merging it, which (like the honest `MergeMode::None` path) leaves
13858        // `status` at `Ready`. The recorded outcome is what actually marks
13859        // this node done.
13860        state.status = RunStatus::Ready;
13861        state.merge = Some(MergeOutcome {
13862            mode: MergeMode::Local,
13863            ok: false,
13864            detail: "already concluded".to_owned(),
13865            empty: false,
13866        });
13867
13868        let mut runner = Runner {
13869            state,
13870            roles: ResolvedRoles {
13871                implementers: Vec::new(),
13872                judges: Vec::new(),
13873                reviewers: Vec::new(),
13874                fixer: None,
13875                conductor: conductor(),
13876                implementer_roster: Vec::new(),
13877                judge_roster: Vec::new(),
13878                reviewer_roster: Vec::new(),
13879            },
13880            sem: Arc::new(Semaphore::new(1)),
13881            pause: Pause::new(),
13882            interrupt: Pause::new(),
13883        };
13884
13885        runner.merge().await.expect("merge");
13886
13887        assert_eq!(
13888            runner.state.status,
13889            RunStatus::Ready,
13890            "a concluded run's status must not change on reentry"
13891        );
13892        assert_eq!(
13893            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13894            Some("already concluded"),
13895            "merge must not run again once the node already recorded an outcome"
13896        );
13897    }
13898
13899    /// `gate` leaves `state.gate_ran` false both before it has ever run and
13900    /// when its last attempt was resource-blocked (the shared build cache
13901    /// could not be acquired or confirmed fresh in time - see
13902    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
13903    /// `Vec` this also leaves behind used to read as "nothing failed" and let
13904    /// a run merge a tree the gate never actually checked - exactly the case
13905    /// a contended cache produces on every retry until it clears. `merge`
13906    /// must refuse until `gate` has actually recorded an attempt.
13907    #[tokio::test]
13908    async fn merge_refuses_a_gate_that_has_not_actually_run() {
13909        let tmp = tempfile::tempdir().expect("tempdir");
13910        let repo = tmp.path().join("repo");
13911        std::fs::create_dir_all(&repo).unwrap();
13912        init_repo(&repo);
13913
13914        let mut config = Config::default();
13915        config.merge.mode = MergeMode::Local;
13916
13917        let mut state = RunState::new(
13918            repo.clone(),
13919            "main".to_owned(),
13920            "deadbeef".to_owned(),
13921            "task".to_owned(),
13922            config,
13923        );
13924        state.candidates = vec![Candidate {
13925            index: 0,
13926            label: 'A',
13927            agent: "alpha".to_owned(),
13928            branch: "does-not-exist".to_owned(),
13929            worktree: repo.clone(),
13930            summary: String::new(),
13931            stat: String::new(),
13932            files: 0,
13933            commits: 0,
13934            empty: false,
13935            failed: None,
13936            verified_noop: None,
13937            duration_ms: 0,
13938            folded: false,
13939        }];
13940        state.tally = Some(Tally {
13941            first_choice: BTreeMap::from([('A', 1)]),
13942            borda: BTreeMap::new(),
13943            winner: 'A',
13944            rankings: 1,
13945            unanimous_initial: true,
13946            deliberated: false,
13947            changed_votes: 0,
13948            unanimous_final: true,
13949            tie_break: None,
13950            judges: 0,
13951            present: 0,
13952            quorum: 0,
13953            met_quorum: true,
13954            uncontested: Some("only candidate A produced a change".to_owned()),
13955        });
13956        state.reviews = vec![ReviewRound {
13957            round: 1,
13958            head: "deadbeef".to_owned(),
13959            verified_head: None,
13960            verified_at: None,
13961            reviews: Vec::new(),
13962            e2e: Vec::new(),
13963            fix: None,
13964            blocking: 0,
13965            answered: 0,
13966            expected: 0,
13967            clean: true,
13968            verify_retried: false,
13969            e2e_deferred: false,
13970            e2e_defer_reason: None,
13971            progressed: false,
13972            vote_split: false,
13973            reconsideration: Vec::new(),
13974            verdict: None,
13975        }];
13976        // The point: `gate` has not recorded anything yet.
13977        state.gate = Vec::new();
13978        state.gate_ran = false;
13979        state.status = RunStatus::Gating;
13980
13981        let mut runner = Runner {
13982            state,
13983            roles: ResolvedRoles {
13984                implementers: Vec::new(),
13985                judges: Vec::new(),
13986                reviewers: Vec::new(),
13987                fixer: None,
13988                conductor: conductor(),
13989                implementer_roster: Vec::new(),
13990                judge_roster: Vec::new(),
13991                reviewer_roster: Vec::new(),
13992            },
13993            sem: Arc::new(Semaphore::new(1)),
13994            pause: Pause::new(),
13995            interrupt: Pause::new(),
13996        };
13997
13998        runner.merge().await.expect("merge");
13999
14000        assert!(
14001            runner.state.merge.is_none(),
14002            "an empty gate must never be read as a passing one: {:?}",
14003            runner.state.merge
14004        );
14005    }
14006
14007    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
14008    /// commands configured and `merge.mode` is `none` (a review-only run).
14009    /// `gate` must still record a real attempt — zero commands, vacuously
14010    /// passed — rather than leaving `state.gate` empty in a way `merge`
14011    /// cannot tell apart from "never ran"; otherwise the run reaches
14012    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
14013    #[tokio::test]
14014    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
14015        let tmp = tempfile::tempdir().expect("tempdir");
14016        let repo = tmp.path().join("repo");
14017        std::fs::create_dir_all(&repo).unwrap();
14018        init_repo(&repo);
14019
14020        // Default config: `verify.gate` empty, `merge.mode` is `none`.
14021        let config = Config::default();
14022
14023        let mut state = RunState::new(
14024            repo.clone(),
14025            "main".to_owned(),
14026            "deadbeef".to_owned(),
14027            "task".to_owned(),
14028            config,
14029        );
14030        state.candidates = vec![Candidate {
14031            index: 0,
14032            label: 'A',
14033            agent: "alpha".to_owned(),
14034            branch: "does-not-exist".to_owned(),
14035            worktree: repo.clone(),
14036            summary: String::new(),
14037            stat: String::new(),
14038            files: 0,
14039            commits: 0,
14040            empty: false,
14041            failed: None,
14042            verified_noop: None,
14043            duration_ms: 0,
14044            folded: false,
14045        }];
14046        state.tally = Some(Tally {
14047            first_choice: BTreeMap::from([('A', 1)]),
14048            borda: BTreeMap::new(),
14049            winner: 'A',
14050            rankings: 1,
14051            unanimous_initial: true,
14052            deliberated: false,
14053            changed_votes: 0,
14054            unanimous_final: true,
14055            tie_break: None,
14056            judges: 0,
14057            present: 0,
14058            quorum: 0,
14059            met_quorum: true,
14060            uncontested: Some("only candidate A produced a change".to_owned()),
14061        });
14062        state.reviews = vec![ReviewRound {
14063            round: 1,
14064            head: "deadbeef".to_owned(),
14065            verified_head: None,
14066            verified_at: None,
14067            reviews: Vec::new(),
14068            e2e: Vec::new(),
14069            fix: None,
14070            blocking: 0,
14071            answered: 0,
14072            expected: 0,
14073            clean: true,
14074            verify_retried: false,
14075            e2e_deferred: false,
14076            e2e_defer_reason: None,
14077            progressed: false,
14078            vote_split: false,
14079            reconsideration: Vec::new(),
14080            verdict: None,
14081        }];
14082
14083        let mut runner = Runner {
14084            state,
14085            roles: ResolvedRoles {
14086                implementers: Vec::new(),
14087                judges: Vec::new(),
14088                reviewers: Vec::new(),
14089                fixer: None,
14090                conductor: conductor(),
14091                implementer_roster: Vec::new(),
14092                judge_roster: Vec::new(),
14093                reviewer_roster: Vec::new(),
14094            },
14095            sem: Arc::new(Semaphore::new(1)),
14096            pause: Pause::new(),
14097            interrupt: Pause::new(),
14098        };
14099
14100        runner.gate().await.expect("gate");
14101        assert!(
14102            runner.state.gate_ran,
14103            "zero configured commands is still a real attempt, not an unrun gate"
14104        );
14105        assert!(runner.state.gate.is_empty());
14106        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
14107        assert_ne!(
14108            runner.state.status,
14109            RunStatus::Blocked,
14110            "a gate with nothing to check must not read as failed"
14111        );
14112
14113        runner.merge().await.expect("merge");
14114        assert_eq!(
14115            runner.state.status,
14116            RunStatus::Ready,
14117            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
14118        );
14119    }
14120
14121    /// `Config::cache_dir` is derived from `verify.e2e` as well as
14122    /// `verify.gate` (so the e2e leg and the final gate never build against
14123    /// different directories). With zero `verify.gate` commands but a
14124    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
14125    /// that lease before discovering it had nothing to run - so a repo with
14126    /// no gate commands could come back `resource_blocked` (and therefore
14127    /// still `gate_ran == false`) on nothing but an unrelated run holding the
14128    /// cache, exactly the contention this run's own zero commands could
14129    /// never have touched. `gate` must recognise there is nothing to check
14130    /// before it ever asks for the lease.
14131    #[tokio::test]
14132    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
14133        crate::run::pin_test_home();
14134        let home = crate::run::home();
14135
14136        let tmp = tempfile::tempdir().expect("tempdir");
14137        let repo = tmp.path().join("repo");
14138        std::fs::create_dir_all(&repo).unwrap();
14139        init_repo(&repo);
14140        // Unique to this test, so holding its lease cannot collide with
14141        // another test sharing the same process-wide `home`.
14142        let cache_dir = tmp.path().join("target");
14143
14144        let mut config = Config::default();
14145        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
14146        // `verify.gate` stays empty (the default). Bounded so a regression
14147        // that does start waiting fails the test in seconds, not hangs it.
14148        config.graph.timeout_verify = Some(2);
14149        config.graph.timeout_resource_wait = 2;
14150
14151        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14152        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14153            .expect("no io error acquiring directly")
14154        {
14155            crate::cache::AcquireOutcome::Acquired(g) => g,
14156            crate::cache::AcquireOutcome::Busy(b) => {
14157                panic!("expected the direct acquire to win the lease first: {b:?}")
14158            }
14159        };
14160
14161        let mut state = RunState::new(
14162            repo.clone(),
14163            "main".to_owned(),
14164            "deadbeef".to_owned(),
14165            "task".to_owned(),
14166            config,
14167        );
14168        state.candidates = vec![Candidate {
14169            index: 0,
14170            label: 'A',
14171            agent: "alpha".to_owned(),
14172            branch: "does-not-exist".to_owned(),
14173            worktree: repo.clone(),
14174            summary: String::new(),
14175            stat: String::new(),
14176            files: 0,
14177            commits: 0,
14178            empty: false,
14179            failed: None,
14180            verified_noop: None,
14181            duration_ms: 0,
14182            folded: false,
14183        }];
14184        state.tally = Some(Tally {
14185            first_choice: BTreeMap::from([('A', 1)]),
14186            borda: BTreeMap::new(),
14187            winner: 'A',
14188            rankings: 1,
14189            unanimous_initial: true,
14190            deliberated: false,
14191            changed_votes: 0,
14192            unanimous_final: true,
14193            tie_break: None,
14194            judges: 0,
14195            present: 0,
14196            quorum: 0,
14197            met_quorum: true,
14198            uncontested: Some("only candidate A produced a change".to_owned()),
14199        });
14200        state.reviews = vec![ReviewRound {
14201            round: 1,
14202            head: "deadbeef".to_owned(),
14203            verified_head: None,
14204            verified_at: None,
14205            reviews: Vec::new(),
14206            e2e: Vec::new(),
14207            fix: None,
14208            blocking: 0,
14209            answered: 0,
14210            expected: 0,
14211            clean: true,
14212            verify_retried: false,
14213            e2e_deferred: false,
14214            e2e_defer_reason: None,
14215            progressed: false,
14216            vote_split: false,
14217            reconsideration: Vec::new(),
14218            verdict: None,
14219        }];
14220
14221        let mut runner = Runner {
14222            state,
14223            roles: ResolvedRoles {
14224                implementers: Vec::new(),
14225                judges: Vec::new(),
14226                reviewers: Vec::new(),
14227                fixer: None,
14228                conductor: conductor(),
14229                implementer_roster: Vec::new(),
14230                judge_roster: Vec::new(),
14231                reviewer_roster: Vec::new(),
14232            },
14233            sem: Arc::new(Semaphore::new(1)),
14234            pause: Pause::new(),
14235            interrupt: Pause::new(),
14236        };
14237
14238        let started = std::time::Instant::now();
14239        runner.gate().await.expect("gate");
14240        assert!(
14241            started.elapsed() < Duration::from_secs(1),
14242            "a gate with nothing to run must never wait on a lease it never needed"
14243        );
14244        assert!(
14245            runner.state.gate_ran,
14246            "zero commands is still a real, immediate attempt"
14247        );
14248        assert!(runner.state.gate.is_empty());
14249        assert_ne!(
14250            runner.state.status,
14251            RunStatus::Blocked,
14252            "must not read as resource-blocked on a lease it never asked for"
14253        );
14254    }
14255
14256    /// The addendum's second gap: a `verify.gate` command running for real
14257    /// wall-clock time had nothing at all to show for it in `active` before
14258    /// `run_commands` learned to record it — a run could sit in `Gating` for
14259    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
14260    /// what was actually happening. Proven with a genuinely still-running
14261    /// command, not just a before/after check on the final state: a poller
14262    /// task reads the same `run.json` `gate()` is writing, the same way the
14263    /// phone or `magi show` would, while the shell command is still blocked
14264    /// on its own release marker.
14265    #[tokio::test]
14266    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
14267        crate::run::pin_test_home();
14268
14269        let tmp = tempfile::tempdir().expect("tempdir");
14270        let repo = tmp.path().join("repo");
14271        std::fs::create_dir_all(&repo).unwrap();
14272        init_repo(&repo);
14273
14274        let mut config = Config::default();
14275        config.verify.gate = vec![
14276            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
14277             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
14278                .to_owned(),
14279        ];
14280
14281        let mut state = RunState::new(
14282            repo.clone(),
14283            "main".to_owned(),
14284            "deadbeef".to_owned(),
14285            "task".to_owned(),
14286            config,
14287        );
14288        let run_id = state.id.clone();
14289        state.candidates = vec![Candidate {
14290            index: 0,
14291            label: 'A',
14292            agent: "alpha".to_owned(),
14293            branch: "does-not-exist".to_owned(),
14294            worktree: repo.clone(),
14295            summary: String::new(),
14296            stat: String::new(),
14297            files: 0,
14298            commits: 0,
14299            empty: false,
14300            failed: None,
14301            verified_noop: None,
14302            duration_ms: 0,
14303            folded: false,
14304        }];
14305        state.tally = Some(Tally {
14306            first_choice: BTreeMap::from([('A', 1)]),
14307            borda: BTreeMap::new(),
14308            winner: 'A',
14309            rankings: 1,
14310            unanimous_initial: true,
14311            deliberated: false,
14312            changed_votes: 0,
14313            unanimous_final: true,
14314            tie_break: None,
14315            judges: 0,
14316            present: 0,
14317            quorum: 0,
14318            met_quorum: true,
14319            uncontested: Some("only candidate A produced a change".to_owned()),
14320        });
14321        state.reviews = vec![ReviewRound {
14322            round: 1,
14323            head: "deadbeef".to_owned(),
14324            verified_head: None,
14325            verified_at: None,
14326            reviews: Vec::new(),
14327            e2e: Vec::new(),
14328            fix: None,
14329            blocking: 0,
14330            answered: 0,
14331            expected: 0,
14332            clean: true,
14333            verify_retried: false,
14334            e2e_deferred: false,
14335            e2e_defer_reason: None,
14336            progressed: false,
14337            vote_split: false,
14338            reconsideration: Vec::new(),
14339            verdict: None,
14340        }];
14341
14342        let mut runner = Runner {
14343            state,
14344            roles: ResolvedRoles {
14345                implementers: Vec::new(),
14346                judges: Vec::new(),
14347                reviewers: Vec::new(),
14348                fixer: None,
14349                conductor: conductor(),
14350                implementer_roster: Vec::new(),
14351                judge_roster: Vec::new(),
14352                reviewer_roster: Vec::new(),
14353            },
14354            sem: Arc::new(Semaphore::new(1)),
14355            pause: Pause::new(),
14356            interrupt: Pause::new(),
14357        };
14358
14359        let started_marker = repo.join("started.marker");
14360        let release_marker = repo.join("release.marker");
14361        let poller = tokio::spawn(async move {
14362            // Bounded so a regression that never records the task entry
14363            // fails this test in seconds instead of hanging the suite —
14364            // the same shape `a_park_requested_while_a_seat_is_mid_call_
14365            // does_not_cut_it_short` uses for the same reason.
14366            for _ in 0..100 {
14367                if started_marker.exists()
14368                    && let Ok(s) = crate::run::RunState::load(&run_id)
14369                    && let Some(a) = s.active.get("gate")
14370                {
14371                    std::fs::write(&release_marker, b"go").expect("release marker");
14372                    return Some(a.clone());
14373                }
14374                tokio::time::sleep(Duration::from_millis(50)).await;
14375            }
14376            None
14377        });
14378
14379        runner.gate().await.expect("gate");
14380        let captured = poller.await.expect("poller task");
14381        let captured = captured.expect(
14382            "the poller never saw a `gate` task entry in run.json while the command was \
14383             still blocked on its own release marker",
14384        );
14385
14386        assert_eq!(captured.task.as_deref(), Some("gate"));
14387        assert_eq!(captured.node, "gate");
14388        assert_eq!(captured.index, Some(1));
14389        assert_eq!(captured.total, Some(1));
14390        assert!(
14391            captured
14392                .command
14393                .as_deref()
14394                .is_some_and(|c| c.contains("started.marker")),
14395            "{captured:?}"
14396        );
14397
14398        assert!(
14399            runner.state.active.is_empty(),
14400            "the entry must be cleared once the command actually finished: {:?}",
14401            runner.state.active
14402        );
14403        assert!(runner.state.gate_ran);
14404        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
14405    }
14406
14407    /// The hand-off over a blocking finding a reviewer rejected on leaves a
14408    /// record for `land`; one with only a Minor, or no reject, leaves none.
14409    #[tokio::test]
14410    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
14411        use crate::verdict::{Finding, ReviewVote, Severity};
14412        crate::run::pin_test_home();
14413        let tmp = tempfile::tempdir().expect("tempdir");
14414        let repo = tmp.path().join("repo");
14415        std::fs::create_dir_all(&repo).unwrap();
14416        init_repo(&repo);
14417
14418        for (severity, vote, expect) in [
14419            (Severity::Major, ReviewVote::Reject, true),
14420            (Severity::Minor, ReviewVote::Reject, false),
14421            (Severity::Major, ReviewVote::Approve, false),
14422        ] {
14423            let mut round = review_round(false, 1, 1, 1, false, true);
14424            round.reviews = vec![ReviewRecord {
14425                reviewer: 1,
14426                agent: "alpha".to_owned(),
14427                summary: String::new(),
14428                findings: vec![Finding {
14429                    id: "R1-1-1".to_owned(),
14430                    severity,
14431                    file: None,
14432                    line: None,
14433                    title: "t".to_owned(),
14434                    detail: String::new(),
14435                }],
14436                vote: Some(vote),
14437                failed: None,
14438                duration_ms: 0,
14439                attempts: 0,
14440            }];
14441            let mut state = RunState::new(
14442                repo.clone(),
14443                "main".to_owned(),
14444                "deadbeef".to_owned(),
14445                "task".to_owned(),
14446                Config::default(),
14447            );
14448            state.reviews = vec![round];
14449            let mut runner = Runner {
14450                state,
14451                roles: ResolvedRoles {
14452                    implementers: Vec::new(),
14453                    judges: Vec::new(),
14454                    reviewers: Vec::new(),
14455                    fixer: None,
14456                    conductor: conductor(),
14457                    implementer_roster: Vec::new(),
14458                    judge_roster: Vec::new(),
14459                    reviewer_roster: Vec::new(),
14460                },
14461                sem: Arc::new(Semaphore::new(1)),
14462                pause: Pause::new(),
14463                interrupt: Pause::new(),
14464            };
14465            let shell = runner.state.config.shell();
14466            runner
14467                .stop_reviewing("round budget spent", &shell, &repo)
14468                .await
14469                .expect("stop_reviewing");
14470            assert_eq!(runner.state.status, RunStatus::Gating);
14471            assert_eq!(
14472                runner.state.contested_handoff.is_some(),
14473                expect,
14474                "{severity:?} + {vote:?}"
14475            );
14476        }
14477    }
14478
14479    /// The shape the incident this whole fix responds to actually had: the
14480    /// round budget spent, the last round's own e2e blocked on the shared
14481    /// build cache (held here by a live pid — this test process — exactly
14482    /// `cache`'s own unit tests' pattern for "another owner, still alive"
14483    /// without forking a process). `stop_reviewing` must retry it — not
14484    /// silently leave the round looking untouched (the catch-up-only half of
14485    /// the bug), and not read the contention as a red `e2e` and block the
14486    /// run on it (the other half). Called directly, the same way
14487    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
14488    /// above exercises `gate`, so this never needs a real cargo build to
14489    /// reach: the lease is never released, so `with_cache_lease` never gets
14490    /// past acquiring it into anything that would need a real workspace.
14491    #[tokio::test]
14492    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
14493        crate::run::pin_test_home();
14494        let home = crate::run::home();
14495
14496        let tmp = tempfile::tempdir().expect("tempdir");
14497        let repo = tmp.path().join("repo");
14498        std::fs::create_dir_all(&repo).unwrap();
14499        init_repo(&repo);
14500        let head = crate::git::rev_parse(&repo, "HEAD")
14501            .await
14502            .expect("rev-parse");
14503        // Unique to this test, so holding its lease cannot collide with
14504        // another test sharing the same process-wide `home`.
14505        let cache_dir = tmp.path().join("target");
14506
14507        let mut config = Config::default();
14508        config.verify.e2e = vec![format!(
14509            "CARGO_TARGET_DIR='{}' test -f README.md",
14510            cache_dir.display()
14511        )];
14512        config.graph.review_rounds = 1;
14513        // Bounded so a regression that does start waiting fails the test in
14514        // seconds, not hangs it.
14515        config.graph.timeout_verify = Some(2);
14516        config.graph.timeout_resource_wait = 2;
14517
14518        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14519        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14520            .expect("no io error acquiring directly")
14521        {
14522            crate::cache::AcquireOutcome::Acquired(g) => g,
14523            crate::cache::AcquireOutcome::Busy(b) => {
14524                panic!("expected the direct acquire to win the lease first: {b:?}")
14525            }
14526        };
14527
14528        let mut state = RunState::new(
14529            repo.clone(),
14530            "main".to_owned(),
14531            head.clone(),
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        // The round budget's last round, deferred: `needs_catchup_run`'s
14568        // other trigger. `stop_reviewing`'s retry machinery must treat this
14569        // exactly like a resource-blocked attempt once it actually runs.
14570        state.reviews = vec![ReviewRound {
14571            round: 1,
14572            head: head.clone(),
14573            verified_head: None,
14574            verified_at: None,
14575            reviews: Vec::new(),
14576            e2e: Vec::new(),
14577            fix: None,
14578            blocking: 1,
14579            answered: 1,
14580            expected: 1,
14581            clean: false,
14582            verify_retried: false,
14583            e2e_deferred: true,
14584            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
14585            progressed: false,
14586            vote_split: false,
14587            reconsideration: Vec::new(),
14588            verdict: None,
14589        }];
14590
14591        let mut runner = Runner {
14592            state,
14593            roles: ResolvedRoles {
14594                implementers: Vec::new(),
14595                judges: Vec::new(),
14596                reviewers: Vec::new(),
14597                fixer: None,
14598                conductor: conductor(),
14599                implementer_roster: Vec::new(),
14600                judge_roster: Vec::new(),
14601                reviewer_roster: Vec::new(),
14602            },
14603            sem: Arc::new(Semaphore::new(1)),
14604            pause: Pause::new(),
14605            interrupt: Pause::new(),
14606        };
14607
14608        let shell = runner.state.config.shell();
14609        runner
14610            .stop_reviewing("round budget spent", &shell, &repo)
14611            .await
14612            .expect("stop_reviewing");
14613
14614        let last = runner.state.reviews.last().expect("round record");
14615        assert_eq!(
14616            last.e2e_status(),
14617            E2eStatus::ResourceBlocked,
14618            "the shared cache is still held; the attempt must read as blocked, not deferred or \
14619             failed: {last:?}"
14620        );
14621        assert_eq!(
14622            last.verified_head.as_deref(),
14623            Some(head.as_str()),
14624            "which commit this attempt targeted is known even though nothing finished checking \
14625             it"
14626        );
14627        let first_attempt_at = last
14628            .verified_at
14629            .expect("when this attempt ran is known too");
14630        assert_ne!(
14631            runner.state.status,
14632            RunStatus::Blocked,
14633            "contention is evidence about the machine, not the patch — it must not settle the \
14634             run as blocked: {:?}",
14635            runner.state.status
14636        );
14637        assert!(
14638            !runner
14639                .state
14640                .events
14641                .iter()
14642                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
14643            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
14644            runner.state.events
14645        );
14646
14647        // The cache is still held: a later reentry must retry the same
14648        // round's verification again — not leave it looking exactly as
14649        // untouched as the first blocked attempt, which is indistinguishable
14650        // from never having tried again at all.
14651        runner
14652            .stop_reviewing("round budget spent", &shell, &repo)
14653            .await
14654            .expect("stop_reviewing retry");
14655        assert_eq!(
14656            runner.state.reviews.len(),
14657            1,
14658            "no new round was started: {:?}",
14659            runner.state.reviews
14660        );
14661        let last = runner.state.reviews.last().expect("round record");
14662        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
14663        assert!(
14664            last.verified_at.expect("still known") > first_attempt_at,
14665            "a second reentry must be a fresh attempt, not a stale copy of the first"
14666        );
14667        assert_ne!(runner.state.status, RunStatus::Blocked);
14668
14669        held.release();
14670    }
14671
14672    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
14673    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
14674    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
14675    /// own top-of-function fast path (`review_conclusion`) correctly reads
14676    /// this shape as `None` rather than guessing `Blocked`, and the loop's
14677    /// own `for` range is empty once the round budget is spent, so
14678    /// `review_loop` must retry the check itself rather than silently doing
14679    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
14680    /// above exercises directly, but through `review_loop`'s own entry point
14681    /// this time, proving the wiring between the two rather than just the
14682    /// retry logic in isolation.
14683    #[tokio::test]
14684    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
14685        crate::run::pin_test_home();
14686        let home = crate::run::home();
14687
14688        let tmp = tempfile::tempdir().expect("tempdir");
14689        let repo = tmp.path().join("repo");
14690        std::fs::create_dir_all(&repo).unwrap();
14691        init_repo(&repo);
14692        let head = crate::git::rev_parse(&repo, "HEAD")
14693            .await
14694            .expect("rev-parse");
14695        let cache_dir = tmp.path().join("target");
14696
14697        let mut config = Config::default();
14698        config.verify.e2e = vec![format!(
14699            "CARGO_TARGET_DIR='{}' test -f README.md",
14700            cache_dir.display()
14701        )];
14702        config.graph.review_rounds = 1;
14703        config.graph.timeout_verify = Some(2);
14704        config.graph.timeout_resource_wait = 2;
14705
14706        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
14707        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
14708            .expect("no io error acquiring directly")
14709        {
14710            crate::cache::AcquireOutcome::Acquired(g) => g,
14711            crate::cache::AcquireOutcome::Busy(b) => {
14712                panic!("expected the direct acquire to win the lease first: {b:?}")
14713            }
14714        };
14715
14716        let mut state = RunState::new(
14717            repo.clone(),
14718            "main".to_owned(),
14719            head.clone(),
14720            "task".to_owned(),
14721            config,
14722        );
14723        state.candidates = vec![Candidate {
14724            index: 0,
14725            label: 'A',
14726            agent: "alpha".to_owned(),
14727            branch: "does-not-exist".to_owned(),
14728            worktree: repo.clone(),
14729            summary: String::new(),
14730            stat: String::new(),
14731            files: 0,
14732            commits: 0,
14733            empty: false,
14734            failed: None,
14735            verified_noop: None,
14736            duration_ms: 0,
14737            folded: false,
14738        }];
14739        state.tally = Some(Tally {
14740            first_choice: BTreeMap::from([('A', 1)]),
14741            borda: BTreeMap::new(),
14742            winner: 'A',
14743            rankings: 1,
14744            unanimous_initial: true,
14745            deliberated: false,
14746            changed_votes: 0,
14747            unanimous_final: true,
14748            tie_break: None,
14749            judges: 0,
14750            present: 0,
14751            quorum: 0,
14752            met_quorum: true,
14753            uncontested: Some("only candidate A produced a change".to_owned()),
14754        });
14755        // The exact shape a prior process's `stop_reviewing` would have left
14756        // on disk: the round budget's last round, a real attempt already
14757        // made and already resource-blocked.
14758        state.reviews = vec![ReviewRound {
14759            round: 1,
14760            head: head.clone(),
14761            verified_head: Some(head.clone()),
14762            verified_at: Some(jiff::Timestamp::now()),
14763            reviews: Vec::new(),
14764            e2e: vec![CommandOutcome {
14765                command: format!(
14766                    "CARGO_TARGET_DIR='{}' test -f README.md",
14767                    cache_dir.display()
14768                ),
14769                code: None,
14770                output_tail: "waiting for the shared build cache".to_owned(),
14771                duration_ms: 0,
14772                resource_blocked: true,
14773            }],
14774            fix: None,
14775            blocking: 1,
14776            answered: 1,
14777            expected: 1,
14778            clean: false,
14779            verify_retried: false,
14780            e2e_deferred: false,
14781            e2e_defer_reason: None,
14782            progressed: false,
14783            vote_split: false,
14784            reconsideration: Vec::new(),
14785            verdict: None,
14786        }];
14787
14788        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
14789        let mut runner = Runner {
14790            state,
14791            roles: ResolvedRoles {
14792                implementers: Vec::new(),
14793                judges: Vec::new(),
14794                reviewers: Vec::new(),
14795                fixer: None,
14796                conductor: conductor(),
14797                implementer_roster: Vec::new(),
14798                judge_roster: Vec::new(),
14799                reviewer_roster: Vec::new(),
14800            },
14801            sem: Arc::new(Semaphore::new(1)),
14802            pause: Pause::new(),
14803            interrupt: Pause::new(),
14804        };
14805
14806        // The lease is still held throughout, so this reentry's own retry is
14807        // also contended — proving `review_loop` actually tried again (not
14808        // that it happened to succeed) is what the timestamp comparison
14809        // below is for.
14810        runner.review_loop().await.expect("review_loop");
14811
14812        assert_eq!(
14813            runner.state.reviews.len(),
14814            1,
14815            "no new round was started on top of the unresolved one: {:?}",
14816            runner.state.reviews
14817        );
14818        let last = &runner.state.reviews[0];
14819        assert_eq!(
14820            last.e2e_status(),
14821            E2eStatus::ResourceBlocked,
14822            "still contended: {last:?}"
14823        );
14824        assert!(
14825            last.verified_at.expect("still known") > first_attempt_at,
14826            "review_loop must have actually retried the check, not left it exactly as found"
14827        );
14828        assert_ne!(
14829            runner.state.status,
14830            RunStatus::Blocked,
14831            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
14832            runner.state.status
14833        );
14834
14835        held.release();
14836    }
14837
14838    #[tokio::test]
14839    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
14840        crate::run::pin_test_home();
14841        let tmp = tempfile::tempdir().expect("tempdir");
14842        let repo = tmp.path().join("repo");
14843        std::fs::create_dir_all(&repo).unwrap();
14844        init_repo(&repo);
14845
14846        let mut config = Config::default();
14847        config.merge.mode = MergeMode::Pr;
14848        config.graph.land = true;
14849        config.graph.land_approval = false;
14850
14851        let mut state = RunState::new(
14852            repo.clone(),
14853            "main".to_owned(),
14854            "deadbeef".to_owned(),
14855            "task".to_owned(),
14856            config,
14857        );
14858        state.candidates = vec![Candidate {
14859            index: 0,
14860            label: 'A',
14861            agent: "alpha".to_owned(),
14862            branch: "does-not-exist".to_owned(),
14863            worktree: repo.clone(),
14864            summary: String::new(),
14865            stat: String::new(),
14866            files: 0,
14867            commits: 0,
14868            empty: false,
14869            failed: None,
14870            verified_noop: None,
14871            duration_ms: 0,
14872            folded: false,
14873        }];
14874        state.tally = Some(Tally {
14875            first_choice: BTreeMap::from([('A', 1)]),
14876            borda: BTreeMap::new(),
14877            winner: 'A',
14878            rankings: 1,
14879            unanimous_initial: true,
14880            deliberated: false,
14881            changed_votes: 0,
14882            unanimous_final: true,
14883            tie_break: None,
14884            judges: 0,
14885            present: 0,
14886            quorum: 0,
14887            met_quorum: true,
14888            uncontested: Some("only candidate A produced a change".to_owned()),
14889        });
14890        state.reviews = vec![ReviewRound {
14891            round: 1,
14892            head: "deadbeef".to_owned(),
14893            verified_head: None,
14894            verified_at: None,
14895            reviews: Vec::new(),
14896            e2e: Vec::new(),
14897            fix: None,
14898            blocking: 0,
14899            answered: 0,
14900            expected: 0,
14901            clean: true,
14902            verify_retried: false,
14903            e2e_deferred: false,
14904            e2e_defer_reason: None,
14905            progressed: false,
14906            vote_split: false,
14907            reconsideration: Vec::new(),
14908            verdict: None,
14909        }];
14910        state.gate = vec![CommandOutcome {
14911            command: "test".to_owned(),
14912            code: Some(0),
14913            output_tail: String::new(),
14914            duration_ms: 0,
14915            resource_blocked: false,
14916        }];
14917        state.gate_ran = true;
14918        // A first pass through `merge` already pushed and opened this pull
14919        // request; `status` is `Landing` because a previous call into `land`
14920        // parked or was interrupted before it reached a terminal outcome.
14921        state.status = RunStatus::Landing;
14922        state.merge = Some(MergeOutcome {
14923            mode: MergeMode::Pr,
14924            ok: true,
14925            detail: "https://example.invalid/x/y/pull/1".to_owned(),
14926            empty: false,
14927        });
14928
14929        // The Landing-resume shortcut calls `run_land` directly rather than
14930        // through `merge`, which is exactly the call site that used to skip
14931        // `settle_questions` - see the fixture below.
14932        ask_test_home();
14933        // A real resume loads a persisted run; driver ownership is checked
14934        // against that record before the Landing shortcut is entered.
14935        state.save().expect("save landing run");
14936        let store = ask::Questions::open();
14937        let q = ask_open_question(&store, &state.id);
14938
14939        let mut runner = Runner {
14940            state,
14941            roles: ResolvedRoles {
14942                implementers: Vec::new(),
14943                judges: Vec::new(),
14944                reviewers: Vec::new(),
14945                fixer: None,
14946                conductor: conductor(),
14947                implementer_roster: Vec::new(),
14948                judge_roster: Vec::new(),
14949                reviewer_roster: Vec::new(),
14950            },
14951            sem: Arc::new(Semaphore::new(1)),
14952            pause: Pause::new(),
14953            interrupt: Pause::new(),
14954        };
14955
14956        // `execute`, not `merge` directly: the Landing-resume shortcut lives
14957        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
14958        // exactly because `review_loop` would otherwise clobber the marker
14959        // first.
14960        runner.execute().await.expect("execute");
14961
14962        assert_eq!(
14963            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
14964            Some("https://example.invalid/x/y/pull/1"),
14965            "reentry must not push again or open a second pull request over the \
14966             one `land` is already watching"
14967        );
14968        assert_ne!(
14969            runner.state.status,
14970            RunStatus::Landing,
14971            "land could not actually reach the fake pull request, so it must \
14972             have given up rather than left the run silently parked forever"
14973        );
14974        // `land` could not reach the fake pull request, so it gave up into
14975        // `Blocked` - still resumable, so the question must not have been
14976        // swept just because this branch now also calls `settle_questions`.
14977        assert_eq!(runner.state.status, RunStatus::Blocked);
14978        assert!(
14979            store.get(&q.id).unwrap().status.open(),
14980            "Blocked is still alive; settle_questions must have been a no-op here"
14981        );
14982    }
14983
14984    fn state_with_round(round: ReviewRound) -> RunState {
14985        let mut s = RunState::new(
14986            PathBuf::from("/repo"),
14987            "main".to_owned(),
14988            "abc1234".to_owned(),
14989            "add retries".to_owned(),
14990            Config::default(),
14991        );
14992        s.reviews = vec![round];
14993        s
14994    }
14995
14996    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
14997        crate::verdict::Finding {
14998            id: id.to_owned(),
14999            severity,
15000            file: None,
15001            line: None,
15002            title: title.to_owned(),
15003            detail: String::new(),
15004        }
15005    }
15006
15007    #[test]
15008    fn pr_body_names_open_findings_and_declined_ones() {
15009        let round = ReviewRound {
15010            round: 2,
15011            head: "deadbee".to_owned(),
15012            verified_head: None,
15013            verified_at: None,
15014            reviews: vec![ReviewRecord {
15015                attempts: 0,
15016                reviewer: 1,
15017                agent: "alpha".to_owned(),
15018                summary: String::new(),
15019                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
15020                vote: None,
15021                failed: None,
15022                duration_ms: 0,
15023            }],
15024            e2e: vec![CommandOutcome {
15025                command: "cargo test".to_owned(),
15026                code: Some(0),
15027                output_tail: String::new(),
15028                duration_ms: 0,
15029                resource_blocked: false,
15030            }],
15031            verify_retried: false,
15032            e2e_deferred: false,
15033            e2e_defer_reason: None,
15034            fix: Some(FixRecord {
15035                agent: "alpha".to_owned(),
15036                addressed: Vec::new(),
15037                rejected: vec![crate::verdict::Rejection {
15038                    id: "R1-1-1".to_owned(),
15039                    why: "not reachable from any caller".to_owned(),
15040                }],
15041                notes: String::new(),
15042                committed: true,
15043                failed: None,
15044                duration_ms: 0,
15045                continuation: None,
15046            }),
15047            blocking: 0,
15048            answered: 1,
15049            expected: 1,
15050            clean: false,
15051            progressed: true,
15052            vote_split: false,
15053            reconsideration: Vec::new(),
15054            verdict: None,
15055        };
15056        let state = state_with_round(round);
15057        let body = pr_message(&state, 'A').body;
15058
15059        assert!(body.contains("add retries"), "the task must still be there");
15060        assert!(body.contains("R2-1-1"), "{body}");
15061        assert!(!body.contains("unused import"), "{body}");
15062        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
15063        assert!(
15064            !body.contains("not reachable from any caller"),
15065            "the reason it was declined: {body}"
15066        );
15067    }
15068
15069    #[test]
15070    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
15071        let round = ReviewRound {
15072            round: 1,
15073            head: "deadbee".to_owned(),
15074            verified_head: None,
15075            verified_at: None,
15076            reviews: vec![ReviewRecord {
15077                attempts: 0,
15078                reviewer: 1,
15079                agent: "alpha".to_owned(),
15080                summary: String::new(),
15081                findings: Vec::new(),
15082                vote: None,
15083                failed: None,
15084                duration_ms: 0,
15085            }],
15086            e2e: Vec::new(),
15087            verify_retried: false,
15088            e2e_deferred: false,
15089            e2e_defer_reason: None,
15090            fix: None,
15091            blocking: 0,
15092            answered: 1,
15093            expected: 1,
15094            clean: true,
15095            progressed: false,
15096            vote_split: false,
15097            reconsideration: Vec::new(),
15098            verdict: None,
15099        };
15100        let state = state_with_round(round);
15101        let body = pr_message(&state, 'A').body;
15102        assert!(!body.contains("Open review findings"), "{body}");
15103        assert!(!body.contains("Declined"), "{body}");
15104    }
15105
15106    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
15107        let mut state = RunState::new(
15108            PathBuf::from("/repo"),
15109            "main".to_owned(),
15110            "abc1234".to_owned(),
15111            instruction.to_owned(),
15112            Config::default(),
15113        );
15114        state.candidates.push(Candidate {
15115            index: 0,
15116            label: 'A',
15117            agent: "alpha".to_owned(),
15118            branch: "magi/x/A".to_owned(),
15119            worktree: PathBuf::from("/wt"),
15120            summary: summary.to_owned(),
15121            stat: String::new(),
15122            files: 1,
15123            commits: 1,
15124            empty: false,
15125            failed: None,
15126            verified_noop: None,
15127            folded: false,
15128            duration_ms: 0,
15129        });
15130        state
15131    }
15132
15133    fn review_state(subjects: &[&str]) -> RunState {
15134        let mut state = state_with_summary(
15135            "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",
15136            "",
15137        );
15138        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15139        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
15140        state
15141    }
15142
15143    /// A branch rebased onto a main that moved past the recorded
15144    /// `base_commit` is titled from its own first commit, never main's.
15145    #[tokio::test]
15146    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
15147        ask_test_home();
15148        let tmp = tempfile::tempdir().unwrap();
15149        let repo = tmp.path().join("repo");
15150        std::fs::create_dir_all(&repo).unwrap();
15151        init_repo(&repo);
15152        let origin = tmp.path().join("origin.git");
15153        let g = |dir: &Path, args: &[&str]| {
15154            let out = std::process::Command::new("git")
15155                .args(args)
15156                .current_dir(dir)
15157                .quiet()
15158                .output()
15159                .expect("spawn git");
15160            assert!(
15161                out.status.success(),
15162                "git {args:?}: {}",
15163                String::from_utf8_lossy(&out.stderr)
15164            );
15165        };
15166        g(
15167            tmp.path(),
15168            &[
15169                "clone",
15170                "--bare",
15171                "-q",
15172                repo.to_str().unwrap(),
15173                origin.to_str().unwrap(),
15174            ],
15175        );
15176        g(
15177            &repo,
15178            &["remote", "add", "origin", origin.to_str().unwrap()],
15179        );
15180        let c1 = git::rev_parse(&repo, "main").await.unwrap();
15181
15182        // Main moves on; the branch is built on top of the new main.
15183        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
15184        g(&repo, &["add", "-A"]);
15185        g(
15186            &repo,
15187            &["commit", "-q", "-m", "chore(deps): update a crate"],
15188        );
15189        g(&repo, &["push", "-q", "origin", "main"]);
15190        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
15191        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
15192        g(&repo, &["add", "-A"]);
15193        g(
15194            &repo,
15195            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
15196        );
15197        g(&repo, &["checkout", "-q", "main"]);
15198
15199        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
15200        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
15201        // Without a readable tracking ref the recorded base's merge base is used.
15202        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
15203        assert_eq!(fallback, c1);
15204
15205        let mut state = review_state(&[
15206            "chore(deps): update a crate",
15207            "fix(daemon): apply a chosen action",
15208        ]);
15209        state.repo = repo.clone();
15210        state.base_branch = "main".to_owned();
15211        state.base_commit = c1;
15212        refresh_reviewed_commits(&mut state, "feat/own").await;
15213        assert_eq!(
15214            state.reviewed_commits,
15215            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
15216        );
15217        assert_eq!(
15218            review_title(&state).as_deref(),
15219            Some("fix(daemon): apply a chosen action")
15220        );
15221        assert_eq!(
15222            leaked_subjects(&state, "feat/own").await,
15223            Some(vec!["chore(deps): update a crate".to_owned()])
15224        );
15225        // A stale tracking ref is still used when the fetch fails, but the
15226        // leak list is withheld.
15227        state.config.merge.remote = "nowhere".to_owned();
15228        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
15229    }
15230
15231    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
15232    /// with `origin/main` one commit behind it, and a summarizing agent that
15233    /// prints whatever `reply` holds.
15234    struct SummaryFixture {
15235        _tmp: tempfile::TempDir,
15236        repo: PathBuf,
15237        reply: PathBuf,
15238        runner: Runner,
15239    }
15240
15241    fn summary_fixture() -> SummaryFixture {
15242        ask_test_home();
15243        let tmp = tempfile::tempdir().unwrap();
15244        let repo = tmp.path().join("repo");
15245        std::fs::create_dir_all(&repo).unwrap();
15246        init_repo(&repo);
15247        let origin = tmp.path().join("origin.git");
15248        let g = |args: &[&str]| {
15249            let out = std::process::Command::new("git")
15250                .args(args)
15251                .current_dir(&repo)
15252                .quiet()
15253                .output()
15254                .expect("spawn git");
15255            assert!(
15256                out.status.success(),
15257                "git {args:?}: {}",
15258                String::from_utf8_lossy(&out.stderr)
15259            );
15260        };
15261        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
15262        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
15263        g(&["push", "-q", "origin", "main"]);
15264        g(&["checkout", "-q", "-b", "feat/own"]);
15265        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
15266        g(&["add", "-A"]);
15267        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
15268        let reply = tmp.path().join("reply.txt");
15269        std::fs::write(
15270            &reply,
15271            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
15272        )
15273        .unwrap();
15274        let mut runner = runner_at(RunStatus::Gating);
15275        let mut state = review_state(&["feat(own): add the own file"]);
15276        state.repo = repo.clone();
15277        state.base_branch = "main".to_owned();
15278        state.candidates[0].branch = "feat/own".to_owned();
15279        state.candidates[0].worktree = repo.clone();
15280        let mut author = spec("alpha");
15281        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
15282        author
15283            .env
15284            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
15285        state.config.agents = vec![author];
15286        runner.state = state;
15287        SummaryFixture {
15288            _tmp: tmp,
15289            repo,
15290            reply,
15291            runner,
15292        }
15293    }
15294
15295    async fn posted(f: &mut SummaryFixture) -> PrMessage {
15296        let winner = f.runner.state.candidates[0].clone();
15297        f.runner
15298            .guarded_pr_message(&winner, None, true)
15299            .await
15300            .unwrap()
15301            .expect("an English description never parks")
15302    }
15303
15304    fn clean_round_with_minor_findings() -> ReviewRound {
15305        let mut round = passing_round(2, None);
15306        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
15307        round.reviews = vec![ReviewRecord {
15308            attempts: 0,
15309            reviewer: 1,
15310            agent: "alpha".into(),
15311            summary: "内部レビュー".into(),
15312            findings: vec![
15313                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
15314                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
15315            ],
15316            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
15317            failed: None,
15318            duration_ms: 0,
15319        }];
15320        round
15321    }
15322
15323    #[tokio::test]
15324    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
15325        for review_only in [false, true] {
15326            let mut f = summary_fixture();
15327            if !review_only {
15328                f.runner.state.candidates[0].agent = "alpha".into();
15329            }
15330            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
15331            assert!(f.runner.state.reviews[0].clean);
15332            assert!(f.runner.state.reviews[0].fix.is_none());
15333            assert!(f.runner.state.open_findings().is_empty());
15334            assert_eq!(
15335                crate::pr_outcome::required_finding_ids(&f.runner.state),
15336                ["R2-1-1", "R2-1-2"]
15337            );
15338            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15339            let evidence = crate::pr_outcome::summary_evidence(
15340                &f.runner.state,
15341                crate::pr_outcome::FinalHead::At(&head),
15342            );
15343            assert!(
15344                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
15345            );
15346            // The ordinary reply omits both IDs: no cached description is adopted.
15347            let omitted = posted(&mut f).await;
15348            assert!(omitted.body.contains("No description"));
15349            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
15350            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
15351            assert!(f.runner.state.change_summary.is_none());
15352            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();
15353            let described = posted(&mut f).await;
15354            assert!(described.body.contains("improve documentation for callers"));
15355            assert!(described.body.contains("remaining edge case"));
15356            assert!(described.body.contains("## Open review findings"));
15357            assert!(!described.body.contains("未修正"));
15358        }
15359    }
15360
15361    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
15362        let mut f = summary_fixture();
15363        if !review_only {
15364            f.runner.state.candidates[0].agent = "alpha".into();
15365        }
15366        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15367        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
15368        fixed
15369            .fix
15370            .as_mut()
15371            .unwrap()
15372            .rejected
15373            .push(crate::verdict::Rejection {
15374                id: "R1-1-1".into(),
15375                why: "呼び出されないコードのため辞退".into(),
15376            });
15377        let mut clean = clean_round_with_minor_findings();
15378        clean.verified_head = Some(head.clone());
15379        f.runner.state.reviews = vec![fixed, clean];
15380        ok_gate(&mut f.runner.state, Some(&head));
15381        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
15382        f.runner.state.config.agents.push(scripted("beta", reply));
15383        f.runner.state.config.roles.language_judge =
15384            Some(crate::config::AgentChoice::One("beta".into()));
15385        f
15386    }
15387
15388    #[tokio::test]
15389    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
15390        for review_only in [false, true] {
15391            let mut f = rewrite_fixture_with_findings(review_only,
15392                "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;
15393            let m = posted(&mut f).await;
15394            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15395            let expected =
15396                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
15397            assert!(m.body.contains(&expected));
15398            assert!(m.body.contains("## Review rounds"));
15399            assert!(
15400                m.body
15401                    .contains("Gate (`verify.gate`): passed on the final commit")
15402            );
15403            assert!(
15404                m.body
15405                    .contains("Tests (`verify.e2e`): passed on the final commit")
15406            );
15407            let c = f.runner.state.change_summary.as_ref().unwrap();
15408            assert!(
15409                m.body
15410                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
15411            );
15412            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
15413            assert!(crate::pr_outcome::covers_required_findings(
15414                &f.runner.state,
15415                &m.body
15416            ));
15417            assert!(m.body.contains("documentation remains incomplete"));
15418            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
15419            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
15420            assert!(f.runner.state.github_text.is_none());
15421        }
15422    }
15423
15424    #[tokio::test]
15425    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
15426        for review_only in [false, true] {
15427            for reply in [
15428                "TITLE: fix: public description\nA short English description.",
15429                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
15430            ] {
15431                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
15432                let winner = f.runner.state.candidates[0].clone();
15433                let posted = f
15434                    .runner
15435                    .guarded_pr_message(&winner, None, true)
15436                    .await
15437                    .unwrap();
15438                assert!(posted.is_none());
15439                assert!(f.runner.state.parked);
15440                let withheld = f.runner.state.github_text.as_ref().unwrap();
15441                assert!(
15442                    withheld.rewrite.is_none(),
15443                    "an incomplete rewrite must not be selectable"
15444                );
15445                let q = ask::Questions::open()
15446                    .get(withheld.question.as_ref().unwrap())
15447                    .unwrap();
15448                assert!(
15449                    !q.choices
15450                        .contains(&crate::github_text::USE_REWRITE.to_owned())
15451                );
15452                assert!(
15453                    q.detail.contains("R1-1-1")
15454                        && q.detail.contains("R2-1-1")
15455                        && q.detail.contains("R2-1-2")
15456                );
15457            }
15458        }
15459    }
15460
15461    #[tokio::test]
15462    async fn japanese_reports_stay_local_in_both_public_run_modes() {
15463        for review_only in [false, true] {
15464            let mut f = summary_fixture();
15465            if !review_only {
15466                f.runner.state.candidates[0].agent = "alpha".into();
15467            }
15468            f.runner.state.instruction = "内部タスクの説明".into();
15469            f.runner.state.candidates[0].summary = "内部の候補報告".into();
15470            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15471            f.runner.state.reviews = vec![
15472                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
15473                round_with_notes(2, Some("二回目の内部修正報告です。")),
15474                passing_round(3, Some(&head)),
15475            ];
15476            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
15477            ok_gate(&mut f.runner.state, Some(&head));
15478            let captured = f.repo.join("public-prompt.txt");
15479            let author = &mut f.runner.state.config.agents[0];
15480            author.command = vec![
15481                "sh".into(),
15482                "-c".into(),
15483                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
15484            ];
15485            author.env.insert(
15486                "PROMPT_FILE".into(),
15487                captured.to_string_lossy().into_owned(),
15488            );
15489            let m = posted(&mut f).await;
15490            assert!(m.body.contains("Adds `own.txt`"));
15491            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
15492            assert!(m.body.contains("R1-1-1"));
15493            assert!(
15494                m.body
15495                    .contains("Gate (`verify.gate`): passed on the final commit")
15496            );
15497            assert!(
15498                m.body
15499                    .contains("Tests (`verify.e2e`): passed on the final commit")
15500            );
15501            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
15502            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
15503            let prompt = std::fs::read_to_string(captured).unwrap();
15504            assert!(prompt.contains(&format!(
15505                "git diff {} {head}",
15506                f.runner.state.change_summary.as_ref().unwrap().base
15507            )));
15508            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
15509            assert_eq!(
15510                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
15511                "二回目の内部修正報告です。"
15512            );
15513        }
15514    }
15515
15516    #[tokio::test]
15517    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
15518        for review_only in [false, true] {
15519            let mut f = summary_fixture();
15520            if !review_only {
15521                f.runner.state.candidates[0].agent = "alpha".into();
15522            }
15523            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
15524            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
15525            std::fs::write(&f.reply, "no usable description").unwrap();
15526            let failed = posted(&mut f).await;
15527            assert!(
15528                failed
15529                    .body
15530                    .contains("No description of what this change does could be generated")
15531            );
15532            assert!(failed.body.is_ascii());
15533            assert!(f.runner.state.github_text.is_none());
15534            assert!(f.runner.state.change_summary.is_none());
15535            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15536            let evidence = crate::pr_outcome::summary_evidence(
15537                &f.runner.state,
15538                crate::pr_outcome::FinalHead::At(&head),
15539            );
15540            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
15541            assert!(
15542                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
15543                    .exists()
15544            );
15545            f.runner.state =
15546                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
15547            std::fs::write(
15548                &f.reply,
15549                "TITLE: feat(own): add the own file\nAdds the committed own file.",
15550            )
15551            .unwrap();
15552            let retried = posted(&mut f).await;
15553            assert!(retried.body.contains("Adds the committed own file."));
15554            assert_eq!(
15555                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
15556                "日本語の修正報告"
15557            );
15558        }
15559    }
15560
15561    #[tokio::test]
15562    async fn same_head_verification_and_findings_invalidate_public_prose() {
15563        let mut f = summary_fixture();
15564        posted(&mut f).await;
15565        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15566        let original = f
15567            .runner
15568            .state
15569            .change_summary
15570            .as_ref()
15571            .unwrap()
15572            .evidence
15573            .clone();
15574        ok_gate(&mut f.runner.state, Some(&head));
15575        std::fs::write(
15576            &f.reply,
15577            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
15578        )
15579        .unwrap();
15580        let verified = posted(&mut f).await;
15581        assert!(verified.body.contains("now verified"));
15582        assert_ne!(
15583            f.runner.state.change_summary.as_ref().unwrap().evidence,
15584            original
15585        );
15586        let mut round = round_with_notes(1, Some("内部報告"));
15587        round.reviews.push(ReviewRecord {
15588            attempts: 0,
15589            reviewer: 1,
15590            agent: "alpha".into(),
15591            summary: "内部レビュー".into(),
15592            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
15593            vote: None,
15594            failed: None,
15595            duration_ms: 0,
15596        });
15597        round
15598            .fix
15599            .as_mut()
15600            .unwrap()
15601            .rejected
15602            .push(crate::verdict::Rejection {
15603                id: "R1-1-1".into(),
15604                why: "呼び出し元から到達できない".into(),
15605            });
15606        f.runner.state.reviews = vec![round];
15607        // A description omitting the required ID is unusable, even with a title.
15608        let omitted = posted(&mut f).await;
15609        assert!(omitted.body.contains("No description"));
15610        assert!(omitted.body.contains("R1-1-1"));
15611        assert!(!omitted.body.contains("到達"));
15612        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();
15613        let described = posted(&mut f).await;
15614        assert!(described.body.contains("unreachable from callers"));
15615        assert!(described.body.contains("R1-1-1"));
15616        assert!(!described.body.contains("到達"));
15617        let evidence = f
15618            .runner
15619            .state
15620            .change_summary
15621            .as_ref()
15622            .unwrap()
15623            .evidence
15624            .clone();
15625        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
15626        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
15627        assert!(posted(&mut f).await.body.contains("rationale updated"));
15628        assert_ne!(
15629            f.runner.state.change_summary.as_ref().unwrap().evidence,
15630            evidence
15631        );
15632        // Old serialized summaries have no evidence fingerprint and must regenerate.
15633        f.runner
15634            .state
15635            .change_summary
15636            .as_mut()
15637            .unwrap()
15638            .evidence
15639            .clear();
15640        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
15641        assert!(posted(&mut f).await.body.contains("current specification"));
15642    }
15643
15644    #[tokio::test]
15645    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
15646        let mut f = summary_fixture();
15647        let m = posted(&mut f).await;
15648        assert_eq!(m.title, "feat(own): add the own file");
15649        assert!(
15650            m.body
15651                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
15652            "{}",
15653            m.body
15654        );
15655        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
15656        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
15657        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
15658        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
15659        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
15660        let kept = f.runner.state.change_summary.as_ref().unwrap();
15661        assert_eq!(
15662            (kept.head.as_str(), kept.base.as_str()),
15663            (head.as_str(), base.as_str())
15664        );
15665        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
15666    }
15667
15668    #[tokio::test]
15669    async fn review_only_summary_receives_the_configured_github_language() {
15670        let mut f = summary_fixture();
15671        f.runner.state.config.graph.github_language = "ja".into();
15672        let captured = f.repo.join("summary-prompt.txt");
15673        let author = &mut f.runner.state.config.agents[0];
15674        author.command = vec![
15675            "sh".into(),
15676            "-c".into(),
15677            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
15678        ];
15679        author.env.insert(
15680            "PROMPT_FILE".into(),
15681            captured.to_string_lossy().into_owned(),
15682        );
15683        std::fs::write(
15684            &f.reply,
15685            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
15686        )
15687        .unwrap();
15688        let m = posted(&mut f).await;
15689        let prompt = std::fs::read_to_string(captured).unwrap();
15690        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
15691        assert!(!prompt.contains("in English"));
15692        assert_eq!(m.title, "feat(own): ファイルを追加");
15693        assert!(m.body.contains("own.txt を追加します。"));
15694    }
15695
15696    #[tokio::test]
15697    async fn review_fixes_come_after_the_whole_change_summary() {
15698        let mut f = summary_fixture();
15699        f.runner.state.reviews = vec![
15700            round_with_notes(1, Some("handled the PATH case")),
15701            round_with_notes(2, None),
15702        ];
15703        let m = posted(&mut f).await;
15704        let summary = m.body.find("## Summary").expect("summary");
15705        let fixes = m.body.find("## Review rounds").expect("review rounds");
15706        assert!(summary < fixes, "{}", m.body);
15707        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
15708        assert!(m.body.contains("## Final verification"), "{}", m.body);
15709    }
15710
15711    #[tokio::test]
15712    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
15713        let mut f = summary_fixture();
15714        let first = posted(&mut f).await;
15715        let first_head = f.runner.state.change_summary.clone().unwrap().head;
15716        // The same head reuses the record: a different reply is not read.
15717        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
15718        let again = posted(&mut f).await;
15719        assert_eq!((again.title, again.body), (first.title, first.body));
15720
15721        let g = |args: &[&str]| {
15722            let out = std::process::Command::new("git")
15723                .args(args)
15724                .current_dir(&f.repo)
15725                .quiet()
15726                .output()
15727                .expect("spawn git");
15728            assert!(out.status.success(), "git {args:?}");
15729        };
15730        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
15731        g(&["add", "-A"]);
15732        g(&["commit", "-q", "-m", "fix(own): handle more"]);
15733        let second = posted(&mut f).await;
15734        let kept = f.runner.state.change_summary.clone().unwrap();
15735        assert_ne!(kept.head, first_head);
15736        assert!(second.body.contains("Not read yet."), "{}", second.body);
15737        assert!(!second.body.contains("so the first summary is read"));
15738        assert_eq!(second.title, "feat(own): other");
15739    }
15740
15741    #[tokio::test]
15742    async fn an_unavailable_summary_is_stated_and_never_invented() {
15743        let mut f = summary_fixture();
15744        std::fs::write(&f.reply, "no title line here").unwrap();
15745        let m = posted(&mut f).await;
15746        assert!(
15747            m.body
15748                .contains("No description of what this change does could be generated"),
15749            "{}",
15750            m.body
15751        );
15752        assert!(m.body.contains("internal reports remain in local records"));
15753        assert!(f.runner.state.change_summary.is_none());
15754        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
15755        // A preview asks nobody and claims nothing.
15756        let winner = f.runner.state.candidates[0].clone();
15757        let preview = f
15758            .runner
15759            .guarded_pr_message(&winner, None, false)
15760            .await
15761            .unwrap()
15762            .unwrap();
15763        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
15764    }
15765
15766    #[test]
15767    fn pr_message_review_single_commit_uses_its_subject() {
15768        let state = review_state(&["feat(nats): per-role user"]);
15769        let m = pr_message(&state, 'A');
15770        assert_eq!(m.title, "feat(nats): per-role user");
15771        assert!(!m.body.contains("Review the work already"), "{}", m.body);
15772        assert!(m.body.contains("## Commits under review"), "{}", m.body);
15773    }
15774
15775    #[test]
15776    fn pr_message_review_multi_commit_takes_the_oldest() {
15777        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
15778        let m = pr_message(&state, 'A');
15779        assert_eq!(m.title, "feat: the change");
15780        for s in ["feat: the change", "fix: typo", "fix: again"] {
15781            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
15782        }
15783    }
15784
15785    #[test]
15786    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
15787        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
15788            let mut state = review_state(&[first, "fix: later fixup"]);
15789            state.candidates[0].branch = "機能/ログイン".to_owned();
15790            let m = pr_message(&state, 'A');
15791            assert!(
15792                m.title.starts_with("chore: land candidate A of run"),
15793                "{}",
15794                m.title
15795            );
15796        }
15797    }
15798
15799    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
15800        BranchFacts {
15801            commits: commits
15802                .iter()
15803                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
15804                .collect(),
15805            stat: stat.to_owned(),
15806        }
15807    }
15808
15809    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
15810        let mut r = review_round(true, 0, 1, 1, true, true);
15811        r.round = round;
15812        r.fix = notes.map(|n| FixRecord {
15813            agent: "fixer".to_owned(),
15814            addressed: Vec::new(),
15815            rejected: Vec::new(),
15816            notes: n.to_owned(),
15817            committed: true,
15818            failed: None,
15819            duration_ms: 0,
15820            continuation: None,
15821        });
15822        r
15823    }
15824
15825    #[test]
15826    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
15827        let state = review_state(&["ignored"]);
15828        let f = facts(
15829            &[
15830                (
15831                    "fix(login): resolve PATH on macOS",
15832                    "Login shells skip rc files.",
15833                ),
15834                ("fix: address review", ""),
15835            ],
15836            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
15837        );
15838        let m = pr_message_with(&state, 'A', Some(&f));
15839        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
15840        assert!(
15841            m.body
15842                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
15843            "{}",
15844            m.body
15845        );
15846        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
15847        assert!(m.body.contains("## Diff stat"), "{}", m.body);
15848        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
15849        for banned in [
15850            "Review the work already",
15851            "no task statement",
15852            "Original task",
15853        ] {
15854            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
15855        }
15856        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
15857    }
15858
15859    #[test]
15860    fn pr_message_review_truncates_a_huge_first_commit_body() {
15861        let state = review_state(&["ignored"]);
15862        let f = facts(
15863            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
15864            "s",
15865        );
15866        let m = pr_message_with(&state, 'A', Some(&f));
15867        assert!(m.body.len() < 30_000, "{}", m.body.len());
15868        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
15869        assert!(
15870            m.body.contains("- ... 1 more commit(s)"),
15871            "{}",
15872            m.body.len()
15873        );
15874        assert!(m.body.ends_with("magi:candidate-a\n"));
15875    }
15876
15877    #[test]
15878    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
15879        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
15880        assert_eq!(m.title, "feat: x");
15881        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
15882        assert!(!m.body.contains("Diff stat"), "{}", m.body);
15883        assert!(!m.body.contains("no task statement"), "{}", m.body);
15884    }
15885
15886    #[test]
15887    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
15888        let mut state = review_state(&["日本語の件名"]);
15889        state.candidates[0].branch = "fix/macos-login-path".to_owned();
15890        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
15891        state.candidates[0].branch = "機能/ログイン".to_owned();
15892        assert!(
15893            pr_message(&state, 'A')
15894                .title
15895                .starts_with("chore: land candidate A")
15896        );
15897    }
15898
15899    #[test]
15900    fn pr_message_review_fixes_survive_a_clean_final_round() {
15901        let mut state = review_state(&["feat: x"]);
15902        state.reviews = vec![
15903            round_with_notes(1, Some("handled the PATH case")),
15904            round_with_notes(2, None),
15905        ];
15906        let body = pr_message(&state, 'A').body;
15907        assert!(
15908            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
15909            "{body}"
15910        );
15911        assert!(body.contains("### Round 1"), "{body}");
15912
15913        state.reviews = vec![
15914            round_with_notes(1, Some("first fix")),
15915            round_with_notes(2, Some("")),
15916            round_with_notes(3, Some("second fix")),
15917            round_with_notes(4, None),
15918        ];
15919        let body = pr_message(&state, 'A').body;
15920        assert!(
15921            body.contains("### Round 1") && !body.contains("first fix"),
15922            "{body}"
15923        );
15924        assert!(
15925            body.contains("### Round 3") && !body.contains("second fix"),
15926            "{body}"
15927        );
15928        assert!(body.contains("### Round 2"), "{body}");
15929    }
15930
15931    const FINAL: &str = "abcdef1234567890";
15932
15933    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
15934        let mut r = round_with_notes(round, None);
15935        r.verified_head = verified.map(str::to_owned);
15936        r
15937    }
15938
15939    fn ok_gate(state: &mut RunState, head: Option<&str>) {
15940        state.gate = vec![CommandOutcome {
15941            command: "gate".to_owned(),
15942            code: Some(0),
15943            output_tail: String::new(),
15944            duration_ms: 0,
15945            resource_blocked: false,
15946        }];
15947        state.gate_ran = true;
15948        state.gate_head = head.map(str::to_owned);
15949    }
15950
15951    fn body_at(state: &RunState) -> String {
15952        pr_message_raw(
15953            state,
15954            'A',
15955            None,
15956            crate::pr_outcome::FinalHead::At(FINAL),
15957            ChangeNote::NotAsked,
15958        )
15959        .body
15960    }
15961
15962    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
15963
15964    #[test]
15965    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
15966        let mut state = review_state(&["feat: x"]);
15967        state.reviews = vec![
15968            round_with_notes(1, Some(SANDBOXED)),
15969            passing_round(2, Some(FINAL)),
15970        ];
15971        ok_gate(&mut state, Some(FINAL));
15972        let body = body_at(&state);
15973        assert!(!body.contains("Handled the PATH case."), "{body}");
15974        assert!(!body.contains("could not commit"), "{body}");
15975        assert!(!body.contains("could not run"), "{body}");
15976        assert!(
15977            body.contains("The fix is committed on the branch"),
15978            "{body}"
15979        );
15980        assert!(!body.contains("sandbox"), "{body}");
15981        assert!(
15982            body.contains("Tests (`verify.e2e`): passed on the final commit"),
15983            "{body}"
15984        );
15985        assert!(
15986            body.contains("Gate (`verify.gate`): passed on the final commit"),
15987            "{body}"
15988        );
15989        // The raw report is untouched in the record.
15990        assert!(
15991            state.reviews[0]
15992                .fix
15993                .as_ref()
15994                .unwrap()
15995                .notes
15996                .contains("could not commit")
15997        );
15998    }
15999
16000    #[test]
16001    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
16002        let mut state = review_state(&["feat: x"]);
16003        let mut r1 = round_with_notes(1, Some(SANDBOXED));
16004        r1.fix.as_mut().unwrap().committed = false;
16005        let mut r2 = passing_round(2, Some(FINAL));
16006        r2.e2e[0].code = Some(1);
16007        state.reviews = vec![r1, r2];
16008        let body = body_at(&state);
16009        assert!(!body.contains("could not commit"), "{body}");
16010        assert!(!body.contains("could not run"), "{body}");
16011        assert!(body.contains("This round produced no commit"), "{body}");
16012        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
16013        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
16014        assert!(!body.contains("passed"), "{body}");
16015    }
16016
16017    #[test]
16018    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
16019        let mut state = review_state(&["feat: x"]);
16020        state.reviews = vec![
16021            round_with_notes(1, Some(SANDBOXED)),
16022            passing_round(2, Some("0123456789abcdef")),
16023        ];
16024        ok_gate(&mut state, Some("0123456789abcdef"));
16025        let body = body_at(&state);
16026        assert!(!body.contains("HTTP tests could not run"), "{body}");
16027        // The commit claim is still settled by the structured record.
16028        assert!(!body.contains("could not commit"), "{body}");
16029        assert!(
16030            body.contains("passed on an earlier commit (`0123456`)"),
16031            "{body}"
16032        );
16033        assert!(!body.contains("passed on the final commit"), "{body}");
16034
16035        // A gate recorded before `gate_head` existed is "not recorded".
16036        ok_gate(&mut state, None);
16037        let body = body_at(&state);
16038        assert!(
16039            body.contains("passed on a commit that was not recorded"),
16040            "{body}"
16041        );
16042    }
16043
16044    #[test]
16045    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
16046        let state = state_with_summary(
16047            "add retries to the client",
16048            "TITLE: feat: retries\n\nDid it.",
16049        );
16050        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
16051        assert_eq!(m.title, "feat: retries");
16052        assert!(
16053            m.body.contains("<summary>Original task</summary>"),
16054            "{}",
16055            m.body
16056        );
16057        assert!(!m.body.contains("Commits under review"), "{}", m.body);
16058        assert!(
16059            m.body
16060                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
16061            "{}",
16062            m.body
16063        );
16064    }
16065
16066    #[test]
16067    fn pr_message_review_bounds_a_long_english_subject() {
16068        let long = format!("feat: {}", "word ".repeat(100));
16069        let m = pr_message(&review_state(&[&long]), 'A');
16070        assert!(m.title.starts_with("feat: word"), "{}", m.title);
16071        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16072    }
16073
16074    #[test]
16075    fn pr_message_implementation_run_is_unchanged_by_review_support() {
16076        let state = state_with_summary("add retries\n\ndetails", "- did some things");
16077        let m = pr_message(&state, 'A');
16078        assert_eq!(m.title, "add retries");
16079        assert!(m.body.contains("<summary>Original task</summary>"));
16080        assert!(!m.body.contains("Commits under review"));
16081        assert_eq!(landing_subject_source(&state), state.instruction);
16082    }
16083
16084    #[test]
16085    fn review_run_squash_subject_is_the_change_not_the_prompt() {
16086        let state = review_state(&["feat: the change", "fix: typo"]);
16087        let source = landing_subject_source(&state);
16088        assert_eq!(land::merge_subject("", &source), "feat: the change");
16089        assert_eq!(
16090            land::merge_subject("magi: candidate A (uncommitted work)", &source),
16091            "feat: the change"
16092        );
16093        // An operator's rename still wins.
16094        assert_eq!(
16095            land::merge_subject("feat: renamed by hand", &source),
16096            "feat: renamed by hand"
16097        );
16098        let blank = review_state(&["日本語"]);
16099        assert!(
16100            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
16101        );
16102    }
16103
16104    #[test]
16105    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
16106        let state = review_state(&["feat: the change"]);
16107        let source = landing_subject_source(&state);
16108        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
16109        assert_eq!(
16110            land::merge_subject(landing_title(&state, old), &source),
16111            "feat: the change"
16112        );
16113        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
16114        let task = state_with_summary("add retries", "");
16115        assert_eq!(landing_title(&task, old), old);
16116    }
16117
16118    #[test]
16119    fn pr_message_describes_the_change_not_the_task() {
16120        let state = state_with_summary(
16121            "今回やってほしいこと: results projector を直す",
16122            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
16123        );
16124        let m = pr_message(&state, 'A');
16125        assert_eq!(m.title, "fix(web): batch the runs list reads");
16126        assert!(
16127            m.body.starts_with("## Summary\n\n- reads run.json once"),
16128            "{}",
16129            m.body
16130        );
16131        assert!(!m.body.contains("TITLE:"), "{}", m.body);
16132        let task_at = m.body.find("今回やってほしいこと").unwrap();
16133        let details_at = m.body.find("<details>").unwrap();
16134        assert!(
16135            details_at < task_at,
16136            "the task lives inside <details>: {}",
16137            m.body
16138        );
16139        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
16140        assert!(m.body.contains("magi:candidate-a"));
16141    }
16142
16143    #[test]
16144    fn pr_message_falls_back_to_the_task_without_a_title_line() {
16145        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
16146        let m = pr_message(&state, 'A');
16147        assert_eq!(m.title, "add retries");
16148        assert!(
16149            m.body.contains("## Summary\n\n- did some things"),
16150            "{}",
16151            m.body
16152        );
16153
16154        let none = RunState::new(
16155            PathBuf::from("/repo"),
16156            "main".to_owned(),
16157            "abc1234".to_owned(),
16158            "add retries".to_owned(),
16159            Config::default(),
16160        );
16161        let m = pr_message(&none, 'A');
16162        assert_eq!(m.title, "add retries");
16163        assert!(!m.body.contains("## Summary"), "{}", m.body);
16164    }
16165
16166    #[test]
16167    fn pr_message_refuses_the_candidate_commit_subject() {
16168        for bad in [
16169            "TITLE: magi: candidate A (uncommitted work)",
16170            "TITLE: chore: stuff (uncommitted work)",
16171            "TITLE:   ",
16172        ] {
16173            let state = state_with_summary("add retries", bad);
16174            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
16175        }
16176    }
16177
16178    #[test]
16179    fn pr_message_bounds_a_very_long_task_and_title() {
16180        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
16181        let state = state_with_summary(&long, "- nothing");
16182        let m = pr_message(&state, 'A');
16183        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16184        assert!(!m.title.contains('\n'));
16185
16186        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
16187        let m = pr_message(&state, 'A');
16188        assert!(m.title.starts_with("feat: "));
16189        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
16190        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
16191    }
16192
16193    fn long_title_of(instruction: &str) -> String {
16194        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
16195    }
16196
16197    #[test]
16198    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
16199        let first = "Make the landing path keep a readable title for long tasks";
16200        let line = format!(
16201            "{first}. {}",
16202            "And then keep going with more words ".repeat(20)
16203        );
16204        let t = long_title_of(&line);
16205        assert_eq!(t, first);
16206        assert!(!t.starts_with("chore: land"));
16207    }
16208
16209    #[test]
16210    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
16211        let line = "word ".repeat(200);
16212        let t = long_title_of(&line);
16213        assert!(t.ends_with("word..."), "{t}");
16214        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
16215    }
16216
16217    #[test]
16218    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
16219        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
16220            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
16221        }
16222    }
16223
16224    #[test]
16225    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
16226        let mut state = state_with_summary("再試行を追加する", "- nothing");
16227        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
16228        state.config.graph.github_language = "ja".into();
16229        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
16230    }
16231
16232    #[test]
16233    fn pr_message_title_limit_is_exact() {
16234        let at = "a".repeat(PR_TITLE_MAX);
16235        assert_eq!(long_title_of(&at), at);
16236        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
16237        assert!(over.ends_with("..."), "{over}");
16238        assert_eq!(over.chars().count(), PR_TITLE_MAX);
16239    }
16240
16241    #[test]
16242    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
16243        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
16244        let t = long_title_of(&line);
16245        assert!(t.ends_with("..."), "{t}");
16246        assert!(t.is_ascii(), "{t}");
16247    }
16248
16249    #[test]
16250    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
16251        let line = format!(
16252            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
16253            "plus more filler words ".repeat(20)
16254        );
16255        let t = long_title_of(&line);
16256        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
16257    }
16258
16259    #[test]
16260    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
16261        let line = format!("\n\n# {}", "title words ".repeat(40));
16262        let state = state_with_summary(&line, "- nothing");
16263        let m = pr_message(&state, 'A');
16264        assert!(m.title.starts_with("title words"), "{}", m.title);
16265        assert_eq!(
16266            land::merge_subject(&m.title, &landing_subject_source(&state)),
16267            m.title
16268        );
16269        // An operator's rename wins untouched.
16270        assert_eq!(
16271            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
16272            "feat: renamed by hand"
16273        );
16274    }
16275
16276    #[tokio::test]
16277    async fn github_text_rewrite_is_bounded_and_falls_back() {
16278        crate::run::pin_test_home();
16279        for (reply, accepted) in [
16280            (
16281                "TITLE: fix: retries\nAdd retries for failed requests.",
16282                true,
16283            ),
16284            (
16285                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
16286                false,
16287            ),
16288            (
16289                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
16290                true,
16291            ),
16292            (
16293                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
16294                true,
16295            ),
16296            (
16297                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
16298                true,
16299            ),
16300            (
16301                "TITLE: fix: retries\n日本語の説明です。 token=secret",
16302                false,
16303            ),
16304            ("TITLE: fix: retries\n", false),
16305        ] {
16306            let dir = tempfile::tempdir().unwrap();
16307            let mut runner = runner_at(RunStatus::Gating);
16308            runner.state = state_with_summary(
16309                "add retries",
16310                "TITLE: fix: retries\n日本語の説明を書きます。",
16311            );
16312            runner.state.repo = dir.path().to_owned();
16313            runner.state.candidates[0].worktree = dir.path().to_owned();
16314            let mut author = spec("alpha");
16315            author.command = vec![
16316                "sh".into(),
16317                "-c".into(),
16318                "printf '%s' \"$REWRITE_REPLY\"".into(),
16319            ];
16320            author.env.insert("REWRITE_REPLY".into(), reply.into());
16321            runner.state.config.agents = vec![author];
16322            seed_public_summary(&mut runner);
16323            let winner = runner.state.candidates[0].clone();
16324            let first = runner
16325                .guarded_pr_message(&winner, None, true)
16326                .await
16327                .unwrap();
16328            let message = if accepted {
16329                first.expect("a passing rewrite never asks")
16330            } else {
16331                // Withheld: the run parks on a question and posts nothing yet.
16332                assert!(first.is_none());
16333                assert!(runner.state.parked);
16334                let store = ask::Questions::open();
16335                let asked: Vec<_> = store
16336                    .list()
16337                    .into_iter()
16338                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
16339                    .collect();
16340                assert_eq!(asked.len(), 1);
16341                assert!(!asked[0].detail.contains("secret"));
16342                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
16343                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
16344                assert_eq!(
16345                    asked[0]
16346                        .choices
16347                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
16348                    rewrite.is_some()
16349                );
16350                // Silence: the question lapses and the neutral text is used,
16351                // without a second rewrite call or a second question.
16352                store
16353                    .update(&asked[0].id, |q| {
16354                        q.abandon("test");
16355                        Ok(())
16356                    })
16357                    .unwrap();
16358                let again = runner
16359                    .guarded_pr_message(&winner, None, true)
16360                    .await
16361                    .unwrap()
16362                    .expect("an abandoned question falls back");
16363                let n = store
16364                    .list()
16365                    .into_iter()
16366                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
16367                    .count();
16368                assert_eq!(n, 1, "asked once per rejected text");
16369                again
16370            };
16371            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
16372            assert!(!message.body.contains("secret"));
16373            assert!(!message.body.contains("prefix"));
16374            assert!(!message.title.contains("secret"));
16375            assert_eq!(
16376                message.body.contains("Add retries for failed requests."),
16377                accepted
16378            );
16379            assert_eq!(
16380                runner.state.seats["github-text-rewrite"].turns, 1,
16381                "only one rewrite invocation"
16382            );
16383            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
16384            if accepted {
16385                assert!(runner.state.github_text.is_none());
16386                assert!(!runner.state.parked);
16387                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
16388                assert!(message.body.contains("## Final verification"));
16389                assert!(message.body.contains("Evidence: final diff"));
16390                assert!(
16391                    message
16392                        .body
16393                        .contains(&format!("magi:run/{}", runner.state.id))
16394                );
16395            }
16396            if !accepted {
16397                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
16398                assert!(
16399                    message
16400                        .body
16401                        .contains(&format!("magi:run/{}", runner.state.id))
16402                );
16403            }
16404        }
16405    }
16406
16407    fn scripted(id: &str, reply: &str) -> AgentSpec {
16408        let mut a = spec(id);
16409        a.command = vec![
16410            "sh".into(),
16411            "-c".into(),
16412            "printf '%s' \"$REWRITE_REPLY\"".into(),
16413        ];
16414        a.env.insert("REWRITE_REPLY".into(), reply.into());
16415        a
16416    }
16417
16418    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
16419    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
16420
16421    /// Gate tests inject generated public output, not internal reports.
16422    fn seed_public_summary(runner: &mut Runner) {
16423        let repo = &runner.state.repo;
16424        let g = |args: &[&str]| {
16425            let out = std::process::Command::new("git")
16426                .args(args)
16427                .current_dir(repo)
16428                .quiet()
16429                .output()
16430                .unwrap();
16431            assert!(out.status.success(), "git {args:?}");
16432            String::from_utf8_lossy(&out.stdout).trim().to_owned()
16433        };
16434        g(&["init", "-q", "-b", "main"]);
16435        g(&[
16436            "-c",
16437            "user.name=Test",
16438            "-c",
16439            "user.email=test@example.invalid",
16440            "commit",
16441            "-q",
16442            "--allow-empty",
16443            "-m",
16444            "feat: fixture",
16445        ]);
16446        let head = g(&["rev-parse", "HEAD"]);
16447        g(&["update-ref", "refs/remotes/origin/main", &head]);
16448        let evidence = crate::pr_outcome::summary_evidence(
16449            &runner.state,
16450            crate::pr_outcome::FinalHead::At(&head),
16451        );
16452        let generated = &runner.state.candidates[0].summary;
16453        runner.state.change_summary = Some(crate::run::ChangeSummary {
16454            base: head.clone(),
16455            head,
16456            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
16457            title: summary_title(generated).unwrap(),
16458            body: summary_without_title(generated),
16459        });
16460    }
16461
16462    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
16463        let mut runner = runner_at(RunStatus::Gating);
16464        runner.state = state_with_summary("add retries", JA_SUMMARY);
16465        if review_only {
16466            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
16467        }
16468        runner.state.repo = dir.to_owned();
16469        runner.state.candidates[0].worktree = dir.to_owned();
16470        runner.state.config.agents = agents;
16471        seed_public_summary(&mut runner);
16472        runner
16473    }
16474
16475    fn gate_questions(runner: &Runner) -> usize {
16476        ask::Questions::open()
16477            .list()
16478            .into_iter()
16479            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
16480            .count()
16481    }
16482
16483    #[tokio::test]
16484    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
16485        crate::run::pin_test_home();
16486        let dir = tempfile::tempdir().unwrap();
16487        let mut runner = rewrite_runner(
16488            dir.path(),
16489            true,
16490            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
16491        );
16492        runner.state.config.roles.language_judge =
16493            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
16494        let winner = runner.state.candidates[0].clone();
16495        let msg = runner
16496            .guarded_pr_message(&winner, None, true)
16497            .await
16498            .unwrap()
16499            .expect("an English rewrite posts without asking");
16500        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
16501        assert!(msg.body.contains("<details>kept</details>"));
16502        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
16503        assert_eq!(gate_questions(&runner), 0);
16504        assert!(runner.state.github_text.is_none());
16505        assert!(!runner.state.seats.contains_key("impl-A"));
16506        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
16507    }
16508
16509    #[tokio::test]
16510    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
16511        crate::run::pin_test_home();
16512        let dir = tempfile::tempdir().unwrap();
16513        let mut runner = rewrite_runner(
16514            dir.path(),
16515            false,
16516            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
16517        );
16518        runner.state.config.roles.language_judge =
16519            Some(crate::config::AgentChoice::One("beta".into()));
16520        runner.state.seats.insert(
16521            "impl-A".into(),
16522            SeatState::new("impl-A", "alpha", runner.state.seed),
16523        );
16524        let winner = runner.state.candidates[0].clone();
16525        let msg = runner
16526            .guarded_pr_message(&winner, None, true)
16527            .await
16528            .unwrap()
16529            .expect("rewritten");
16530        assert!(msg.body.contains("<details>kept</details>"));
16531        assert_eq!(runner.state.seats["impl-A"].turns, 0);
16532        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
16533        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
16534    }
16535
16536    #[tokio::test]
16537    async fn rewrite_reuses_a_resumable_implementer_session() {
16538        crate::run::pin_test_home();
16539        let dir = tempfile::tempdir().unwrap();
16540        let mut runner = rewrite_runner(
16541            dir.path(),
16542            false,
16543            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
16544        );
16545        runner.state.config.roles.language_judge =
16546            Some(crate::config::AgentChoice::One("beta".into()));
16547        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
16548        seat.turns = 1;
16549        runner.state.seats.insert("impl-A".into(), seat);
16550        let winner = runner.state.candidates[0].clone();
16551        runner
16552            .guarded_pr_message(&winner, None, true)
16553            .await
16554            .unwrap()
16555            .expect("rewritten");
16556        assert_eq!(runner.state.seats["impl-A"].turns, 2);
16557        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
16558    }
16559
16560    #[tokio::test]
16561    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
16562        crate::run::pin_test_home();
16563        let dir = tempfile::tempdir().unwrap();
16564        let mut broken = spec("bad");
16565        broken.command = vec!["false".into()];
16566        let mut runner = rewrite_runner(
16567            dir.path(),
16568            true,
16569            vec![
16570                broken,
16571                scripted("noformat", "Add retries."),
16572                scripted("good", EN_REWRITE),
16573            ],
16574        );
16575        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
16576            "bad".into(),
16577            "noformat".into(),
16578            "good".into(),
16579        ]));
16580        let winner = runner.state.candidates[0].clone();
16581        let msg = runner
16582            .guarded_pr_message(&winner, None, true)
16583            .await
16584            .unwrap()
16585            .expect("the third agent rewrites");
16586        assert!(msg.body.contains("`src/net.rs`"));
16587        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
16588        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
16589    }
16590
16591    #[tokio::test]
16592    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
16593        crate::run::pin_test_home();
16594        // (agents, role): still Japanese; no TITLE; nothing resolvable.
16595        for (agents, role, saved) in [
16596            (
16597                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
16598                Some("beta"),
16599                true,
16600            ),
16601            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
16602            (Vec::new(), None, false),
16603        ] {
16604            let dir = tempfile::tempdir().unwrap();
16605            let mut runner = rewrite_runner(dir.path(), true, agents);
16606            runner.state.config.roles.language_judge =
16607                role.map(|r| crate::config::AgentChoice::One(r.into()));
16608            let winner = runner.state.candidates[0].clone();
16609            let first = runner
16610                .guarded_pr_message(&winner, None, true)
16611                .await
16612                .unwrap();
16613            assert!(first.is_none(), "withheld text parks the run");
16614            assert!(runner.state.parked);
16615            assert_eq!(gate_questions(&runner), 1);
16616            let g = runner.state.github_text.as_ref().unwrap();
16617            assert_eq!(g.rewrite.is_some(), saved);
16618            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
16619        }
16620    }
16621
16622    #[tokio::test]
16623    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
16624        crate::run::pin_test_home();
16625        let dir = tempfile::tempdir().unwrap();
16626        let mut runner = runner_at(RunStatus::Gating);
16627        runner.state =
16628            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
16629        runner.state.repo = dir.path().to_owned();
16630        runner.state.candidates[0].worktree = dir.path().to_owned();
16631        let mut author = spec("alpha");
16632        author.command = vec![
16633            "sh".into(),
16634            "-c".into(),
16635            "printf '%s' \"$REWRITE_REPLY\"".into(),
16636        ];
16637        author.env.insert(
16638            "REWRITE_REPLY".into(),
16639            format!(
16640                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
16641                "English sentence. ".repeat(600)
16642            ),
16643        );
16644        runner.state.config.agents = vec![author];
16645        seed_public_summary(&mut runner);
16646        let winner = runner.state.candidates[0].clone();
16647        assert!(
16648            runner
16649                .guarded_pr_message(&winner, None, true)
16650                .await
16651                .unwrap()
16652                .is_none()
16653        );
16654        let record = runner.state.github_text.clone().unwrap();
16655        let rewrite = record.rewrite.clone().unwrap();
16656        assert_eq!(rewrite.failed, ["body-language"]);
16657        assert!(
16658            rewrite
16659                .body
16660                .ends_with(&format!("magi:run/{}", runner.state.id))
16661        );
16662        let store = ask::Questions::open();
16663        let q = store.get(record.question.as_ref().unwrap()).unwrap();
16664        assert!(
16665            q.choices
16666                .contains(&crate::github_text::USE_REWRITE.to_owned())
16667        );
16668        for label in [
16669            "ORIGINAL",
16670            "FALLBACK",
16671            "REWRITE",
16672            "Rules still failed: body-language",
16673            "truncated",
16674        ] {
16675            assert!(q.detail.contains(label), "{label}");
16676        }
16677        let artifact = std::fs::read_to_string(crate::run::artifact_path(
16678            &runner.state,
16679            &crate::github_text::artifact_name(&record.fingerprint),
16680        ))
16681        .unwrap();
16682        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
16683        // Restart from serialized state, then retry after a bad owner title.
16684        runner.state =
16685            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
16686        store
16687            .update(&q.id, |q| {
16688                q.say("まだ日本語")?;
16689                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
16690            })
16691            .unwrap();
16692        assert!(
16693            runner
16694                .guarded_pr_message(&winner, None, true)
16695                .await
16696                .unwrap()
16697                .is_none()
16698        );
16699        let retry = runner.state.github_text.as_ref().unwrap();
16700        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
16701        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
16702        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
16703        assert!(
16704            q.posting_comparison
16705                .as_ref()
16706                .unwrap()
16707                .matches_detail(&q.detail)
16708        );
16709        store
16710            .update(&q.id, |q| {
16711                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
16712            })
16713            .unwrap();
16714        let message = runner
16715            .guarded_pr_message(&winner, None, true)
16716            .await
16717            .unwrap()
16718            .unwrap();
16719        assert_eq!(message.title, rewrite.title);
16720        assert_eq!(message.body, rewrite.body);
16721        assert_eq!(
16722            runner.state.github_text.as_ref().unwrap().choice,
16723            Some(GithubTextChoice::Rewrite)
16724        );
16725        let again = runner
16726            .guarded_pr_message(&winner, None, true)
16727            .await
16728            .unwrap()
16729            .unwrap();
16730        assert_eq!(again.title, message.title);
16731        assert_eq!(again.body, message.body);
16732        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
16733        assert!(
16734            !runner
16735                .state
16736                .events
16737                .iter()
16738                .any(|e| e.message.contains(&rewrite.title)
16739                    || e.message.contains("English sentence."))
16740        );
16741    }
16742
16743    #[tokio::test]
16744    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
16745        crate::run::pin_test_home();
16746        let dir = tempfile::tempdir().unwrap();
16747        let mut runner = runner_at(RunStatus::Gating);
16748        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
16749        runner.state.repo = dir.path().to_owned();
16750        runner.state.candidates[0].worktree = dir.path().to_owned();
16751        seed_public_summary(&mut runner);
16752        let winner = runner.state.candidates[0].clone();
16753        assert!(
16754            runner
16755                .guarded_pr_message(&winner, None, true)
16756                .await
16757                .unwrap()
16758                .is_none()
16759        );
16760        let record = runner.state.github_text.as_ref().unwrap();
16761        assert!(record.rewrite.is_none());
16762        // Records written before the additive field deserialize with no rewrite.
16763        let mut old = serde_json::to_value(record).unwrap();
16764        old.as_object_mut().unwrap().remove("rewrite");
16765        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
16766        assert!(old.rewrite.is_none());
16767        let id = record.question.clone().unwrap();
16768        ask::Questions::open()
16769            .update(&id, |q| {
16770                q.choices.push(crate::github_text::USE_REWRITE.into());
16771                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
16772            })
16773            .unwrap();
16774        let message = runner
16775            .guarded_pr_message(&winner, None, true)
16776            .await
16777            .unwrap()
16778            .unwrap();
16779        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
16780        assert_eq!(
16781            runner.state.github_text.as_ref().unwrap().choice,
16782            Some(GithubTextChoice::Fallback)
16783        );
16784    }
16785
16786    #[tokio::test]
16787    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
16788        crate::run::pin_test_home();
16789        let dir = tempfile::tempdir().unwrap();
16790        let mut runner = runner_at(RunStatus::Gating);
16791        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
16792        runner.state.repo = dir.path().to_owned();
16793        runner.state.candidates[0].worktree = dir.path().to_owned();
16794        seed_public_summary(&mut runner);
16795        let winner = runner.state.candidates[0].clone();
16796        assert!(
16797            runner
16798                .guarded_pr_message(&winner, None, true)
16799                .await
16800                .unwrap()
16801                .is_none()
16802        );
16803        let store = ask::Questions::open();
16804        let find = |run: &str| {
16805            store
16806                .list()
16807                .into_iter()
16808                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
16809                .unwrap()
16810        };
16811        let run = runner.state.id.clone();
16812        let q = find(&run);
16813        let fp = runner
16814            .state
16815            .github_text
16816            .as_ref()
16817            .unwrap()
16818            .fingerprint
16819            .clone();
16820        let name = crate::github_text::artifact_name(&fp);
16821        let path = crate::run::artifact_path(&runner.state, &name);
16822        assert!(q.detail.contains(&path.display().to_string()));
16823        assert!(q.detail.contains("title-language: title"));
16824        let saved = std::fs::read_to_string(&path).expect("artifact written");
16825        assert!(saved.contains("日本語のタイトル"));
16826        assert!(q.cwd.is_none());
16827        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
16828        // A bad replacement is refused and asked about once more.
16829        let say = |id: &str, text: &str, choice: &str| {
16830            store
16831                .update(id, |q| {
16832                    q.thread.push(ask::Turn {
16833                        who: ask::Who::Operator,
16834                        body: text.to_owned(),
16835                        at: jiff::Timestamp::now(),
16836                        note: None,
16837                    });
16838                    q.answer(ask::Answer::Choice(choice.to_owned()))
16839                })
16840                .unwrap();
16841        };
16842        say(&q.id, "まだ日本語", "use my text");
16843        assert!(
16844            runner
16845                .guarded_pr_message(&winner, None, true)
16846                .await
16847                .unwrap()
16848                .is_none()
16849        );
16850        let second = find(&run);
16851        assert_ne!(second.id, q.id);
16852        assert!(second.detail.contains("did not pass"));
16853        // The retry still compares the original text; the rejected
16854        // replacement is named by category only.
16855        assert!(second.detail.contains("日本語のタイトル"));
16856        assert!(!second.detail.contains("まだ日本語"));
16857        assert!(second.detail.contains(&path.display().to_string()));
16858        say(&second.id, "fix: retry failed requests", "use my text");
16859        let message = runner
16860            .guarded_pr_message(&winner, None, true)
16861            .await
16862            .unwrap()
16863            .expect("a vetted replacement is posted");
16864        assert_eq!(message.title, "fix: retry failed requests");
16865        assert_eq!(
16866            runner
16867                .state
16868                .github_text
16869                .as_ref()
16870                .unwrap()
16871                .chosen_title
16872                .as_deref(),
16873            Some("fix: retry failed requests")
16874        );
16875        assert!(runner.state.github_text.as_ref().unwrap().resolved);
16876        // Resumed again: the saved decision is applied, nothing is re-asked.
16877        let again = runner
16878            .guarded_pr_message(&winner, None, true)
16879            .await
16880            .unwrap()
16881            .unwrap();
16882        assert_eq!(again.title, message.title);
16883        assert_eq!(
16884            store
16885                .list()
16886                .into_iter()
16887                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
16888                .count(),
16889            2
16890        );
16891    }
16892
16893    #[test]
16894    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
16895        // What magi itself writes stays English under any configured language,
16896        // so a future localisation of these headings fails here. (The agents'
16897        // own text is also checked by the posting gate.)
16898        let mut state = state_with_summary(
16899            "add retries",
16900            "TITLE: fix(web): batch reads\n- reads run.json once",
16901        );
16902        state.config.graph.language = "ja".to_owned();
16903        let m = pr_message(&state, 'A');
16904        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
16905        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
16906
16907        // The task is the operator's own text: it goes in untouched, and the
16908        // fallback title (no summary) may be in its language too.
16909        let task = "今回やってほしいこと: results projector を直す";
16910        let mut state = state_with_summary(task, "- no title line");
16911        state.config.graph.language = "ja".to_owned();
16912        let m = pr_message(&state, 'A');
16913        assert_eq!(
16914            m.title,
16915            format!("chore: land candidate A of run {}", state.id)
16916        );
16917        assert!(
16918            m.body.contains(&format!(
16919                "<summary>Original task</summary>\n\n{task}\n\n</details>"
16920            )),
16921            "{}",
16922            m.body
16923        );
16924    }
16925
16926    #[test]
16927    fn pr_message_scrubs_home_paths_and_addresses() {
16928        let state = state_with_summary(
16929            "fix it in /Users/someone/src/x",
16930            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
16931        );
16932        let m = pr_message(&state, 'A');
16933        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
16934            assert!(!m.body.contains(leak), "{}", m.body);
16935        }
16936        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
16937    }
16938
16939    #[test]
16940    fn pr_message_survives_a_task_that_closes_details() {
16941        let state = state_with_summary("a </details> b", "TITLE: fix: x");
16942        let m = pr_message(&state, 'A');
16943        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
16944    }
16945
16946    #[test]
16947    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
16948        let cmd = manual_merge_command(
16949            MergeStyle::Squash,
16950            Path::new("/repo"),
16951            "b",
16952            "fix: \"quoted\" $(x) `y`\n\nbody",
16953        );
16954        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
16955    }
16956
16957    #[test]
16958    fn manual_merge_command_matches_the_configured_style() {
16959        let repo = Path::new("/repo");
16960        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
16961
16962        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
16963        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
16964
16965        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
16966        assert_eq!(
16967            squash,
16968            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
16969             \"Merge magi run 0832 (candidate A)\""
16970        );
16971
16972        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
16973        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
16974    }
16975
16976    #[test]
16977    fn a_nudge_gets_a_quarter_of_the_budget() {
16978        // The judge and implement budgets magi ships with.
16979        assert_eq!(retry_budget(secs(1200), true), secs(300));
16980        assert_eq!(retry_budget(secs(3600), true), secs(900));
16981    }
16982
16983    #[test]
16984    fn a_resent_prompt_keeps_the_whole_budget() {
16985        // The seat kept no context, so the retry is the original job again and
16986        // shortening it would only guarantee a second failure.
16987        assert_eq!(retry_budget(secs(1200), false), secs(1200));
16988        assert_eq!(retry_budget(secs(60), false), secs(60));
16989    }
16990
16991    #[test]
16992    fn the_floor_never_exceeds_the_original_budget() {
16993        // A short configured timeout must not be *raised* by the floor: the
16994        // operator asked for a bound, and a retry may not outlast the attempt
16995        // it is retrying.
16996        assert_eq!(retry_budget(secs(60), true), secs(60));
16997        assert_eq!(retry_budget(secs(480), true), secs(120));
16998        assert_eq!(retry_budget(secs(0), true), secs(0));
16999    }
17000
17001    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
17002        agent::CommandEvidence {
17003            id: "item1".to_owned(),
17004            description: "cargo test".to_owned(),
17005            exit_code,
17006            result_summary: String::new(),
17007            source: "codex".to_owned(),
17008        }
17009    }
17010
17011    #[test]
17012    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
17013        // No evidence is not the same fact as unconfirmed evidence: a
17014        // backend with no adapter, or a reply that ran no commands at all,
17015        // must not be misread as carrying a dangling job.
17016        assert!(!has_unconfirmed_command(&[]));
17017    }
17018
17019    #[test]
17020    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
17021        // Deliberately not a check on the exit code's *value*: a fixer
17022        // legitimately runs something that fails mid-iteration before it
17023        // succeeds, and that must never by itself reopen a valid report.
17024        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
17025        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
17026        assert!(!has_unconfirmed_command(&[
17027            evidence(Some(0)),
17028            evidence(Some(101))
17029        ]));
17030    }
17031
17032    #[test]
17033    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
17034        assert!(has_unconfirmed_command(&[
17035            evidence(Some(0)),
17036            evidence(None)
17037        ]));
17038    }
17039
17040    #[test]
17041    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
17042        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17043        assert_eq!(
17044            verified_noop_claim(true, &[], text).as_deref(),
17045            Some("already fixed by b32cfc4, on main.")
17046        );
17047    }
17048
17049    #[test]
17050    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
17051        // A timeout or a bad exit code reads as the ordinary loss it is,
17052        // whatever the reply's own prose claims.
17053        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17054        assert!(verified_noop_claim(false, &[], text).is_none());
17055    }
17056
17057    #[test]
17058    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
17059        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
17060        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
17061        // A confirmed command alongside the marker is fine.
17062        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
17063    }
17064
17065    #[test]
17066    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
17067        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
17068    }
17069
17070    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
17071    /// pair, in order, labelled A, B, C, ...
17072    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
17073        runner.state.candidates = shape
17074            .iter()
17075            .enumerate()
17076            .map(|(i, &(empty, verified))| Candidate {
17077                index: i,
17078                label: (b'A' + i as u8) as char,
17079                agent: "sonnet".to_owned(),
17080                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
17081                worktree: PathBuf::from(format!("/wt/{i}")),
17082                summary: String::new(),
17083                stat: String::new(),
17084                files: 0,
17085                commits: 0,
17086                empty,
17087                failed: None,
17088                verified_noop: verified.map(str::to_owned),
17089                duration_ms: 0,
17090                folded: false,
17091            })
17092            .collect();
17093    }
17094
17095    #[test]
17096    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
17097        ask_test_home();
17098        let mut runner = runner_at(RunStatus::Implementing);
17099        set_candidates(
17100            &mut runner,
17101            &[
17102                (true, Some("already on main at b32cfc4")),
17103                (true, Some("same fix, see the existing test")),
17104            ],
17105        );
17106
17107        runner
17108            .after_implement()
17109            .expect("a verified no-op is not an error");
17110
17111        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
17112    }
17113
17114    #[test]
17115    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
17116        ask_test_home();
17117        let mut runner = runner_at(RunStatus::Implementing);
17118        // Candidate A declares a verified no-op; candidate B simply wrote
17119        // nothing and said nothing about why. One candidate's claim is not
17120        // the whole run's agreement.
17121        set_candidates(
17122            &mut runner,
17123            &[(true, Some("already on main at b32cfc4")), (true, None)],
17124        );
17125
17126        let err = runner
17127            .after_implement()
17128            .expect_err("an unverified empty candidate must still fail the run");
17129
17130        assert!(
17131            err.to_string().contains("no candidate produced a change"),
17132            "{err}"
17133        );
17134        assert_eq!(runner.state.status, RunStatus::Failed);
17135    }
17136
17137    #[test]
17138    fn after_implement_still_fails_an_ordinary_all_empty_run() {
17139        ask_test_home();
17140        let mut runner = runner_at(RunStatus::Implementing);
17141        set_candidates(&mut runner, &[(true, None), (true, None)]);
17142
17143        let err = runner
17144            .after_implement()
17145            .expect_err("no candidate declared anything; this is an ordinary failure");
17146
17147        assert!(
17148            err.to_string().contains("no candidate produced a change"),
17149            "{err}"
17150        );
17151        assert_eq!(runner.state.status, RunStatus::Failed);
17152    }
17153}
17154
17155/// A graft rollback whose restore could not be confirmed (see
17156/// [`Runner::graft`]).
17157#[derive(Debug)]
17158struct GraftRestoreFailed(String);
17159
17160impl std::fmt::Display for GraftRestoreFailed {
17161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17162        write!(
17163            f,
17164            "could not restore the winner to its pre-graft commit: {}",
17165            self.0
17166        )
17167    }
17168}
17169
17170impl std::error::Error for GraftRestoreFailed {}
17171
17172/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
17173/// not the agent: they never draw on an agent's own timeout, and running out of
17174/// them never reads as an agent failure.
17175#[cfg(test)]
17176mod resource_wait_tests {
17177    use super::*;
17178    use std::collections::BTreeMap;
17179
17180    fn sh(id: &str, script: &str) -> AgentSpec {
17181        AgentSpec {
17182            id: id.to_owned(),
17183            kind: crate::config::AgentKind::Command,
17184            model: None,
17185            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
17186            extra_args: Vec::new(),
17187            env: BTreeMap::new(),
17188            prompt_delivery: None,
17189        }
17190    }
17191
17192    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
17193        SeatJob {
17194            seat: SeatState::new("impl-A", &spec.id, 1),
17195            spec,
17196            cwd: dir.to_path_buf(),
17197            prompt: "do it".to_owned(),
17198            timeout,
17199            allow_write: true,
17200            sessions: false,
17201            artifacts: dir.join("art"),
17202            stem: "impl-A".to_owned(),
17203            handover: None,
17204        }
17205    }
17206
17207    fn state(resource_wait_secs: u64) -> RunState {
17208        let mut config = Config::default();
17209        config.graph.timeout_resource_wait = resource_wait_secs;
17210        RunState::new(
17211            PathBuf::from("/repo"),
17212            "main".to_owned(),
17213            "abc1234".to_owned(),
17214            "task".to_owned(),
17215            config,
17216        )
17217    }
17218
17219    fn hold(cache: &Path) -> crate::cache::Guard {
17220        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
17221        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
17222            crate::cache::AcquireOutcome::Acquired(g) => g,
17223            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
17224        }
17225    }
17226
17227    async fn run(
17228        jobs: Vec<SeatJob>,
17229        sem: Arc<Semaphore>,
17230        cache: Option<&Path>,
17231        st: &mut RunState,
17232    ) -> Vec<(usize, SeatState, AgentOutcome)> {
17233        let prompts = Prompts::default();
17234        let ctx = WaveCtx {
17235            run: "r",
17236            node: "implement",
17237            prompts: &prompts,
17238            cache,
17239            round: None,
17240            carry_seats: false,
17241            pauses: None,
17242        };
17243        wave(jobs, sem, &ctx, st, 0).await
17244    }
17245
17246    #[tokio::test]
17247    async fn verification_cache_wait_preserves_the_command_budget() {
17248        crate::run::pin_test_home();
17249        let tmp = tempfile::tempdir().unwrap();
17250        let cache = tmp.path().join("target");
17251        let guard = hold(&cache);
17252        let release = tokio::spawn(async move {
17253            tokio::time::sleep(Duration::from_millis(100)).await;
17254            guard.release();
17255        });
17256        let mut st = state(10);
17257        let budget = Duration::from_millis(50);
17258        let wait = repository_wait(&st, None);
17259        let (outcomes, _) = with_cache_lease(
17260            &mut st,
17261            Some(&cache),
17262            "gate",
17263            "gate",
17264            tmp.path(),
17265            "abc1234",
17266            budget,
17267            "test gate",
17268            &wait,
17269            |_, available| async move {
17270                assert_eq!(
17271                    available, budget,
17272                    "contention must not shorten verification"
17273                );
17274                (Vec::new(), false, Vec::new())
17275            },
17276        )
17277        .await
17278        .unwrap();
17279        release.await.unwrap();
17280        assert!(outcomes.is_empty(), "{outcomes:?}");
17281        assert!(
17282            st.events
17283                .iter()
17284                .any(|event| event.message.contains("waiting for"))
17285        );
17286        assert!(st.resource_wait.is_none());
17287        // The verification body no longer owns the lease after returning.
17288        hold(&cache).release();
17289    }
17290
17291    #[tokio::test]
17292    async fn verification_cache_wait_expires_without_starting_commands() {
17293        crate::run::pin_test_home();
17294        let tmp = tempfile::tempdir().unwrap();
17295        let cache = tmp.path().join("target");
17296        let _held = hold(&cache);
17297        let mut st = state(0);
17298        let wait = repository_wait(&st, None);
17299        let (outcomes, _) = with_cache_lease(
17300            &mut st,
17301            Some(&cache),
17302            "gate",
17303            "gate",
17304            tmp.path(),
17305            "abc1234",
17306            Duration::from_secs(60),
17307            "test gate",
17308            &wait,
17309            |_, _| async { panic!("commands must not start without the cache lease") },
17310        )
17311        .await
17312        .unwrap();
17313        assert_eq!(outcomes.len(), 1);
17314        assert!(outcomes[0].resource_blocked);
17315        assert!(st.resource_wait.is_none());
17316    }
17317
17318    #[tokio::test]
17319    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
17320        crate::run::pin_test_home();
17321        let tmp = tempfile::tempdir().unwrap();
17322        let cache = tmp.path().join("target-a");
17323        let guard = hold(&cache);
17324        // Held for longer than the agent's whole 1s budget.
17325        tokio::spawn(async move {
17326            tokio::time::sleep(Duration::from_millis(1500)).await;
17327            guard.release();
17328        });
17329        let mut st = state(60);
17330        let results = run(
17331            vec![job(
17332                sh("alpha", "echo ok"),
17333                tmp.path(),
17334                Duration::from_secs(1),
17335            )],
17336            Arc::new(Semaphore::new(1)),
17337            Some(&cache),
17338            &mut st,
17339        )
17340        .await;
17341        assert!(
17342            matches!(results[0].2, AgentOutcome::Ok(_)),
17343            "the wait must not be charged to the agent"
17344        );
17345        assert!(st.resource_block.is_none());
17346        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
17347        assert!(st.resource_wait.is_none() && st.active.is_empty());
17348    }
17349
17350    #[tokio::test]
17351    #[cfg(unix)]
17352    async fn repository_wait_in_a_wave_is_a_bounded_cancellable_resource_block() {
17353        use std::os::fd::AsRawFd as _;
17354        crate::run::pin_test_home();
17355        let tmp = tempfile::tempdir().unwrap();
17356        crate::git::git(tmp.path(), &["init"]).await.unwrap();
17357        let common = tmp.path().join(".git");
17358        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
17359        let external = std::fs::File::create(common.join("updater.lock")).unwrap();
17360        assert_eq!(
17361            unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
17362            0
17363        );
17364        let mut st = state(1);
17365        st.config.coordination = crate::config::test_coordination();
17366        let mut reader = job(
17367            sh("reader", "echo must-not-run"),
17368            tmp.path(),
17369            Duration::from_secs(30),
17370        );
17371        reader.allow_write = false;
17372        let results = run(
17373            vec![reader.clone()],
17374            Arc::new(Semaphore::new(1)),
17375            None,
17376            &mut st,
17377        )
17378        .await;
17379        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
17380        assert_eq!(results[0].1.turns, 0);
17381        assert!(FailClass::of(&results[0].2).is_none());
17382        assert_eq!(
17383            st.resource_block.as_ref().unwrap().kind,
17384            "repository coordination"
17385        );
17386        assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
17387        assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
17388        drop(external);
17389        let editor = crate::repo_lock::SeatLease::for_checkout(
17390            tmp.path(),
17391            true,
17392            crate::config::test_coordination().as_ref(),
17393        )
17394        .await
17395        .unwrap();
17396        let pause = Pause::new();
17397        let interrupt = Pause::new();
17398        let prompts = Prompts::default();
17399        let ctx = WaveCtx {
17400            run: "r",
17401            node: "review",
17402            prompts: &prompts,
17403            cache: None,
17404            round: None,
17405            carry_seats: false,
17406            pauses: Some((&pause, &interrupt)),
17407        };
17408        let (results, ()) = tokio::join!(
17409            wave(vec![reader], Arc::new(Semaphore::new(1)), &ctx, &mut st, 0),
17410            async {
17411                tokio::time::sleep(Duration::from_millis(50)).await;
17412                pause.park();
17413            }
17414        );
17415        assert!(
17416            matches!(&results[0].2, AgentOutcome::ResourceBlocked(why) if why.contains("parking"))
17417        );
17418        assert_eq!(results[0].1.turns, 0);
17419        assert!(st.active.is_empty() && st.quota.is_empty() && st.handovers.is_empty());
17420        assert!(!tmp.path().join("art/impl-A.prompt.md").exists());
17421        drop(editor);
17422    }
17423
17424    #[tokio::test]
17425    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
17426        crate::run::pin_test_home();
17427        let tmp = tempfile::tempdir().unwrap();
17428        let cache = tmp.path().join("target-b");
17429        let _held = hold(&cache);
17430        let mut st = state(1);
17431        let results = run(
17432            vec![job(
17433                sh("alpha", "echo ok"),
17434                tmp.path(),
17435                Duration::from_secs(1),
17436            )],
17437            Arc::new(Semaphore::new(1)),
17438            Some(&cache),
17439            &mut st,
17440        )
17441        .await;
17442        let (_, seat, out) = &results[0];
17443        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
17444        assert!(wave_blocked(&results));
17445        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
17446        assert_eq!(seat.turns, 0, "nothing was asked");
17447        assert!(
17448            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
17449            "no agent was spawned"
17450        );
17451        assert!(FailClass::of(out).is_none(), "not a failure class");
17452        let block = st.resource_block.as_ref().expect("recorded for the resume");
17453        assert_eq!(block.kind, "shared build cache");
17454        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
17455        assert!(st.active.is_empty());
17456    }
17457
17458    #[tokio::test]
17459    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
17460        crate::run::pin_test_home();
17461        let tmp = tempfile::tempdir().unwrap();
17462        let mut st = state(1);
17463        let results = run(
17464            vec![job(
17465                sh("alpha", "echo ok"),
17466                tmp.path(),
17467                Duration::from_secs(1),
17468            )],
17469            Arc::new(Semaphore::new(0)),
17470            None,
17471            &mut st,
17472        )
17473        .await;
17474        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
17475        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
17476        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
17477    }
17478
17479    #[tokio::test]
17480    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
17481        crate::run::pin_test_home();
17482        let tmp = tempfile::tempdir().unwrap();
17483        let mut st = state(60);
17484        let results = run(
17485            vec![job(
17486                sh("alpha", "sleep 30"),
17487                tmp.path(),
17488                Duration::from_secs(1),
17489            )],
17490            Arc::new(Semaphore::new(1)),
17491            None,
17492            &mut st,
17493        )
17494        .await;
17495        let out = &results[0].2;
17496        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
17497        let class = FailClass::of(out).expect("a failure");
17498        assert_eq!(class, FailClass::Timeout);
17499        assert!(should_hand_over(None, &class), "existing handover policy");
17500        assert!(st.resource_block.is_none());
17501    }
17502}