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 released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
721        if let Some(released) = &released {
722            state.event(
723                "release",
724                format!(
725                    "took `{branch}` over from run {}: its worktree was released: {}",
726                    crate::run::short_of(&released.old_id),
727                    released.audit
728                ),
729            );
730        }
731        // The owner's answer to an earlier divergence question is applied
732        // here: after the release (git will not move a checked-out branch)
733        // and before the sync that would otherwise ask again.
734        if let Some(choice) = takeover.choice.as_ref()
735            && let Err(e) =
736                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
737                    .await
738        {
739            if let Some(released) = &released {
740                released.restore(&repo, branch).await;
741            }
742            return Err(e.context("applying the owner's answer about the diverged branch"));
743        }
744        // An earlier attempt of this task that published the task head, and
745        // whose work is the branch under review, hands that head over: the
746        // review run updates the same pull request. Without one (a legacy
747        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
748        // own head and its own open pull request is adopted as before.
749        for id in takeover.earlier.iter().rev() {
750            if let Ok(old) = RunState::load_under(id, &takeover.home)
751                && let Some(head) = old.pr_head.clone()
752                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
753            {
754                state.pr_head = Some(head);
755                break;
756            }
757        }
758        let opened = Self::open_review(
759            &repo,
760            branch,
761            state,
762            roles,
763            max_parallel,
764            base_commit,
765            &takeover,
766        )
767        .await;
768        if opened.is_err()
769            && let Some(released) = &released
770        {
771            released.restore(&repo, branch).await;
772        }
773        opened
774    }
775
776    /// Recover exactly the operator-selected public implementation, without touching old sources.
777    pub async fn recover_pr(
778        repo: &Path,
779        task: &str,
780        selection: crate::pr_recovery::Selection,
781        config: Config,
782    ) -> Result<Self> {
783        selection.check_task(task, &selection.instruction)?;
784        let base_branch =
785            git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
786                .await?;
787        let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
788        selection
789            .verify(repo, &config.merge.remote, &base_branch, &selection.head)
790            .await?;
791        let roles = config.resolve_roles()?;
792        let max_parallel = config.graph.max_parallel.max(1);
793        let mut state = RunState::new(
794            repo.to_path_buf(),
795            base_branch,
796            base_commit.clone(),
797            selection.instruction.clone(),
798            config,
799        );
800        state.origin = Some(crate::run::Origin::queue(task));
801        state.pr_head = Some(selection.branch.clone());
802        state.pr_recovery = Some(selection.clone());
803        crate::pr_head::check_predecessors(&state, &selection.branch).await?;
804        Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
805    }
806
807    async fn open_selected(
808        repo: &Path,
809        state: RunState,
810        roles: ResolvedRoles,
811        max_parallel: usize,
812        base_commit: String,
813        selection: crate::pr_recovery::Selection,
814    ) -> Result<Self> {
815        let mut state = state;
816        let branch = state.branch_for('A');
817        git::git(repo, &["branch", &branch, &selection.head]).await?;
818        state.event(
819            "recovery",
820            format!(
821                "selected public implementation {} from {}",
822                selection.head, selection.url
823            ),
824        );
825        let takeover = crate::handover::Takeover {
826            earlier: Vec::new(),
827            home: crate::run::home(),
828            choice: None,
829            task_instruction: Some(selection.instruction.clone()),
830        };
831        state.save()?;
832        let id = state.id.clone();
833        let opened = Self::open_review(
834            repo,
835            &branch,
836            state,
837            roles,
838            max_parallel,
839            base_commit,
840            &takeover,
841        )
842        .await;
843        match opened {
844            Ok(runner) => Ok(runner),
845            Err(e) => {
846                if let Ok(mut partial) = RunState::load(&id) {
847                    partial.status = RunStatus::Blocked;
848                    partial.driver_exited = true;
849                    partial.event(
850                        "recovery",
851                        format!("startup failed; retained source and candidate {branch}: {e:#}"),
852                    );
853                    partial.save()?;
854                }
855                Err(crate::pr_recovery::StartupFailed {
856                    run: id,
857                    cause: format!("{e:#}"),
858                }
859                .into())
860            }
861        }
862    }
863
864    /// What the newest earlier attempt that reviewed `branch` leaves for this
865    /// run (see [`crate::recovery`]). `None` for a hand-run review, which has
866    /// no earlier attempts. Reads the earlier record only: ownership of its
867    /// worktree and branch is [`crate::handover`]'s business.
868    async fn inherit(
869        repo: &Path,
870        branch: &str,
871        base_commit: &str,
872        takeover: &crate::handover::Takeover,
873    ) -> Option<crate::recovery::RecoveryRecord> {
874        use crate::recovery::{Relation, assess};
875        let newest = takeover.earlier.last()?;
876        let mut chosen = None;
877        for id in takeover.earlier.iter().rev() {
878            if let Ok(old) = RunState::load_under(id, &takeover.home)
879                && !old.reviews.is_empty()
880                && (old.winner().is_some_and(|w| w.branch == branch)
881                    || old.pr_head.as_deref() == Some(branch))
882            {
883                chosen = Some(old);
884                break;
885            }
886        }
887        let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
888            .await
889            .unwrap_or_default();
890        let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
891            Some(last) if last.head == tip => Relation::Same,
892            Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
893            _ => Relation::Diverged,
894        };
895        let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
896        Some(assess(from, chosen.as_ref(), base_commit, relation))
897    }
898
899    /// The half of [`Runner::review_taking_over`] that can fail after an
900    /// earlier attempt's worktree was released.
901    async fn open_review(
902        repo: &Path,
903        branch: &str,
904        mut state: RunState,
905        roles: ResolvedRoles,
906        max_parallel: usize,
907        base_commit: String,
908        takeover: &crate::handover::Takeover,
909    ) -> Result<Self> {
910        if state.pr_recovery.is_none() {
911            sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
912        }
913        // The commit subjects are the closest thing to a task statement that
914        // existing work carries, and the reviewers are told as much.
915        let start = review_base(
916            repo,
917            &state.config.merge.remote,
918            &state.base_branch,
919            &base_commit,
920            branch,
921        )
922        .await;
923        let log = git::log_oneline(repo, &start, branch)
924            .await
925            .unwrap_or_default();
926        let log_text = if log.trim().is_empty() {
927            "(no commit messages)"
928        } else {
929            log.trim()
930        };
931        let mut instruction = format!(
932            "Review the work already on branch `{branch}`. There is no task \
933             statement: what the change claims to do is whatever its commits \
934             say.\n\n{log_text}"
935        );
936        // A retry of a task whose earlier attempt left reviews behind carries
937        // that attempt's evidence, and the task's own statement with it: the
938        // completion criteria are the task's, not whatever the commits claim.
939        if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
940            if let Some(task) = takeover
941                .task_instruction
942                .as_deref()
943                .filter(|t| !t.trim().is_empty())
944                && rec.reviewed_head.is_some()
945            {
946                instruction = format!(
947                    "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
948                    task.trim()
949                );
950                rec.criteria_inherited = true;
951            }
952            state.event("recovery", rec.summary());
953            state.recovery = Some(rec);
954        }
955        if let Some(selected) = &state.pr_recovery {
956            instruction = selected.instruction.clone();
957        }
958        state.task_instruction = takeover.task_instruction.clone();
959        state.instruction = instruction;
960        state.reviewed_commits = Some(
961            git::subjects(repo, &start, branch)
962                .await
963                .unwrap_or_default(),
964        );
965
966        // An attached worktree, so the fixer's commits land on the branch under
967        // review rather than on a detached head nobody will look at again.
968        let worktree = state.worktree_root().join("under-review");
969        if let Some(parent) = worktree.parent() {
970            tokio::fs::create_dir_all(parent).await.ok();
971        }
972        let path = worktree.to_string_lossy().to_string();
973        git::git(repo, &["worktree", "add", &path, branch])
974            .await
975            .with_context(|| {
976                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
977            })?;
978
979        let setup = match ensure_setup_config(&mut state, repo).await {
980            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
981            Err(e) => Err(e),
982        };
983        if let Err(e) = setup {
984            git::worktree_remove(repo, &worktree).await.ok();
985            return Err(e);
986        }
987        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
988            .await
989            .unwrap_or(0);
990        if commits == 0 {
991            git::worktree_remove(repo, &worktree).await.ok();
992            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
993        }
994        let files = git::changed_files(&worktree, &base_commit, "HEAD")
995            .await
996            .map(|f| f.len())
997            .unwrap_or(0);
998        if files == 0
999            && let (Ok(head_tree), Ok(base_tree)) = (
1000                git::tree_of(&worktree, "HEAD").await,
1001                git::tree_of(&worktree, &base_commit).await,
1002            )
1003            && head_tree == base_tree
1004        {
1005            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1006            git::worktree_remove(repo, &worktree).await.ok();
1007            bail!(
1008                "`{branch}` at {} has a tree identical to base {}; this usually means \
1009                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1010                 against `{}/{branch}`) rather than an empty change",
1011                short(&head),
1012                short(&base_commit),
1013                state.config.merge.remote
1014            );
1015        }
1016        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1017            .await
1018            .unwrap_or_default();
1019
1020        state.candidates.push(Candidate {
1021            index: 0,
1022            label: 'A',
1023            // Not an agent id on purpose: nothing in the roster wrote this, and
1024            // the stats tables must not credit anyone with a win for it.
1025            agent: EXISTING_BRANCH.to_owned(),
1026            branch: branch.to_owned(),
1027            worktree,
1028            summary: String::new(),
1029            stat,
1030            files,
1031            commits,
1032            empty: false,
1033            failed: None,
1034            verified_noop: None,
1035            duration_ms: 0,
1036            folded: false,
1037        });
1038        state.tally = Some(Tally {
1039            first_choice: BTreeMap::from([('A', 0)]),
1040            borda: BTreeMap::new(),
1041            winner: 'A',
1042            rankings: 0,
1043            unanimous_initial: false,
1044            deliberated: false,
1045            changed_votes: 0,
1046            unanimous_final: false,
1047            tie_break: None,
1048            // No panel sat, so no quorum applies. Zero judges is the correct
1049            // number for work that never competed, and must not be reported as
1050            // a collapsed panel.
1051            judges: 0,
1052            present: 0,
1053            quorum: 0,
1054            met_quorum: true,
1055            uncontested: Some("review-only run: nothing competed".to_owned()),
1056        });
1057        state.status = RunStatus::Reviewing;
1058        state.prep_complete = Some(true);
1059        state.event(
1060            "start",
1061            format!(
1062                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1063                state.id
1064            ),
1065        );
1066        state.save()?;
1067        Ok(Self {
1068            state,
1069            roles,
1070            sem: Arc::new(Semaphore::new(max_parallel)),
1071            pause: Pause::new(),
1072            interrupt: Pause::new(),
1073        })
1074    }
1075
1076    /// Reopen an existing run.
1077    pub fn resume(id: &str) -> Result<Self> {
1078        let state = RunState::load(id)?;
1079        crate::pr_head::ensure_owner(&state)?;
1080        if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1081            bail!(
1082                "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1083            );
1084        }
1085        if let Some(to) = &state.released_to {
1086            bail!(
1087                "run {} cannot be resumed: its worktree was released to run {}",
1088                state.short(),
1089                crate::run::short_of(to)
1090            );
1091        }
1092        let roles = state.config.resolve_roles()?;
1093        if let Some(id) = &state.config.graph.selected_implementer
1094            && state.candidates.iter().any(|c| c.agent != *id)
1095        {
1096            bail!(
1097                "replacement implementer pin does not match the saved candidates; task must stay held"
1098            );
1099        }
1100        let max_parallel = state.config.graph.max_parallel.max(1);
1101        Ok(Self {
1102            state,
1103            roles,
1104            sem: Arc::new(Semaphore::new(max_parallel)),
1105            pause: Pause::new(),
1106            interrupt: Pause::new(),
1107        })
1108    }
1109
1110    /// Walk the graph to a terminal state, skipping nodes already recorded.
1111    ///
1112    /// Every way a run is driven - the queue loop, `magi run`, a resume from
1113    /// the phone - ends here, so this is the one place a run that ended
1114    /// Blocked / Stalled / Failed, or died with an error, is announced to the
1115    /// notification centre. Best-effort: see [`crate::notices::raise`].
1116    pub async fn execute(&mut self) -> Result<()> {
1117        // Queue claims cover daemon work; this also serializes manual drivers
1118        // consuming a repair answer, including the new review and decision.
1119        let _repair_claim = if self.state.config.graph.selected_implementer.is_some()
1120            || self.state.land_approval.is_some()
1121            || self.state.merge_decision.is_some()
1122            || !self.state.merge_repairs.is_empty()
1123            || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1124        {
1125            let claim = FixClaim::acquire(&self.state.dir())?;
1126            let disk = RunState::load(&self.state.id)?;
1127            let checkpoint = |s: &RunState| {
1128                (
1129                    s.merge_repairs.len(),
1130                    s.merge_repairs
1131                        .last()
1132                        .map(|r| (r.question.clone(), r.phase)),
1133                    s.land_approval
1134                        .as_ref()
1135                        .map(|a| (a.question.clone(), a.head.clone())),
1136                )
1137            };
1138            if checkpoint(&disk) != checkpoint(&self.state) {
1139                bail!(
1140                    "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1141                );
1142            }
1143            Some(claim)
1144        } else {
1145            None
1146        };
1147        let result = self.execute_graph().await;
1148        self.mark_driver_exited();
1149        let ended = if result.is_err() {
1150            Some(crate::notices::run_stopped(&self.state.id, &self.state))
1151        } else {
1152            crate::notices::run_ended(&self.state)
1153        };
1154        if let Some(notice) = ended {
1155            crate::notices::raise(notice);
1156        }
1157        result
1158    }
1159
1160    /// Record that this process no longer drives the run, so its pid (a
1161    /// daemon's outlives the run) is not read as a live driver.
1162    ///
1163    /// Written onto the record as it is on disk, never this copy: another
1164    /// process may have resumed the run (recording its own pid and clearing
1165    /// the flag) or released its worktree since this copy was read, and
1166    /// saving over that would mark a running driver dead. Only a record still
1167    /// naming this process as the driver is touched.
1168    fn mark_driver_exited(&mut self) {
1169        self.state.driver_exited = true;
1170        let pid = std::process::id();
1171        let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1172            return;
1173        };
1174        let Ok(mut disk) = RunState::load(&self.state.id) else {
1175            return;
1176        };
1177        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1178            return;
1179        }
1180        disk.driver_exited = true;
1181        if let Err(e) = disk.save() {
1182            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1183        }
1184    }
1185
1186    /// Validate ownership and persist the driver claim in one critical section.
1187    fn claim_driver(&mut self) -> Result<()> {
1188        let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1189        let disk = RunState::load(&self.state.id)?;
1190        crate::pr_head::ensure_owner(&disk)?;
1191        // Moving again, so it is no longer parked. Set before the walk rather
1192        // than in `resume`, so every way of re-entering the graph clears it
1193        // and a card cannot claim a run is waiting to be resumed while the
1194        // agents are already working.
1195        self.state.parked = false;
1196        self.state.resource_wait = None;
1197        self.state.resource_block = None;
1198        // Any seat this state still lists as answering belongs to whatever
1199        // process last drove this run — this one included, if it crashed
1200        // mid-wave. Cleared and flushed immediately, before anything else
1201        // runs, so a resume can never show a seat as live when nothing is
1202        // asking it anything yet; the node that actually dispatches the next
1203        // wave repopulates it.
1204        self.state.clear_active();
1205        // Recorded in the same spot, and flushed together with the clear
1206        // above: this is the pid a reader checks (`RunState::liveness`) when
1207        // no daemon claim exists to answer "is a process still driving this
1208        // run" — a plain `magi run` / `magi review` typed into a terminal
1209        // claims nothing there. Always overwritten, never only-if-absent, so
1210        // a resumed run's stale pid from a previous, possibly-dead process
1211        // can never survive into this one's own report. Unlike
1212        // `clear_active`, this changes on every single `execute()` call, so
1213        // the save below is now unconditional rather than only-if-cleared.
1214        //
1215        // `driver_started_at` is recorded in the same breath, from this same
1216        // pid, so `liveness` can tell a live pid that is genuinely still us
1217        // apart from one the OS has since handed to an unrelated process —
1218        // see that field's own doc for why the pid alone is not enough.
1219        // A resume that raced a takeover: the record on disk says the worktree
1220        // was handed to a later run after this copy was read. Saving over it
1221        // would erase that and drive a run with nothing to run in.
1222        if let Some(to) = &disk.released_to {
1223            bail!(
1224                "run {} cannot continue: its worktree was released to run {}",
1225                self.state.short(),
1226                crate::run::short_of(to)
1227            );
1228        }
1229        let pid = std::process::id();
1230        self.state.driver_pid = Some(pid);
1231        self.state.driver_started_at = crate::proc::process_started_at(pid);
1232        self.state.driver_exited = false;
1233        self.state.begin_pass();
1234        self.state.save()?;
1235        Ok(())
1236    }
1237
1238    async fn execute_graph(&mut self) -> Result<()> {
1239        self.claim_driver()?;
1240        if self
1241            .state
1242            .merge_decision
1243            .as_ref()
1244            .is_some_and(|decision| !decision.closed)
1245            && self.state.status != RunStatus::Landing
1246        {
1247            if crate::merge_decision::resumable_hold(&self.state) {
1248                self.state.merge = Some(MergeOutcome {
1249                    mode: MergeMode::Pr,
1250                    ok: false,
1251                    detail: self
1252                        .state
1253                        .merge_decision
1254                        .as_ref()
1255                        .unwrap()
1256                        .target
1257                        .pr
1258                        .clone(),
1259                    empty: false,
1260                });
1261                self.run_land().await?;
1262                self.settle_questions();
1263                return Ok(());
1264            }
1265            bail!(
1266                "owner stop decision is held; inspect the existing PR and request an explicit fresh review/decision before further work"
1267            );
1268        }
1269        if self.state.config.graph.selected_implementer.is_some()
1270            && self.state.status == RunStatus::Blocked
1271            && self.state.tally.is_none()
1272        {
1273            bail!(
1274                "selected implementation is held; explicitly release the owning task to retry it"
1275            );
1276        }
1277
1278        if self.state.merge_repairs.last().is_some_and(|r| {
1279            matches!(
1280                r.phase,
1281                crate::run::MergeRepairPhase::Requested
1282                    | crate::run::MergeRepairPhase::Fixing
1283                    | crate::run::MergeRepairPhase::Reviewing
1284            )
1285        }) {
1286            if self.repair_merge_review().await? {
1287                self.run_land().await?;
1288            }
1289            self.settle_questions();
1290            return Ok(());
1291        }
1292        // Publication already passed review and gate. Resume only that node:
1293        // syncing to a newer base here could change the verified commit.
1294        if self.state.pr_publication_pending {
1295            self.merge().await?;
1296            if self.resource_halt()? {
1297                return Ok(());
1298            }
1299            self.state.save()?;
1300            self.settle_questions();
1301            return Ok(());
1302        }
1303        // A run that already lost its quorum never resumes into the verdict
1304        // machinery: `deliberate` and `vote` would otherwise clobber the
1305        // stalled marker back to Voting and the run would keep going past a
1306        // verdict that is no longer trustworthy. Everything already recorded is
1307        // kept, so the run stays resumable (or foldable) for a human to pick up.
1308        //
1309        // On --resume the run gets one chance to repair itself: the seats a
1310        // rate limit took out are re-asked. If their quota has since reset and
1311        // the quorum is restored, the run picks up and finishes; otherwise it
1312        // stays stale and still-resumable for a later retry. If it does not
1313        // recover, the returned status stays `Stalled` and nothing was
1314        // clobbered (the recovery only mutates entries for the lost seats).
1315        if self.state.status == RunStatus::Stalled {
1316            if self.recover_stall().await? {
1317                self.finish_after_tally().await?;
1318            } else {
1319                // Still below quorum: persist the marker and stay resumable.
1320                self.state.save()?;
1321            }
1322            return Ok(());
1323        }
1324        // A run parked inside `land` - watching CI, mid fix-round, or
1325        // waiting on the owner's merge approval - resumes directly into it,
1326        // never back through `prep`. Everything before `merge` already
1327        // concluded; that is the only way `status` reaches `Landing` in the
1328        // first place. Re-walking `review_loop` first would also be actively
1329        // wrong: its own status recomputation (see its doc) treats any
1330        // clean round as reason to set `status` to `Gating`, which would
1331        // clobber this marker before `merge` ever ran, and this run would
1332        // never find its way back into `land` at all.
1333        if self.state.status == RunStatus::Landing {
1334            self.run_land().await?;
1335            // `run_land` may have settled the run right here - CI came back
1336            // green and the PR merged, say - without ever passing back
1337            // through `merge`'s own trailing call. Whatever it left `status`
1338            // as is what this has to read.
1339            self.settle_questions();
1340            return Ok(());
1341        }
1342        // A run parked at `merge` on the owner's word about a withheld PR text
1343        // resumes straight into `merge`. Re-walking the tail would sync to the
1344        // base again (possibly rebasing, possibly spending the fixer) after the
1345        // gate had passed, and then push a tree nobody reviewed or gated.
1346        if self.state.github_text.is_some()
1347            && self.state.merge.is_none()
1348            && self.state.status == RunStatus::Gating
1349        {
1350            self.merge().await?;
1351            if self.resource_halt()? {
1352                return Ok(());
1353            }
1354            self.state.save()?;
1355            return Ok(());
1356        }
1357        if let Err(e) = self.prep().await {
1358            self.state.status = RunStatus::Blocked;
1359            self.state
1360                .prep_failure
1361                .get_or_insert_with(|| format!("{e:#}"));
1362            self.state
1363                .event("prep", format!("preparation stopped: {e:#}"));
1364            self.state.save()?;
1365            return Err(e);
1366        }
1367        if self.park_here()? {
1368            return Ok(());
1369        }
1370        self.advise().await?;
1371        if self.park_here()? {
1372            return Ok(());
1373        }
1374        self.implement().await?;
1375        if self.park_here()? {
1376            return Ok(());
1377        }
1378        // `after_implement` already saved the state and settled any open
1379        // questions when it set this; nothing later in the graph has
1380        // anything to judge.
1381        if self.state.status == RunStatus::VerifiedNoop {
1382            return Ok(());
1383        }
1384        self.judge().await?;
1385        if self.park_here()? {
1386            return Ok(());
1387        }
1388        self.deliberate().await?;
1389        if self.park_here()? {
1390            return Ok(());
1391        }
1392        self.vote().await?;
1393        if self.park_here()? {
1394            return Ok(());
1395        }
1396        self.tally()?;
1397        // A verdict that lost its quorum is not trustworthy: do not review,
1398        // gate, or merge on it. Everything already done is kept, so the run
1399        // stays resumable (or foldable); the human can replace the agent that
1400        // ran out of quota and pick it up.
1401        if self.state.status == RunStatus::Stalled {
1402            // Persist the stalled marker now — the normal end-of-execute save
1403            // below is below this early return, and without it a resumed run
1404            // would reload a pre-tally status and keep going.
1405            self.state.save()?;
1406            return Ok(());
1407        }
1408        self.finish_after_tally().await?;
1409        Ok(())
1410    }
1411
1412    /// A wave could not start its agents because a shared resource stayed busy
1413    /// (see [`RunState::resource_block`]): end the node without recording any
1414    /// result, leaving the run `Blocked` and resumable. No seat history,
1415    /// handover, quota loss or candidate state was written for it.
1416    fn stop_for_resource(&mut self) -> Result<()> {
1417        // A wait abandoned because the run is parking stays a park.
1418        if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1419        {
1420            self.state.status = RunStatus::Blocked;
1421        }
1422        self.state.save()
1423    }
1424
1425    /// Stop walking the graph if a node ended on a resource block.
1426    fn resource_halt(&mut self) -> Result<bool> {
1427        if self.state.resource_block.is_none() {
1428            return Ok(false);
1429        }
1430        self.stop_for_resource()?;
1431        Ok(true)
1432    }
1433
1434    /// Park here if asked to, recording it in the run's own timeline.
1435    ///
1436    /// Returns whether the caller should stop walking the graph. The state is
1437    /// saved either way by the node that just finished; this adds the event so
1438    /// the operator's card says why a run that is neither finished nor moving
1439    /// is sitting where it is.
1440    fn park_here(&mut self) -> Result<bool> {
1441        let parking = self.pause.parked() || self.interrupt.parked();
1442        if !parking && self.state.resource_block.is_some() {
1443            self.stop_for_resource()?;
1444            return Ok(true);
1445        }
1446        // Either handle asking is enough - see `Pause`'s own doc for why
1447        // they are never the same one. `interrupt` is checked second so a
1448        // reason it carries is preferred in the message below over a plain
1449        // shutdown park racing it at the same boundary.
1450        if !self.pause.parked() && !self.interrupt.parked() {
1451            return Ok(false);
1452        }
1453        let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1454        let why = match cause.as_ref().map(ParkCause::label) {
1455            Some(reason) => format!(
1456                "parked after `{}` ({reason}) — resume to carry on from here",
1457                self.state.status.as_str()
1458            ),
1459            None => format!(
1460                "parked after `{}` — resume to carry on from here",
1461                self.state.status.as_str()
1462            ),
1463        };
1464        self.state.event("park", why);
1465        self.state
1466            .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1467        self.state.parked = true;
1468        self.state.save()?;
1469        Ok(true)
1470    }
1471
1472    /// Only a new explicit operator approval may retry failed preparation.
1473    pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1474        if self.state.prep_failure.is_none() {
1475            return Ok(());
1476        }
1477        let failed_at = self
1478            .state
1479            .events
1480            .iter()
1481            .rev()
1482            .find(|event| event.node == "prep")
1483            .map_or(self.state.updated_at, |event| event.at);
1484        anyhow::ensure!(
1485            approval_at > failed_at,
1486            "preparation retry requires a new explicit release after the failure"
1487        );
1488        self.state.prep_failure = None;
1489        self.state.prep_complete = Some(false);
1490        self.state
1491            .event("prep-retry", "operator approved retry of preparation");
1492        Ok(())
1493    }
1494
1495    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1496    pub fn on_pause(&mut self, pause: Pause) {
1497        self.pause = pause;
1498    }
1499
1500    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1501    /// scheduler asking this one run - and no other - to park so a task
1502    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1503    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1504    /// handle.
1505    pub fn watch_interrupt(&mut self, pause: Pause) {
1506        self.interrupt = pause;
1507    }
1508
1509    /// Abandon this run's own open questions, once `status` has actually
1510    /// settled rather than merely paused.
1511    ///
1512    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1513    /// either back up with the candidates, the review round and the seat
1514    /// sessions already on disk, so a question an implementer asked mid-round
1515    /// may still get a real answer read by a real resume. Only the statuses
1516    /// `resumable` excludes are actually final: the run merged, it reached
1517    /// `Ready` with nothing left to do, it failed outright with no
1518    /// established point to continue from, or every candidate agreed, with
1519    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1520    /// every one of those the seat that asked is gone for good, exactly like
1521    /// the run being deleted under `magi run rm` - so the same cleanup
1522    /// applies, worded for what actually happened instead of "the run was
1523    /// deleted".
1524    ///
1525    /// Best-effort and silent on success: called from every place `status`
1526    /// can land on one of those three, including ones a resumed run revisits,
1527    /// so it must cost nothing when there was nothing open to begin with.
1528    fn settle_questions(&mut self) {
1529        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1530            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1531        }
1532    }
1533
1534    /// The tail of the graph after a trustworthy tally: fold losers, review,
1535    /// gate, merge, and persist.
1536    async fn finish_after_tally(&mut self) -> Result<()> {
1537        // Before the losers are folded: their branches are what the graft
1538        // reads from.
1539        self.graft().await?;
1540        if self.resource_halt()? {
1541            return Ok(());
1542        }
1543        self.fold_losers().await?;
1544        // Before review starts, and again right before the gate: a run's
1545        // review rounds can themselves take long enough for the base to move
1546        // a second time, and the gate is the one node whose "green" gets
1547        // acted on.
1548        self.sync_to_base().await?;
1549        if self.state.status == RunStatus::AlreadyInBase {
1550            return Ok(());
1551        }
1552        self.review_loop().await?;
1553        if self.resource_halt()? {
1554            return Ok(());
1555        }
1556        self.sync_to_base().await?;
1557        if self.state.status == RunStatus::AlreadyInBase {
1558            return Ok(());
1559        }
1560        self.gate().await?;
1561        self.merge().await?;
1562        if self.resource_halt()? {
1563            return Ok(());
1564        }
1565        self.state.save()?;
1566        Ok(())
1567    }
1568
1569    // ---------------------------------------------------------------- prep
1570
1571    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1572    /// run with the step and its output in the event; it is never swallowed.
1573    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1574    /// resume starts over instead of finding candidates and missing seats.
1575    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1576        let result = match ensure_setup_config(&mut self.state, repo).await {
1577            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1578            Err(e) => Err(e),
1579        };
1580        if let Err(e) = result {
1581            git::worktree_remove(repo, wt).await.ok();
1582            if in_prep {
1583                self.rollback_prep(repo).await;
1584            }
1585            self.state.status = RunStatus::Blocked;
1586            self.state.event(
1587                "setup",
1588                format!("worktree setup failed in {}: {e:#}", wt.display()),
1589            );
1590            self.state.save()?;
1591            return Err(e);
1592        }
1593        Ok(())
1594    }
1595
1596    /// Undo everything `prep` created, so a resume re-runs it whole.
1597    async fn rollback_prep(&mut self, repo: &Path) {
1598        let root = self.state.worktree_root();
1599        for c in std::mem::take(&mut self.state.candidates) {
1600            git::worktree_remove(repo, &c.worktree).await.ok();
1601            git::branch_delete(repo, &c.branch).await.ok();
1602        }
1603        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1604        for j in 1..=seats {
1605            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1606                .await
1607                .ok();
1608        }
1609        for k in 1..=self.state.config.graph.advisors {
1610            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1611                .await
1612                .ok();
1613        }
1614    }
1615
1616    async fn prep(&mut self) -> Result<()> {
1617        if let Some(reason) = &self.state.prep_failure {
1618            bail!(
1619                "candidate preparation previously failed: {reason}; correct the source before retrying"
1620            );
1621        }
1622        if self
1623            .state
1624            .prep_complete
1625            .unwrap_or(!self.state.candidates.is_empty())
1626        {
1627            return Ok(());
1628        }
1629        let repo = self.state.repo.clone();
1630        self.rollback_prep(&repo).await;
1631        self.state.prep_complete = Some(false);
1632        self.state.status = RunStatus::Prep;
1633        self.state.save()?;
1634        let repo = self.state.repo.clone();
1635        let base = self.state.base_commit.clone();
1636        let plan = refs::plan(&repo, &self.state.seeds).await?;
1637        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1638        let root = self.state.worktree_root();
1639        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1640
1641        // The hook is the write-time half of the blindness contract; the
1642        // presentation filter in `blind` is the half that cannot be bypassed.
1643        let hooks_dir = self.state.dir().join("hooks");
1644        if self.state.config.blind.commit_msg_hook {
1645            std::fs::create_dir_all(&hooks_dir)
1646                .with_context(|| format!("create {}", hooks_dir.display()))?;
1647            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1648            let path = hooks_dir.join("commit-msg");
1649            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1650            make_executable(&path)?;
1651            // Ref-counted rather than a plain idempotent set: with more than
1652            // one run able to be in flight in the same repository at once
1653            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1654            // true?" check cannot tell "another run of mine still needs
1655            // this" from "nobody does", and the run that happens to finish
1656            // first would disable the hook out from under a sibling still
1657            // relying on it.
1658            git::acquire_worktree_config(&repo).await?;
1659            self.state.enabled_worktree_config = true;
1660        }
1661
1662        // `[worktree] setup` hides its products per worktree, which needs the
1663        // same per-worktree config the hook does. Held until fold, like it.
1664        ensure_setup_config(&mut self.state, &repo).await?;
1665
1666        for (index, (spec, label)) in self
1667            .roles
1668            .implementers
1669            .clone()
1670            .into_iter()
1671            .zip(labels)
1672            .enumerate()
1673        {
1674            let branch = self.state.branch_for(label);
1675            let worktree = root.join(format!("cand-{label}"));
1676            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1677            if self.state.config.blind.commit_msg_hook {
1678                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1679            }
1680            git::local_exclude(&worktree, "/.magi/").await?;
1681            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1682                // Nothing of this prep may survive: a resume must start over,
1683                // not find half the candidates and skip the rest.
1684                git::worktree_remove(&repo, &worktree).await.ok();
1685                git::branch_delete(&repo, &branch).await.ok();
1686                self.rollback_prep(&repo).await;
1687                self.state.status = RunStatus::Blocked;
1688                self.state
1689                    .event("prep", format!("worktree setup failed: {e:#}"));
1690                self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1691                self.state.save()?;
1692                return Err(e);
1693            }
1694            self.state.candidates.push(Candidate {
1695                index,
1696                label,
1697                agent: spec.id.clone(),
1698                branch,
1699                worktree,
1700                summary: String::new(),
1701                stat: String::new(),
1702                files: 0,
1703                commits: 0,
1704                empty: false,
1705                failed: None,
1706                verified_noop: None,
1707                duration_ms: 0,
1708                folded: false,
1709            });
1710            self.state.save()?;
1711            for pick in &plan.picks {
1712                if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1713                {
1714                    self.state.status = RunStatus::Blocked;
1715                    self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1716                    self.state
1717                        .event("prep", format!("cannot apply referenced commit: {e}"));
1718                    self.state.save()?;
1719                    return Err(e);
1720                }
1721            }
1722        }
1723
1724        for j in 1..=self.roles.judges.len() {
1725            let wt = root.join(format!("judge-{j}"));
1726            if !wt.exists() {
1727                git::worktree_add_detached(&repo, &wt, &base).await?;
1728                self.setup_seat_worktree(&repo, &wt, true).await?;
1729            }
1730        }
1731
1732        // Disposable, detached checkouts for the design-deliberation stage's
1733        // advisor seats — the same shape as the judges' above, at the same
1734        // base commit, since advisors also only ever read. Sized off the
1735        // configured count directly rather than a resolved roster: unlike
1736        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1737        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1738        // `prep` has no `ResolvedRoles` field to read a count from here.
1739        if self.state.config.graph.advise {
1740            for k in 1..=self.state.config.graph.advisors {
1741                let wt = root.join(format!("advisor-{k}"));
1742                if !wt.exists() {
1743                    git::worktree_add_detached(&repo, &wt, &base).await?;
1744                    self.setup_seat_worktree(&repo, &wt, true).await?;
1745                }
1746            }
1747        }
1748
1749        // A judge cannot tell it is looking at its own patch — the seats keep
1750        // separate conversations — but a panel that shares agents with the
1751        // field is less independent than it looks, and that is worth saying out
1752        // loud once per run rather than leaving it in the config.
1753        let authors: Vec<&str> = self
1754            .roles
1755            .implementers
1756            .iter()
1757            .map(|a| a.id.as_str())
1758            .collect();
1759        let overlap: Vec<String> = self
1760            .roles
1761            .judges
1762            .iter()
1763            .enumerate()
1764            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1765            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1766            .collect();
1767        if !overlap.is_empty() {
1768            let note = format!(
1769                "{} also authored a candidate; blind, but the panel is less \
1770                 independent than {} distinct agents would be",
1771                overlap.join(", "),
1772                self.roles.judges.len()
1773            );
1774            self.state.event("prep", note);
1775        }
1776
1777        self.state.event(
1778            "prep",
1779            format!(
1780                "{} candidates, {} judges, base {} ({})",
1781                self.state.candidates.len(),
1782                self.roles.judges.len(),
1783                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1784                self.state.base_branch
1785            ),
1786        );
1787        self.state.prep_complete = Some(true);
1788        self.state.status = RunStatus::Implementing;
1789        self.state.save()?;
1790        Ok(())
1791    }
1792
1793    // -------------------------------------------------------------- advise
1794
1795    /// The design-deliberation stage: independent, read-only advisor seats
1796    /// each sketch a design before any implementer touches the repository,
1797    /// and (when at least one produced a usable proposal) a synthesis seat
1798    /// blends them into a brief `implement` carries in every candidate's
1799    /// prompt.
1800    ///
1801    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1802    /// advisors` is the proposal count. Everything here is best-effort and
1803    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1804    /// that cannot reach quota, or a synthesis seat that produced nothing
1805    /// usable all leave `implement` exactly as it was before this stage
1806    /// existed — the task instruction alone — rather than failing the whole
1807    /// competition over an enrichment stage. Every outcome is still recorded
1808    /// as an event, so a run that got nothing from this stage says why.
1809    ///
1810    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1811    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1812    /// resumed run whose stage failed would re-run it, and re-spend the
1813    /// agent calls, on every reentry before `implement`.
1814    ///
1815    /// Also skipped once any candidate shows implementation progress — the
1816    /// exact predicate `implement` itself uses to decide a candidate is no
1817    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1818    /// is not enough: a run created by an older binary that predates this
1819    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1820    /// an already-`Implementing`-or-later run under this build would
1821    /// otherwise walk straight back through `prep` (a no-op once candidates
1822    /// exist) into this node and spawn every advisor seat against worktrees
1823    /// `prep` never recreated — after implementation has already started,
1824    /// which is exactly the invariant this stage exists to guarantee.
1825    async fn advise(&mut self) -> Result<()> {
1826        let implement_untouched = self
1827            .state
1828            .candidates
1829            .iter()
1830            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1831        if !self.state.config.graph.advise || self.state.advise_attempted {
1832            return Ok(());
1833        }
1834        if !implement_untouched {
1835            self.state.event(
1836                "advise",
1837                "skipping the design-deliberation stage: at least one \
1838                 candidate already shows implementation progress, so this \
1839                 run is past the point the stage exists to run before"
1840                    .to_owned(),
1841            );
1842            self.state.advise_attempted = true;
1843            self.state.save()?;
1844            return Ok(());
1845        }
1846        let run_id = self.state.id.clone();
1847        let prompts = self.state.config.prompts.clone();
1848        let instruction = self.state.instruction.clone();
1849        let language = self.state.config.graph.language.clone();
1850        let root = self.state.worktree_root();
1851        let n = self.state.config.graph.advisors;
1852        let where_recorded = self.state.dir().join("run.json");
1853
1854        let seats = match self.state.config.advisors() {
1855            Ok(seats) if !seats.is_empty() => seats,
1856            Ok(_) => {
1857                self.state.event(
1858                    "advise",
1859                    format!(
1860                        "[graph] advisors is 0; skipping the design-deliberation \
1861                         stage and continuing without a synthesis brief (see {})",
1862                        where_recorded.display()
1863                    ),
1864                );
1865                self.state.advise_attempted = true;
1866                self.state.save()?;
1867                return Ok(());
1868            }
1869            Err(e) => {
1870                self.state.event(
1871                    "advise",
1872                    format!(
1873                        "could not resolve advisor seats ({e:#}); continuing \
1874                         without a design-deliberation brief (see {})",
1875                        where_recorded.display()
1876                    ),
1877                );
1878                self.state.advise_attempted = true;
1879                self.state.save()?;
1880                return Ok(());
1881            }
1882        };
1883
1884        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1885        let artifacts = agent::artifacts_dir(&self.state.dir());
1886        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1887
1888        let mut jobs = Vec::new();
1889        for (i, spec) in seats.iter().cloned().enumerate() {
1890            let seat_key = format!("advisor-{}", i + 1);
1891            let seat = self.seat(&seat_key, &spec.id);
1892            jobs.push(SeatJob {
1893                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1894                spec,
1895                seat,
1896                cwd: worktrees[i % worktrees.len()].clone(),
1897                timeout,
1898                allow_write: false,
1899                sessions: false,
1900                artifacts: artifacts.clone(),
1901                stem: seat_key,
1902                handover: None,
1903            });
1904        }
1905
1906        self.state.event(
1907            "advise",
1908            format!(
1909                "{} advisor seat(s) sketching a design in parallel",
1910                jobs.len()
1911            ),
1912        );
1913        let mut quota_losses = Vec::new();
1914        let cache = self.state.config.cache_dir();
1915        let ctx = WaveCtx {
1916            carry_seats: false,
1917            pauses: Some((&self.pause, &self.interrupt)),
1918            run: &run_id,
1919            node: "advise",
1920            prompts: &prompts,
1921            cache: cache.as_deref(),
1922            round: None,
1923        };
1924        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1925        let results = ask_json_wave::<Proposal>(
1926            jobs,
1927            Arc::clone(&self.sem),
1928            self.state.config.graph.retries,
1929            &advisor_roster,
1930            &ctx,
1931            &mut quota_losses,
1932            &mut self.state,
1933            &|p: &Proposal| p.validate(),
1934        )
1935        .await;
1936        if self.state.resource_block.is_some() {
1937            return self.stop_for_resource();
1938        }
1939        self.state.quota.extend(quota_losses);
1940
1941        let mut records = Vec::with_capacity(results.len());
1942        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1943            let agent_id = seat.agent.clone();
1944            self.state.seats.insert(seat.key.clone(), seat);
1945            match res {
1946                Ok((proposal, out)) => {
1947                    self.state
1948                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1949                    records.push(advise::AdvisorRecord::proposed(
1950                        i + 1,
1951                        agent_id,
1952                        proposal,
1953                        out.duration_ms,
1954                    ));
1955                }
1956                Err(e) => {
1957                    self.state.event(
1958                        "advise",
1959                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1960                    );
1961                    records.push(advise::AdvisorRecord::failed(
1962                        i + 1,
1963                        agent_id,
1964                        e.to_string(),
1965                    ));
1966                }
1967            }
1968        }
1969
1970        let mut advice = advise::Advice {
1971            records,
1972            synthesis: None,
1973        };
1974        if advice.proposals().is_empty() {
1975            self.state.event(
1976                "advise",
1977                "no advisor produced a usable proposal; continuing without a \
1978                 synthesis brief"
1979                    .to_owned(),
1980            );
1981        } else {
1982            match self
1983                .synthesize_brief(
1984                    &advice,
1985                    &instruction,
1986                    &language,
1987                    &worktrees[0],
1988                    &artifacts,
1989                    &run_id,
1990                    &prompts,
1991                    cache.as_deref(),
1992                )
1993                .await
1994            {
1995                Ok(Some(text)) => {
1996                    self.state.event(
1997                        "advise",
1998                        "synthesized a design brief for the implementer".to_owned(),
1999                    );
2000                    advice.synthesis = Some(text);
2001                }
2002                Ok(None) => {
2003                    self.state.event(
2004                        "advise",
2005                        "the synthesis seat produced nothing usable; continuing \
2006                         without a design brief"
2007                            .to_owned(),
2008                    );
2009                }
2010                Err(e) => {
2011                    self.state.event(
2012                        "advise",
2013                        format!("could not synthesize a design brief: {e:#}"),
2014                    );
2015                }
2016            }
2017        }
2018        advise::apply_reflection(&mut advice);
2019
2020        self.state.advice = Some(advice);
2021        self.state.advise_attempted = true;
2022        self.state.save()?;
2023        Ok(())
2024    }
2025
2026    /// The synthesis seat: reads every advisor's proposal and blends them
2027    /// into the design brief `advise` stores on [`RunState::advice`]. Split
2028    /// out of [`Runner::advise`] only for readability — it is not called
2029    /// anywhere else.
2030    ///
2031    /// Picked the same way [`crate::talk`]'s standing conversation and
2032    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
2033    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
2034    /// order (a claude seat, else the first runnable agent in roster order)
2035    /// used when that field is unset — see `[roles] synthesizer`'s own doc
2036    /// in [`crate::config`] for why a dedicated field exists here at all.
2037    #[allow(clippy::too_many_arguments)]
2038    async fn synthesize_brief(
2039        &mut self,
2040        advice: &advise::Advice,
2041        instruction: &str,
2042        language: &str,
2043        cwd: &Path,
2044        artifacts: &Path,
2045        run_id: &str,
2046        prompts: &Prompts,
2047        cache: Option<&Path>,
2048    ) -> Result<Option<String>> {
2049        let chain = agent::pick_chain(
2050            &self.state.config.agents,
2051            self.state.config.roles.synthesizer.as_ref(),
2052            &agent::installed,
2053            "synthesizer",
2054        )?;
2055        let proposals = advice.proposals();
2056        let mut prompt = prompt::with_overlay(
2057            prompt::synthesize_brief(instruction, &proposals, language),
2058            prompts.overlay("advise"),
2059        );
2060        if cache.is_some() {
2061            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
2062            // below — see `prompt::build_cache_note`'s doc for why telling a
2063            // read-only seat to build through the shared cache is exactly how
2064            // a sandbox's write refusal gets misread as a defect.
2065            prompt.push('\n');
2066            prompt.push_str(&prompt::build_cache_note("advise", false));
2067        }
2068        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2069        // Each id is tried once, in order; a quota hit, error or unusable
2070        // answer moves to the next. The seat is single-turn (`sessions:
2071        // false`) and the prompt is the whole context, so a fallback agent
2072        // needs nothing carried over.
2073        let mut last = None;
2074        for (n, spec) in chain.iter().enumerate() {
2075            if n > 0 {
2076                self.state
2077                    .event("advise", format!("synthesis falling back to {}", spec.id));
2078            }
2079            let mut seat = self.seat("advise-synthesis", &spec.id);
2080            let outcome = agent::invoke(
2081                spec,
2082                &mut seat,
2083                &Invocation {
2084                    cwd,
2085                    prompt: &prompt,
2086                    timeout,
2087                    allow_write: false,
2088                    unsandboxed: false,
2089                    sessions: false,
2090                    artifacts,
2091                    stem: &if n == 0 {
2092                        "advise-synthesis".to_owned()
2093                    } else {
2094                        format!("advise-synthesis-{}", spec.id)
2095                    },
2096                    run: run_id,
2097                    node: "advise",
2098                    cache_dir: None,
2099                    attachments: &[],
2100                    writable: &[],
2101                },
2102            )
2103            .await;
2104            if outcome.is_ok() {
2105                self.state.seats.insert(seat.key.clone(), seat);
2106            }
2107            let advance = agent::chain_advances(&outcome);
2108            last = Some(outcome);
2109            if !advance {
2110                break;
2111            }
2112        }
2113        // Exhausted: the last attempt's result is what a single failed seat
2114        // would have produced.
2115        let out = last.expect("a chain holds at least one agent")?;
2116        if !out.usable() {
2117            return Ok(None);
2118        }
2119        let text =
2120            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2121        Ok((!text.trim().is_empty()).then_some(text))
2122    }
2123
2124    // ----------------------------------------------------------- implement
2125
2126    async fn implement(&mut self) -> Result<()> {
2127        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2128        // agent files with `magi task add` name the run that paid for it. The
2129        // prompt overlay is cloned alongside it because the waves borrow it
2130        // while `self` is mutably borrowed by the node's own bookkeeping.
2131        let run_id = self.state.id.clone();
2132        let prompts = self.state.config.prompts.clone();
2133        let todo: Vec<usize> = self
2134            .state
2135            .candidates
2136            .iter()
2137            .enumerate()
2138            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2139            .map(|(i, _)| i)
2140            .collect();
2141        if todo.is_empty() {
2142            return self.after_implement();
2143        }
2144        self.state.status = RunStatus::Implementing;
2145
2146        let language = self.state.config.graph.language.clone();
2147        let github_language = self.state.config.graph.github_language.clone();
2148        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2149        let sessions = self.state.config.graph.sessions;
2150        let artifacts = agent::artifacts_dir(&self.state.dir());
2151        // The design-deliberation stage's blended brief, when `advise` found
2152        // one — carried into every implementer's prompt the same way
2153        // regardless of which candidate it is.
2154        let brief = self
2155            .state
2156            .advice
2157            .as_ref()
2158            .and_then(|a| a.synthesis.as_deref())
2159            .map(str::to_owned);
2160        let attachments = self.state.attachments.clone();
2161
2162        let mut jobs = Vec::new();
2163        for &i in &todo {
2164            let (index, label, worktree) = {
2165                let c = &self.state.candidates[i];
2166                (c.index, c.label, c.worktree.clone())
2167            };
2168            let spec = self.roles.implementers[index].clone();
2169            let seat_key = format!("impl-{label}");
2170            let seat = self.seat(&seat_key, &spec.id);
2171            let instruction = seeded_instruction(&self.state);
2172            jobs.push(SeatJob {
2173                spec,
2174                seat,
2175                prompt: prompt::implement_in(
2176                    &instruction,
2177                    &worktree.to_string_lossy(),
2178                    &language,
2179                    &github_language,
2180                    brief.as_deref(),
2181                    &attachments,
2182                ),
2183                cwd: worktree,
2184                timeout,
2185                allow_write: true,
2186                sessions,
2187                artifacts: artifacts.clone(),
2188                stem: format!("impl-{label}"),
2189                handover: None,
2190            });
2191        }
2192
2193        self.state.event(
2194            "implement",
2195            format!("{} candidates in parallel", jobs.len()),
2196        );
2197        // Kept so a seat whose CLI hung up can be asked again from the same
2198        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
2199        // can update a seat's own entry once a fallback agent takes it over —
2200        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
2201        // whichever agent actually answered, not the one that quota'd out.
2202        let mut sent = jobs.clone();
2203        let cache = self.state.config.cache_dir();
2204        let ctx = WaveCtx {
2205            carry_seats: false,
2206            pauses: Some((&self.pause, &self.interrupt)),
2207            run: &run_id,
2208            node: "implement",
2209            prompts: &prompts,
2210            cache: cache.as_deref(),
2211            round: None,
2212        };
2213        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2214        if wave_blocked(&results) {
2215            return self.stop_for_resource();
2216        }
2217        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2218            .await;
2219        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2220            .await;
2221        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2222            .await;
2223        if self.state.resource_block.is_some() {
2224            return self.stop_for_resource();
2225        }
2226
2227        let selected_failed = self.state.config.graph.selected_implementer.is_some()
2228            && results
2229                .iter()
2230                .any(|(_, _, out)| FailClass::of(out).is_some());
2231        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2232            let seat_key = seat.key.clone();
2233            // A quota fallback (`resume_seat_handovers`) may have handed this
2234            // seat to a different agent than the one `prep` recorded on the
2235            // candidate; the stats tables and any later fixer-defaults-to-
2236            // winner's-author lookup must credit whoever actually answered —
2237            // unless every fallback also quota'd out, in which case nobody
2238            // actually answered and crediting the last agent tried would
2239            // erase every earlier agent's own quota loss from the stats
2240            // tables instead of just this one seat's.
2241            let agent = seat.agent.clone();
2242            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2243            self.state.seats.insert(seat.key.clone(), seat);
2244            let label = self.state.candidates[i].label;
2245            let worktree = self.state.candidates[i].worktree.clone();
2246            let base = self.state.base_commit.clone();
2247
2248            let (summary, duration, failed, verified_claim) = match out {
2249                AgentOutcome::Ok(o) => {
2250                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2251                    let failed = (!o.usable()).then(|| {
2252                        if o.timed_out {
2253                            "agent timed out".to_owned()
2254                        } else {
2255                            format!("agent exited with {:?}", o.exit_code)
2256                        }
2257                    });
2258                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2259                    (text, o.duration_ms, failed, verified_claim)
2260                }
2261                // Left un-resumed by `resume_undelivered` (a dirty tree
2262                // already rescues the work, or there was no session left to
2263                // resume into) — reported like the ordinary failure it is,
2264                // never as if `o.text` (the CLI's raw error JSON) were an
2265                // answer.
2266                AgentOutcome::Dropped(o) => {
2267                    let why = o
2268                        .dropped
2269                        .as_ref()
2270                        .map(|d| d.why.as_str())
2271                        .unwrap_or("the CLI ended the stream without delivering its answer");
2272                    (
2273                        String::new(),
2274                        o.duration_ms,
2275                        Some(format!("the CLI dropped the stream ({why})")),
2276                        None,
2277                    )
2278                }
2279                AgentOutcome::Quota(o) => {
2280                    self.state.quota.push(QuotaLoss {
2281                        seat: seat_key,
2282                        node: "implement".to_owned(),
2283                        at: Timestamp::now(),
2284                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2285                    });
2286                    (
2287                        String::new(),
2288                        o.duration_ms,
2289                        Some("rate limited (quota); produced no change".to_owned()),
2290                        None,
2291                    )
2292                }
2293                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2294                    (String::new(), 0, Some(e), None)
2295                }
2296            };
2297
2298            // Rescue anything the agent edited but never committed: an
2299            // uncommitted candidate would silently be an empty one.
2300            let rescued = match git::rescue_commit(
2301                &worktree,
2302                &format!("magi: candidate {label} (uncommitted work)"),
2303            )
2304            .await
2305            {
2306                Ok(r) => {
2307                    self.state.note_withheld("implement", &r.withheld);
2308                    r.committed
2309                }
2310                Err(_) => false,
2311            };
2312            let commits = git::commits_ahead(&worktree, &base, "HEAD")
2313                .await
2314                .unwrap_or(0);
2315            let patch = git::diff(&worktree, &base, "HEAD")
2316                .await
2317                .unwrap_or_default();
2318            let stat = git::diff_stat(&worktree, &base, "HEAD")
2319                .await
2320                .unwrap_or_default();
2321            let files = git::changed_files(&worktree, &base, "HEAD")
2322                .await
2323                .map(|f| f.len())
2324                .unwrap_or(0);
2325            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2326
2327            let c = &mut self.state.candidates[i];
2328            if !exhausted_the_fallback_chain {
2329                c.agent = agent;
2330            }
2331            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2332            c.stat = stat;
2333            c.files = files;
2334            c.commits = commits;
2335            c.duration_ms = duration;
2336            c.empty = commits == 0 || patch.trim().is_empty();
2337            // An agent that failed but still produced a committed change stays
2338            // in the running: the patch is what gets judged, not the exit code.
2339            c.failed = match failed {
2340                Some(_) if c.empty => failed,
2341                _ => None,
2342            };
2343            // Only an empty candidate can be a verified no-op: a claim next
2344            // to a real patch is not what the marker is for, and `c.failed`
2345            // being `Some` here already implies `verified_claim` was never
2346            // set (see the guard above the match that produced it).
2347            c.verified_noop = if c.empty { verified_claim } else { None };
2348            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2349                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2350                (None, true, Some(_), _) => {
2351                    format!("candidate {label}: no change produced (agent-verified no-op)")
2352                }
2353                (None, true, None, _) => format!("candidate {label}: no change produced"),
2354                (None, false, _, true) => {
2355                    format!(
2356                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2357                    )
2358                }
2359                (None, false, _, false) => {
2360                    format!("candidate {label}: {files} files, {commits} commits")
2361                }
2362            };
2363            self.state.event("implement", note);
2364            self.state.save()?;
2365        }
2366
2367        if selected_failed {
2368            self.state.status = RunStatus::Blocked;
2369            self.state.event("implement", "selected implementer failed or hit quota; holding without fallback, review or publication");
2370            self.state.save()?;
2371            bail!(
2372                "selected implementer unavailable or quota-limited; restore availability and explicitly release the task"
2373            );
2374        }
2375        self.after_implement()
2376    }
2377
2378    /// Ask again, once, for work a CLI did and then failed to hand over.
2379    ///
2380    /// [`agent::dropped_stream`] recognises the one shape observed: an error
2381    /// status with an empty response and a usage report showing output tokens,
2382    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
2383    /// seven minutes and 14,267 output tokens that arrived as an empty
2384    /// candidate, because `agy`'s own subscriber fell behind and hung up.
2385    ///
2386    /// Two conditions, and both matter:
2387    ///
2388    /// - **Only when the tree is untouched.** Often the agent has already
2389    ///   written its files and only the closing message was lost; the rescue
2390    ///   commit below picks that up and there is nothing to ask for. Re-asking
2391    ///   then would pay for a second implementation of work already on disk.
2392    /// - **Once.** A CLI that drops one stream can drop the next, and this
2393    ///   node is the most expensive in the graph.
2394    ///
2395    /// The re-ask is a resume, not a re-run: `has_context` is true because the
2396    /// dropped reply still carried its `conversation_id`, so the seat is asked
2397    /// to finish what it was doing rather than sent the whole task again. It
2398    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2399    /// node's - for the same reason a re-ranked judge does: restating finished
2400    /// work is not the work.
2401    ///
2402    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2403    /// same way until it resets, while an abandoned conversation is still
2404    /// there to be picked up.
2405    async fn resume_undelivered(
2406        &mut self,
2407        results: &mut [(usize, SeatState, AgentOutcome)],
2408        sent: &[SeatJob],
2409        prompts: &Prompts,
2410        run_id: &str,
2411    ) {
2412        for (wi, seat, out) in results.iter_mut() {
2413            let Some(dropped) = (match &*out {
2414                AgentOutcome::Dropped(o) => o.dropped.clone(),
2415                _ => None,
2416            }) else {
2417                continue;
2418            };
2419            let Some(job) = sent.get(*wi) else { continue };
2420            // Already on disk? Then only the closing message was lost.
2421            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2422                self.state.event(
2423                    "implement",
2424                    format!(
2425                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2426                         work is in the tree",
2427                        seat.key, dropped.output_tokens, dropped.why
2428                    ),
2429                );
2430                continue;
2431            }
2432            // The re-ask only makes sense as a resume: `resume_after_drop`
2433            // says nothing about the task, trusting the seat to still hold it.
2434            // Without a session to resume — sessions disabled, or this CLI's
2435            // drop shape happened not to carry a session id — that prompt
2436            // would open a brand-new conversation with no context at all,
2437            // which is worse than leaving this as the ordinary failure it
2438            // already is.
2439            if !has_context(&job.spec, seat, job.sessions) {
2440                self.state.event(
2441                    "implement",
2442                    format!(
2443                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2444                         is no session left to resume",
2445                        seat.key, dropped.output_tokens, dropped.why
2446                    ),
2447                );
2448                continue;
2449            }
2450            self.state.event(
2451                "implement",
2452                format!(
2453                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2454                     conversation",
2455                    seat.key, dropped.output_tokens, dropped.why
2456                ),
2457            );
2458            let mut retry = job.clone();
2459            retry.seat = seat.clone();
2460            retry.prompt = prompt::resume_after_drop(&dropped.why);
2461            retry.timeout = retry_budget(job.timeout, true);
2462            retry.stem = format!("{}-resume", job.stem);
2463            let cache = self.state.config.cache_dir();
2464            let ctx = WaveCtx {
2465                carry_seats: false,
2466                pauses: Some((&self.pause, &self.interrupt)),
2467                run: run_id,
2468                node: "implement",
2469                prompts,
2470                cache: cache.as_deref(),
2471                round: None,
2472            };
2473            let (resumed_seat, resumed) =
2474                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2475            *seat = resumed_seat;
2476            *out = resumed;
2477        }
2478    }
2479
2480    /// Fall an implement seat through to the next untried agent in the
2481    /// implementer roster when it lost to quota — or, since the handover was
2482    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2483    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2484    /// quota path itself being unchanged — instead of leaving the
2485    /// seat's loss final the moment one agent's account runs dry.
2486    ///
2487    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2488    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2489    /// truncates to the single slot rotation picked, so a solo task whose one
2490    /// implementer hits quota mid-run used to have nothing else to try. This
2491    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2492    /// unrotated roster — which is the only place the *other* candidates in
2493    /// the machine's roster still exist once `implementers` has been cut down
2494    /// to size.
2495    ///
2496    /// Walks forward from just past the seat's own original position in the
2497    /// roster, never wrapping back to the front: a later candidate slot (say
2498    /// `beta`, the roster's second entry) must fall through to the *next*
2499    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2500    /// almost certainly a different candidate's own agent already — and once
2501    /// the roster's tail is exhausted there is nothing left to fall through
2502    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2503    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2504    /// let this retry that id forever. The loop keeps falling through until
2505    /// an attempt lands something other than `Quota` or the roster's tail
2506    /// runs out of untried ids, at which point the seat is left exactly as
2507    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2508    /// `QuotaLoss` recorded, the candidate failed/empty.
2509    ///
2510    /// `sent` is taken mutably and updated with the fallback agent's spec:
2511    /// `resume_unconfirmed_commands`, which runs after this and also reads
2512    /// `sent`, must see whichever agent actually ended up answering the seat
2513    /// — reading the stale, original spec there would check session
2514    /// eligibility against the wrong CLI and could hand a fallback agent's
2515    /// session id to the agent that just lost the seat to quota.
2516    ///
2517    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2518    /// — `self.seat` only reuses state when the agent id is unchanged, so
2519    /// handing it a different id already gets this for free. Reusing the old
2520    /// seat would resume a different CLI's session as if it were a
2521    /// continuation of this one.
2522    ///
2523    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2524    /// a quota loss cuts an agent off mid-turn, so anything already in the
2525    /// tree is unfinished work, not a completed candidate a re-ask would pay
2526    /// for twice. A dirty tree is rescued into a commit first (the same
2527    /// neutral-identity rescue `implement`'s own outcome loop gives every
2528    /// candidate) so the next agent starts clean.
2529    ///
2530    /// The new agent gets the implementer's full prompt and full
2531    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2532    /// it has no session and no context, and is implementing the task from
2533    /// nothing, unlike a resumed drop which is only restating work already
2534    /// done.
2535    ///
2536    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2537    /// `implement` event, never into `self.state.quota` — that is what
2538    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2539    /// go unspent, and a seat that ultimately recovered on its second or
2540    /// third agent is not the stalled panel that check exists to catch. Only
2541    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2542    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2543    /// outcome loop already has — this helper never pushes to it itself.
2544    async fn resume_seat_handovers(
2545        &mut self,
2546        results: &mut [(usize, SeatState, AgentOutcome)],
2547        sent: &mut [SeatJob],
2548        prompts: &Prompts,
2549        run_id: &str,
2550    ) {
2551        let instruction = seeded_instruction(&self.state);
2552        let language = self.state.config.graph.language.clone();
2553        let github_language = self.state.config.graph.github_language.clone();
2554        let brief = self
2555            .state
2556            .advice
2557            .as_ref()
2558            .and_then(|a| a.synthesis.as_deref())
2559            .map(str::to_owned);
2560        let attachments = self.state.attachments.clone();
2561        for (wi, seat, out) in results.iter_mut() {
2562            // Who holds the other candidate seats of this wave right now
2563            // (earlier handovers already written back to `sent`).
2564            let others: BTreeSet<String> = sent
2565                .iter()
2566                .enumerate()
2567                .filter(|(j, _)| j != wi)
2568                .map(|(_, j)| j.spec.id.clone())
2569                .collect();
2570            let Some(job) = sent.get_mut(*wi) else {
2571                continue;
2572            };
2573            // Where the seat's own original agent sits in the roster — the
2574            // fallback walk starts just past here, never at the front, so a
2575            // later candidate slot's quota loss does not fall back onto an
2576            // earlier slot's own agent.
2577            let start = self
2578                .roles
2579                .implementer_roster
2580                .iter()
2581                .position(|s| s.id == job.spec.id)
2582                .unwrap_or(0);
2583            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2584            let mut fallback_attempt = 0usize;
2585            let mut prev: Option<FailClass> = None;
2586            while let Some(cur) = FailClass::of(&*out) {
2587                if !should_hand_over(prev.as_ref(), &cur) {
2588                    break;
2589                }
2590                let Some(next) =
2591                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2592                        .cloned()
2593                else {
2594                    break;
2595                };
2596                tried.insert(next.id.clone());
2597                fallback_attempt += 1;
2598
2599                if let Ok(r) = git::rescue_commit(
2600                    &job.cwd,
2601                    &format!(
2602                        "magi: candidate {} (uncommitted work before {} fallback)",
2603                        seat.key,
2604                        if cur == FailClass::Quota {
2605                            "quota"
2606                        } else {
2607                            "handover"
2608                        }
2609                    ),
2610                )
2611                .await
2612                {
2613                    self.state.note_withheld("implement", &r.withheld);
2614                }
2615
2616                record_handover(
2617                    &mut self.state,
2618                    "implement",
2619                    &seat.key,
2620                    &seat.agent,
2621                    &next.id,
2622                    &cur,
2623                    &fail_reason(&*out),
2624                );
2625                prev = Some(cur.clone());
2626
2627                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2628                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2629                // Kept in sync on `sent` itself, not just the local retry: a
2630                // later helper (`resume_unconfirmed_commands`) reads `sent`
2631                // after this one returns and must see whichever agent is now
2632                // occupying the seat, not the one that just quota'd out —
2633                // otherwise it would judge session/continuation eligibility
2634                // by the wrong CLI and could resend a fallback's session id
2635                // to the agent that lost it the seat in the first place.
2636                job.spec = next.clone();
2637                let mut retry = job.clone();
2638                retry.seat = new_seat;
2639                retry.prompt = prompt::implement_in(
2640                    &instruction,
2641                    &job.cwd.to_string_lossy(),
2642                    &language,
2643                    &github_language,
2644                    brief.as_deref(),
2645                    &attachments,
2646                );
2647                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2648                let cache = self.state.config.cache_dir();
2649                let ctx = WaveCtx {
2650                    carry_seats: false,
2651                    pauses: Some((&self.pause, &self.interrupt)),
2652                    run: run_id,
2653                    node: "implement",
2654                    prompts,
2655                    cache: cache.as_deref(),
2656                    round: None,
2657                };
2658                let (fallback_seat, fallback_out) = run_one(
2659                    retry,
2660                    Arc::clone(&self.sem),
2661                    &ctx,
2662                    &mut self.state,
2663                    fallback_attempt,
2664                )
2665                .await;
2666                *seat = fallback_seat;
2667                *out = fallback_out;
2668            }
2669        }
2670    }
2671
2672    /// Ask an implement seat's own CLI to confirm what it started, once, when
2673    /// its reply reported a command whose completion status it never
2674    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2675    /// that does and does not mean.
2676    ///
2677    /// The completion contract this task asks for, extended to `implement`
2678    /// with the same signal `continue_fix_report` reads for the fixer,
2679    /// rather than a keyword search over the reply or a hard requirement on
2680    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2681    /// a candidate's CLI turn ended cleanly while a test run it had started
2682    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2683    /// named in it at all is untouched by this: `commands` is empty, so
2684    /// there is nothing to be unconfirmed.
2685    ///
2686    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2687    /// this is not about recovering edits that might already be on disk, it
2688    /// is about a result the seat itself never vouched for, which resuming
2689    /// asks for regardless of what the tree already holds. Bounded to one
2690    /// attempt for the same reason `resume_undelivered` is — this is the
2691    /// most expensive node in the graph — and a seat that still cannot
2692    /// confirm on that attempt is left as whatever its (possibly still
2693    /// unconfirmed) reply says; this does not invent a new "failed" reason
2694    /// for a candidate that otherwise produced a real, committed change.
2695    async fn resume_unconfirmed_commands(
2696        &mut self,
2697        results: &mut [(usize, SeatState, AgentOutcome)],
2698        sent: &[SeatJob],
2699        prompts: &Prompts,
2700        run_id: &str,
2701    ) {
2702        for (wi, seat, out) in results.iter_mut() {
2703            let AgentOutcome::Ok(o) = &*out else {
2704                continue;
2705            };
2706            if !has_unconfirmed_command(&o.commands) {
2707                continue;
2708            }
2709            let Some(job) = sent.get(*wi) else { continue };
2710            if !has_context(&job.spec, seat, job.sessions) {
2711                self.state.event(
2712                    "implement",
2713                    format!(
2714                        "{}: the reply named a command whose own CLI never confirmed the exit \
2715                         status of, but there is no session left to resume",
2716                        seat.key
2717                    ),
2718                );
2719                continue;
2720            }
2721            self.state.event(
2722                "implement",
2723                format!(
2724                    "{}: the reply named a command whose own CLI never confirmed the exit \
2725                     status of; resuming the conversation",
2726                    seat.key
2727                ),
2728            );
2729            let mut retry = job.clone();
2730            retry.seat = seat.clone();
2731            retry.prompt = prompt::resume_incomplete(
2732                "a command in your last reply had no confirmed exit status",
2733            );
2734            retry.timeout = retry_budget(job.timeout, true);
2735            retry.stem = format!("{}-confirm", job.stem);
2736            let cache = self.state.config.cache_dir();
2737            let ctx = WaveCtx {
2738                carry_seats: false,
2739                pauses: Some((&self.pause, &self.interrupt)),
2740                run: run_id,
2741                node: "implement",
2742                prompts,
2743                cache: cache.as_deref(),
2744                round: None,
2745            };
2746            let (resumed_seat, resumed) =
2747                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2748            *seat = resumed_seat;
2749            *out = resumed;
2750        }
2751    }
2752
2753    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2754    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2755    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2756    /// that motivated this.
2757    ///
2758    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2759    /// own nudge loop already covers for judge/review/vote seats, and not a
2760    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2761    /// implement seats: here the CLI turn genuinely finished while the node's
2762    /// own work — the fixer's account of what it did — had not. Gated purely
2763    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2764    /// reply, never on any wording in it, so a fixer whose valid, first-try
2765    /// `FixReport` happens to mention having waited on a background test is
2766    /// never resumed — the `Ok(report)` branch at the call site returns
2767    /// before this is ever invoked.
2768    ///
2769    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2770    /// attempt ([`retry_budget`]), nothing attempted once the session is
2771    /// gone, and a quota hit ends the loop immediately rather than retrying a
2772    /// rate limit that fails the same way again.
2773    async fn continue_fix_report(
2774        &mut self,
2775        mut seat: SeatState,
2776        parse_err: String,
2777        job: &SeatJob,
2778        prompts: &Prompts,
2779        run_id: &str,
2780        round: usize,
2781    ) -> (
2782        SeatState,
2783        Option<FixReport>,
2784        Option<String>,
2785        ContinuationRecord,
2786    ) {
2787        let mut last_err = parse_err;
2788        let mut cumulative_wait_ms = 0u64;
2789        let mut attempts = 0usize;
2790        loop {
2791            if !has_context(&job.spec, &seat, job.sessions) {
2792                self.state.event(
2793                    "fix",
2794                    format!(
2795                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2796                         session left to resume into"
2797                    ),
2798                );
2799                let outcome = if attempts == 0 {
2800                    ContinuationOutcome::NoSession
2801                } else {
2802                    ContinuationOutcome::Exhausted
2803                };
2804                return (
2805                    seat,
2806                    None,
2807                    Some(format!("unparsable fix report: {last_err}")),
2808                    ContinuationRecord {
2809                        attempts,
2810                        cumulative_wait_ms,
2811                        outcome,
2812                    },
2813                );
2814            }
2815            if attempts >= MAX_FIX_CONTINUATIONS {
2816                self.state.event(
2817                    "fix",
2818                    format!(
2819                        "round {round}: fixer's reply still had no adoption report after \
2820                         {attempts} continuation(s) ({last_err}); giving up"
2821                    ),
2822                );
2823                return (
2824                    seat,
2825                    None,
2826                    Some(format!(
2827                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2828                    )),
2829                    ContinuationRecord {
2830                        attempts,
2831                        cumulative_wait_ms,
2832                        outcome: ContinuationOutcome::Exhausted,
2833                    },
2834                );
2835            }
2836            attempts += 1;
2837            self.state.event(
2838                "fix",
2839                format!(
2840                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2841                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2842                ),
2843            );
2844            let mut retry = job.clone();
2845            retry.seat = seat.clone();
2846            retry.prompt = prompt::resume_incomplete(&last_err);
2847            retry.timeout = retry_budget(job.timeout, true);
2848            retry.stem = format!("{}-continue{attempts}", job.stem);
2849            let cache = self.state.config.cache_dir();
2850            let ctx = WaveCtx {
2851                carry_seats: false,
2852                pauses: Some((&self.pause, &self.interrupt)),
2853                run: run_id,
2854                node: "fix",
2855                prompts,
2856                cache: cache.as_deref(),
2857                round: Some(round),
2858            };
2859            let (resumed_seat, resumed_out) = run_one(
2860                retry,
2861                Arc::clone(&self.sem),
2862                &ctx,
2863                &mut self.state,
2864                attempts,
2865            )
2866            .await;
2867            seat = resumed_seat;
2868            match resumed_out {
2869                AgentOutcome::Ok(o) => {
2870                    cumulative_wait_ms += o.duration_ms;
2871                    match verdict::extract_json::<FixReport>(&o.text) {
2872                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2873                            self.state.event(
2874                                "fix",
2875                                format!(
2876                                    "round {round}: fixer's adoption report recovered after \
2877                                     {attempts} continuation(s)"
2878                                ),
2879                            );
2880                            return (
2881                                seat,
2882                                Some(report),
2883                                None,
2884                                ContinuationRecord {
2885                                    attempts,
2886                                    cumulative_wait_ms,
2887                                    outcome: ContinuationOutcome::Resumed,
2888                                },
2889                            );
2890                        }
2891                        // The report parsed, but this same reply's own
2892                        // CommandEvidence — the identical record `state.jobs`
2893                        // renders — names a command whose CLI never
2894                        // confirmed an exit status. Read together, that is
2895                        // not a resolved answer: keep nudging rather than
2896                        // accept a report standing next to a command the
2897                        // seat's own CLI cannot vouch for.
2898                        Ok(_) => {
2899                            last_err = "the reply parsed, but it reported a command whose own CLI \
2900                                 never confirmed an exit status"
2901                                .to_owned();
2902                        }
2903                        Err(e) => last_err = e.to_string(),
2904                    }
2905                }
2906                AgentOutcome::Quota(o) => {
2907                    cumulative_wait_ms += o.duration_ms;
2908                    self.state.quota.push(QuotaLoss {
2909                        seat: seat.key.clone(),
2910                        node: "fix".to_owned(),
2911                        at: Timestamp::now(),
2912                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2913                    });
2914                    self.state.event(
2915                        "fix",
2916                        format!(
2917                            "round {round}: continuation rate limited (quota); not retrying now"
2918                        ),
2919                    );
2920                    return (
2921                        seat,
2922                        None,
2923                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2924                        ContinuationRecord {
2925                            attempts,
2926                            cumulative_wait_ms,
2927                            outcome: ContinuationOutcome::QuotaLost,
2928                        },
2929                    );
2930                }
2931                AgentOutcome::Dropped(o) => {
2932                    cumulative_wait_ms += o.duration_ms;
2933                    let why = o
2934                        .dropped
2935                        .as_ref()
2936                        .map(|d| d.why.as_str())
2937                        .unwrap_or("the CLI ended the stream without delivering its answer");
2938                    last_err = format!("the CLI dropped the stream ({why})");
2939                }
2940                AgentOutcome::Failed(e) => last_err = e,
2941                // The continuation was never started; the report stays lost
2942                // and the caller records that, without any handover.
2943                AgentOutcome::ResourceBlocked(e) => {
2944                    return (
2945                        seat,
2946                        None,
2947                        Some(format!("the continuation was not started: {e}")),
2948                        ContinuationRecord {
2949                            attempts: attempts.saturating_sub(1),
2950                            cumulative_wait_ms,
2951                            outcome: ContinuationOutcome::NoSession,
2952                        },
2953                    );
2954                }
2955            }
2956        }
2957    }
2958
2959    fn after_implement(&mut self) -> Result<()> {
2960        // Scan every candidate patch once the set is complete.
2961        if self.state.leaks.is_empty() {
2962            let cfg = self.state.config.blind.clone();
2963            let mut leaks = Vec::new();
2964            for c in &self.state.candidates {
2965                let Some(patch) =
2966                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2967                else {
2968                    continue;
2969                };
2970                leaks.extend(blind::scan(
2971                    &format!("candidate {} patch", c.label),
2972                    &patch,
2973                    &cfg.vendor_tokens,
2974                ));
2975            }
2976            if !leaks.is_empty() {
2977                let summary = leaks
2978                    .iter()
2979                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2980                    .collect::<Vec<_>>()
2981                    .join(", ");
2982                match cfg.on_leak {
2983                    LeakPolicy::Fail => {
2984                        self.state.status = RunStatus::Failed;
2985                        self.state
2986                            .event("blind", format!("vendor text in a patch: {summary}"));
2987                        self.state.leaks = leaks;
2988                        self.state.save()?;
2989                        self.settle_questions();
2990                        bail!(
2991                            "blind.on_leak = \"fail\" and vendor text reached a \
2992                             judged patch: {summary}"
2993                        );
2994                    }
2995                    LeakPolicy::Redact => self.state.event(
2996                        "blind",
2997                        format!("redacting vendor text for judging: {summary}"),
2998                    ),
2999                    LeakPolicy::Warn => self.state.event(
3000                        "blind",
3001                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
3002                    ),
3003                }
3004                self.state.leaks = leaks;
3005            }
3006        }
3007
3008        if self.state.viable().is_empty() {
3009            if self.state.all_candidates_verified_noop() {
3010                // Every candidate agreed, with evidence the adoption guard
3011                // accepted, that nothing belongs in this worktree. That is
3012                // not the same fact as a candidate that simply failed to
3013                // write anything, and settling it as an ordinary `Failed`
3014                // (see `SCHEMA`'s doc for schema 10) is what let two of
3015                // task 391f's attempts burn a retry each re-discovering the
3016                // same already-landed fix. Terminal either way, so `judge`
3017                // must never run over an empty candidate set — unlike the
3018                // `Failed` branch below this returns `Ok`, not an error:
3019                // nothing here failed.
3020                self.state.status = RunStatus::VerifiedNoop;
3021                self.state.save()?;
3022                self.settle_questions();
3023                return Ok(());
3024            }
3025            self.state.status = RunStatus::Failed;
3026            self.state.save()?;
3027            self.settle_questions();
3028            bail!("no candidate produced a change; nothing to judge");
3029        }
3030        self.state.status = RunStatus::Judging;
3031        self.state.save()?;
3032        Ok(())
3033    }
3034
3035    // --------------------------------------------------------------- judge
3036
3037    async fn judge(&mut self) -> Result<()> {
3038        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3039        // agent files with `magi task add` name the run that paid for it. The
3040        // prompt overlay is cloned alongside it because the waves borrow it
3041        // while `self` is mutably borrowed by the node's own bookkeeping.
3042        let run_id = self.state.id.clone();
3043        let prompts = self.state.config.prompts.clone();
3044        if !self.state.judgements.is_empty() || self.state.judge_skipped {
3045            return Ok(());
3046        }
3047        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3048        if viable.len() == 1 {
3049            // Recorded so this is a one-time event: `judgements` stays empty
3050            // either way, which without this flag is indistinguishable from
3051            // "not yet judged" on the next reentry — and status is left
3052            // untouched, so a later node's conclusion (e.g. `Blocked` after
3053            // the review budget ran out) survives a resume instead of being
3054            // clobbered back to `Judging` by this node running again.
3055            self.state.judge_skipped = true;
3056            self.state.event(
3057                "judge",
3058                format!(
3059                    "only candidate {} produced a change; judging skipped",
3060                    viable[0].label
3061                ),
3062            );
3063            self.state.save()?;
3064            return Ok(());
3065        }
3066        self.state.status = RunStatus::Judging;
3067
3068        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3069        let language = self.state.config.graph.language.clone();
3070        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3071        let sessions = self.state.config.graph.sessions;
3072        let artifacts = agent::artifacts_dir(&self.state.dir());
3073        let root = self.state.worktree_root();
3074        let base_short = short(&self.state.base_commit);
3075
3076        let mut jobs = Vec::new();
3077        let mut orders = Vec::new();
3078        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3079            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3080            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3081            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3082            let seat_key = format!("judge-{}", j + 1);
3083            let seat = self.seat(&seat_key, &spec.id);
3084            jobs.push(SeatJob {
3085                prompt: prompt::judge(
3086                    &self.state.instruction,
3087                    &views,
3088                    self.roles.judges.len(),
3089                    &base_short,
3090                    &language,
3091                ),
3092                spec,
3093                seat,
3094                cwd: root.join(format!("judge-{}", j + 1)),
3095                timeout,
3096                allow_write: false,
3097                sessions,
3098                artifacts: artifacts.clone(),
3099                stem: format!("judge-{}", j + 1),
3100                handover: None,
3101            });
3102        }
3103
3104        self.state.event(
3105            "judge",
3106            format!(
3107                "{} judges ranking {} candidates blind",
3108                jobs.len(),
3109                viable.len()
3110            ),
3111        );
3112        let labels_for_check = labels.clone();
3113        let mut quota_losses = Vec::new();
3114        let cache = self.state.config.cache_dir();
3115        let ctx = WaveCtx {
3116            carry_seats: false,
3117            pauses: Some((&self.pause, &self.interrupt)),
3118            run: &run_id,
3119            node: "judge",
3120            prompts: &prompts,
3121            cache: cache.as_deref(),
3122            round: None,
3123        };
3124        let results = ask_json_wave::<Ranking>(
3125            jobs,
3126            Arc::clone(&self.sem),
3127            self.state.config.graph.retries,
3128            &self.roles.judge_roster,
3129            &ctx,
3130            &mut quota_losses,
3131            &mut self.state,
3132            &move |r: &Ranking| r.validate(&labels_for_check),
3133        )
3134        .await;
3135        if self.state.resource_block.is_some() {
3136            return self.stop_for_resource();
3137        }
3138        self.state.quota.extend(quota_losses);
3139
3140        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3141            let agent_id = seat.agent.clone();
3142            self.state.seats.insert(seat.key.clone(), seat);
3143            let mut record = Judgement {
3144                judge: j + 1,
3145                seat: format!("judge-{}", j + 1),
3146                agent: agent_id,
3147                ranking: Vec::new(),
3148                reasons: BTreeMap::new(),
3149                confidence: None,
3150                strengths: BTreeMap::new(),
3151                order: orders[j].clone(),
3152                failed: None,
3153                duration_ms: 0,
3154            };
3155            match res {
3156                Ok((ranking, out)) => {
3157                    record.ranking = ranking.normalized();
3158                    record.reasons = ranking.reasons;
3159                    record.strengths = ranking.strengths;
3160                    record.confidence = ranking.confidence;
3161                    record.duration_ms = out.duration_ms;
3162                    self.state.event(
3163                        "judge",
3164                        format!(
3165                            "judge {} ranked {}",
3166                            j + 1,
3167                            record.ranking.iter().collect::<String>()
3168                        ),
3169                    );
3170                }
3171                Err(e) => {
3172                    record.failed = Some(e.to_string());
3173                    self.state
3174                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3175                }
3176            }
3177            self.state.judgements.push(record);
3178            self.state.save()?;
3179        }
3180        Ok(())
3181    }
3182
3183    // ---------------------------------------------------------- deliberate
3184
3185    async fn deliberate(&mut self) -> Result<()> {
3186        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3187        // agent files with `magi task add` name the run that paid for it. The
3188        // prompt overlay is cloned alongside it because the waves borrow it
3189        // while `self` is mutably borrowed by the node's own bookkeeping.
3190        let run_id = self.state.id.clone();
3191        let prompts = self.state.config.prompts.clone();
3192        if !self.state.deliberation.is_empty() {
3193            return Ok(());
3194        }
3195        let tops: Vec<char> = self
3196            .state
3197            .judgements
3198            .iter()
3199            .filter_map(|j| j.ranking.first().copied())
3200            .collect();
3201        let rounds = self.state.config.graph.deliberate_rounds;
3202        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3203            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3204                self.state.event(
3205                    "deliberate",
3206                    format!("judges agreed on {} outright; no deliberation", tops[0]),
3207                );
3208            }
3209            self.state.status = RunStatus::Voting;
3210            self.state.save()?;
3211            return Ok(());
3212        }
3213
3214        self.state.status = RunStatus::Deliberating;
3215        self.state.event(
3216            "deliberate",
3217            format!(
3218                "split: first choices were {} — opening {rounds} round(s)",
3219                tops.iter().collect::<String>()
3220            ),
3221        );
3222
3223        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3224        let language = self.state.config.graph.language.clone();
3225        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3226        let sessions = self.state.config.graph.sessions;
3227        let artifacts = agent::artifacts_dir(&self.state.dir());
3228        let root = self.state.worktree_root();
3229        let base_short = short(&self.state.base_commit);
3230
3231        // Judges argue in sequence so that a turn can answer the one before it;
3232        // that is the difference between deliberation and three parallel
3233        // monologues.
3234        for round in 1..=rounds {
3235            let mut turns: Vec<DeliberationTurn> = Vec::new();
3236            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3237                if self.state.judgements[j].failed.is_some() {
3238                    continue;
3239                }
3240                let seat_key = format!("judge-{}", j + 1);
3241                let spec = self.occupant(&seat_key, spec);
3242                let mut seat = self.seat(&seat_key, &spec.id);
3243                let transcript = self.transcript(&turns, j);
3244                let build = |context: Option<&str>| {
3245                    prompt::deliberate(
3246                        &self.state.instruction,
3247                        context,
3248                        &transcript,
3249                        round,
3250                        rounds,
3251                        &language,
3252                    )
3253                };
3254                let block = self.candidate_block(&viable, &base_short);
3255                let full = build(Some(&block));
3256                let text = if has_context(&spec, &seat, sessions) {
3257                    build(None)
3258                } else {
3259                    full.clone()
3260                };
3261                let job = SeatJob {
3262                    spec,
3263                    seat: seat.clone(),
3264                    prompt: text,
3265                    cwd: root.join(format!("judge-{}", j + 1)),
3266                    timeout,
3267                    allow_write: false,
3268                    sessions,
3269                    artifacts: artifacts.clone(),
3270                    stem: format!("delib-{round}-judge-{}", j + 1),
3271                    handover: Some(full),
3272                };
3273                let cache = self.state.config.cache_dir();
3274                let ctx = WaveCtx {
3275                    carry_seats: false,
3276                    pauses: Some((&self.pause, &self.interrupt)),
3277                    run: &run_id,
3278                    node: "deliberate",
3279                    prompts: &prompts,
3280                    cache: cache.as_deref(),
3281                    round: None,
3282                };
3283                // A turn is never nudged (`retries` 0): a failed seat is
3284                // handed to the next roster agent, which gets the full
3285                // context. An empty answer is a turn, not a failure.
3286                let mut losses = Vec::new();
3287                let mut results = ask_wave_with::<String>(
3288                    vec![job],
3289                    Arc::clone(&self.sem),
3290                    0,
3291                    &self.roles.judge_roster,
3292                    &ctx,
3293                    &mut losses,
3294                    &mut self.state,
3295                    &|text: &str| {
3296                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3297                    },
3298                )
3299                .await;
3300                self.state.quota.extend(losses);
3301                let (updated, res, _) = results.pop().expect("one job in, one result out");
3302                seat = updated;
3303                let agent_id = seat.agent.clone();
3304                self.state.seats.insert(seat.key.clone(), seat);
3305                let body = match res {
3306                    Ok((body, _)) => body,
3307                    // Skip the seat; a CLI's raw error JSON is never read as
3308                    // this judge's position.
3309                    Err(e) => {
3310                        self.state
3311                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3312                        continue;
3313                    }
3314                };
3315                let tentative = verdict::extract_json::<Position>(&body)
3316                    .ok()
3317                    .and_then(|p| p.tentative)
3318                    .and_then(|s| s.trim().chars().next())
3319                    .map(|c| c.to_ascii_uppercase());
3320                self.state.event(
3321                    "deliberate",
3322                    format!(
3323                        "round {round}: judge {} now favours {}",
3324                        j + 1,
3325                        tentative.map_or("—".to_owned(), |c| c.to_string())
3326                    ),
3327                );
3328                turns.push(DeliberationTurn {
3329                    judge: j + 1,
3330                    agent: agent_id,
3331                    body: blind::sanitize_prose(&body, &self.state.config.blind),
3332                    tentative,
3333                });
3334            }
3335            self.state
3336                .deliberation
3337                .push(DeliberationRound { round, turns });
3338            self.state.save()?;
3339        }
3340
3341        self.state.status = RunStatus::Voting;
3342        self.state.save()?;
3343        Ok(())
3344    }
3345
3346    // ---------------------------------------------------------------- vote
3347
3348    async fn vote(&mut self) -> Result<()> {
3349        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3350        // agent files with `magi task add` name the run that paid for it. The
3351        // prompt overlay is cloned alongside it because the waves borrow it
3352        // while `self` is mutably borrowed by the node's own bookkeeping.
3353        let run_id = self.state.id.clone();
3354        let prompts = self.state.config.prompts.clone();
3355        if !self.state.votes.is_empty() {
3356            return Ok(());
3357        }
3358        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3359        if viable.len() == 1 {
3360            return Ok(());
3361        }
3362        self.state.status = RunStatus::Voting;
3363
3364        let language = self.state.config.graph.language.clone();
3365        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3366        let sessions = self.state.config.graph.sessions;
3367        let artifacts = agent::artifacts_dir(&self.state.dir());
3368        let root = self.state.worktree_root();
3369        let base_short = short(&self.state.base_commit);
3370        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3371
3372        let mut jobs = Vec::new();
3373        let mut seats_at = Vec::new();
3374        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3375            if self
3376                .state
3377                .judgements
3378                .get(j)
3379                .is_some_and(|r| r.failed.is_some())
3380            {
3381                continue;
3382            }
3383            let seat_key = format!("judge-{}", j + 1);
3384            let spec = self.occupant(&seat_key, spec);
3385            let seat = self.seat(&seat_key, &spec.id);
3386            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3387            let text = if has_context(&spec, &seat, sessions) {
3388                prompt::final_vote(&viable, &language)
3389            } else {
3390                full.clone()
3391            };
3392            jobs.push(SeatJob {
3393                spec,
3394                seat,
3395                prompt: text,
3396                cwd: root.join(format!("judge-{}", j + 1)),
3397                timeout,
3398                allow_write: false,
3399                sessions,
3400                artifacts: artifacts.clone(),
3401                stem: format!("vote-judge-{}", j + 1),
3402                handover: Some(full),
3403            });
3404            seats_at.push(j);
3405        }
3406
3407        self.state.event(
3408            "vote",
3409            format!(
3410                "collecting {} final votes one by one, privately",
3411                jobs.len()
3412            ),
3413        );
3414        let allowed = viable.clone();
3415        let mut quota_losses = Vec::new();
3416        let cache = self.state.config.cache_dir();
3417        let ctx = WaveCtx {
3418            carry_seats: false,
3419            pauses: Some((&self.pause, &self.interrupt)),
3420            run: &run_id,
3421            node: "vote",
3422            prompts: &prompts,
3423            cache: cache.as_deref(),
3424            round: None,
3425        };
3426        let results = ask_json_wave::<FinalVote>(
3427            jobs,
3428            Arc::clone(&self.sem),
3429            self.state.config.graph.retries,
3430            &self.roles.judge_roster,
3431            &ctx,
3432            &mut quota_losses,
3433            &mut self.state,
3434            &move |v: &FinalVote| match v.label() {
3435                Some(c) if allowed.contains(&c) => Ok(()),
3436                other => bail!("vote {other:?} is not one of {allowed:?}"),
3437            },
3438        )
3439        .await;
3440        if self.state.resource_block.is_some() {
3441            return self.stop_for_resource();
3442        }
3443        self.state.quota.extend(quota_losses);
3444
3445        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3446            let agent_id = seat.agent.clone();
3447            self.state.seats.insert(seat.key.clone(), seat);
3448            let initial = self
3449                .state
3450                .judgements
3451                .get(j)
3452                .and_then(|r| r.ranking.first().copied());
3453            let mut record = VoteRecord {
3454                judge: j + 1,
3455                agent: agent_id,
3456                vote: None,
3457                reason: String::new(),
3458                changed: false,
3459            };
3460            match res {
3461                Ok((v, _)) => {
3462                    record.vote = v.label();
3463                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3464                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3465                    self.state.event(
3466                        "vote",
3467                        format!(
3468                            "judge {} voted {}{}",
3469                            j + 1,
3470                            record.vote.unwrap_or('?'),
3471                            if record.changed { " (changed)" } else { "" }
3472                        ),
3473                    );
3474                }
3475                Err(e) => {
3476                    self.state
3477                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3478                }
3479            }
3480            self.state.votes.push(record);
3481            self.state.save()?;
3482        }
3483        Ok(())
3484    }
3485
3486    // --------------------------------------------------------------- tally
3487
3488    fn tally(&mut self) -> Result<()> {
3489        if self.state.tally.is_some() {
3490            return Ok(());
3491        }
3492        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3493        let tops: Vec<char> = self
3494            .state
3495            .judgements
3496            .iter()
3497            .filter_map(|j| j.ranking.first().copied())
3498            .collect();
3499        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3500
3501        // A judge whose private vote failed still counted once, in the initial
3502        // ranking; using it beats discarding a whole seat.
3503        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3504        let mut cast: Vec<char> = Vec::new();
3505        for (i, j) in self.state.judgements.iter().enumerate() {
3506            let vote = self
3507                .state
3508                .votes
3509                .iter()
3510                .find(|v| v.judge == i + 1)
3511                .and_then(|v| v.vote)
3512                .or_else(|| j.ranking.first().copied());
3513            if let Some(v) = vote {
3514                *first_choice.entry(v).or_insert(0) += 1;
3515                cast.push(v);
3516            }
3517        }
3518
3519        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3520        for j in &self.state.judgements {
3521            let n = j.ranking.len();
3522            for (pos, label) in j.ranking.iter().enumerate() {
3523                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3524            }
3525        }
3526
3527        let best = first_choice.values().copied().max().unwrap_or(0);
3528        let mut leaders: Vec<char> = first_choice
3529            .iter()
3530            .filter(|(_, v)| **v == best)
3531            .map(|(k, _)| *k)
3532            .collect();
3533        let mut tie_break = None;
3534        if leaders.len() > 1 {
3535            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3536            let borda_leaders: Vec<char> = leaders
3537                .iter()
3538                .copied()
3539                .filter(|l| borda[l] == top_borda)
3540                .collect();
3541            tie_break = Some(if borda_leaders.len() == 1 {
3542                format!(
3543                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3544                    leaders.len()
3545                )
3546            } else {
3547                format!(
3548                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3549                    leaders.len()
3550                )
3551            });
3552            leaders = borda_leaders;
3553            leaders.sort_unstable();
3554        }
3555        let winner = *leaders
3556            .first()
3557            .or(viable.first())
3558            .context("no candidate to declare a winner from")?;
3559
3560        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3561        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3562        let deliberated = !self.state.deliberation.is_empty();
3563
3564        // Whose verdict is this? A rate-limited seat is absent even if it
3565        // ranked before the limit hit, so presence is measured against the
3566        // recorded losses, not just "did a ranking ever appear".
3567        let quota_seats: std::collections::BTreeSet<&str> =
3568            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3569        let mut present = 0usize;
3570        for (i, j) in self.state.judgements.iter().enumerate() {
3571            if quota_seats.contains(j.seat.as_str()) {
3572                continue;
3573            }
3574            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3575            let voted = self
3576                .state
3577                .votes
3578                .iter()
3579                .any(|v| v.judge == i + 1 && v.vote.is_some());
3580            if ranked || voted {
3581                present += 1;
3582            }
3583        }
3584        // Strict majority of the configured panel. A bare majority is real
3585        // signal we can act on, while a minority verdict must never stand in
3586        // for a healthy one. A one-candidate run needs no panel at all, and
3587        // `judges` stays `0` rather than the roster size a panel that never
3588        // sat would otherwise be credited with.
3589        let needs_quorum = viable.len() > 1;
3590        let judges_total = if needs_quorum {
3591            self.roles.judges.len()
3592        } else {
3593            0
3594        };
3595        let quorum = if needs_quorum {
3596            judges_total / 2 + 1
3597        } else {
3598            0
3599        };
3600        let met_quorum = !needs_quorum || present >= quorum;
3601        let uncontested = (!needs_quorum).then(|| {
3602            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3603        });
3604
3605        self.state.event(
3606            "tally",
3607            match &uncontested {
3608                Some(reason) => format!("winner {winner} — {reason}"),
3609                None => format!(
3610                    "winner {winner} — votes {} | initial {} | {} changed | \
3611                     {present}/{judges_total} judges{}",
3612                    first_choice
3613                        .iter()
3614                        .map(|(k, v)| format!("{k}:{v}"))
3615                        .collect::<Vec<_>>()
3616                        .join(" "),
3617                    if unanimous_initial {
3618                        "unanimous"
3619                    } else {
3620                        "split"
3621                    },
3622                    changed_votes,
3623                    if met_quorum {
3624                        String::new()
3625                    } else {
3626                        format!(" — below quorum ({quorum} required)")
3627                    },
3628                ),
3629            },
3630        );
3631        if !met_quorum {
3632            self.state.event(
3633                "stall",
3634                format!(
3635                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3636                     the run stops here, resumable"
3637                ),
3638            );
3639        }
3640        self.state.tally = Some(Tally {
3641            first_choice,
3642            borda,
3643            winner,
3644            rankings: tops.len(),
3645            unanimous_initial,
3646            deliberated,
3647            changed_votes,
3648            unanimous_final,
3649            tie_break,
3650            judges: judges_total,
3651            present,
3652            quorum,
3653            met_quorum,
3654            uncontested,
3655        });
3656        self.state.status = if met_quorum {
3657            RunStatus::Reviewing
3658        } else {
3659            RunStatus::Stalled
3660        };
3661        self.state.save()?;
3662        Ok(())
3663    }
3664
3665    // ------------------------------------------------------------- recover
3666
3667    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3668    /// resumed toward completion once the transient cause clears.
3669    ///
3670    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3671    /// it toward the quorum, which is exactly the set of seats whose absence
3672    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3673    /// not depend on which node happened to hit the limit), or an ordinary
3674    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3675    /// seat is never disturbed.
3676    ///
3677    /// A seat that now answers with a usable ranking is "recovered": its
3678    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3679    /// `tally` counts it present again), and its vote re-collected. A seat that
3680    /// still fails keeps its loss and stays absent.
3681    ///
3682    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3683    /// to review/gate/merge), `false` when it is still below quorum (the run
3684    /// stays `Stalled`, still resumable for a later retry).
3685    #[allow(clippy::too_many_lines)]
3686    async fn recover_stall(&mut self) -> Result<bool> {
3687        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3688        // agent files with `magi task add` name the run that paid for it. The
3689        // prompt overlay is cloned alongside it because the waves borrow it
3690        // while `self` is mutably borrowed by the node's own bookkeeping.
3691        let run_id = self.state.id.clone();
3692        let prompts = self.state.config.prompts.clone();
3693        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3694        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3695        // is what keeps a non-quota collapse — or a quota loss recorded at the
3696        // deliberate node — from being a permanent dead-end on `--resume`.
3697        let quota_seats: BTreeSet<&str> =
3698            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3699        let absent: Vec<String> = self
3700            .state
3701            .judgements
3702            .iter()
3703            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3704            .map(|j| j.seat.clone())
3705            .collect();
3706        if absent.is_empty() {
3707            return Ok(false);
3708        }
3709        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3710        if viable.len() <= 1 {
3711            return Ok(false);
3712        }
3713        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3714        let language = self.state.config.graph.language.clone();
3715        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3716        let sessions = self.state.config.graph.sessions;
3717        let artifacts = agent::artifacts_dir(&self.state.dir());
3718        let root = self.state.worktree_root();
3719        let base_short = short(&self.state.base_commit);
3720        let candidates: Vec<Candidate> = viable.clone();
3721
3722        // Map each absent seat key to its 0-based position in `roles.judges`.
3723        let mut positions: Vec<usize> = absent
3724            .iter()
3725            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3726            .collect();
3727        if positions.is_empty() {
3728            return Ok(false);
3729        }
3730        positions.sort_unstable();
3731        positions.dedup();
3732
3733        // Re-rank the lost seats, one blind prompt each.
3734        let mut judge_jobs = Vec::new();
3735        for &j in &positions {
3736            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3737            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3738            let seat_key = format!("judge-{}", j + 1);
3739            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3740            let seat = self.seat(&seat_key, &spec.id);
3741            judge_jobs.push(SeatJob {
3742                spec,
3743                seat,
3744                prompt: prompt::judge(
3745                    &self.state.instruction,
3746                    &views,
3747                    self.roles.judges.len(),
3748                    &base_short,
3749                    &language,
3750                ),
3751                cwd: root.join(seat_key),
3752                timeout,
3753                allow_write: false,
3754                sessions,
3755                artifacts: artifacts.clone(),
3756                stem: format!("judge-{}-recover", j + 1),
3757                handover: None,
3758            });
3759        }
3760
3761        let labels_for_check = labels.clone();
3762        let mut judge_losses = Vec::new();
3763        let retries = self.state.config.graph.retries;
3764        let cache = self.state.config.cache_dir();
3765        let ctx = WaveCtx {
3766            carry_seats: false,
3767            pauses: Some((&self.pause, &self.interrupt)),
3768            run: &run_id,
3769            node: "judge",
3770            prompts: &prompts,
3771            cache: cache.as_deref(),
3772            round: None,
3773        };
3774        let results = ask_json_wave::<Ranking>(
3775            judge_jobs,
3776            Arc::clone(&self.sem),
3777            retries,
3778            &self.roles.judge_roster,
3779            &ctx,
3780            &mut judge_losses,
3781            &mut self.state,
3782            &move |r: &Ranking| r.validate(&labels_for_check),
3783        )
3784        .await;
3785        if self.state.resource_block.is_some() {
3786            self.stop_for_resource()?;
3787            return Ok(false);
3788        }
3789
3790        // Refresh the judgement of every seat that ranked again.
3791        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3792        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3793            let agent_id = seat.agent.clone();
3794            self.state.seats.insert(seat.key.clone(), seat);
3795            let record = &mut self.state.judgements[j];
3796            match res {
3797                Ok((ranking, out)) => {
3798                    record.agent = agent_id;
3799                    record.ranking = ranking.normalized();
3800                    record.reasons = ranking.reasons;
3801                    record.strengths = ranking.strengths;
3802                    record.confidence = ranking.confidence;
3803                    record.failed = None;
3804                    record.duration_ms = out.duration_ms;
3805                    recovered.insert(j);
3806                    self.state.event(
3807                        "recover",
3808                        format!("judge {} ranked again after the limit", j + 1),
3809                    );
3810                }
3811                Err(e) => {
3812                    self.state
3813                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3814                }
3815            }
3816        }
3817
3818        // Re-ask the votes of the seats that recovered a ranking.
3819        let mut vote_jobs = Vec::new();
3820        let mut vote_pos: Vec<usize> = Vec::new();
3821        for &j in &recovered {
3822            let seat_key = format!("judge-{}", j + 1);
3823            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3824            let seat = self.seat(&seat_key, &spec.id);
3825            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3826            let text = if has_context(&spec, &seat, sessions) {
3827                prompt::final_vote(&labels, &language)
3828            } else {
3829                full.clone()
3830            };
3831            vote_jobs.push(SeatJob {
3832                spec,
3833                seat,
3834                prompt: text,
3835                cwd: root.join(seat_key),
3836                timeout,
3837                allow_write: false,
3838                sessions,
3839                artifacts: artifacts.clone(),
3840                stem: format!("vote-judge-{}-recover", j + 1),
3841                handover: Some(full),
3842            });
3843            vote_pos.push(j);
3844        }
3845        let allowed = labels.clone();
3846        let mut vote_losses = Vec::new();
3847        let vote_retries = self.state.config.graph.retries;
3848        let vote_cache = self.state.config.cache_dir();
3849        let ctx = WaveCtx {
3850            carry_seats: false,
3851            pauses: Some((&self.pause, &self.interrupt)),
3852            run: &run_id,
3853            node: "vote",
3854            prompts: &prompts,
3855            cache: vote_cache.as_deref(),
3856            round: None,
3857        };
3858        let votes = ask_json_wave::<FinalVote>(
3859            vote_jobs,
3860            Arc::clone(&self.sem),
3861            vote_retries,
3862            &self.roles.judge_roster,
3863            &ctx,
3864            &mut vote_losses,
3865            &mut self.state,
3866            &move |v: &FinalVote| match v.label() {
3867                Some(c) if allowed.contains(&c) => Ok(()),
3868                other => bail!("vote {other:?} is not one of {allowed:?}"),
3869            },
3870        )
3871        .await;
3872        if self.state.resource_block.is_some() {
3873            self.stop_for_resource()?;
3874            return Ok(false);
3875        }
3876        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3877            let agent_id = seat.agent.clone();
3878            self.state.seats.insert(seat.key.clone(), seat);
3879            match res {
3880                Ok((v, _)) => {
3881                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3882                        rec.vote = v.label();
3883                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3884                    } else {
3885                        self.state.votes.push(VoteRecord {
3886                            judge: j + 1,
3887                            agent: agent_id,
3888                            vote: v.label(),
3889                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3890                            changed: false,
3891                        });
3892                    }
3893                    self.state.event(
3894                        "recover",
3895                        format!("judge {} voted again after the limit", j + 1),
3896                    );
3897                }
3898                Err(e) => {
3899                    self.state
3900                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3901                }
3902            }
3903        }
3904
3905        // A seat that ranked again is present even if its re-vote failed —
3906        // `tally` falls back to the initial ranking's first choice — so clear
3907        // its quota loss. Seats that still fail keep theirs and stay absent.
3908        let recovered_keys: BTreeSet<String> = recovered
3909            .iter()
3910            .map(|&j| format!("judge-{}", j + 1))
3911            .collect();
3912        self.state
3913            .quota
3914            .retain(|q| !recovered_keys.contains(&q.seat));
3915        // A seat that hit the limit again is a fresh loss, not the old one:
3916        // replace the stale entry so the history stays one-per-seat and the
3917        // daemon can tell this attempt's loss from a previous session's.
3918        for loss in judge_losses.into_iter().chain(vote_losses) {
3919            if recovered_keys.contains(&loss.seat) {
3920                continue;
3921            }
3922            self.state.quota.retain(|q| q.seat != loss.seat);
3923            self.state.quota.push(loss);
3924        }
3925
3926        // Recompute the verdict from the refreshed panel.
3927        self.state.tally = None;
3928        self.tally()?;
3929        Ok(self
3930            .state
3931            .tally
3932            .as_ref()
3933            .map(|t| t.met_quorum)
3934            .unwrap_or(false))
3935    }
3936
3937    // ---------------------------------------------------------------- graft
3938
3939    /// Port the strengths the judges attributed to the losing candidates onto
3940    /// the winner, once, between the tally and the review loop.
3941    ///
3942    /// Best-effort by construction: it returns nothing and fails nothing. A
3943    /// skip, a fixer that failed or hit quota, a round that changed nothing
3944    /// and a result over the size limit ([`graft::limit`]) all leave the
3945    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
3946    /// run carries on as if the phase were off. See [`graft`] for the
3947    /// decisions and `AGENTS.md` for the invariants.
3948    ///
3949    /// The one exception is a rollback that could not be confirmed: the
3950    /// rejected commit may still be the winner's tip, so the run is blocked
3951    /// (resumable; the record stays `Running`, which makes a resume retry the
3952    /// restore) instead of reviewing, gating and merging it.
3953    async fn graft(&mut self) -> Result<()> {
3954        let Err(e) = self.graft_inner().await else {
3955            return Ok(());
3956        };
3957        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3958            self.state.status = RunStatus::Blocked;
3959            self.state.event("graft", format!("blocked: {e:#}"));
3960            self.state.save()?;
3961            return Err(e);
3962        }
3963        // An error after the `Running` record was saved may have left the
3964        // fixer's work in the tree: the record must not stay unsettled
3965        // (a later resume would reset past the review fixes), so confirm the
3966        // restore now, or block.
3967        if let Some(rec) = self.state.graft.clone()
3968            && !rec.outcome.settled()
3969        {
3970            if let Some(winner) = self.state.winner().cloned() {
3971                if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3972                    self.state.status = RunStatus::Blocked;
3973                    self.state.event("graft", format!("blocked: {re:#}"));
3974                    self.state.save()?;
3975                    return Err(re);
3976                }
3977            }
3978            if let Some(g) = self.state.graft.as_mut() {
3979                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3980            }
3981        }
3982        self.state.event("graft", format!("skipped: {e:#}"));
3983        if let Err(e) = self.state.save() {
3984            tracing::warn!("save after graft: {e:#}");
3985        }
3986        Ok(())
3987    }
3988
3989    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
3990    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
3991    async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3992        crate::recovery::restore_repair(wt, pre)
3993            .await
3994            .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
3995    }
3996
3997    /// A skip is a run event, recorded once however often the node is re-entered.
3998    fn graft_skipped(&mut self, why: &str) {
3999        let message = format!("skipped: {why}");
4000        if !self
4001            .state
4002            .events
4003            .iter()
4004            .any(|e| e.node == "graft" && e.message == message)
4005        {
4006            self.state.event("graft", message);
4007        }
4008    }
4009
4010    async fn graft_inner(&mut self) -> Result<()> {
4011        // A round that was started and never finished (crash, kill): the
4012        // fixer may have left anything in the tree, so put the winner back.
4013        if let Some(rec) = self.state.graft.clone()
4014            && !rec.outcome.settled()
4015        {
4016            // Reviews exist only if the graft ended without its record being
4017            // settled (a swallowed error): the tree has moved on since, and
4018            // a reset to `pre` would discard the review fixes.
4019            if self.state.reviews.is_empty()
4020                && let Some(winner) = self.state.winner().cloned()
4021            {
4022                Self::graft_restore(&winner.worktree, &rec.pre).await?;
4023            }
4024            if let Some(g) = self.state.graft.as_mut() {
4025                g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
4026            }
4027            self.state.event(
4028                "graft",
4029                "rolled back: the previous round was interrupted; the winner is untouched",
4030            );
4031            self.state.save()?;
4032            return Ok(());
4033        }
4034        let plan = match graft::plan(&self.state) {
4035            Ok(plan) => plan,
4036            Err(skip) => {
4037                if skip != graft::Skip::AlreadyDone {
4038                    self.graft_skipped(skip.reason());
4039                    self.state.save()?;
4040                }
4041                return Ok(());
4042            }
4043        };
4044        let Some(winner) = self.state.winner().cloned() else {
4045            return Ok(());
4046        };
4047        let wt = winner.worktree.clone();
4048        if !git::is_clean(&wt).await? {
4049            self.graft_skipped("the winner's worktree has uncommitted changes");
4050            self.state.save()?;
4051            return Ok(());
4052        }
4053        let pre = git::rev_parse(&wt, "HEAD").await?;
4054        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
4055        else {
4056            self.graft_skipped("the winner's diff could not be measured");
4057            self.state.save()?;
4058            return Ok(());
4059        };
4060        let limit = graft::limit(winner_lines);
4061        // Saved before the fixer is called: a stop from here on is repaired
4062        // by the branch above on the next entry.
4063        self.state.graft = Some(GraftRecord {
4064            pre: pre.clone(),
4065            post: None,
4066            lines: None,
4067            limit: Some(limit),
4068            agent: None,
4069            outcome: GraftOutcome::Running,
4070            requested: graft::requested(&plan),
4071            adoption: None,
4072            adoption_error: None,
4073        });
4074        self.state.event(
4075            "graft",
4076            format!(
4077                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4078                plan.strengths
4079                    .iter()
4080                    .map(|(l, _, _)| l.to_string())
4081                    .collect::<Vec<_>>()
4082                    .join(", "),
4083                plan.winner
4084            ),
4085        );
4086        self.state.save()?;
4087
4088        let fix_prompt = prompt::graft(
4089            &self.state.instruction,
4090            plan.winner,
4091            &plan.strengths,
4092            limit,
4093            &self.state.config.graph.language,
4094        );
4095        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4096        let sessions = self.state.config.graph.sessions;
4097        let artifacts = agent::artifacts_dir(&self.state.dir());
4098        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4099        let (_, seat, out) = self
4100            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4101                SeatJob {
4102                    prompt: fix_prompt.clone(),
4103                    spec,
4104                    seat,
4105                    cwd: wt.clone(),
4106                    timeout,
4107                    allow_write: true,
4108                    sessions,
4109                    artifacts: artifacts.clone(),
4110                    stem: "graft".to_owned(),
4111                    handover: None,
4112                }
4113            })
4114            .await;
4115        // Never spawned: nothing changed (`HEAD == pre` is confirmed), so the
4116        // record goes back to "not started" - not `RolledBack`, which would
4117        // settle it - and the run stops resumably. The seat, the chain and
4118        // the quota are untouched.
4119        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4120            Self::graft_restore(&wt, &pre).await?;
4121            self.state.graft = None;
4122            self.state.save()?;
4123            return self.stop_for_resource();
4124        }
4125        let mut why: Option<String> = None;
4126        match &out {
4127            AgentOutcome::Ok(o) if agent::output_advances(o) => {
4128                why = Some("the fixer produced nothing usable".to_owned());
4129            }
4130            AgentOutcome::Ok(_) => {}
4131            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4132            AgentOutcome::Quota(o) => {
4133                self.state.quota.push(QuotaLoss {
4134                    seat: seat.key.clone(),
4135                    node: "graft".to_owned(),
4136                    at: Timestamp::now(),
4137                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4138                });
4139                why = Some("rate limited (quota)".to_owned());
4140            }
4141            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4142                why = Some(format!(
4143                    "the fixer failed: {}",
4144                    e.lines()
4145                        .next()
4146                        .unwrap_or("")
4147                        .chars()
4148                        .take(160)
4149                        .collect::<String>()
4150                ));
4151            }
4152        }
4153        let agent_id = seat.agent.clone();
4154        self.state.seats.insert(seat.key.clone(), seat);
4155
4156        let mut post = None;
4157        let mut lines = None;
4158        if why.is_none() {
4159            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4160            if let Ok(r) = &rescued {
4161                self.state.note_withheld("graft", &r.withheld);
4162            }
4163            let head = git::rev_parse(&wt, "HEAD").await?;
4164            // What is measured must be what gets reviewed and gated: a failed
4165            // rescue, or anything still uncommitted (withheld lockfiles
4166            // aside, which are never part of the tree), rejects the graft.
4167            let dirty = match &rescued {
4168                Ok(r) => git::status_porcelain(&wt)
4169                    .await
4170                    .map(|st| {
4171                        st.lines().any(|l| {
4172                            !r.withheld
4173                                .iter()
4174                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
4175                        })
4176                    })
4177                    .unwrap_or(true),
4178                Err(_) => true,
4179            };
4180            if dirty {
4181                why = Some("the fixer left changes that could not be committed".to_owned());
4182            } else if head == pre {
4183                why = Some("the fixer changed nothing".to_owned());
4184            } else {
4185                lines = git::changed_lines(&wt, &pre, &head).await;
4186                if graft::within_budget(lines, limit) {
4187                    post = Some(head);
4188                } else {
4189                    why = Some(format!(
4190                        "{} changed lines against a limit of {limit}",
4191                        lines.map_or_else(
4192                            || "an unmeasurable number of".to_owned(),
4193                            |l| l.to_string()
4194                        )
4195                    ));
4196                }
4197            }
4198        }
4199        let (adoption, adoption_error) = match &out {
4200            AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4201            _ => (None, None),
4202        };
4203        let requested = graft::requested(&plan);
4204        let outcome = match why {
4205            None => {
4206                self.state.event(
4207                    "graft",
4208                    format!(
4209                        "applied: {} changed lines (limit {limit})",
4210                        lines.unwrap_or(0)
4211                    ),
4212                );
4213                GraftOutcome::Applied
4214            }
4215            Some(why) => {
4216                // Not settled until the restore is confirmed: the record
4217                // stays `Running`, so a resume retries it.
4218                Self::graft_restore(&wt, &pre).await?;
4219                self.state.event(
4220                    "graft",
4221                    format!("rolled back: {why}; the winner is untouched"),
4222                );
4223                GraftOutcome::RolledBack(why)
4224            }
4225        };
4226        self.state.graft = Some(GraftRecord {
4227            pre,
4228            post,
4229            lines,
4230            limit: Some(limit),
4231            agent: Some(agent_id),
4232            outcome,
4233            requested,
4234            adoption,
4235            adoption_error,
4236        });
4237        self.state.save()?;
4238        Ok(())
4239    }
4240
4241    // ----------------------------------------------------------------- fold
4242
4243    async fn fold_losers(&mut self) -> Result<()> {
4244        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4245            return Ok(());
4246        };
4247        let repo = self.state.repo.clone();
4248        let mut folded = Vec::new();
4249        for i in 0..self.state.candidates.len() {
4250            let c = &self.state.candidates[i];
4251            if c.label == winner || c.folded {
4252                continue;
4253            }
4254            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4255            git::worktree_remove(&repo, &wt).await.ok();
4256            git::branch_delete(&repo, &branch).await.ok();
4257            self.state.candidates[i].folded = true;
4258            folded.push(label.to_string());
4259        }
4260        // The judges are finished; their checkouts are pure cost from here.
4261        let root = self.state.worktree_root();
4262        for j in 1..=self.roles.judges.len() {
4263            let wt = root.join(format!("judge-{j}"));
4264            if wt.exists() {
4265                git::worktree_remove(&repo, &wt).await.ok();
4266            }
4267        }
4268        // The design-deliberation stage is finished by the time a tally
4269        // exists — same reasoning as the judges above.
4270        if self.state.config.graph.advise {
4271            for k in 1..=self.state.config.graph.advisors {
4272                let wt = root.join(format!("advisor-{k}"));
4273                if wt.exists() {
4274                    git::worktree_remove(&repo, &wt).await.ok();
4275                }
4276            }
4277        }
4278        if !folded.is_empty() {
4279            self.state
4280                .event("fold", format!("folded candidates {}", folded.join(", ")));
4281            self.state.save()?;
4282        }
4283        Ok(())
4284    }
4285
4286    // ------------------------------------------------------------ base sync
4287
4288    /// Land the winner's tree on the current tip of `<remote>/<base>` before
4289    /// anything verifies it.
4290    ///
4291    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
4292    /// read whatever is checked out in the winner's worktree. Left alone that
4293    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
4294    /// it when the run *branched* - and a run takes long enough that the base
4295    /// has usually moved by the time it gets here. A gate that ran there
4296    /// answers "green on the commit this run started from", not "green on
4297    /// what is about to land", and the difference showed up three times in
4298    /// one day as a green run whose merge would have reverted a file another
4299    /// pull request had already landed.
4300    ///
4301    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
4302    /// `land::Step::Rebase` calls, rather than a second implementation of the
4303    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
4304    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
4305    /// doc warns about ("two answers to a question nobody notices until a
4306    /// diff is wrong").
4307    ///
4308    /// A conflict is not the end of the road: the standing rebase is handed
4309    /// to the fixer seat, at most `graph.review_rounds` times, counted in
4310    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
4311    /// land). Once it finishes, review and the gate run as usual on the
4312    /// rebased tree, which is where a breakage the new base caused is caught
4313    /// by the ordinary gate-fix round. magi resolves nothing itself.
4314    ///
4315    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
4316    /// `state.base_sync.attempts`, is how many times the base is *rebased
4317    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
4318    /// *conflict* was given to a fixer. When the fixer cannot finish the
4319    /// rebase the branch is restored, `state.base_sync.conflict` is set with
4320    /// what was tried (rounds spent, paths still conflicted) and the branch
4321    /// and worktree stay exactly as they were - untouched, for a person to
4322    /// look at - which is also what makes re-entering this function
4323    /// afterwards a no-op instead of a second attempt at the same wall. A
4324    /// push failure ends the same way.
4325    async fn sync_to_base(&mut self) -> Result<()> {
4326        if self.state.status == RunStatus::AlreadyInBase {
4327            return Ok(());
4328        }
4329        let conflicted = self
4330            .state
4331            .base_sync
4332            .as_ref()
4333            .is_some_and(|s| s.conflict.is_some());
4334        let Some(winner) = self.state.winner().cloned() else {
4335            return Ok(());
4336        };
4337
4338        let repo = self.state.repo.clone();
4339        let remote = self.state.config.merge.remote.clone();
4340        let base_branch = self.state.base_branch.clone();
4341        let tracking = format!("{remote}/{base_branch}");
4342
4343        git::fetch(&repo, &remote, &base_branch).await.ok();
4344        // No network, or the remote never had this branch: the run already
4345        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
4346        // otherwise), and one that got this far is not blocked by a fetch
4347        // that fails now. It just has no newer tip to compare against.
4348        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4349            return Ok(());
4350        };
4351
4352        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4353        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4354        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4355
4356        // Before any rebase, and before a recorded conflict is honoured: a
4357        // branch whose change reached the base under other commit ids has
4358        // nothing to rebase and nothing to conflict with, and a run that
4359        // already stopped on that phantom conflict recovers here on resume.
4360        // `behind == 0` with head == tip is a branch the base has since taken
4361        // in whole, whether or not a conflict was ever recorded: the ancestry
4362        // proof must still run (`classify` ignores a head still on the start
4363        // commit).
4364        if (behind > 0 || conflicted || head == tip)
4365            && self
4366                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4367                .await?
4368        {
4369            return Ok(());
4370        }
4371        if conflicted {
4372            return Ok(());
4373        }
4374
4375        if behind == 0 {
4376            // A fixer-finished rebase moves the branch ref before the
4377            // winner's worktree is told (`sync_to_head` below). A run that
4378            // died in between resumes here with `behind == 0` and a tree still
4379            // holding the pre-rebase files, which review and the gate would
4380            // then read. That state is exactly: HEAD moved off the tip the
4381            // rebase started from, yet the tree is still identical to that
4382            // tip. A tree with edits of its own differs from it, so nothing
4383            // is thrown away.
4384            if let Some(from) = self
4385                .state
4386                .rebase_fixes
4387                .iter()
4388                .rev()
4389                .find_map(|r| r.from.clone())
4390                && from != head
4391                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4392                    .await
4393                    .is_ok_and(|o| o.ok())
4394            {
4395                git::sync_to_head(&winner.worktree).await?;
4396            }
4397            // An earlier attempt may have rebased the branch locally and died
4398            // before pushing it (only the fresh-rebase arm below pushes).
4399            // Publish it now, so the plain push at PR time is not refused as
4400            // a non-fast-forward. A run already holding a recorded conflict
4401            // never reaches here; that case is out of scope.
4402            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4403            if let Some(why) = &conflict {
4404                self.state.status = RunStatus::Blocked;
4405                self.state.event("land", why.clone());
4406            }
4407            self.state.base_sync = Some(BaseSync {
4408                tip,
4409                behind: 0,
4410                attempts,
4411                conflict,
4412                already_in: None,
4413            });
4414            self.state.save()?;
4415            return Ok(());
4416        }
4417
4418        if attempts >= BASE_SYNC_ROUNDS {
4419            let why = format!(
4420                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4421                 rebase(s); rebasing again would only race it",
4422                winner.branch
4423            );
4424            self.state.status = RunStatus::Blocked;
4425            self.state.base_sync = Some(BaseSync {
4426                tip,
4427                behind,
4428                attempts,
4429                conflict: Some(why.clone()),
4430                already_in: None,
4431            });
4432            self.state.event("land", why);
4433            self.state.save()?;
4434            return Ok(());
4435        }
4436
4437        self.state.event(
4438            "land",
4439            format!(
4440                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4441                winner.branch
4442            ),
4443        );
4444        self.state.save()?;
4445
4446        // The remote's copy of the branch, read now and only if the fetch
4447        // really succeeded (a stale tracking ref must never pin a lease). It is
4448        // pushed over after a rebase only when it is a commit this branch
4449        // already contains, by ancestry or by patch (an earlier rebase of ours
4450        // that never reached the remote): anything else is somebody else's work.
4451        let branch_tracking = format!("{remote}/{}", winner.branch);
4452        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4453        let remote_tip = if self.state.task_pr_branch().is_none()
4454            && matches!(&fetched_branch, Ok(o) if o.ok())
4455        {
4456            git::rev_parse(&repo, &branch_tracking).await.ok()
4457        } else {
4458            None
4459        };
4460        // A remote tip this branch does not contain is somebody else's work:
4461        // rebasing would leave a local tip that can never be pushed. Stop
4462        // before touching anything and say so.
4463        if let Some(theirs) = &remote_tip
4464            && !git::is_ancestor(&repo, theirs, &head).await
4465            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4466                .await
4467                .is_ok_and(|missing| missing.is_empty())
4468        {
4469            let why = format!(
4470                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4471                 them",
4472                short(theirs),
4473                winner.branch
4474            );
4475            self.state.status = RunStatus::Blocked;
4476            self.state.base_sync = Some(BaseSync {
4477                tip,
4478                behind,
4479                attempts,
4480                conflict: Some(why.clone()),
4481                already_in: None,
4482            });
4483            self.state.event("land", why);
4484            self.state.save()?;
4485            return Ok(());
4486        }
4487
4488        let scratch = self.state.dir().join("base-sync");
4489        let rebased = match crate::rebase::rebase_with_fixer(
4490            &mut self.state,
4491            &scratch,
4492            &winner.branch,
4493            &tracking,
4494        )
4495        .await
4496        {
4497            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4498            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4499            Err(e) => Err(e),
4500        };
4501        let attempts = attempts + 1;
4502        match rebased {
4503            Ok(None) => {
4504                // The branch ref moved, but a worktree that already had it
4505                // checked out (the winner's) was not told; sync its index and
4506                // files before anything reads them.
4507                git::sync_to_head(&winner.worktree).await?;
4508                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4509                let mut conflict = None;
4510                if let Some(pinned) = &remote_tip {
4511                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4512                    match pushed {
4513                        Ok(o) if o.ok() => self.state.event(
4514                            "land",
4515                            format!("pushed rebased {} to {remote}", winner.branch),
4516                        ),
4517                        Ok(o) => {
4518                            conflict = Some(format!(
4519                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4520                                winner.branch,
4521                                short(pinned),
4522                                o.stderr.chars().take(600).collect::<String>()
4523                            ));
4524                        }
4525                        Err(e) => {
4526                            conflict = Some(format!(
4527                                "rebased {} locally but could not push it: {e:#}",
4528                                winner.branch
4529                            ));
4530                        }
4531                    }
4532                }
4533                if let Some(why) = &conflict {
4534                    self.state.status = RunStatus::Blocked;
4535                    self.state.event("land", why.clone());
4536                }
4537                self.state.base_sync = Some(BaseSync {
4538                    tip: tip.clone(),
4539                    behind: 0,
4540                    attempts,
4541                    conflict,
4542                    already_in: None,
4543                });
4544                self.state
4545                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4546            }
4547            Ok(Some(conflict)) => {
4548                let why = format!(
4549                    "{} conflicts with {tracking} and did not rebase: {}",
4550                    winner.branch,
4551                    conflict.chars().take(600).collect::<String>()
4552                );
4553                self.state.status = RunStatus::Blocked;
4554                self.state.base_sync = Some(BaseSync {
4555                    tip,
4556                    behind,
4557                    attempts,
4558                    conflict: Some(why.clone()),
4559                    already_in: None,
4560                });
4561                self.state.event("land", why);
4562            }
4563            Err(e) => {
4564                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4565                self.state.status = RunStatus::Blocked;
4566                self.state.base_sync = Some(BaseSync {
4567                    tip,
4568                    behind,
4569                    attempts,
4570                    conflict: Some(why.clone()),
4571                    already_in: None,
4572                });
4573                self.state.event("land", why);
4574            }
4575        }
4576        self.state.save()?;
4577        Ok(())
4578    }
4579
4580    /// Push a branch an earlier attempt rebased locally but never published,
4581    /// pinned to the remote tip read right after a successful fetch. Returns
4582    /// the reason when the run must stop; `None` when there was nothing to do
4583    /// (no remote copy, the same tip, or a remote copy this branch already
4584    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4585    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4586        // Task competitions publish only after the PR text gate, to their
4587        // public head; never resurrect a candidate branch on the remote.
4588        if self.state.task_pr_branch().is_some() {
4589            return None;
4590        }
4591        let repo = self.state.repo.clone();
4592        let remote = self.state.config.merge.remote.clone();
4593        let fetched = git::fetch(&repo, &remote, branch).await;
4594        if !matches!(&fetched, Ok(o) if o.ok()) {
4595            return None;
4596        }
4597        let branch_tracking = format!("{remote}/{branch}");
4598        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4599        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4600            return None;
4601        }
4602        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4603            .await
4604            .is_ok_and(|missing| missing.is_empty())
4605        {
4606            return Some(format!(
4607                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4608                 them",
4609                short(&theirs)
4610            ));
4611        }
4612        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4613            Ok(o) if o.ok() => {
4614                self.state
4615                    .event("land", format!("pushed rebased {branch} to {remote}"));
4616                None
4617            }
4618            Ok(o) => Some(format!(
4619                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4620                 someone may have pushed): {}",
4621                short(&theirs),
4622                o.stderr.chars().take(600).collect::<String>()
4623            )),
4624            Err(e) => Some(format!(
4625                "{branch} is rebased locally but could not be pushed: {e:#}"
4626            )),
4627        }
4628    }
4629
4630    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4631    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4632    /// whether it did.
4633    ///
4634    /// Checked only when the base is ahead of the branch. A failing check is
4635    /// "not proven" - the ordinary rebase path then decides - never a reason to
4636    /// stop the run.
4637    ///
4638    /// The remote copy of the branch is held to the same standard as the local
4639    /// one: if it carries a tip this worktree does not, that tip must itself be
4640    /// proven in the base, or nothing is settled (a pull request would
4641    /// otherwise be closed over commits nobody checked). The pull request is
4642    /// closed *before* the terminal status is saved; if that fails for a
4643    /// reason other than a refusal (no network, a `gh` error) the run is left
4644    /// `Blocked` with the reason as its conflict, which a resume retries -
4645    /// the same recovery a phantom conflict gets.
4646    async fn settle_already_in(
4647        &mut self,
4648        branch: &str,
4649        tip: &str,
4650        head: &str,
4651        attempts: usize,
4652        behind: usize,
4653    ) -> Result<bool> {
4654        let repo = self.state.repo.clone();
4655        let remote = self.state.config.merge.remote.clone();
4656        let start = self.state.base_commit.clone();
4657        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4658            Ok(Some(e)) => e,
4659            Ok(None) => return Ok(false),
4660            Err(e) => {
4661                tracing::warn!("already-in-base check for {branch}: {e:#}");
4662                return Ok(false);
4663            }
4664        };
4665        let mut verified = vec![head.to_owned()];
4666        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4667        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4668        if matches!(&fetched, Ok(o) if o.ok())
4669            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4670            && theirs != head
4671        {
4672            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4673                Ok(Some(_)) => verified.push(theirs),
4674                _ => return Ok(false),
4675            }
4676        }
4677        let base_branch = self.state.base_branch.clone();
4678        let message = format!(
4679            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4680             land",
4681            evidence.names(),
4682            evidence.proof.as_str()
4683        );
4684        let closed =
4685            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4686                .await;
4687        match closed {
4688            Ok(Ok(url)) => self
4689                .state
4690                .event("land", format!("closed {url}: superseded on {base_branch}")),
4691            Ok(Err(why)) => self
4692                .state
4693                .event("land", format!("did not close a pull request: {why}")),
4694            Err(e) => {
4695                let why = format!(
4696                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4697                     not be closed ({e:#}); resume to retry"
4698                );
4699                self.state.status = RunStatus::Blocked;
4700                self.state.base_sync = Some(BaseSync {
4701                    tip: tip.to_owned(),
4702                    behind,
4703                    attempts,
4704                    conflict: Some(why.clone()),
4705                    already_in: None,
4706                });
4707                self.state.event("land", why);
4708                self.state.save()?;
4709                return Ok(true);
4710            }
4711        }
4712        self.state.status = RunStatus::AlreadyInBase;
4713        self.state.base_sync = Some(BaseSync {
4714            tip: tip.to_owned(),
4715            behind,
4716            attempts,
4717            conflict: None,
4718            already_in: Some(evidence),
4719        });
4720        self.state.event("land", message);
4721        self.state.save()?;
4722        self.settle_questions();
4723        Ok(true)
4724    }
4725
4726    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4727    /// last landed the winner on, once it has run, else the commit the run
4728    /// branched from.
4729    ///
4730    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4731    /// and `vote` all happen before there is a winner to rebase, so they
4732    /// compare every candidate against the branch point on purpose, and a
4733    /// base that moves after they are already done cannot change an answer
4734    /// they already gave.
4735    fn landing_base(&self) -> String {
4736        self.state
4737            .base_sync
4738            .as_ref()
4739            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4740    }
4741
4742    // ------------------------------------------------------- operator fix
4743
4744    /// Route specific, already-recorded review findings to a fixer for a
4745    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4746    /// entry point.
4747    ///
4748    /// Distinct from `review_loop`'s own fix step in three ways: it never
4749    /// runs a reviewer wave, it never spends review-round budget, and what
4750    /// happened is recorded as an [`OperatorFixRequest`] appended to
4751    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4752    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4753    /// and vote must never be rewritten to look like a manufactured blocking
4754    /// verdict.
4755    ///
4756    /// Only meaningful once review has actually concluded: `Ready` (handed
4757    /// off with findings still open, or simply concluded clean while minor
4758    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4759    /// gate failed). Everything else is refused: a run still in progress
4760    /// should simply be resumed, and a `Merged` run's branch has already
4761    /// landed — reopening *this* run's own record cannot change that, so the
4762    /// answer there is a fresh `magi review <branch>`.
4763    ///
4764    /// A real commit here re-verifies through a fresh, ordinary review-only
4765    /// run on the same branch ([`Self::review`]) rather than reopening this
4766    /// run's own `review_loop`: once any round in this run's history went
4767    /// clean, `review_conclusion` treats that as permanent by design (the
4768    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4769    /// way to force one more genuine reviewer wave out of *this* run without
4770    /// either rewriting history or weakening that guarantee for every other
4771    /// caller. A review-only run costs nothing extra — no implementation, no
4772    /// judging, no vote — and exercises the exact same review → verify →
4773    /// gate → (human) merge path, unmodified.
4774    pub async fn fix_selected(
4775        &mut self,
4776        ids: &[String],
4777        reason: &str,
4778        allow_stale: bool,
4779    ) -> Result<()> {
4780        let reason = reason.trim();
4781        if reason.is_empty() {
4782            bail!("a fix request needs a reason — that is the operator's own record of why");
4783        }
4784        if ids.is_empty() {
4785            bail!("no finding id given");
4786        }
4787        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4788            bail!(
4789                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4790                 has already concluded — can be given a targeted fix. A run still \
4791                 in progress should simply be resumed; a `merged` run's branch has \
4792                 already landed, so its answer is a fresh `magi review <branch>`, \
4793                 not reopening this run's own record",
4794                self.state.id,
4795                self.state.status.as_str()
4796            );
4797        }
4798        let Some(winner) = self.state.winner().cloned() else {
4799            bail!("run {} has no winning candidate to fix", self.state.id);
4800        };
4801        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4802            bail!(
4803                "branch `{}` no longer exists; this run cannot be extended",
4804                winner.branch
4805            );
4806        }
4807        let home = crate::run::home();
4808        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4809            bail!(
4810                "run {} is currently being worked on by another magi process",
4811                self.state.id
4812            );
4813        }
4814        // Held for the rest of this call, including the follow-up review
4815        // below: two `magi fix` invocations against the same run must not
4816        // both reach the worktree manipulation further down, which would
4817        // otherwise race to remove and recreate the same directory — see
4818        // [`FixClaim`]'s own doc.
4819        let _claim = FixClaim::acquire(&self.state.dir())?;
4820
4821        // Resolve every id before spending anything — an unknown id refuses
4822        // the whole request rather than silently dropping it — and dedup
4823        // while keeping the operator's own order.
4824        let mut seen = BTreeSet::new();
4825        let mut findings = Vec::new();
4826        let mut missing = Vec::new();
4827        for id in ids {
4828            if !seen.insert(id.clone()) {
4829                continue;
4830            }
4831            match self.state.finding(id) {
4832                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4833                    id: f.id.clone(),
4834                    severity: f.severity,
4835                    reviewer_vote: rec.vote,
4836                    round: round.round,
4837                    round_head: round.head.clone(),
4838                    reviewer: rec.reviewer,
4839                    agent: rec.agent.clone(),
4840                    file: f.file.clone(),
4841                    line: f.line,
4842                    title: f.title.clone(),
4843                    detail: f.detail.clone(),
4844                    outcome: OperatorFixOutcome::Pending,
4845                }),
4846                None => missing.push(id.clone()),
4847            }
4848        }
4849        if !missing.is_empty() {
4850            bail!(
4851                "unknown finding id(s): {}; nothing was changed",
4852                missing.join(", ")
4853            );
4854        }
4855
4856        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4857        let stale_details: Vec<(String, String)> = findings
4858            .iter()
4859            .filter(|f| f.round_head != head_at_request)
4860            .map(|f| (f.id.clone(), f.round_head.clone()))
4861            .collect();
4862        let stale = !stale_details.is_empty();
4863        if stale && !allow_stale {
4864            bail!(
4865                "the branch has moved since some finding(s) were raised — {} — now \
4866                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4867                stale_details
4868                    .iter()
4869                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4870                    .collect::<Vec<_>>()
4871                    .join(", "),
4872                short(&head_at_request)
4873            );
4874        }
4875
4876        let request = OperatorFixRequest {
4877            requested_at: Timestamp::now(),
4878            reason: reason.to_owned(),
4879            findings,
4880            head_at_request: head_at_request.clone(),
4881            allow_stale,
4882            stale,
4883            fix: None,
4884            result_head: None,
4885            follow_up_review_run: None,
4886        };
4887        self.state.event(
4888            "fix",
4889            format!(
4890                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4891                request.findings.len(),
4892                request
4893                    .findings
4894                    .iter()
4895                    .map(|f| f.id.as_str())
4896                    .collect::<Vec<_>>()
4897                    .join(", "),
4898            ),
4899        );
4900        // Recorded now, before any worktree work or the fixer call itself —
4901        // and re-saved at each checkpoint below: a crash at any point after
4902        // this (mid fixer call, mid follow-up review) must not lose the fact
4903        // that this was requested, for which findings, and why. Everything
4904        // past this point reads and writes through `request_index` rather
4905        // than a local variable, since `request` itself is moved here.
4906        self.state.operator_fixes.push(request);
4907        self.state.save()?;
4908        let request_index = self.state.operator_fixes.len() - 1;
4909
4910        // A fresh, dedicated worktree for this one call, never the winner's
4911        // own worktree in place: that one may already be gone (folded away),
4912        // and reusing it in place would leave the branch checked out there
4913        // when the follow-up review below tries to check it out again. Freed
4914        // immediately after, either way — but only once confirmed clean:
4915        // `worktree_remove` is a `git worktree remove --force`, which would
4916        // otherwise discard uncommitted work left there by the operator or
4917        // another process before this had a chance to even look at it.
4918        if winner.worktree.exists() {
4919            // Lockfiles a rescue commit withheld stay untracked on purpose and
4920            // are already recorded; they are not the operator's work to protect.
4921            let dirty = git::git(
4922                &winner.worktree,
4923                &["status", "--porcelain", "--untracked-files=all"],
4924            )
4925            .await?;
4926            let only_withheld = dirty.lines().all(|l| {
4927                l.strip_prefix("?? ")
4928                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4929            });
4930            if !only_withheld {
4931                bail!(
4932                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4933                     discard them first",
4934                    winner.worktree.display()
4935                );
4936            }
4937            git::worktree_remove(&self.state.repo, &winner.worktree)
4938                .await
4939                .ok();
4940        }
4941        let fix_worktree = self.state.worktree_root().join("operator-fix");
4942        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4943        git::git(
4944            &self.state.repo,
4945            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4946        )
4947        .await
4948        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4949        if !git::is_clean(&fix_worktree).await? {
4950            git::worktree_remove(&self.state.repo, &fix_worktree)
4951                .await
4952                .ok();
4953            bail!(
4954                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4955                winner.branch
4956            );
4957        }
4958        let repo_for_setup = self.state.repo.clone();
4959        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4960            Ok(()) => {
4961                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4962            }
4963            Err(e) => Err(e),
4964        };
4965        if let Err(e) = setup {
4966            git::worktree_remove(&self.state.repo, &fix_worktree)
4967                .await
4968                .ok();
4969            return Err(e);
4970        }
4971
4972        let run_id = self.state.id.clone();
4973        let prompts = self.state.config.prompts.clone();
4974        let language = self.state.config.graph.language.clone();
4975        let sessions = self.state.config.graph.sessions;
4976        let artifacts = agent::artifacts_dir(&self.state.dir());
4977        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4978            .findings
4979            .iter()
4980            .map(|f| Finding {
4981                id: f.id.clone(),
4982                severity: f.severity,
4983                file: f.file.clone(),
4984                line: f.line,
4985                title: f.title.clone(),
4986                detail: f.detail.clone(),
4987            })
4988            .collect();
4989        let fix_prompt = prompt::operator_fix_in(
4990            &self.state.instruction,
4991            &finding_list,
4992            reason,
4993            &stale_details,
4994            &head_at_request,
4995            &language,
4996            &self.state.config.graph.github_language,
4997        );
4998        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4999        let (job, seat, out) = self
5000            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
5001                prompt: fix_prompt.clone(),
5002                spec,
5003                seat,
5004                cwd: fix_worktree.clone(),
5005                timeout,
5006                allow_write: true,
5007                sessions,
5008                artifacts: artifacts.clone(),
5009                stem: "operator-fix".to_owned(),
5010                handover: None,
5011            })
5012            .await;
5013        let agent_id = seat.agent.clone();
5014
5015        let mut fix = FixRecord {
5016            agent: agent_id,
5017            addressed: Vec::new(),
5018            rejected: Vec::new(),
5019            notes: String::new(),
5020            committed: false,
5021            failed: None,
5022            duration_ms: 0,
5023            continuation: None,
5024        };
5025        let mut final_seat = seat.clone();
5026        match out {
5027            AgentOutcome::Ok(o) => {
5028                fix.duration_ms = o.duration_ms;
5029                let parsed = verdict::extract_json::<FixReport>(&o.text);
5030                let incomplete_reason = match &parsed {
5031                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5032                        "the reply parsed, but it reported a command whose own CLI \
5033                         never confirmed an exit status"
5034                            .to_owned(),
5035                    ),
5036                    Ok(_) => None,
5037                    Err(e) => Some(e.to_string()),
5038                };
5039                match incomplete_reason {
5040                    None => {
5041                        let report = parsed.expect("checked Ok above");
5042                        fix.addressed = report.addressed;
5043                        fix.rejected = report.rejected;
5044                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5045                    }
5046                    Some(reason) => {
5047                        let (resumed_seat, resolved, failure, cont) = self
5048                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
5049                            .await;
5050                        fix.duration_ms += cont.cumulative_wait_ms;
5051                        fix.continuation = Some(cont);
5052                        final_seat = resumed_seat;
5053                        match resolved {
5054                            Some(report) => {
5055                                fix.addressed = report.addressed;
5056                                fix.rejected = report.rejected;
5057                                fix.notes =
5058                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
5059                            }
5060                            None => fix.failed = failure,
5061                        }
5062                    }
5063                }
5064            }
5065            AgentOutcome::Dropped(o) => {
5066                fix.duration_ms = o.duration_ms;
5067                let why = o
5068                    .dropped
5069                    .as_ref()
5070                    .map(|d| d.why.as_str())
5071                    .unwrap_or("the CLI ended the stream without delivering its answer");
5072                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5073            }
5074            AgentOutcome::Quota(o) => {
5075                self.state.quota.push(QuotaLoss {
5076                    seat: final_seat.key.clone(),
5077                    node: "fix".to_owned(),
5078                    at: Timestamp::now(),
5079                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5080                });
5081                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5082            }
5083            AgentOutcome::Failed(e) => fix.failed = Some(e),
5084            AgentOutcome::ResourceBlocked(e) => {
5085                fix.failed = Some(format!("the fixer was not started: {e}"));
5086            }
5087        }
5088        if fix.continuation.is_none() {
5089            fix.continuation = Some(ContinuationRecord::not_needed());
5090        }
5091        self.state.seats.insert(final_seat.key.clone(), final_seat);
5092
5093        let rescue_message = format!(
5094            "magi: operator-selected fix ({}) (uncommitted work)",
5095            self.state.operator_fixes[request_index]
5096                .findings
5097                .iter()
5098                .map(|f| f.id.as_str())
5099                .collect::<Vec<_>>()
5100                .join(", ")
5101        );
5102        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5103            self.state.note_withheld("fix", &r.withheld);
5104        }
5105        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5106        fix.committed = after != head_at_request;
5107        git::worktree_remove(&self.state.repo, &fix_worktree)
5108            .await
5109            .ok();
5110
5111        self.state.event(
5112            "fix",
5113            match &fix.failed {
5114                Some(reason) => format!(
5115                    "operator fix: adoption report was lost ({reason}); {}",
5116                    if fix.committed {
5117                        "committed"
5118                    } else {
5119                        "NO new commit"
5120                    }
5121                ),
5122                None => format!(
5123                    "operator fix: {} addressed, {} rejected, {}",
5124                    fix.addressed.len(),
5125                    fix.rejected.len(),
5126                    if fix.committed {
5127                        "committed"
5128                    } else {
5129                        "NO new commit"
5130                    }
5131                ),
5132            },
5133        );
5134
5135        // Every selected finding gets an outcome — never left `Pending` once
5136        // the fixer's own turn is over. A report that never came back at all
5137        // marks every one of them `Unreported`, not silently "not addressed":
5138        // quota, a dropped stream, or an exhausted continuation are gaps in
5139        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
5140        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
5141        for f in &mut self.state.operator_fixes[request_index].findings {
5142            f.outcome = if fix.failed.is_some() {
5143                OperatorFixOutcome::Unreported
5144            } else if fix.addressed.contains(&f.id) {
5145                OperatorFixOutcome::Addressed
5146            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5147                OperatorFixOutcome::Rejected { why: r.why.clone() }
5148            } else {
5149                OperatorFixOutcome::Unreported
5150            };
5151        }
5152
5153        let committed = fix.committed;
5154        if committed {
5155            self.state.operator_fixes[request_index].result_head = Some(after.clone());
5156        }
5157        self.state.operator_fixes[request_index].fix = Some(fix);
5158        // Saved again now that the fixer's own outcome is final, on top of
5159        // the save right after the request was first pushed above.
5160        self.state.save()?;
5161
5162        if committed {
5163            self.state.event(
5164                "fix",
5165                format!(
5166                    "operator fix committed {}; opening a follow-up review-only run",
5167                    short(&after)
5168                ),
5169            );
5170            // The operator asked for the fix, and the follow-up serves whatever
5171            // task the run it follows served.
5172            let origin =
5173                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5174            match Self::review(
5175                &self.state.repo,
5176                &winner.branch,
5177                self.state.config.clone(),
5178                origin,
5179            )
5180            .await
5181            {
5182                Ok(mut follow_up) => {
5183                    follow_up.state.event(
5184                        "start",
5185                        format!(
5186                            "requested by an operator fix on run {} for finding(s) {}",
5187                            self.state.id,
5188                            self.state.operator_fixes[request_index]
5189                                .findings
5190                                .iter()
5191                                .map(|f| f.id.as_str())
5192                                .collect::<Vec<_>>()
5193                                .join(", "),
5194                        ),
5195                    );
5196                    follow_up.state.save()?;
5197                    let follow_up_id = follow_up.state.id.clone();
5198                    // The follow-up is a run like any other: it belongs to the
5199                    // task of the run it follows, or to one filed for it.
5200                    let adopted = match crate::direct::adopt(
5201                        &follow_up.state,
5202                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5203                    ) {
5204                        Ok(a) => a,
5205                        Err(e) => {
5206                            // An ownerless run must not spend agent calls; it
5207                            // stays saved, and `magi run --resume` adopts it.
5208                            self.state.event(
5209                                "fix",
5210                                format!(
5211                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5212                                ),
5213                            );
5214                            self.state.save()?;
5215                            return Ok(());
5216                        }
5217                    };
5218                    let executed = follow_up.execute().await;
5219                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5220                    if let Some(a) = adopted {
5221                        a.finish(&follow_up.state, executed);
5222                    }
5223                    if let Some(e) = failure {
5224                        self.state.event(
5225                            "fix",
5226                            format!(
5227                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5228                            ),
5229                        );
5230                    }
5231                    self.state.operator_fixes[request_index].follow_up_review_run =
5232                        Some(follow_up_id);
5233                }
5234                Err(e) => {
5235                    self.state.event(
5236                        "fix",
5237                        format!("committed the fix but could not open a follow-up review: {e:#}"),
5238                    );
5239                }
5240            }
5241            self.state.save()?;
5242        }
5243
5244        Ok(())
5245    }
5246
5247    // --------------------------------------------------------------- review
5248
5249    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
5250    /// names, in order, each at most once, moving on only when the call
5251    /// advances (an error, a quota hit or nothing usable - the same decision
5252    /// point `agent::chain_advances` is for the other chained roles).
5253    ///
5254    /// Each agent gets its own seat from `Runner::seat`: the same agent
5255    /// continues its conversation (the winner's own implementer seat when it
5256    /// is the winner's author, now that the competition is over), another
5257    /// takes a fresh one so the full prompt is sent again. A handover is
5258    /// recorded under `node`, which is what makes the fallback stick for the
5259    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
5260    /// is returned, so an exhausted chain reads exactly like a single failed
5261    /// fixer: one quota loss, the same wording, the same refund. An earlier
5262    /// attempt's edits are left in the tree and judged, with the final
5263    /// attempt's, by what git says afterwards.
5264    ///
5265    /// Returns the job that produced the outcome, for `continue_fix_report`.
5266    async fn ask_fixer(
5267        &mut self,
5268        winner: &Candidate,
5269        node: &'static str,
5270        round: Option<usize>,
5271        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5272    ) -> (SeatJob, SeatState, AgentOutcome) {
5273        let attempts = fixer::attempts(&self.state, &self.roles, winner);
5274        self.ask_fixer_among(attempts, node, round, None, build)
5275            .await
5276    }
5277
5278    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
5279    /// worktree and a commit: before the next agent is asked after one that
5280    /// did not satisfy the call, the worktree is put back on that commit, so
5281    /// a failed agent's partial edits are not carried into the next one.
5282    async fn ask_fixer_among(
5283        &mut self,
5284        attempts: Vec<(AgentSpec, String)>,
5285        node: &'static str,
5286        round: Option<usize>,
5287        restore: Option<(&Path, &str)>,
5288        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5289    ) -> (SeatJob, SeatState, AgentOutcome) {
5290        let run_id = self.state.id.clone();
5291        let prompts = self.state.config.prompts.clone();
5292        let cache = self.state.config.cache_dir();
5293        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5294        let mut last = None;
5295        for (i, (spec, key)) in attempts.into_iter().enumerate() {
5296            let seat = self.seat(&key, &spec.id);
5297            let mut job = build(spec.clone(), seat);
5298            if i > 0 {
5299                job.stem = format!("{}-{}", job.stem, spec.id);
5300            }
5301            let ctx = WaveCtx {
5302                carry_seats: false,
5303                pauses: Some((&self.pause, &self.interrupt)),
5304                run: &run_id,
5305                node,
5306                prompts: &prompts,
5307                cache: cache.as_deref(),
5308                round,
5309            };
5310            let (seat, out) =
5311                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5312            // A resource block never advances the chain: the chosen agent
5313            // was not asked, so it must be the one asked again on resume.
5314            let advances = match &out {
5315                AgentOutcome::Ok(o) => agent::output_advances(o),
5316                AgentOutcome::ResourceBlocked(_) => false,
5317                _ => true,
5318            };
5319            if let Some(next) = ids.get(i + 1)
5320                && advances
5321            {
5322                self.state.seats.insert(seat.key.clone(), seat.clone());
5323                let class =
5324                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5325                let reason = match &out {
5326                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5327                    other => fail_reason(other),
5328                };
5329                record_handover(
5330                    &mut self.state,
5331                    node,
5332                    &seat.key,
5333                    &spec.id,
5334                    next,
5335                    &class,
5336                    &reason,
5337                );
5338                // A failed agent's edits must not reach the next one, and a
5339                // restore that did not work ends the chain: the call fails
5340                // and the caller's own (confirmed) restore decides.
5341                if let Some((wt, rev)) = restore
5342                    && let Err(e) = git::restore_to(wt, rev).await
5343                {
5344                    let why =
5345                        format!("could not restore the worktree before the next fixer: {e:#}");
5346                    tracing::warn!("{why}");
5347                    last = Some((job, seat, AgentOutcome::Failed(why)));
5348                    break;
5349                }
5350                continue;
5351            }
5352            last = Some((job, seat, out));
5353            break;
5354        }
5355        last.expect("the fixer chain always has an entry")
5356    }
5357
5358    /// The one repair a verification-only failure earns (see
5359    /// [`crate::recovery`]), before the first review round of a run that took
5360    /// a branch over.
5361    ///
5362    /// Saved as started *before* the fixer is called, so a resume never
5363    /// repairs twice; a start found on re-entry is rolled back to the commit
5364    /// it began from. A failed or unusable fixer leaves the tree as it was and
5365    /// the review goes on over it, so the first round's own verification
5366    /// reports the failure again. The failure is never carried into a round as
5367    /// a result: verification is re-run on whatever commit this leaves.
5368    async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5369        use crate::recovery::{Mode, Repair};
5370        if !self.state.reviews.is_empty() {
5371            return Ok(());
5372        }
5373        let Some(mut rec) = self.state.recovery.clone() else {
5374            return Ok(());
5375        };
5376        if rec.mode != Mode::VerifyRepair {
5377            return Ok(());
5378        }
5379        let wt = winner.worktree.clone();
5380        let head = git::rev_parse(&wt, "HEAD").await?;
5381        match rec.repair.clone() {
5382            Repair::Done { .. } => return Ok(()),
5383            Repair::Started { before } => {
5384                // Interrupted: the unfinished edits are not trusted.
5385                // An unconfirmed rollback blocks the run with `Started` kept,
5386                // so a resume retries it; the tree is never reviewed as is.
5387                if let Err(e) = Self::graft_restore(&wt, &before).await {
5388                    return self
5389                        .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5390                        .await;
5391                }
5392                rec.repair = Repair::Done {
5393                    before: before.clone(),
5394                    after: before,
5395                    committed: false,
5396                    failed: Some("interrupted; rolled back".to_owned()),
5397                };
5398                self.state
5399                    .event("recovery", "the repair was interrupted and rolled back");
5400                self.state.recovery = Some(rec);
5401                self.state.save()?;
5402                return Ok(());
5403            }
5404            Repair::NotStarted => {}
5405        }
5406        if crate::recovery::recheck(&mut rec, &head) {
5407            self.state.event("recovery", rec.summary());
5408            self.state.recovery = Some(rec);
5409            self.state.save()?;
5410            return Ok(());
5411        }
5412        rec.repair = Repair::Started {
5413            before: head.clone(),
5414        };
5415        self.state.recovery = Some(rec.clone());
5416        self.state.save()?;
5417
5418        let language = self.state.config.graph.language.clone();
5419        let sessions = self.state.config.graph.sessions;
5420        let artifacts = agent::artifacts_dir(&self.state.dir());
5421        let fix_prompt = prompt::recovery_fix(
5422            &self.state.instruction,
5423            &rec,
5424            self.state.config.graph.review_rounds,
5425            &language,
5426        );
5427        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5428        self.state.event(
5429            "recovery",
5430            format!(
5431                "repair: the fixer starts from the failed verification on {}",
5432                short(&head)
5433            ),
5434        );
5435        let (_job, seat, out) = self
5436            .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5437                prompt: fix_prompt.clone(),
5438                spec,
5439                seat,
5440                cwd: wt.clone(),
5441                timeout,
5442                allow_write: true,
5443                sessions,
5444                artifacts: artifacts.clone(),
5445                stem: "fix-recovery".to_owned(),
5446                handover: None,
5447            })
5448            .await;
5449        // Never spawned: nothing changed, so the repair goes back to "not
5450        // started" and the run stops resumably, as for a graft.
5451        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5452            if let Err(e) = Self::graft_restore(&wt, &head).await {
5453                return self
5454                    .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5455                    .await;
5456            }
5457            rec.repair = Repair::NotStarted;
5458            self.state.recovery = Some(rec);
5459            self.state.save()?;
5460            return self.stop_for_resource();
5461        }
5462        let failed = match &out {
5463            AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5464            AgentOutcome::Quota(o) => {
5465                self.state.quota.push(QuotaLoss {
5466                    seat: seat.key.clone(),
5467                    node: "fix".to_owned(),
5468                    at: Timestamp::now(),
5469                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5470                });
5471                Some("rate limited (quota); the fixer could not run".to_owned())
5472            }
5473            AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5474            AgentOutcome::Failed(e) => Some(e.clone()),
5475        };
5476        self.state.seats.insert(seat.key.clone(), seat);
5477        let failed = match failed {
5478            // The agent's partial edits are not kept.
5479            Some(why) => {
5480                if let Err(e) = git::restore_to(&wt, &head).await {
5481                    return self
5482                        .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5483                        .await;
5484                }
5485                Some(why)
5486            }
5487            None => {
5488                if let Ok(r) = git::rescue_commit(
5489                    &wt,
5490                    "magi: recovery repair of the failed verification (uncommitted work)",
5491                )
5492                .await
5493                {
5494                    self.state.note_withheld("fix", &r.withheld);
5495                }
5496                None
5497            }
5498        };
5499        let after = git::rev_parse(&wt, "HEAD").await?;
5500        self.state.event(
5501            "recovery",
5502            match &failed {
5503                Some(why) => format!("repair: nothing kept ({why})"),
5504                None if after != head => {
5505                    format!("repair: committed {}", short(&after))
5506                }
5507                None => "repair: the fixer made no commit".to_owned(),
5508            },
5509        );
5510        rec.repair = Repair::Done {
5511            before: head.clone(),
5512            committed: after != head,
5513            after,
5514            failed,
5515        };
5516        self.state.recovery = Some(rec);
5517        self.state.save()?;
5518        Ok(())
5519    }
5520
5521    /// A repair whose rollback could not be confirmed: the run is blocked and
5522    /// resumable, and the unconfirmed state is never reviewed.
5523    async fn stop_recovery(
5524        &mut self,
5525        mut rec: crate::recovery::RecoveryRecord,
5526        head: &str,
5527        why: String,
5528    ) -> Result<()> {
5529        // `Started` stays recorded: a resume retries the restore.
5530        rec.repair = crate::recovery::Repair::Started {
5531            before: head.to_owned(),
5532        };
5533        self.state.recovery = Some(rec);
5534        self.state
5535            .event("recovery", format!("repair could not be undone: {why}"));
5536        self.state.status = RunStatus::Blocked;
5537        self.state.save()?;
5538        bail!("recovery repair could not be rolled back: {why}")
5539    }
5540
5541    async fn review_loop(&mut self) -> Result<()> {
5542        // A base that would not rebase is a person's decision, not a review
5543        // round: nothing here would change the answer, and reviewers and a
5544        // fixer would be spending real budget on a tree that cannot land
5545        // regardless of what they find.
5546        if self
5547            .state
5548            .base_sync
5549            .as_ref()
5550            .is_some_and(|s| s.conflict.is_some())
5551        {
5552            return Ok(());
5553        }
5554        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
5555        // agent files with `magi task add` name the run that paid for it. The
5556        // prompt overlay is cloned alongside it because the waves borrow it
5557        // while `self` is mutably borrowed by the node's own bookkeeping.
5558        let run_id = self.state.id.clone();
5559        let prompts = self.state.config.prompts.clone();
5560        let Some(winner) = self.state.winner().cloned() else {
5561            return Ok(());
5562        };
5563        let max_rounds = self.state.config.graph.review_rounds;
5564        // A clean round, an exhausted round budget, or a stalled tree (see
5565        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
5566        // they are recorded — recomputed here, not read off `status`, so a
5567        // reentry into a run that already stopped restates the identical
5568        // verdict instead of silently handing back whatever an earlier node
5569        // in this same walk clobbered `status` to (a solo-candidate
5570        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
5571        // below runs an empty range once the budget is spent, and would
5572        // otherwise fall through without touching `status` at all.
5573        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5574            // A reentry after a crash between the last round's save and
5575            // `stop_reviewing` reaches the hand-off here, not there.
5576            if status == RunStatus::Gating {
5577                self.record_contested_handoff();
5578            }
5579            self.state.status = status;
5580            self.state.save()?;
5581            return Ok(());
5582        }
5583        self.state.status = RunStatus::Reviewing;
5584        // A last recorded round whose own verification never resolved
5585        // (`ResourceBlocked` — the shared build cache, not the patch) is
5586        // never a concluded round, whatever the round budget says: starting
5587        // a fresh round on top of it would spend a whole new reviewer wave
5588        // re-reading an unchanged patch instead of just retrying the one
5589        // check that actually needs it, and once the budget is spent the
5590        // loop below has nothing left to do at all (its range is empty).
5591        // Retry that check directly instead, exactly the same retry
5592        // `stop_reviewing` already does for its own catch-up case.
5593        if self
5594            .state
5595            .reviews
5596            .last()
5597            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5598        {
5599            let shell = self.state.config.shell();
5600            return self
5601                .stop_reviewing(
5602                    "the last round's own verification never resolved",
5603                    &shell,
5604                    &winner.worktree,
5605                )
5606                .await;
5607        }
5608
5609        self.recovery_repair(&winner).await?;
5610        if self.resource_halt()? {
5611            return Ok(());
5612        }
5613
5614        let repo = self.state.repo.clone();
5615        let root = self.state.worktree_root();
5616        let language = self.state.config.graph.language.clone();
5617        let sessions = self.state.config.graph.sessions;
5618        let artifacts = self.merge_repair_artifacts();
5619        let base = self.landing_base();
5620        let base_short = short(&base);
5621        let reviewers = self.roles.reviewers.clone();
5622        let shell = self.state.config.shell();
5623
5624        for round in (self.state.reviews.len() + 1)..=max_rounds {
5625            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5626            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5627            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5628            // The prior round's own record, already persisted — never a
5629            // hand-carried variable of just its failing output: that is
5630            // exactly what let a round's e2e result drift out of sync with
5631            // which commit it was actually about (see `SCHEMA`'s doc for
5632            // schema 8). Judged against `head`, the commit reviewers are
5633            // about to look at now, so the summary always reads as "an
5634            // earlier head" here — this round's own patch has not been
5635            // checked yet.
5636            let prev_verification = self
5637                .state
5638                .reviews
5639                .last()
5640                .and_then(|r| r.verification_summary(&head));
5641            // Only the first round of a run that took a branch over: later
5642            // rounds have this run's own history.
5643            let prior_section = match (&self.state.recovery, round) {
5644                (Some(rec), 1) if self.state.reviews.is_empty() => {
5645                    let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5646                        (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5647                            Some(crate::recovery::head_of(
5648                                &git::diff(&winner.worktree, reviewed, "HEAD")
5649                                    .await
5650                                    .unwrap_or_default(),
5651                                crate::recovery::SINCE_DIFF_MAX,
5652                            ))
5653                        }
5654                        _ => None,
5655                    };
5656                    Some(prompt::recovery_review(rec, since.as_deref()))
5657                }
5658                _ => None,
5659            };
5660
5661            // Each reviewer gets its own detached checkout of exactly this
5662            // commit: nobody can perturb the winner's tree, and the fixer can
5663            // keep working without racing a reviewer.
5664            let mut jobs = Vec::new();
5665            for (r, spec) in reviewers.iter().cloned().enumerate() {
5666                let wt = root.join(format!("review-{}", r + 1));
5667                // `clean -fdx` in a reset wipes setup's products, so every
5668                // round sets the seat up again.
5669                if wt.exists() {
5670                    git::reset_detached(&wt, &head).await?;
5671                } else {
5672                    git::worktree_add_detached(&repo, &wt, &head).await?;
5673                }
5674                self.setup_seat_worktree(&repo, &wt, false).await?;
5675                let seat_key = format!("review-{}", r + 1);
5676                // The seat starts the round on whoever answered it last, not
5677                // on the agent the spec names, so a failure is not re-paid.
5678                let spec = pick_start_spec(
5679                    &self.roles.reviewer_roster,
5680                    spec,
5681                    self.state.seat_history.get(&seat_key),
5682                );
5683                let seat = self.seat(&seat_key, &spec.id);
5684                jobs.push(SeatJob {
5685                    prompt: prompt::review(&prompt::ReviewCtx {
5686                        instruction: &self.state.instruction,
5687                        branch: &winner.branch,
5688                        base_short: &base_short,
5689                        stat: &stat,
5690                        patch: &patch,
5691                        verification: prev_verification.as_ref(),
5692                        prior: prior_section.as_deref(),
5693                        reviewers: reviewers.len(),
5694                        round,
5695                        rounds: max_rounds,
5696                        // A review-only run has no rankings, so nothing
5697                        // competed for this patch and the reviewer is told so.
5698                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5699                        lens: Lens::for_seat(r),
5700                        language: &language,
5701                        github_language: &self.state.config.graph.github_language,
5702                    }),
5703                    spec,
5704                    seat,
5705                    cwd: wt,
5706                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5707                    allow_write: false,
5708                    sessions,
5709                    artifacts: artifacts.clone(),
5710                    stem: format!("review-{round}-{}", r + 1),
5711                    handover: None,
5712                });
5713            }
5714
5715            self.state.event(
5716                "review",
5717                format!(
5718                    "round {round}: {} reviewers on {}",
5719                    jobs.len(),
5720                    short(&head)
5721                ),
5722            );
5723            let mut quota_losses = Vec::new();
5724            let review_retries = self.state.config.graph.retries;
5725            let review_cache = self.state.config.cache_dir();
5726            let ctx = WaveCtx {
5727                carry_seats: true,
5728                pauses: Some((&self.pause, &self.interrupt)),
5729                run: &run_id,
5730                node: "review",
5731                prompts: &prompts,
5732                cache: review_cache.as_deref(),
5733                round: Some(round),
5734            };
5735            let results = ask_json_wave::<Review>(
5736                jobs,
5737                Arc::clone(&self.sem),
5738                review_retries,
5739                &self.roles.reviewer_roster,
5740                &ctx,
5741                &mut quota_losses,
5742                &mut self.state,
5743                &|_: &Review| Ok(()),
5744            )
5745            .await;
5746            if self.state.resource_block.is_some() {
5747                return self.stop_for_resource();
5748            }
5749            // Counted before the move below: how many of *this* round's
5750            // reviewer seats were lost to their own rate limit, as opposed to
5751            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5752            let round_quota_missing = quota_losses.len();
5753            self.state.quota.extend(quota_losses);
5754
5755            let mut records = Vec::new();
5756            let mut all_findings = Vec::new();
5757            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5758                let agent_id = seat.agent.clone();
5759                self.state.seats.insert(seat.key.clone(), seat);
5760                let mut record = ReviewRecord {
5761                    reviewer: r + 1,
5762                    agent: agent_id,
5763                    summary: String::new(),
5764                    findings: Vec::new(),
5765                    vote: None,
5766                    failed: None,
5767                    duration_ms: 0,
5768                    // Set for both outcomes: `failed: Some(_)` with
5769                    // `attempts > 0` is a seat every retry still lost, not a
5770                    // recovered one — only `failed: None` with `attempts > 0`
5771                    // reads as "answered after a nudge" (see this field's own
5772                    // doc).
5773                    attempts,
5774                };
5775                match res {
5776                    Ok((review, out)) => {
5777                        // Sanitized here, at the point every other piece of
5778                        // agent prose in this file is (candidate summaries,
5779                        // deliberation turns, vote reasons): a reviewer's own
5780                        // words are the one thing about it that could name
5781                        // it, and reconsideration below broadcasts this same
5782                        // summary and these same findings to every other
5783                        // seat on the panel.
5784                        record.summary =
5785                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5786                        record.vote = Some(review.vote);
5787                        record.duration_ms = out.duration_ms;
5788                        for (n, mut f) in review.findings.into_iter().enumerate() {
5789                            // ids are magi's, never the agent's: the fixer's
5790                            // adoption report is keyed by them.
5791                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5792                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5793                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5794                            // `file` is agent-supplied prose too, never
5795                            // checked against the real tree — the same
5796                            // exposure `title`/`detail` above have, just in
5797                            // a field easy to forget because it looks like a
5798                            // path rather than free text.
5799                            f.file = f
5800                                .file
5801                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5802                            all_findings.push(f.clone());
5803                            record.findings.push(f);
5804                        }
5805                        self.state.event(
5806                            "review",
5807                            format!(
5808                                "round {round}: reviewer {} voted {} with {} finding(s)",
5809                                r + 1,
5810                                review.vote.label(),
5811                                record.findings.len()
5812                            ),
5813                        );
5814                    }
5815                    Err(e) => {
5816                        record.failed = Some(e.to_string());
5817                        self.state.event(
5818                            "review",
5819                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5820                        );
5821                    }
5822                }
5823                records.push(record);
5824            }
5825
5826            // Tally the round's votes and, if they split, spend the one
5827            // round of reconsideration the split -> deliberate -> revote
5828            // shape `judge`/`vote` use for the panel, sized down to what a
5829            // read-only review round can afford: one round, and a revote
5830            // rather than an argument, because the panel already wrote its
5831            // reasoning down as findings the first time around.
5832            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5833            let vote_split =
5834                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5835            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5836            if vote_split {
5837                self.state.event(
5838                    "review",
5839                    format!(
5840                        "round {round}: votes split ({}) — one round of reconsideration",
5841                        initial_votes
5842                            .iter()
5843                            .map(|v| v.label())
5844                            .collect::<Vec<_>>()
5845                            .join(", ")
5846                    ),
5847                );
5848                // Seats read every seat's findings and votes, still numbered
5849                // and never named — the same anonymity `review` itself keeps.
5850                let panel: Vec<ReviewSeatReport<'_>> = records
5851                    .iter()
5852                    .filter_map(|r| {
5853                        r.vote.map(|vote| ReviewSeatReport {
5854                            reviewer: r.reviewer,
5855                            vote,
5856                            summary: &r.summary,
5857                            findings: &r.findings,
5858                        })
5859                    })
5860                    .collect();
5861
5862                let mut jobs = Vec::new();
5863                let mut seats_at = Vec::new();
5864                for (r, spec) in reviewers.iter().cloned().enumerate() {
5865                    // A seat with no initial vote has nothing to reconsider
5866                    // from and stays absent, the same as it stayed absent
5867                    // from `panel` above.
5868                    if records[r].vote.is_none() {
5869                        continue;
5870                    }
5871                    let wt = root.join(format!("review-{}", r + 1));
5872                    let seat_key = format!("review-{}", r + 1);
5873                    let spec = self.occupant(&seat_key, spec);
5874                    let seat = self.seat(&seat_key, &spec.id);
5875                    // A seat with no live session has already forgotten the
5876                    // initial review's prompt — restate the patch it is
5877                    // voting on, the same as `deliberate`/`vote` do for a
5878                    // judge in the same position.
5879                    // The panel already carries this seat's own review and
5880                    // vote, so restating the patch makes the prompt whole for
5881                    // a seat handed to another agent.
5882                    let build = |with_patch: bool| {
5883                        prompt::review_reconsider(&ReviewReconsiderCtx {
5884                            instruction: &self.state.instruction,
5885                            reviewer: r + 1,
5886                            lens: Lens::for_seat(r),
5887                            panel: &panel,
5888                            patch: with_patch.then_some(ReviewPatch {
5889                                branch: &winner.branch,
5890                                base_short: &base_short,
5891                                stat: &stat,
5892                                patch: &patch,
5893                            }),
5894                            round,
5895                            rounds: max_rounds,
5896                            language: &language,
5897                        })
5898                    };
5899                    let full = build(true);
5900                    let prompt = if has_context(&spec, &seat, sessions) {
5901                        build(false)
5902                    } else {
5903                        full.clone()
5904                    };
5905                    jobs.push(SeatJob {
5906                        prompt,
5907                        spec,
5908                        seat,
5909                        cwd: wt,
5910                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5911                        allow_write: false,
5912                        sessions,
5913                        artifacts: artifacts.clone(),
5914                        stem: format!("review-{round}-reconsider-{}", r + 1),
5915                        handover: Some(full),
5916                    });
5917                    seats_at.push(r);
5918                }
5919
5920                let mut recon_quota_losses = Vec::new();
5921                let recon_cache = self.state.config.cache_dir();
5922                let recon_ctx = WaveCtx {
5923                    carry_seats: true,
5924                    pauses: Some((&self.pause, &self.interrupt)),
5925                    run: &run_id,
5926                    node: "review",
5927                    prompts: &prompts,
5928                    cache: recon_cache.as_deref(),
5929                    round: Some(round),
5930                };
5931                let recon_results = ask_json_wave::<ReviewRevote>(
5932                    jobs,
5933                    Arc::clone(&self.sem),
5934                    review_retries,
5935                    &self.roles.reviewer_roster,
5936                    &recon_ctx,
5937                    &mut recon_quota_losses,
5938                    &mut self.state,
5939                    &|_: &ReviewRevote| Ok(()),
5940                )
5941                .await;
5942                if self.state.resource_block.is_some() {
5943                    return self.stop_for_resource();
5944                }
5945                self.state.quota.extend(recon_quota_losses);
5946
5947                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5948                    let agent_id = seat.agent.clone();
5949                    self.state.seats.insert(seat.key.clone(), seat);
5950                    let mut rec = ReviewRevoteRecord {
5951                        reviewer: r + 1,
5952                        agent: agent_id,
5953                        vote: None,
5954                        reason: String::new(),
5955                        failed: None,
5956                    };
5957                    match res {
5958                        Ok((rv, _)) => {
5959                            rec.vote = Some(rv.vote);
5960                            rec.reason =
5961                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5962                            self.state.event(
5963                                "review",
5964                                format!(
5965                                    "round {round}: reviewer {} revoted {}",
5966                                    r + 1,
5967                                    rv.vote.label()
5968                                ),
5969                            );
5970                        }
5971                        Err(e) => {
5972                            rec.failed = Some(e.to_string());
5973                            self.state.event(
5974                                "review",
5975                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5976                            );
5977                        }
5978                    }
5979                    reconsideration.push(rec);
5980                }
5981            } else if initial_votes.len() > 1 {
5982                self.state.event(
5983                    "review",
5984                    format!(
5985                        "round {round}: votes agreed ({}) — no reconsideration",
5986                        initial_votes[0].label()
5987                    ),
5988                );
5989            }
5990
5991            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5992            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5993            // A round that already has a blocking finding and a round left to
5994            // try is going back to the fixer no matter what `verify.e2e`
5995            // says, so running it first only spends the loop's slowest step
5996            // (minutes, for a Rust repo's full test suite) on a head about
5997            // to be rewritten. Deferred, never skipped: `verify.e2e` still
5998            // runs once a round has no blocking findings left (see
5999            // `round_is_clean`, which a deferred — empty — `e2e` can never
6000            // satisfy since `blocking` is nonzero whenever this branch is
6001            // taken), and `stop_reviewing` forces a real run before it will
6002            // ever read a deferred round as green.
6003            let defer_e2e =
6004                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
6005            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
6006                let reason =
6007                    format!("{blocking} blocking finding(s) already required a fix this round");
6008                self.state.event(
6009                    "verify",
6010                    format!(
6011                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
6012                         {}); it will run once a round has none left",
6013                        short(&head)
6014                    ),
6015                );
6016                (Vec::new(), false, true, Some(reason))
6017            } else {
6018                let e2e_commands = self.state.config.verify.e2e.clone();
6019                let cache_dir = self.state.config.cache_dir();
6020                let context = format!("round {round}");
6021                let (e2e, verify_retried) = with_cache_lease(
6022                    &mut self.state,
6023                    cache_dir.as_deref(),
6024                    "e2e",
6025                    "e2e",
6026                    &winner.worktree,
6027                    &head,
6028                    verify_timeout,
6029                    &context,
6030                    |state, budget| {
6031                        let shell = shell.clone();
6032                        let e2e_commands = e2e_commands.clone();
6033                        let worktree = winner.worktree.clone();
6034                        let context = context.clone();
6035                        async move {
6036                            run_e2e_with_retry(
6037                                state,
6038                                &shell,
6039                                &e2e_commands,
6040                                &worktree,
6041                                budget,
6042                                &context,
6043                            )
6044                            .await
6045                        }
6046                    },
6047                )
6048                .await;
6049                (e2e, verify_retried, false, None)
6050            };
6051
6052            let expected = records.len();
6053            let answered = records.iter().filter(|r| r.failed.is_none()).count();
6054            let incomplete = answered < expected;
6055            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
6056            let policy = self.state.config.graph.incomplete_review;
6057            let clean = round_is_clean(
6058                blocking,
6059                e2e_ok,
6060                answered,
6061                expected,
6062                round_quota_missing,
6063                policy,
6064            );
6065
6066            let mut round_record = ReviewRound {
6067                round,
6068                head: head.clone(),
6069                verified_head: None,
6070                verified_at: None,
6071                reviews: records,
6072                e2e,
6073                verify_retried,
6074                e2e_deferred,
6075                e2e_defer_reason,
6076                fix: None,
6077                blocking,
6078                answered,
6079                expected,
6080                clean,
6081                progressed: false,
6082                vote_split,
6083                reconsideration,
6084                verdict: None,
6085            };
6086            // The final vote per seat is its revote where reconsideration
6087            // ran and answered, its initial vote otherwise — the same
6088            // fallback `tally` uses for a judge whose private vote failed.
6089            round_record.verdict = ReviewVote::worst(
6090                round_record
6091                    .final_votes()
6092                    .into_iter()
6093                    .map(|(_, _, vote)| vote),
6094            );
6095            // Which commit and when magi actually attempted to check —
6096            // known the moment a command was dispatched against `head`,
6097            // whether or not it finished: a resource-blocked attempt still
6098            // targeted a specific commit at a specific time, and leaving
6099            // that unrecorded is exactly what made `verification_summary`
6100            // report a fresh attempt as "commit unknown ... recorded before
6101            // this was tracked", indistinguishable from a genuinely old,
6102            // untracked record. Only a deferred or unconfigured round never
6103            // ran at all and has nothing to record — see
6104            // `ReviewRound::verified_head`'s own doc.
6105            if !matches!(
6106                round_record.e2e_status(),
6107                E2eStatus::Deferred | E2eStatus::NotConfigured
6108            ) {
6109                round_record.verified_head = Some(head.clone());
6110                round_record.verified_at = Some(Timestamp::now());
6111            }
6112            let this_round_verification = round_record.verification_summary(&head);
6113
6114            if incomplete {
6115                let missing: Vec<String> = round_record
6116                    .reviews
6117                    .iter()
6118                    .filter(|r| r.failed.is_some())
6119                    .map(|r| format!("review-{}", r.reviewer))
6120                    .collect();
6121                self.state.event(
6122                    "review",
6123                    format!(
6124                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6125                        missing.join(", ")
6126                    ),
6127                );
6128            }
6129
6130            if clean {
6131                self.state.event(
6132                    "review",
6133                    if incomplete && policy == IncompleteReviewPolicy::Warn {
6134                        format!(
6135                            "round {round}: clean (warn policy, incomplete panel) — no \
6136                             blocking findings from the seats that answered, verification green"
6137                        )
6138                    } else if incomplete {
6139                        format!(
6140                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6141                             quorum) — no blocking findings from the seats that answered, \
6142                             verification green",
6143                            expected - answered
6144                        )
6145                    } else {
6146                        format!("round {round}: clean — no blocking findings, verification green")
6147                    },
6148                );
6149                self.state.reviews.push(round_record);
6150                self.state.status = RunStatus::Gating;
6151                self.state.save()?;
6152                return Ok(());
6153            }
6154
6155            // Nothing was raised and verification passed, but not every seat
6156            // answered and `round_is_clean` still refused to call it clean —
6157            // either a seat is missing for a reason other than its own quota
6158            // (a crash, a timeout, unparsable output — worth another try), or
6159            // every seat that could have answered lost its quota and nobody
6160            // is left to decide on: re-review rather than send the fixer
6161            // after a round with nothing to fix.
6162            if incomplete && blocking == 0 && e2e_ok {
6163                self.state.reviews.push(round_record);
6164                self.state.save()?;
6165                if round == max_rounds {
6166                    self.state.status = RunStatus::Blocked;
6167                    self.state.event(
6168                        "review",
6169                        format!(
6170                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6171                             refusing to call it clean",
6172                            expected - answered
6173                        ),
6174                    );
6175                    return Ok(());
6176                }
6177                continue;
6178            }
6179
6180            // Nothing for the fixer to act on (`blocking == 0`) and the only
6181            // reason this round is not clean is that magi itself never got
6182            // a command to run — the shared build cache, not the patch (see
6183            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
6184            // the fixer would invite a change to appease contention that has
6185            // nothing to do with the diff, and would leave this attempt
6186            // sitting in the next round's prompt as if it were about an
6187            // earlier, superseded commit rather than what it actually is:
6188            // the same head, still waiting to be checked. Wait for it the
6189            // same way the final round's own contention is already handled,
6190            // whatever round this happens to be.
6191            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6192                self.state.reviews.push(round_record);
6193                return self
6194                    .stop_reviewing(
6195                        "the round's own verification could not run",
6196                        &shell,
6197                        &winner.worktree,
6198                    )
6199                    .await;
6200            }
6201
6202            if round == max_rounds {
6203                self.state.reviews.push(round_record);
6204                return self
6205                    .stop_reviewing(
6206                        &format!(
6207                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6208                        ),
6209                        &shell,
6210                        &winner.worktree,
6211                    )
6212                    .await;
6213            }
6214
6215            // Fix. The winner's own implementer seat continues its conversation:
6216            // the competition is over, so context is pure benefit now.
6217            let blocking_findings: Vec<_> = all_findings
6218                .iter()
6219                .filter(|f| f.severity.blocks())
6220                .cloned()
6221                .collect();
6222            let fix_prompt = prompt::fix_in(
6223                &self.state.instruction,
6224                &blocking_findings,
6225                this_round_verification.as_ref(),
6226                round,
6227                max_rounds,
6228                &language,
6229                &self.state.config.graph.github_language,
6230            );
6231            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6232            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6233            let (job, seat, out) = self
6234                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6235                    prompt: fix_prompt.clone(),
6236                    spec,
6237                    seat,
6238                    cwd: winner.worktree.clone(),
6239                    timeout,
6240                    allow_write: true,
6241                    sessions,
6242                    artifacts: artifacts.clone(),
6243                    stem: format!("fix-{round}"),
6244                    handover: None,
6245                })
6246                .await;
6247            // Not asked: the round is not recorded (so a resume repeats it),
6248            // no fixer was handed over to, and the seat is left as it was.
6249            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6250                return self.stop_for_resource();
6251            }
6252            let agent_id = seat.agent.clone();
6253
6254            let mut fix = FixRecord {
6255                agent: agent_id,
6256                addressed: Vec::new(),
6257                rejected: Vec::new(),
6258                notes: String::new(),
6259                committed: false,
6260                failed: None,
6261                duration_ms: 0,
6262                continuation: None,
6263            };
6264            let mut continuation = ContinuationRecord::not_needed();
6265            let mut final_seat = seat.clone();
6266            match out {
6267                AgentOutcome::Ok(o) => {
6268                    fix.duration_ms = o.duration_ms;
6269                    let parsed = verdict::extract_json::<FixReport>(&o.text);
6270                    // A parsed report standing next to a command this same
6271                    // reply's own CLI never confirmed the exit status of is
6272                    // not a resolved answer — the identical `CommandEvidence`
6273                    // `state.jobs` renders, read here instead of only on
6274                    // display, per the completion judgment and the shown
6275                    // record needing to agree.
6276                    let incomplete_reason = match &parsed {
6277                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6278                            "the reply parsed, but it reported a command whose own CLI never \
6279                             confirmed an exit status"
6280                                .to_owned(),
6281                        ),
6282                        Ok(_) => None,
6283                        Err(e) => Some(e.to_string()),
6284                    };
6285                    match incomplete_reason {
6286                        None => {
6287                            let report = parsed.expect("checked Ok above");
6288                            fix.addressed = report.addressed;
6289                            fix.rejected = report.rejected;
6290                            fix.notes =
6291                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
6292                        }
6293                        Some(reason) => {
6294                            let (resumed_seat, resolved, failure, cont) = self
6295                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6296                                .await;
6297                            fix.duration_ms += cont.cumulative_wait_ms;
6298                            continuation = cont;
6299                            final_seat = resumed_seat;
6300                            match resolved {
6301                                Some(report) => {
6302                                    fix.addressed = report.addressed;
6303                                    fix.rejected = report.rejected;
6304                                    fix.notes = blind::sanitize_prose(
6305                                        &report.notes,
6306                                        &self.state.config.blind,
6307                                    );
6308                                }
6309                                None => fix.failed = failure,
6310                            }
6311                        }
6312                    }
6313                }
6314                // The CLI's raw error JSON is not a fix report to parse.
6315                AgentOutcome::Dropped(o) => {
6316                    fix.duration_ms = o.duration_ms;
6317                    let why = o
6318                        .dropped
6319                        .as_ref()
6320                        .map(|d| d.why.as_str())
6321                        .unwrap_or("the CLI ended the stream without delivering its answer");
6322                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6323                }
6324                AgentOutcome::Quota(o) => {
6325                    self.state.quota.push(QuotaLoss {
6326                        seat: final_seat.key.clone(),
6327                        node: "fix".to_owned(),
6328                        at: Timestamp::now(),
6329                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6330                    });
6331                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6332                }
6333                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6334            }
6335            fix.continuation = Some(continuation);
6336            self.state.seats.insert(final_seat.key.clone(), final_seat);
6337            if let Ok(r) = git::rescue_commit(
6338                &winner.worktree,
6339                &format!("magi: review round {round} fixes (uncommitted work)"),
6340            )
6341            .await
6342            {
6343                self.state.note_withheld("fix", &r.withheld);
6344            }
6345            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6346            fix.committed = after != before;
6347            // Judged by what `git` says moved against base, never by the
6348            // fixer's own `addressed`/`rejected` count — see
6349            // `ReviewRound::progressed`. Propagated with `?`, the same as the
6350            // `patch` snapshot above: swallowing this error would default
6351            // `diff_after` to empty, which almost always differs from a
6352            // non-empty `patch` and reads as "progressed" — exactly backwards
6353            // for a `git` failure the stagnation check cannot see through.
6354            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6355            let progressed = diff_after != patch;
6356            let commit_note = if fix.committed {
6357                "committed"
6358            } else {
6359                "NO new commit"
6360            };
6361            let tree_note = if progressed {
6362                "changed vs base"
6363            } else {
6364                "unchanged vs base"
6365            };
6366            self.state.event(
6367                "fix",
6368                match &fix.failed {
6369                    // Distinct on purpose from "0 addressed, 0 rejected": the
6370                    // fixer's own diff still landed (blocking counts do keep
6371                    // falling round over round), only its adoption report did
6372                    // not come back, so this must never read like every
6373                    // finding was reviewed and declined.
6374                    Some(reason) => {
6375                        format!(
6376                            "round {round}: fixer's adoption report was lost ({reason}); \
6377                             {commit_note}, tree {tree_note}"
6378                        )
6379                    }
6380                    None => format!(
6381                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6382                         {tree_note}{}",
6383                        fix.addressed.len(),
6384                        fix.rejected.len(),
6385                        if continuation.outcome == ContinuationOutcome::Resumed {
6386                            format!(
6387                                " (adoption report recovered after {} continuation(s))",
6388                                continuation.attempts
6389                            )
6390                        } else {
6391                            String::new()
6392                        },
6393                    ),
6394                },
6395            );
6396            round_record.fix = Some(fix);
6397            round_record.progressed = progressed;
6398            self.state.reviews.push(round_record);
6399            self.state.save()?;
6400
6401            // The fixer's own report never came back this round, even after
6402            // `continue_fix_report`'s own budget was spent on it — not an
6403            // ordinary "no report" (dropped stream, quota, plain failure),
6404            // which already reads that way and is left to the existing round
6405            // budget. Stopping here, rather than opening another round, is
6406            // what keeps a next reviewer/fixer wave from ever being
6407            // dispatched onto `winner.worktree` while whatever the seat's
6408            // last call may still have running there is unaccounted for: no
6409            // process liveness check exists (and none is being added — see
6410            // AGENTS.md/this task's own scope), so the only way to honour
6411            // "nothing starts before a valid report returns" is to not start
6412            // anything further on this worktree from this run at all.
6413            if matches!(
6414                continuation.outcome,
6415                ContinuationOutcome::Exhausted
6416                    | ContinuationOutcome::QuotaLost
6417                    | ContinuationOutcome::NoSession
6418            ) {
6419                return self
6420                    .stop_reviewing(
6421                        "the fixer's adoption report never came back, even after resuming its \
6422                         own seat; refusing to start another round against the same worktree \
6423                         while that is unresolved",
6424                        &shell,
6425                        &winner.worktree,
6426                    )
6427                    .await;
6428            }
6429
6430            let streak = self
6431                .state
6432                .reviews
6433                .iter()
6434                .rev()
6435                .take_while(|r| !r.progressed)
6436                .count();
6437            if streak >= STAGNANT_LIMIT {
6438                return self
6439                    .stop_reviewing(
6440                        &format!(
6441                            "the tree has not moved against base for {streak} round(s) in a row"
6442                        ),
6443                        &shell,
6444                        &winner.worktree,
6445                    )
6446                    .await;
6447            }
6448        }
6449        Ok(())
6450    }
6451
6452    /// Decide, from the last recorded round's own verification, whether
6453    /// stopping the review loop is a hand-off or a genuine block.
6454    ///
6455    /// Called once the loop has given up trying — the round budget is spent,
6456    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
6457    /// findings still open, never while a round is still clean or the
6458    /// incomplete-panel case handled inline above. Gate and e2e are facts
6459    /// about the tree; a lingering review finding is an opinion, and this
6460    /// workload's own `magi stats` puts reviewer precision low enough
6461    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
6462    /// must not by itself stand between a green, verified change and the
6463    /// human who decides what to do with it. A red e2e is not an opinion, so
6464    /// that case still blocks, with the failing command and a tail of its
6465    /// output recorded here rather than left in `run.json` for someone to go
6466    /// find.
6467    ///
6468    /// A round that deferred its own e2e (see [`Config::graph`]'s
6469    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
6470    /// only because nothing ran, and treating an empty list as a passing one
6471    /// here is exactly the "deferred painted green" bug this function exists
6472    /// to not have. When the last round's own verification never resolved —
6473    /// deferred on purpose, or a real attempt the shared build cache blocked
6474    /// — this makes (or retries) the real run, on the actual worktree this
6475    /// loop is about to stop touching, before deciding anything. A
6476    /// resource-blocked attempt is likewise never read as either green or
6477    /// red: it is evidence about the machine, not the patch (see
6478    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
6479    /// blocked cache leaves this call without deciding rather than guessing
6480    /// — the caller retries on a later reentry.
6481    /// Record, once, that the review loop handed off over a blocking finding
6482    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
6483    /// `land` asks the owner even with `land_approval` off. Called from every
6484    /// path that concludes `Gating`; a reentry keeps the first record.
6485    fn record_contested_handoff(&mut self) {
6486        if self.state.contested_handoff.is_some() {
6487            return;
6488        }
6489        let Some(contested) = self
6490            .state
6491            .reviews
6492            .last()
6493            .and_then(ReviewRound::contested_handoff)
6494        else {
6495            return;
6496        };
6497        self.state.event(
6498            "review",
6499            format!(
6500                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6501                 wait for the owner's approval",
6502                contested.findings.len(),
6503                contested.rejecters.len()
6504            ),
6505        );
6506        self.state.contested_handoff = Some(contested);
6507    }
6508
6509    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6510        let round_idx = self.state.reviews.len() - 1;
6511        // A deferred round and a resource-blocked one are the same shape
6512        // here: neither has a real result yet, and both get one more
6513        // attempt. Read off `e2e_status` — the single source for this —
6514        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
6515        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
6516        // still retries instead of being read as a settled result the
6517        // instant it stops being empty.
6518        let needs_catchup_run = matches!(
6519            self.state.reviews[round_idx].e2e_status(),
6520            E2eStatus::Deferred | E2eStatus::ResourceBlocked
6521        );
6522        if needs_catchup_run {
6523            let round = self.state.reviews[round_idx].round;
6524            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6525            let commands = self.state.config.verify.e2e.clone();
6526            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6527            let cache_dir = self.state.config.cache_dir();
6528            let context = format!(
6529                "round {round}: verification unresolved, catching up before the final decision"
6530            );
6531            let (outcomes, verify_retried) = with_cache_lease(
6532                &mut self.state,
6533                cache_dir.as_deref(),
6534                "e2e",
6535                "e2e",
6536                worktree,
6537                &attempted_head,
6538                timeout,
6539                &context,
6540                |state, budget| {
6541                    let shell = shell.to_vec();
6542                    let commands = commands.clone();
6543                    let context = context.clone();
6544                    async move {
6545                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6546                            .await
6547                    }
6548                },
6549            )
6550            .await;
6551            let last = &mut self.state.reviews[round_idx];
6552            last.e2e = outcomes;
6553            last.verify_retried = verify_retried;
6554            // Always the commit and time this attempt actually targeted,
6555            // whether or not it happens to equal the reviewed `head` and
6556            // whether or not a command finished — see
6557            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
6558            // attempt is recorded too, so a later reader sees "attempted
6559            // again at T2" rather than silence.
6560            last.verified_head = Some(attempted_head);
6561            last.verified_at = Some(Timestamp::now());
6562            if verify_inconclusive(&last.e2e) {
6563                // Still not a real result: `e2e_deferred` is left exactly
6564                // as it was, so `needs_catchup_run` above reads
6565                // `ResourceBlocked` (via `e2e_status`, which checks
6566                // `resource_blocked` before `e2e_deferred`) and retries
6567                // again on the next reentry, rather than recording
6568                // contention as a red e2e and blocking the run on it.
6569                self.state.save()?;
6570                return Ok(());
6571            }
6572            last.e2e_deferred = false;
6573        }
6574        let last = &self.state.reviews[round_idx];
6575        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6576
6577        match last.e2e_status() {
6578            E2eStatus::Failed => {
6579                let red: Vec<String> = last
6580                    .e2e
6581                    .iter()
6582                    .filter(|o| !o.ok())
6583                    .map(|o| {
6584                        format!(
6585                            "`{}` -> {:?}\n{}",
6586                            o.command,
6587                            o.code,
6588                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6589                        )
6590                    })
6591                    .collect();
6592                self.state
6593                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6594                self.state.status = RunStatus::Blocked;
6595            }
6596            // `needs_catchup_run` above already retried once this call; if
6597            // it is still blocked, this is magi's own admission it could
6598            // not get a command to run, never a verdict on the patch — the
6599            // run is left exactly where a later reentry can retry again.
6600            E2eStatus::ResourceBlocked => {
6601                self.state.event(
6602                    "review",
6603                    format!(
6604                        "{why}; e2e could not run (shared build cache unavailable); not \
6605                         deciding yet"
6606                    ),
6607                );
6608            }
6609            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6610                self.state.event(
6611                    "review",
6612                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6613                );
6614                self.record_contested_handoff();
6615                self.state.status = RunStatus::Gating;
6616            }
6617        }
6618        self.state.save()?;
6619        Ok(())
6620    }
6621
6622    // ----------------------------------------------------------------- gate
6623
6624    async fn gate(&mut self) -> Result<()> {
6625        // Judged by the review record itself, not by `status`: a solo
6626        // candidate's `judge`/`deliberate` skip rewrites `status` on every
6627        // reentry (see `judge`), and trusting it here is exactly how a run
6628        // that exhausted its review budget got gated and merged a second
6629        // time around. `review_conclusion` recomputes the review loop's own
6630        // verdict from the round records themselves — `Gating` for a clean
6631        // round or a hand-off (see `stop_reviewing`), anything else means the
6632        // loop is still going or genuinely blocked.
6633        // A base the winner could not be replayed onto is a decision, not a
6634        // round: there is no landing tree to gate. Read as its own record for
6635        // the same reason the review verdict is.
6636        if self.state.status == RunStatus::Failed
6637            || self
6638                .state
6639                .base_sync
6640                .as_ref()
6641                .is_some_and(|s| s.conflict.is_some())
6642            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6643                != Some(RunStatus::Gating)
6644        {
6645            return Ok(());
6646        }
6647        if self.state.gate_ran {
6648            // `review_loop` derives its conclusion from the clean review
6649            // record on every reentry and therefore puts a completed run back
6650            // in `Gating`. A recorded gate is a stronger, terminal fact:
6651            // retain its original command output (or lack of any, for a repo
6652            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
6653            // doc) and restore `Blocked` on a real failure rather than
6654            // pretending the command is still running or running it a second
6655            // time. `gate_ran == false` remains the only shape — unattempted,
6656            // or a resource-blocked retry — that may still need to execute a
6657            // command.
6658            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6659                self.state.status = RunStatus::Blocked;
6660                self.state.save()?;
6661            }
6662            return Ok(());
6663        }
6664        let Some(winner) = self.state.winner().cloned() else {
6665            return Ok(());
6666        };
6667        self.state.status = RunStatus::Gating;
6668        let mut outcomes = self.run_gate(&winner).await?;
6669        loop {
6670            // A resource-blocked outcome means the gate command never actually
6671            // ran - the shared build cache could not be acquired or confirmed
6672            // fresh in time - which is evidence about the machine, not about
6673            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
6674            // Recording it as a red gate would mark a run `Blocked` on nothing
6675            // but contention magi has already logged; leaving `self.state.gate`
6676            // empty and `self.state.gate_ran` false instead keeps the shape
6677            // this function already treats as "still needs to run" (see the
6678            // early-return above), so the next call retries the command
6679            // rather than concluding anything.
6680            if verify_inconclusive(&outcomes) {
6681                self.state.save()?;
6682                return Ok(());
6683            }
6684            if outcomes.iter().all(CommandOutcome::ok) {
6685                break;
6686            }
6687            match self.gate_fix_round(&winner, &outcomes).await? {
6688                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6689                GateFix::Stop => break,
6690                GateFix::Defer => {
6691                    self.state.save()?;
6692                    return Ok(());
6693                }
6694            }
6695        }
6696        let passed = outcomes.iter().all(CommandOutcome::ok);
6697        self.state.gate = outcomes;
6698        self.state.gate_ran = true;
6699        self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6700        if !passed {
6701            self.state.status = RunStatus::Blocked;
6702            let spent = self.state.gate_fixes.len();
6703            self.state.event(
6704                "gate",
6705                if spent == 0 {
6706                    "gate failed; not merging".to_owned()
6707                } else {
6708                    format!("gate failed after {spent} gate-fix round(s); not merging")
6709                },
6710            );
6711        }
6712        self.state.save()?;
6713        Ok(())
6714    }
6715
6716    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6717    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6718    /// after review is clean and never on a candidate awaiting judging.
6719    ///
6720    /// Never fails the run: a non-zero exit or timeout is a warning and a
6721    /// recorded outcome, and the gate remains the single arbiter. Nothing
6722    /// configured means nothing happens - no event, no commit. `commit_all`
6723    /// commits any leftover change under the neutral identity and returns
6724    /// `false` when the tree is clean, so no empty commit is ever made.
6725    async fn run_pre_gate(&mut self, winner: &Candidate) {
6726        let commands = self.state.config.verify.pre_gate.clone();
6727        if commands.is_empty() {
6728            return;
6729        }
6730        let shell = self.state.config.shell();
6731        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6732        let (outcomes, _) = run_commands(
6733            &mut self.state,
6734            "pre_gate",
6735            "pre_gate",
6736            0,
6737            &shell,
6738            &commands,
6739            &winner.worktree,
6740            timeout,
6741        )
6742        .await;
6743        for o in &outcomes {
6744            if !o.ok() {
6745                tracing::warn!(
6746                    "pre_gate `{}` failed ({:?}); the gate decides",
6747                    o.command,
6748                    o.code
6749                );
6750            }
6751            self.state.event(
6752                "pre_gate",
6753                format!(
6754                    "`{}` -> {}",
6755                    o.command,
6756                    if o.ok() {
6757                        "pass".to_owned()
6758                    } else {
6759                        format!(
6760                            "FAIL ({:?})\n{}",
6761                            o.code,
6762                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6763                        )
6764                    }
6765                ),
6766            );
6767        }
6768        self.state.pre_gate = outcomes;
6769        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6770            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6771                Ok(head) => {
6772                    self.state
6773                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
6774                    self.state.pre_gate_commit = Some(head);
6775                }
6776                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6777            },
6778            Ok(false) => {}
6779            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6780        }
6781        if let Err(e) = self.state.save() {
6782            tracing::warn!("could not persist the pre_gate record: {e:#}");
6783        }
6784    }
6785
6786    /// Run `verify.gate` once against the winner's current tree, logging one
6787    /// event per command. Empty when nothing is configured.
6788    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6789        self.run_pre_gate(winner).await;
6790        let shell = self.state.config.shell();
6791        let gate_commands = self.state.config.verify.gate.clone();
6792        // Zero commands has nothing to run and nothing that could touch the
6793        // shared build cache, so it never needs a lease: `Config::cache_dir`
6794        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
6795        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
6796        // otherwise queue behind an unrelated run's lease and come back
6797        // resource-blocked - `gate_ran` would stay false on nothing but
6798        // cache contention, for a step that had nothing to check in the
6799        // first place.
6800        let outcomes = if gate_commands.is_empty() {
6801            Vec::new()
6802        } else {
6803            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6804            let cache_dir = self.state.config.cache_dir();
6805            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6806            let (outcomes, _) = with_cache_lease(
6807                &mut self.state,
6808                cache_dir.as_deref(),
6809                "gate",
6810                "gate",
6811                &winner.worktree,
6812                &head,
6813                timeout,
6814                "final gate",
6815                |state, budget| {
6816                    let shell = shell.clone();
6817                    let gate_commands = gate_commands.clone();
6818                    let worktree = winner.worktree.clone();
6819                    async move {
6820                        let (outcomes, timed_out_pids) = run_commands(
6821                            state,
6822                            "gate",
6823                            "gate",
6824                            0,
6825                            &shell,
6826                            &gate_commands,
6827                            &worktree,
6828                            budget,
6829                        )
6830                        .await;
6831                        (outcomes, false, timed_out_pids)
6832                    }
6833                },
6834            )
6835            .await;
6836            outcomes
6837        };
6838        if outcomes.is_empty() {
6839            // Nothing configured to check — distinct from every other
6840            // silence in this run's event log, since an empty `gate` alone
6841            // no longer says whether the gate ran at all (see
6842            // `RunState::gate_ran`'s own doc).
6843            self.state.event(
6844                "gate",
6845                "no gate commands configured; nothing to check, passing",
6846            );
6847        }
6848        for o in &outcomes {
6849            self.state.event(
6850                "gate",
6851                format!(
6852                    "`{}` -> {}",
6853                    o.command,
6854                    if o.ok() {
6855                        "pass".to_owned()
6856                    } else {
6857                        format!(
6858                            "FAIL ({:?})\n{}",
6859                            o.code,
6860                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6861                        )
6862                    }
6863                ),
6864            );
6865        }
6866        Ok(outcomes)
6867    }
6868
6869    /// One bounded fix round for a failing gate.
6870    ///
6871    /// The fixer is told the failure came from the gate itself, not from a
6872    /// reviewer, and is shown the failed commands, their exit codes and a tail
6873    /// of their output - whatever `[verify].gate` holds, nothing here knows
6874    /// what those commands run. Only a normal non-zero exit that printed
6875    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6876    /// command or a full disk says nothing about the code, and a fixer sent
6877    /// after it can only appease the machine. The round is judged by what git
6878    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6879    /// again before the gate does, so a fix cannot trade a green gate for a
6880    /// red e2e unnoticed.
6881    async fn gate_fix_round(
6882        &mut self,
6883        winner: &Candidate,
6884        outcomes: &[CommandOutcome],
6885    ) -> Result<GateFix> {
6886        let cap = self.state.config.graph.gate_fix_rounds;
6887        let spent = self.state.gate_fixes.len();
6888        if spent >= cap {
6889            if cap > 0 {
6890                self.state.event(
6891                    "gate",
6892                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6893                );
6894            }
6895            return Ok(GateFix::Stop);
6896        }
6897        if !gate_fixable(outcomes) {
6898            self.state.event(
6899                "gate",
6900                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6901                 command or similar); not spending a fix round on it",
6902            );
6903            return Ok(GateFix::Stop);
6904        }
6905        let min_free = self.state.config.disk.min_free_bytes;
6906        if min_free > 0 {
6907            match crate::disk::free_bytes(&winner.worktree) {
6908                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6909                Ok(free) => {
6910                    self.state.event(
6911                        "gate",
6912                        format!(
6913                            "only {free} bytes free ({min_free} required by `[disk] \
6914                             min_free_bytes`); not spending a fix round on a failure the disk \
6915                             may explain"
6916                        ),
6917                    );
6918                    return Ok(GateFix::Stop);
6919                }
6920                Err(e) => {
6921                    self.state.event(
6922                        "gate",
6923                        format!("free disk space could not be measured ({e:#}); no fix round"),
6924                    );
6925                    return Ok(GateFix::Stop);
6926                }
6927            }
6928        }
6929
6930        let attempt = spent + 1;
6931        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6932        let base = self.landing_base();
6933        let fix_prompt = prompt::gate_fix_in(
6934            &self.state.instruction,
6935            &failed,
6936            attempt,
6937            cap,
6938            &self.state.config.graph.language,
6939            &self.state.config.graph.github_language,
6940        );
6941        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6942        let sessions = self.state.config.graph.sessions;
6943        let artifacts = self.merge_repair_artifacts();
6944        self.state.event(
6945            "gate",
6946            format!("gate failed; gate-fix round {attempt} of {cap}"),
6947        );
6948        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6949        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6950        let (_, seat, out) = self
6951            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6952                prompt: fix_prompt.clone(),
6953                spec,
6954                seat,
6955                cwd: winner.worktree.clone(),
6956                timeout,
6957                allow_write: true,
6958                sessions,
6959                artifacts: artifacts.clone(),
6960                stem: format!("gate-fix-{attempt}"),
6961                handover: None,
6962            })
6963            .await;
6964        // The fixer was never asked: no round is spent (nothing is recorded)
6965        // and a later reentry asks the same agent again.
6966        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6967            return Ok(GateFix::Defer);
6968        }
6969        let mut record = GateFixRecord {
6970            agent: seat.agent.clone(),
6971            failed,
6972            notes: String::new(),
6973            committed: false,
6974            error: None,
6975        };
6976        match out {
6977            AgentOutcome::Ok(o) => {
6978                // A missing report is not a failed fix: the round is judged
6979                // by the tree below, and the report only carries prose.
6980                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6981                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6982                }
6983            }
6984            AgentOutcome::Dropped(_) => {
6985                record.error = Some("the CLI dropped the stream".to_owned());
6986            }
6987            AgentOutcome::Quota(o) => {
6988                self.state.quota.push(QuotaLoss {
6989                    seat: seat.key.clone(),
6990                    node: "gate-fix".to_owned(),
6991                    at: Timestamp::now(),
6992                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6993                });
6994                record.error = Some("rate limited (quota); fixer could not run".to_owned());
6995            }
6996            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6997        }
6998        self.state.seats.insert(seat.key.clone(), seat);
6999        if let Ok(r) = git::rescue_commit(
7000            &winner.worktree,
7001            &format!("magi: gate fix {attempt} (uncommitted work)"),
7002        )
7003        .await
7004        {
7005            self.state.note_withheld("gate-fix", &r.withheld);
7006        }
7007        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
7008        record.committed = after != before;
7009        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
7010        let note = record.error.clone();
7011        self.state.gate_fixes.push(record);
7012        self.state.save()?;
7013        if !changed {
7014            self.state.event(
7015                "gate",
7016                match note {
7017                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
7018                    None => format!("gate-fix round {attempt}: the tree did not change"),
7019                },
7020            );
7021            return Ok(GateFix::Stop);
7022        }
7023        self.state.event(
7024            "gate",
7025            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
7026        );
7027
7028        let commands = self.state.config.verify.e2e.clone();
7029        if !commands.is_empty() {
7030            let shell = self.state.config.shell();
7031            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
7032            let cache_dir = self.state.config.cache_dir();
7033            let context = format!("gate-fix round {attempt}");
7034            let (e2e, _) = with_cache_lease(
7035                &mut self.state,
7036                cache_dir.as_deref(),
7037                "e2e",
7038                "e2e",
7039                &winner.worktree,
7040                &after,
7041                timeout,
7042                &context,
7043                |state, budget| {
7044                    let shell = shell.clone();
7045                    let commands = commands.clone();
7046                    let context = context.clone();
7047                    let worktree = winner.worktree.clone();
7048                    async move {
7049                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
7050                            .await
7051                    }
7052                },
7053            )
7054            .await;
7055            if verify_inconclusive(&e2e) {
7056                return Ok(GateFix::Defer);
7057            }
7058            if e2e.iter().any(|o| !o.ok()) {
7059                self.state.event(
7060                    "gate",
7061                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7062                );
7063                return Ok(GateFix::Stop);
7064            }
7065        }
7066        Ok(GateFix::Retry)
7067    }
7068
7069    // ---------------------------------------------------------------- merge
7070
7071    /// One read-only rewrite by the summary's author; a text the gate still
7072    /// withholds goes to the owner (see `github_text`) and the fixed fallback
7073    /// applies on their word or on silence. `None` means the run is parked on
7074    /// that question and nothing may be pushed or opened yet.
7075    async fn guarded_pr_message(
7076        &mut self,
7077        winner: &Candidate,
7078        facts: Option<&BranchFacts>,
7079        posting: bool,
7080    ) -> Result<Option<PrMessage>> {
7081        let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7082        let head = match final_head.as_deref() {
7083            Some(h) => crate::pr_outcome::FinalHead::At(h),
7084            None => crate::pr_outcome::FinalHead::Unknown,
7085        };
7086        // Only a pull request that is about to be posted pays for a summary.
7087        let summarized = if posting {
7088            Some(self.summarize_change(winner, final_head.as_deref()).await)
7089        } else {
7090            None
7091        };
7092        // A busy shared resource is not a permanent cause: publishing an
7093        // "unavailable" description would stick (a later head move never
7094        // edits the body). Stop before anything is pushed or opened; the
7095        // run is `Blocked` and a resume asks the summary again.
7096        if self.state.resource_block.is_some() {
7097            self.state.event(
7098                "pr-summary",
7099                "a shared resource was busy; nothing was published, resume to summarize again",
7100            );
7101            return Ok(None);
7102        }
7103        if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7104            anyhow::bail!(
7105                "the final commit moved during public description generation; retry before publishing"
7106            );
7107        }
7108        let note = match (&summarized, &self.state.change_summary) {
7109            (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7110            (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7111            (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7112            (None, _) => ChangeNote::NotAsked,
7113        };
7114        let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7115        if !posting {
7116            let identity = crate::scrub::Identity::current();
7117            return Ok(Some(PrMessage {
7118                title: crate::scrub::scrub(&pr.title, &identity),
7119                body: crate::scrub::scrub(&pr.body, &identity),
7120            }));
7121        }
7122        // This very text was already rejected and asked about: no second
7123        // judge call, rewrite or question, just carry the record on.
7124        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7125        if let Some(known) = self
7126            .state
7127            .github_text
7128            .clone()
7129            .filter(|g| g.fingerprint == raw_fp)
7130        {
7131            return self.settle_withheld(winner, &pr, known).await;
7132        }
7133        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7134        let mut violations =
7135            crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7136        let identity = crate::scrub::Identity::current();
7137        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7138            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7139            && !violations.contains(&crate::github_text::Violation::SensitiveData)
7140        {
7141            violations.push(crate::github_text::Violation::SensitiveData);
7142        }
7143        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
7144        // only a language violation starts one; sensitive data alone goes
7145        // straight to the owner, with the redacted text as the fallback.
7146        let has_language = violations
7147            .iter()
7148            .any(|v| *v != crate::github_text::Violation::SensitiveData);
7149        if self.state.config.graph.github_text_guard && !violations.is_empty() {
7150            self.state.event(
7151                "github-text",
7152                if has_language {
7153                    format!("description rejected: {violations:?}; requesting one rewrite")
7154                } else {
7155                    format!("description rejected: {violations:?}; asking the owner")
7156                },
7157            );
7158            let mut rewritten = false;
7159            let mut rejected_rewrite = None;
7160            if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7161            {
7162                let output = text;
7163                if let Some(title) = summary_title(&output) {
7164                    // Judge the redacted candidate that will actually be posted.
7165                    // Sensitive data alone must not discard a useful rewrite.
7166                    let raw_body = summary_without_title(&output);
7167                    let clean_title = crate::scrub::scrub(&title, &identity);
7168                    let mut body = crate::scrub::scrub(&raw_body, &identity);
7169                    let redacted = clean_title != title || body != raw_body;
7170                    let title = clean_title;
7171                    let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7172                    if !complete {
7173                        self.state.event(
7174                            "github-text",
7175                            "description rewrite omitted required findings",
7176                        );
7177                    }
7178                    if !body.trim().is_empty() {
7179                        body.push_str("\n\n");
7180                        body.push_str(&public_pr_evidence(&self.state, head));
7181                    }
7182                    let marker = format!("magi:run/{}", self.state.id);
7183                    if !body.trim().is_empty() && !body.contains(&marker) {
7184                        body.push_str(&format!("\n\n{marker}"));
7185                    }
7186                    let rewrite = PrMessage {
7187                        title: title.clone(),
7188                        body: body.clone(),
7189                    };
7190                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7191                    let mut failed = crate::github_text::check_for(
7192                        &self.state.config.graph,
7193                        &title,
7194                        &body,
7195                        redecision,
7196                    );
7197                    if (!crate::github_text::shareable(&title)
7198                        || !crate::github_text::shareable(&body))
7199                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
7200                    {
7201                        failed.push(crate::github_text::Violation::SensitiveData);
7202                    }
7203                    if complete
7204                        && !title.trim().is_empty()
7205                        && !body.trim().is_empty()
7206                        && !failed.is_empty()
7207                    {
7208                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
7209                            title: title.clone(),
7210                            body: body.clone(),
7211                            failed: failed
7212                                .iter()
7213                                .map(|v| crate::github_text::category(*v).to_owned())
7214                                .collect(),
7215                        });
7216                    }
7217                    if complete
7218                        && !title.trim().is_empty()
7219                        && !raw_body.trim().is_empty()
7220                        && failed.is_empty()
7221                    {
7222                        decision = redecision;
7223                        pr = PrMessage { title, body };
7224                        rewritten = true;
7225                        self.state
7226                            .event("github-text", "description rewrite passed");
7227                        if redacted {
7228                            self.state.event(
7229                                "github-text",
7230                                "sensitive data removed from description rewrite",
7231                            );
7232                        }
7233                    }
7234                }
7235            }
7236            if !rewritten {
7237                self.state.event(
7238                    "github-text",
7239                    "description rewrite unavailable or rejected; asking the owner",
7240                );
7241                if let Some(w) =
7242                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7243                {
7244                    // A different text than the one asked about before: that
7245                    // question no longer describes anything.
7246                    self.retire_github_text_question("the withheld text changed");
7247                    let record = crate::run::GithubTextAsk {
7248                        rewrite: rejected_rewrite,
7249                        fingerprint: raw_fp,
7250                        title: w.title,
7251                        body: w.body,
7252                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7253                        question: None,
7254                        asks: 0,
7255                        resolved: false,
7256                        chosen_title: None,
7257                        choice: None,
7258                    };
7259                    return self.settle_withheld(winner, &pr, record).await;
7260                }
7261            }
7262        }
7263        let (title, body) =
7264            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7265        Ok(Some(PrMessage { title, body }))
7266    }
7267
7268    /// Describe the whole change from its final diff in either run mode.
7269    ///
7270    /// The summary is tied to the final commit and to the merge base of the
7271    /// resolved remote base; a record for any other pair is dropped and the
7272    /// agent asked again, so a description never outlives the diff it was
7273    /// written from. The seat is read-only and the prompt tells it to read the
7274    /// diff and the code around it. `[roles] synthesizer` selects the agents
7275    /// (unset: the default pick), each asked at most once, advancing on an
7276    /// error, quota, unusable answer or a reply with no `TITLE:` line. `Err`
7277    /// carries the reason it is unavailable; nothing is invented then.
7278    async fn summarize_change(
7279        &mut self,
7280        winner: &Candidate,
7281        head: Option<&str>,
7282    ) -> std::result::Result<(), String> {
7283        let Some(head) = head else {
7284            return Err("the final commit could not be read".to_owned());
7285        };
7286        let remote = self.state.config.merge.remote.clone();
7287        let tracking = format!("{remote}/{}", self.state.base_branch);
7288        if !git::rev_exists(&winner.worktree, &tracking).await {
7289            return Err(format!("the remote base `{tracking}` could not be read"));
7290        }
7291        let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7292            Ok(b) if !b.is_empty() => b,
7293            _ => return Err("the merge base with the remote base could not be read".to_owned()),
7294        };
7295        let evidence = crate::pr_outcome::summary_evidence(
7296            &self.state,
7297            crate::pr_outcome::FinalHead::At(head),
7298        );
7299        let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7300        if self
7301            .state
7302            .change_summary
7303            .as_ref()
7304            .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7305        {
7306            return Ok(());
7307        }
7308        if self.state.change_summary.take().is_some() {
7309            self.state.event(
7310                "pr-summary",
7311                "the final diff or evidence changed; the earlier change summary was discarded",
7312            );
7313        }
7314        // Save the source snapshot even if no generation agent is available.
7315        if let Err(e) = write_artifact(
7316            &self.state,
7317            &format!("public-description-{fingerprint}.md"),
7318            &format!("Final diff: {base}..{head}\n\n{evidence}"),
7319        ) {
7320            tracing::warn!("could not save public description evidence: {e:#}");
7321        }
7322        let chain = match agent::pick_chain(
7323            &self.state.config.agents,
7324            self.state.config.roles.synthesizer.as_ref(),
7325            &agent::installed,
7326            "synthesizer",
7327        ) {
7328            Ok(chain) => chain,
7329            Err(e) => {
7330                tracing::warn!("no agent for the change summary: {e:#}");
7331                self.state.event("pr-summary", "no summary agent available");
7332                return Err("no agent was available to summarize it".to_owned());
7333            }
7334        };
7335        let subjects = git::subjects(&winner.worktree, &base, head)
7336            .await
7337            .unwrap_or_default();
7338        let stat = git::diff_stat(&winner.worktree, &base, head)
7339            .await
7340            .unwrap_or_default();
7341        let stat: String = stat.chars().take(6_000).collect();
7342        let mut prompt = crate::prompt::change_summary_in(
7343            &base,
7344            head,
7345            &subjects,
7346            stat.trim(),
7347            &self.state.config.graph.github_language,
7348        );
7349        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."));
7350        let sessions = self.state.config.graph.sessions;
7351        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7352        let prompts = self.state.config.prompts.clone();
7353        let cache = self.state.config.cache_dir();
7354        let run = self.state.id.clone();
7355        for (i, spec) in chain.into_iter().enumerate() {
7356            let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7357            let job = SeatJob {
7358                spec: spec.clone(),
7359                seat,
7360                cwd: winner.worktree.clone(),
7361                prompt: prompt.clone(),
7362                timeout,
7363                allow_write: false,
7364                sessions,
7365                artifacts: agent::artifacts_dir(&self.state.dir()),
7366                stem: if i == 0 {
7367                    "change-summary".to_owned()
7368                } else {
7369                    format!("change-summary-{}", spec.id)
7370                },
7371                handover: None,
7372            };
7373            let ctx = WaveCtx {
7374                run: &run,
7375                node: "pr-summary",
7376                prompts: &prompts,
7377                cache: cache.as_deref(),
7378                round: None,
7379                carry_seats: false,
7380                pauses: Some((&self.pause, &self.interrupt)),
7381            };
7382            let (seat, outcome) =
7383                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7384            self.state.seats.insert(seat.key.clone(), seat);
7385            let text = match outcome {
7386                AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7387                AgentOutcome::ResourceBlocked(why) => {
7388                    // No agent was asked: the run stops resumably before
7389                    // anything is published (see `guarded_pr_message`).
7390                    if self.state.resource_block.is_none() {
7391                        self.state.resource_block = Some(crate::run::ResourceBlock {
7392                            node: "pr-summary".to_owned(),
7393                            kind: "shared resource".to_owned(),
7394                            detail: why,
7395                            at: Timestamp::now(),
7396                        });
7397                    }
7398                    return Err("a shared resource was busy".to_owned());
7399                }
7400                _ => continue,
7401            };
7402            let blind = &self.state.config.blind;
7403            let Some(title) = summary_title(&text) else {
7404                continue;
7405            };
7406            let body: String = summary_without_title(&text)
7407                .lines()
7408                .filter(|l| !l.contains("magi:run/"))
7409                .collect::<Vec<_>>()
7410                .join("\n");
7411            let body = blind::sanitize_prose(body.trim(), blind);
7412            let title = blind::sanitize_prose(&title, blind);
7413            if body.trim().is_empty() || title.trim().is_empty() {
7414                continue;
7415            }
7416            if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7417                continue;
7418            }
7419            // The branch must still be the one the agent was shown.
7420            if git::rev_parse(&winner.worktree, "HEAD")
7421                .await
7422                .ok()
7423                .as_deref()
7424                != Some(head)
7425            {
7426                return Err("the branch moved while the summary was written".to_owned());
7427            }
7428            if git::merge_base(&winner.worktree, &tracking, head)
7429                .await
7430                .ok()
7431                .as_deref()
7432                != Some(base.as_str())
7433                || crate::pr_outcome::summary_evidence(
7434                    &self.state,
7435                    crate::pr_outcome::FinalHead::At(head),
7436                ) != evidence
7437            {
7438                return Err("the summary evidence changed while it was written".to_owned());
7439            }
7440            self.state.change_summary = Some(crate::run::ChangeSummary {
7441                head: head.to_owned(),
7442                base,
7443                evidence: fingerprint,
7444                title,
7445                body,
7446            });
7447            self.state.event(
7448                "pr-summary",
7449                format!("change summary written by {} for the final diff", spec.id),
7450            );
7451            if let Err(e) = self.state.save() {
7452                tracing::warn!("could not save the change summary: {e:#}");
7453            }
7454            return Ok(());
7455        }
7456        self.state
7457            .event("pr-summary", "no agent produced a change summary");
7458        Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7459    }
7460
7461    /// One read-only rewrite request for a withheld description. The
7462    /// implementer's own conversation is reused only when it exists and can be
7463    /// resumed; otherwise the `[roles] language_judge` chain (or the default
7464    /// pick) writes it in a separate conversation. The prompt carries the whole
7465    /// text, so no agent needs any memory. Each agent is asked at most once and
7466    /// the chain advances on an error, quota, unusable answer or a reply with
7467    /// no `TITLE:` line. Nothing is recorded as a handover.
7468    async fn rewrite_pr_text(
7469        &mut self,
7470        winner: &Candidate,
7471        pr: &PrMessage,
7472        violations: &[crate::github_text::Violation],
7473    ) -> Option<String> {
7474        let prompt = format!(
7475            "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{}",
7476            pr.title, pr.body
7477        );
7478        let sessions = self.state.config.graph.sessions;
7479        let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7480        // (spec, seat, resumed)
7481        let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7482        let key = format!("impl-{}", winner.label);
7483        if let Some(spec) = self
7484            .state
7485            .config
7486            .agents
7487            .iter()
7488            .find(|a| a.id == winner.agent)
7489            .cloned()
7490            && let Some(existing) = self.state.seats.get(&key)
7491            && existing.agent == spec.id
7492            && agent::has_session(spec.kind, existing, sessions)
7493        {
7494            attempts.push((spec, existing.clone(), true));
7495        } else {
7496            let chain = agent::pick_chain(
7497                &self.state.config.agents,
7498                self.state.config.roles.language_judge.as_ref(),
7499                &agent::installed,
7500                "language_judge",
7501            );
7502            match chain {
7503                Ok(chain) => {
7504                    for spec in chain {
7505                        let seat = crate::fixer::seat_for(
7506                            &mut self.state,
7507                            "github-text-rewrite",
7508                            &spec.id,
7509                        );
7510                        attempts.push((spec, seat, false));
7511                    }
7512                }
7513                Err(e) => {
7514                    tracing::warn!("no agent for the description rewrite: {e:#}");
7515                    self.state
7516                        .event("github-text", "no rewrite agent available");
7517                    return None;
7518                }
7519            }
7520        }
7521        let prompts = self.state.config.prompts.clone();
7522        let cache = self.state.config.cache_dir();
7523        let run = self.state.id.clone();
7524        for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7525            let job = SeatJob {
7526                spec: spec.clone(),
7527                seat,
7528                cwd: winner.worktree.clone(),
7529                prompt: prompt.clone(),
7530                timeout: retry_budget(timeout_full, resumed),
7531                allow_write: false,
7532                sessions,
7533                artifacts: agent::artifacts_dir(&self.state.dir()),
7534                stem: if i == 0 {
7535                    "github-text-rewrite".to_owned()
7536                } else {
7537                    format!("github-text-rewrite-{}", spec.id)
7538                },
7539                handover: None,
7540            };
7541            let ctx = WaveCtx {
7542                run: &run,
7543                node: "github-text",
7544                prompts: &prompts,
7545                cache: cache.as_deref(),
7546                round: None,
7547                carry_seats: false,
7548                pauses: Some((&self.pause, &self.interrupt)),
7549            };
7550            let (seat, outcome) =
7551                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7552            self.state.seats.insert(seat.key.clone(), seat);
7553            match outcome {
7554                AgentOutcome::Ok(o)
7555                    if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7556                {
7557                    return Some(o.text);
7558                }
7559                AgentOutcome::ResourceBlocked(_) => return None,
7560                _ => {}
7561            }
7562        }
7563        None
7564    }
7565
7566    /// Abandon the standing posting-gate question, if any.
7567    fn retire_github_text_question(&mut self, why: &str) {
7568        let Some(id) = self
7569            .state
7570            .github_text
7571            .as_ref()
7572            .and_then(|g| g.question.clone())
7573        else {
7574            return;
7575        };
7576        let _ = ask::Questions::open().update(&id, |q| {
7577            q.abandon(why);
7578            Ok(())
7579        });
7580    }
7581
7582    /// Carry a withheld text to its end: file the question, park while it is
7583    /// open, and on an answer, silence or timeout pick the text to post. The
7584    /// decision is saved before it is used, so it applies once.
7585    async fn settle_withheld(
7586        &mut self,
7587        winner: &Candidate,
7588        pr: &PrMessage,
7589        mut g: crate::run::GithubTextAsk,
7590    ) -> Result<Option<PrMessage>> {
7591        use crate::github_text as gt;
7592        let store = ask::Questions::open();
7593        let timeout = self.state.config.graph.answer_timeout;
7594        if !g.resolved {
7595            let mut standing = g
7596                .question
7597                .as_ref()
7598                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7599            if let Some(q) = standing.as_ref()
7600                && q.status.open()
7601                && gt::expired(q, timeout)
7602            {
7603                let why = format!("no answer within {}s of asking", timeout.max(1));
7604                standing = store
7605                    .update(&q.id, |q| {
7606                        q.abandon(&why);
7607                        Ok(())
7608                    })
7609                    .ok()
7610                    .map(|(q, ())| q);
7611            }
7612            let mut ask_again: Option<Option<Vec<String>>> = None;
7613            match standing {
7614                // Recorded but never written (a stop between the two saves),
7615                // or removed: ask again without spending the retry.
7616                None if g.asks <= 1 => {
7617                    g.asks = 0;
7618                    ask_again = Some(None);
7619                }
7620                None => {
7621                    g.resolved = true;
7622                    g.chosen_title = None;
7623                    g.choice = Some(GithubTextChoice::Fallback);
7624                }
7625                Some(q) if q.status.open() => {
7626                    self.park_on_github_text(&g)?;
7627                    return Ok(None);
7628                }
7629                Some(q) => match gt::read_reply(&q) {
7630                    gt::Reply::Fallback => {
7631                        g.resolved = true;
7632                        g.chosen_title = None;
7633                        g.choice = Some(GithubTextChoice::Fallback);
7634                    }
7635                    gt::Reply::Original => {
7636                        g.resolved = true;
7637                        g.chosen_title = None;
7638                        g.choice = Some(GithubTextChoice::Original);
7639                    }
7640                    gt::Reply::Rewrite => {
7641                        g.resolved = true;
7642                        g.chosen_title = None;
7643                        g.choice = Some(if g.rewrite.is_some() {
7644                            GithubTextChoice::Rewrite
7645                        } else {
7646                            GithubTextChoice::Fallback
7647                        });
7648                    }
7649                    gt::Reply::Title(text) => {
7650                        let first = text
7651                            .lines()
7652                            .map(str::trim)
7653                            .find(|l| !l.is_empty())
7654                            .unwrap_or("");
7655                        let decision = if first.is_empty() {
7656                            None
7657                        } else {
7658                            let candidate = PrMessage {
7659                                title: first.to_owned(),
7660                                body: String::new(),
7661                            };
7662                            self.judge_pr_language(&winner.worktree, &candidate).await
7663                        };
7664                        match gt::vet_title(
7665                            &text,
7666                            decision,
7667                            self.state.config.graph.github_english(),
7668                        ) {
7669                            Ok(title) => {
7670                                g.resolved = true;
7671                                g.chosen_title = Some(title);
7672                                g.choice = Some(GithubTextChoice::MyTitle);
7673                            }
7674                            Err(cats) if g.asks < 2 => {
7675                                ask_again = Some(Some(
7676                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7677                                ))
7678                            }
7679                            Err(_) => {
7680                                g.resolved = true;
7681                                g.chosen_title = None;
7682                                g.choice = Some(GithubTextChoice::Fallback);
7683                            }
7684                        }
7685                    }
7686                },
7687            }
7688            if let Some(rejected) = ask_again {
7689                // A retry (after a rejected replacement title) is still about
7690                // the original text: the same ORIGINAL / FALLBACK comparison.
7691                let w = gt::Withheld {
7692                    title: g.title,
7693                    body: g.body,
7694                    categories: g.categories.clone(),
7695                };
7696                // Named by the original text's fingerprint alone, so a resume
7697                // rewrites the same file and the detail stays identical.
7698                let artifact = match crate::run::write_artifact(
7699                    &self.state,
7700                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
7701                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7702                ) {
7703                    Ok(p) => Some(p),
7704                    Err(e) => {
7705                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
7706                        None
7707                    }
7708                };
7709                let (detail, comparison) = gt::question_content(
7710                    &w,
7711                    &pr.title,
7712                    &pr.body,
7713                    &self.state.id,
7714                    rejected.as_deref(),
7715                    artifact.as_deref(),
7716                    g.rewrite.as_ref(),
7717                );
7718                let mut q = ask::Question::new(
7719                    self.state.id.clone(),
7720                    gt::ASK_NODE.to_owned(),
7721                    gt::ASK_SEAT.to_owned(),
7722                    gt::question_summary(&self.state.config.graph.language, &w),
7723                    detail,
7724                    gt::question_choices(&w, g.rewrite.as_ref()),
7725                );
7726                q.posting_comparison = Some(comparison);
7727                q.answer_timeout = timeout;
7728                // Recorded before it is written: a stop in between leaves a
7729                // record naming a question the resume then files again.
7730                g.question = Some(q.id.clone());
7731                g.asks += 1;
7732                self.state.github_text = Some(g.clone());
7733                self.state.event(
7734                    "github-text",
7735                    format!("asking the owner about the withheld text ({})", q.short()),
7736                );
7737                self.state.save()?;
7738                store
7739                    .put(&mut q)
7740                    .context("file the posting-gate question")?;
7741                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7742                    tracing::warn!(
7743                        "could not notify about posting-gate question {}: {e:#}",
7744                        q.short()
7745                    );
7746                }
7747                self.park_on_github_text(&g)?;
7748                return Ok(None);
7749            }
7750        }
7751        let resolved = g.chosen_title.clone();
7752        let original = g.choice == Some(GithubTextChoice::Original);
7753        // Field names only: the event log never records the text.
7754        self.state.event(
7755            "github-text",
7756            if original {
7757                format!(
7758                    "posting the original text for: {}",
7759                    match (g.title, g.body) {
7760                        (true, true) => "title, description",
7761                        (true, false) => "title",
7762                        _ => "description",
7763                    }
7764                )
7765            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7766                "posting the saved rewrite title and description".to_owned()
7767            } else if resolved.is_some() {
7768                "posting the owner's replacement title".to_owned()
7769            } else {
7770                "posting the neutral text for the withheld fields".to_owned()
7771            },
7772        );
7773        self.state.github_text = Some(g.clone());
7774        self.state.save()?;
7775        if g.choice == Some(GithubTextChoice::Rewrite)
7776            && let Some(r) = g.rewrite.as_ref()
7777        {
7778            return Ok(Some(PrMessage {
7779                title: r.title.clone(),
7780                body: r.body.clone(),
7781            }));
7782        }
7783        let w = gt::Withheld {
7784            title: g.title,
7785            body: g.body,
7786            categories: g.categories.clone(),
7787        };
7788        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7789        if let Some(t) = resolved {
7790            title = t;
7791        }
7792        // Every withheld field is now neutral or vetted English.
7793        let english = gt::LanguageDecision {
7794            title_english: true,
7795            body_english: true,
7796            body_complete: true,
7797        };
7798        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7799        if original {
7800            // The owner's explicit choice overrides the gate for the fields it
7801            // was made for; the others keep what the gate produced.
7802            if g.title {
7803                title = pr.title.clone();
7804            }
7805            if g.body {
7806                body = pr.body.clone();
7807            }
7808        }
7809        Ok(Some(PrMessage { title, body }))
7810    }
7811
7812    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7813        self.state.github_text = Some(g.clone());
7814        self.state.end_pass_parked(ParkCause::Question);
7815        self.state.parked = true;
7816        self.state.event(
7817            "github-text",
7818            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7819        );
7820        self.state.save()
7821    }
7822
7823    /// Ask `[roles] language_judge` about this text and record which source
7824    /// decided; the text itself is never recorded.
7825    async fn judge_pr_language(
7826        &mut self,
7827        cwd: &Path,
7828        pr: &PrMessage,
7829    ) -> Option<crate::github_text::LanguageDecision> {
7830        self.state.config.roles.language_judge.as_ref()?;
7831        if !self.state.config.graph.language_gate() {
7832            return None;
7833        }
7834        let decision =
7835            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7836        self.state.event(
7837            "github-text",
7838            if decision.is_some() {
7839                "language decided by the language judge"
7840            } else {
7841                "language judge unavailable; using built-in heuristics"
7842            },
7843        );
7844        decision
7845    }
7846
7847    async fn merge(&mut self) -> Result<()> {
7848        // Same reasoning as `gate`: ask the review and gate records directly
7849        // rather than `status`, which a solo-candidate `judge`/`deliberate`
7850        // skip can rewrite on reentry to something that no longer says
7851        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
7852        // so a hand-off (open findings, green verification) reaches merge
7853        // exactly like a genuinely clean round does.
7854        //
7855        // A run resumed mid-`land` never reaches here at all: `execute`
7856        // recognises `RunStatus::Landing` before it even calls `prep`, and
7857        // routes straight to `run_land` instead. That has to happen a level
7858        // up from this function, not with a check in here, because
7859        // `review_loop`'s own status recomputation (see its doc) runs
7860        // *before* `merge` on every reentry and would otherwise overwrite
7861        // the `Landing` marker with `Gating` before this node ever saw it.
7862        if self
7863            .state
7864            .base_sync
7865            .as_ref()
7866            .is_some_and(|s| s.conflict.is_some())
7867            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7868                != Some(RunStatus::Gating)
7869            // `gate_ran == false` is not "passed" - `gate` leaves it false
7870            // both before it has ever run and when its last attempt was
7871            // resource-blocked (see `Runner::gate`'s own doc), and neither is
7872            // permission to merge on nothing but the review record. Only a
7873            // gate that actually ran - zero commands configured and
7874            // vacuously passed, or one or more that all exited 0 - may
7875            // proceed; `RunState::gate_status` is the single place that
7876            // reading is computed.
7877            || !self.state.gate_status().ok()
7878        {
7879            return Ok(());
7880        }
7881        // This node's own record, not `status`: `status == Ready` is not
7882        // unique to the harmless `MergeMode::None` path this line was
7883        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
7884        // run's PR was closed without merging — and on that run `mode` is
7885        // still `Pr`, so a reentry that fell through here would push and
7886        // open a second pull request. `self.state.merge` is set exactly once
7887        // this node (or `land`) has already produced a verdict, under every
7888        // mode, which is what "already done" actually means here.
7889        if self.state.merge.is_some() && !self.state.pr_publication_pending {
7890            return Ok(());
7891        }
7892        let Some(winner) = self.state.winner().cloned() else {
7893            return Ok(());
7894        };
7895        let repo = self.state.repo.clone();
7896        let base = self.state.base_branch.clone();
7897        let mode = self.state.config.merge.mode;
7898        let style = self.state.config.merge.style;
7899        let facts = if is_review_run(&self.state) {
7900            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7901            let start = review_base(
7902                &repo,
7903                &self.state.config.merge.remote,
7904                &base,
7905                &self.state.base_commit,
7906                &winner.branch,
7907            )
7908            .await;
7909            branch_facts(&repo, &start, &winner.branch).await
7910        } else {
7911            None
7912        };
7913        // `None` when the base could not be freshly read: then an adopted
7914        // pull request's title is left alone.
7915        let leaked = if is_review_run(&self.state) {
7916            leaked_subjects(&self.state, &winner.branch).await
7917        } else {
7918            Some(Vec::new())
7919        };
7920        // `None`: parked on the owner's word about a withheld title or body.
7921        // Nothing has been pushed or opened, and `merge` stays unrecorded so
7922        // the resume arrives here again.
7923        let Some(pr) = self
7924            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7925            .await?
7926        else {
7927            return Ok(());
7928        };
7929        let message = pr.commit_message();
7930
7931        let outcome = match mode {
7932            MergeMode::None => MergeOutcome {
7933                mode,
7934                ok: true,
7935                detail: manual_merge_command(style, &repo, &winner.branch, &message),
7936                empty: false,
7937            },
7938            MergeMode::Pr | MergeMode::Local
7939                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7940            {
7941                MergeOutcome {
7942                    mode,
7943                    ok: false,
7944                    detail: empty_candidate_detail(&self.state, &base),
7945                    empty: true,
7946                }
7947            }
7948            MergeMode::Local => {
7949                let on = git::current_branch(&repo).await?;
7950                if on.as_deref() != Some(base.as_str()) {
7951                    MergeOutcome {
7952                        mode,
7953                        ok: false,
7954                        detail: format!(
7955                            "{} has {} checked out, not the base branch {base}",
7956                            repo.display(),
7957                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
7958                        ),
7959                        empty: false,
7960                    }
7961                } else if !git::is_clean(&repo).await? {
7962                    MergeOutcome {
7963                        mode,
7964                        ok: false,
7965                        detail: format!("{} is dirty; refusing to merge", repo.display()),
7966                        empty: false,
7967                    }
7968                } else {
7969                    let out = match style {
7970                        MergeStyle::Merge => {
7971                            git::merge_no_ff(&repo, &winner.branch, &message).await?
7972                        }
7973                        MergeStyle::Squash => {
7974                            git::merge_squash(&repo, &winner.branch, &message).await?
7975                        }
7976                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7977                    };
7978                    MergeOutcome {
7979                        mode,
7980                        ok: out.ok(),
7981                        detail: if out.ok() { out.stdout } else { out.stderr },
7982                        empty: false,
7983                    }
7984                }
7985            }
7986            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7987                self.state.pr_publication_pending = true;
7988                self.state.save()?;
7989                let out =
7990                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7991                self.state.pr_publication_pending = out.is_err();
7992                MergeOutcome {
7993                    mode,
7994                    ok: out.is_ok(),
7995                    detail: match out {
7996                        Ok(url) => url,
7997                        Err(e) => format!("{e:#}"),
7998                    },
7999                    empty: false,
8000                }
8001            }
8002            MergeMode::Pr => {
8003                let remote = self.state.config.merge.remote.clone();
8004                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
8005                if !pushed.ok() {
8006                    MergeOutcome {
8007                        mode,
8008                        ok: false,
8009                        detail: pushed.stderr,
8010                        empty: false,
8011                    }
8012                } else {
8013                    // A retry or resume of a run whose branch already has an
8014                    // open pull request adopts it rather than failing on a
8015                    // duplicate. Only this winner branch into this base:
8016                    // `branch_for` derives the name from the run id, so a
8017                    // different run's pull request never matches.
8018                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
8019                    let out = match pr_merge_plan(found) {
8020                        PrPlan::Create => {
8021                            gh_pr_create(
8022                                &winner.worktree,
8023                                &base,
8024                                &winner.branch,
8025                                &pr.title,
8026                                &pr.body,
8027                            )
8028                            .await
8029                        }
8030                        PrPlan::Adopt { url, title } => {
8031                            self.state
8032                                .event("merge", format!("Pr: adopted open pull request {url}"));
8033                            if self.state.github_text.as_ref().is_some_and(|g| {
8034                                g.resolved
8035                                    && g.choice == Some(GithubTextChoice::Rewrite)
8036                                    && g.rewrite.is_some()
8037                            }) {
8038                                // The owner chose the entire pair, even if the
8039                                // existing title already matches or was edited.
8040                                land::set_pr_text_verbatim(
8041                                    &winner.worktree,
8042                                    &url,
8043                                    &pr.title,
8044                                    &pr.body,
8045                                )
8046                                .await
8047                                .map(|()| url)
8048                            } else {
8049                                if title != pr.title
8050                                    && (!is_review_run(&self.state)
8051                                        || leaked
8052                                            .as_deref()
8053                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
8054                                    && let Err(e) =
8055                                        if self.state.github_text.as_ref().is_some_and(|g| {
8056                                            g.resolved
8057                                                && g.title
8058                                                && g.choice == Some(GithubTextChoice::Original)
8059                                        }) {
8060                                            // The owner chose to post this title unmodified.
8061                                            land::set_pr_title_verbatim(
8062                                                &winner.worktree,
8063                                                &url,
8064                                                &pr.title,
8065                                            )
8066                                            .await
8067                                        } else {
8068                                            land::set_pr_title(
8069                                                &mut self.state,
8070                                                &winner.worktree,
8071                                                &url,
8072                                                &pr.title,
8073                                            )
8074                                            .await
8075                                        }
8076                                {
8077                                    tracing::warn!("could not refresh title of {url}: {e:#}");
8078                                    self.state
8079                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
8080                                }
8081                                Ok(url)
8082                            }
8083                        }
8084                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8085                    };
8086                    match out {
8087                        Ok(url) => MergeOutcome {
8088                            mode,
8089                            ok: true,
8090                            detail: url,
8091                            empty: false,
8092                        },
8093                        Err(e) => MergeOutcome {
8094                            mode,
8095                            ok: false,
8096                            detail: e.to_string(),
8097                            empty: false,
8098                        },
8099                    }
8100                }
8101            }
8102        };
8103
8104        self.state.status = match (mode, outcome.ok) {
8105            (MergeMode::None, _) => RunStatus::Ready,
8106            (_, true) => RunStatus::Merged,
8107            (_, false) => RunStatus::Blocked,
8108        };
8109        self.state.event(
8110            "merge",
8111            format!(
8112                "{:?}: {}",
8113                mode,
8114                outcome.detail.lines().next().unwrap_or("")
8115            ),
8116        );
8117        self.state.merge = Some(outcome);
8118        self.state.save()?;
8119
8120        // The PR is open and the run would historically stop here, leaving the
8121        // operator to watch checks, feed review comments back to a fixer, and
8122        // merge. That was done by hand six times in one session before this
8123        // existed. Opt-in, because merging is the one irreversible thing magi
8124        // can do to a repository.
8125        if self.state.config.graph.land
8126            && mode == MergeMode::Pr
8127            && self.state.status == RunStatus::Merged
8128        {
8129            self.run_land().await?;
8130        }
8131        // `run_land` may have left `status` at `Landing` - still waiting on
8132        // CI or the owner's approval, not actually settled - so this has to
8133        // read whatever `status` ended up as here, not the `Merged` this
8134        // function set a few lines up.
8135        self.settle_questions();
8136        Ok(())
8137    }
8138
8139    /// Enter `land`.
8140    ///
8141    /// Shared between a fresh run's first pass through [`Runner::merge`] and
8142    /// a resumed run's re-entry. `land::land` itself is what serialises the
8143    /// two git-mutating moments inside the loop — the rebase push and
8144    /// `gh pr merge` — per repository (see its own doc); nothing here needs
8145    /// to hold a lock across the whole call, and doing so would serialise
8146    /// this run's CI wait against a *different* run's land-approval resume
8147    /// in the same repository, which is exactly the "must not wait on
8148    /// another task" property the daemon's slot-freeing exists to give.
8149    async fn run_land(&mut self) -> Result<()> {
8150        crate::pr_head::ensure_owner(&self.state)?;
8151        let url = self
8152            .state
8153            .merge
8154            .as_ref()
8155            .map(|m| m.detail.clone())
8156            .unwrap_or_default();
8157        let url = url.lines().next().unwrap_or("").trim().to_owned();
8158        if !url.starts_with("http") {
8159            return Ok(());
8160        }
8161        // A land failure is not a lost run: the work is on a branch and the
8162        // pull request is open, which is exactly where a human takes over.
8163        loop {
8164            match land::land(&mut self.state, &url).await {
8165                Ok(_)
8166                    if self
8167                        .state
8168                        .merge_repairs
8169                        .last()
8170                        .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8171                {
8172                    if self.repair_merge_review().await? {
8173                        continue;
8174                    }
8175                }
8176                Ok(pr) if self.state.parked => {
8177                    // `land` already saved the parked marker; nothing here
8178                    // overrides `status` back to a terminal value while an
8179                    // approval is still outstanding.
8180                    let _ = pr;
8181                }
8182                Ok(_) if self.state.merge_decision.as_ref().is_some_and(|d| d.closed) => {
8183                    self.state.status = RunStatus::Superseded;
8184                    self.state.save()?;
8185                }
8186                Ok(pr) => {
8187                    self.state.status = match pr.state {
8188                        land::PrLifecycle::Merged => RunStatus::Merged,
8189                        _ => RunStatus::Blocked,
8190                    };
8191                    // Downstream of a confirmed merge only - see
8192                    // `bump::should_release_bump`'s own doc for why this one
8193                    // check covers all three of `land`'s success paths.
8194                    // Best-effort: the run already landed, so a failure here
8195                    // (the decision call, `gh`, `cargo`) is recorded and never
8196                    // turns a landed run into a failed one.
8197                    if bump::should_release_bump(self.state.status)
8198                        && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8199                    {
8200                        // The event is the run's own record. Not-eligible cases
8201                        // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
8202                        // `Err` is a bump that was tried and failed:
8203                        // `after_merge` itself raises the operator notice for
8204                        // that, whether or not a release PR exists yet.
8205                        self.state
8206                            .event("bump", format!("release bump skipped: {e:#}"));
8207                    }
8208                    // Independent of the bump, and best-effort in the same way:
8209                    // findings the merge left open become follow-up tasks.
8210                    if self.state.status == RunStatus::Merged {
8211                        crate::followup::after_merge(&mut self.state, &pr.url).await;
8212                    }
8213                    self.state.save()?;
8214                }
8215                Err(e) => {
8216                    self.state.status = RunStatus::Blocked;
8217                    self.state.event("land", format!("gave up: {e}"));
8218                    self.state.save()?;
8219                }
8220            }
8221            break;
8222        }
8223        Ok(())
8224    }
8225
8226    /// One repair cycle stays under the existing driver/task claim. An
8227    /// interrupted fixer is rolled back and held rather than run twice.
8228    async fn repair_merge_review(&mut self) -> Result<bool> {
8229        use crate::run::MergeRepairPhase as Phase;
8230        crate::pr_head::ensure_owner(&self.state)?;
8231        let i = self.state.merge_repairs.len() - 1;
8232        let request = self.state.merge_repairs[i].clone();
8233        if request.phase == Phase::Fixing {
8234            let restored = match self.state.winner() {
8235                Some(winner) => {
8236                    crate::recovery::restore_repair(&winner.worktree, &request.head).await
8237                }
8238                None => Err(anyhow::anyhow!("repair has no winning branch")),
8239            };
8240            let why = match restored {
8241                Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8242                Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8243            };
8244            self.hold_merge_repair(i, why)?;
8245            return Ok(false);
8246        }
8247        let failure = if i >= self.state.config.graph.land_rounds
8248            || self.state.config.graph.review_rounds == 0
8249        {
8250            Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8251        } else {
8252            None
8253        };
8254        if let Some(why) = failure {
8255            self.hold_merge_repair(i, why)?;
8256            return Ok(false);
8257        }
8258        if request.phase == Phase::Requested {
8259            let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8260                Ok(pr) => pr,
8261                Err(e) => {
8262                    self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8263                    return Ok(false);
8264                }
8265            };
8266            self.state.merge_repairs[i].phase = Phase::Fixing;
8267            self.state.land_approval = None;
8268            self.state.land_armed_head = None;
8269            self.state.save()?;
8270            // Serialize the exact records, including finding ids/details,
8271            // reconsideration reasons, final votes and verification output.
8272            let evidence = serde_json::to_string_pretty(&request)?;
8273            let reason = format!(
8274                "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}"
8275            );
8276            let budget = self.state.config.graph.land_rounds;
8277            let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8278            match result {
8279                Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8280                Ok(land::Fixed::Failed(why)) => {
8281                    self.hold_merge_repair(
8282                        i,
8283                        format!(
8284                            "repair failed: {why}; inspect the branch and run an explicit review"
8285                        ),
8286                    )?;
8287                    return Ok(false);
8288                }
8289                Err(e) => {
8290                    self.hold_merge_repair(
8291                        i,
8292                        format!(
8293                            "repair failed: {e:#}; inspect the branch and run an explicit review"
8294                        ),
8295                    )?;
8296                    return Ok(false);
8297                }
8298            }
8299            // Even no change gets a real new reviewer wave. The previous
8300            // evidence remains in the request; a former clean/concluded round
8301            // cannot short-circuit the new review or gate.
8302            self.begin_merge_repair_review(i)?;
8303        }
8304        self.review_loop().await?;
8305        self.gate().await?;
8306        if self.resource_halt()? || self.state.parked {
8307            return Ok(false);
8308        }
8309        if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8310            != Some(RunStatus::Gating)
8311            || !self.state.gate_status().ok()
8312        {
8313            self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8314            return Ok(false);
8315        }
8316        // Review and gate may have added commits after the initial repair.
8317        // Publish exactly their final head to the existing PR, with a lease.
8318        let winner = self
8319            .state
8320            .winner()
8321            .cloned()
8322            .context("repair has no winning branch")?;
8323        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8324        if self.state.gate_head.as_deref() != Some(after.as_str())
8325            || !git::is_clean(&winner.worktree).await?
8326        {
8327            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())?;
8328            return Ok(false);
8329        }
8330        let expected = self
8331            .state
8332            .pr_pushed_head
8333            .as_deref()
8334            .unwrap_or(&request.head)
8335            .to_owned();
8336        let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8337        // Push the checked commit itself: a local ref moving after the check
8338        // must not change which commit the forge receives.
8339        let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8340        let refspec = format!("{after}:refs/heads/{public}");
8341        let push = git::git_raw(
8342            &winner.worktree,
8343            &["push", &lease, &self.state.config.merge.remote, &refspec],
8344        )
8345        .await;
8346        match push {
8347            Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8348            Ok(out) => {
8349                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8350                return Ok(false);
8351            }
8352            Err(e) => {
8353                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8354                return Ok(false);
8355            }
8356        }
8357        self.state.merge_repairs[i].phase = Phase::Complete;
8358        self.state.status = RunStatus::Landing;
8359        self.state.event(
8360            "repair",
8361            "new review and gate completed; fresh owner merge decision required",
8362        );
8363        self.state.save()?;
8364        Ok(true)
8365    }
8366
8367    fn merge_repair_artifacts(&self) -> PathBuf {
8368        let artifacts = agent::artifacts_dir(&self.state.dir());
8369        match self
8370            .state
8371            .merge_repairs
8372            .last()
8373            .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8374        {
8375            Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8376            None => artifacts,
8377        }
8378    }
8379
8380    fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8381        self.state.reviews.clear();
8382        self.state.gate.clear();
8383        self.state.gate_ran = false;
8384        self.state.gate_head = None;
8385        self.state.gate_fixes.clear();
8386        self.state.pre_gate.clear();
8387        self.state.pre_gate_commit = None;
8388        self.state.contested_handoff = None;
8389        self.state.land_reruns = None;
8390        self.state.land_approval = None;
8391        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8392        self.state.status = RunStatus::Reviewing;
8393        self.state.save()
8394    }
8395
8396    fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8397        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8398        self.state.merge_repairs[index].failure = Some(why.clone());
8399        self.state.status = RunStatus::Blocked;
8400        self.state.event("repair", why);
8401        self.state.save()
8402    }
8403
8404    // -------------------------------------------------------------- helpers
8405
8406    /// Fetch or create a seat, keeping its conversation across nodes.
8407    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8408        if let Some(existing) = self.state.seats.get(key)
8409            && existing.agent == agent
8410        {
8411            return existing.clone();
8412        }
8413        // A seat that changes agent mints its session id from the agent too,
8414        // like a handover: the old agent's uuid is already taken by the CLI.
8415        let fresh = if self.state.seats.contains_key(key) {
8416            handover_seat(key, agent, self.state.next_seat_seed())
8417        } else {
8418            SeatState::new(key, agent, self.state.seed)
8419        };
8420        self.state.seats.insert(key.to_owned(), fresh.clone());
8421        fresh
8422    }
8423
8424    /// The agent now holding seat `key`: `spec`, unless a handover moved the
8425    /// seat to another roster agent, in which case that agent. Nodes that
8426    /// continue a seat's conversation (deliberation, the votes, a reviewer's
8427    /// reconsideration) must keep talking to whoever answered it, not slip
8428    /// back to the agent that failed it.
8429    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8430        match self.state.seats.get(key) {
8431            Some(s) if s.agent != spec.id => {
8432                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8433            }
8434            _ => spec,
8435        }
8436    }
8437
8438    /// A candidate rendered for judging, with the leak policy applied.
8439    fn view(&self, c: &Candidate) -> CandidateView {
8440        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8441            .unwrap_or_default();
8442        let (patch, _) = blind::sanitize_patch(
8443            &format!("candidate {} patch", c.label),
8444            &raw,
8445            &self.state.config.blind,
8446        );
8447        CandidateView {
8448            label: c.label,
8449            branch: c.branch.clone(),
8450            summary: c.summary.clone(),
8451            stat: c.stat.clone(),
8452            patch,
8453        }
8454    }
8455
8456    /// The full candidate set as prompt text, for seats with no live session.
8457    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8458        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8459        prompt::judge(
8460            "(see above)",
8461            &views,
8462            self.roles.judges.len(),
8463            base_short,
8464            "en",
8465        )
8466    }
8467
8468    /// The final-vote prompt with everything a seat that has no session of its
8469    /// own needs: the candidates, the seat's own ranking and reasons, and the
8470    /// anonymised deliberation it took part in (only when there was one, so a
8471    /// handed-over seat never sees more than the seat it replaces did). The
8472    /// `Final vote` heading stays first.
8473    fn vote_prompt_full(
8474        &self,
8475        j: usize,
8476        labels: &[char],
8477        language: &str,
8478        candidates: &[Candidate],
8479        base_short: &str,
8480    ) -> String {
8481        let mut text = format!(
8482            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8483            prompt::final_vote(labels, language),
8484            self.state.instruction,
8485            self.candidate_block(candidates, base_short)
8486        );
8487        if let Some(own) = self
8488            .state
8489            .judgements
8490            .get(j)
8491            .filter(|r| !r.ranking.is_empty())
8492        {
8493            let reasons = own
8494                .reasons
8495                .iter()
8496                .map(|(k, v)| format!("- {k}: {v}"))
8497                .collect::<Vec<_>>()
8498                .join("\n");
8499            text.push_str(&format!(
8500                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8501                own.ranking.iter().collect::<String>(),
8502                if reasons.is_empty() {
8503                    ""
8504                } else {
8505                    ", because:\n"
8506                }
8507            ));
8508        }
8509        if !self.state.deliberation.is_empty() {
8510            text.push_str("\n# What was argued before this vote\n");
8511            for t in self.transcript(&[], j) {
8512                text.push_str(&format!(
8513                    "\n## {}{}\n\n{}\n",
8514                    t.who,
8515                    if t.is_self { " (you)" } else { "" },
8516                    t.body.trim()
8517                ));
8518            }
8519        }
8520        text
8521    }
8522
8523    /// Anonymised transcript for judge `self_idx`.
8524    ///
8525    /// The initial rankings are always the opening statements. Seeding them
8526    /// only when no turn had been taken yet meant every judge after the first
8527    /// argued against a single voice instead of against the actual split — the
8528    /// disagreement is the information, so it is always on the table.
8529    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8530        let mut turns = Vec::new();
8531        for j in &self.state.judgements {
8532            if j.ranking.is_empty() {
8533                continue;
8534            }
8535            let reasons = j
8536                .reasons
8537                .iter()
8538                .map(|(k, v)| format!("- {k}: {v}"))
8539                .collect::<Vec<_>>()
8540                .join("\n");
8541            turns.push(Turn {
8542                who: format!("Judge {} (opening ranking)", j.judge),
8543                is_self: j.judge == self_idx + 1,
8544                body: format!(
8545                    "Ranked {}{}{reasons}",
8546                    j.ranking.iter().collect::<String>(),
8547                    if reasons.is_empty() {
8548                        ""
8549                    } else {
8550                        ", because:\n"
8551                    }
8552                ),
8553            });
8554        }
8555        for t in self
8556            .state
8557            .deliberation
8558            .iter()
8559            .flat_map(|r| r.turns.iter())
8560            .chain(current)
8561        {
8562            turns.push(Turn {
8563                who: format!("Judge {}", t.judge),
8564                is_self: t.judge == self_idx + 1,
8565                body: t.body.clone(),
8566            });
8567        }
8568        turns
8569    }
8570}
8571
8572/// Does this seat still hold the context a follow-up prompt would rely on?
8573fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8574    agent::has_session(spec.kind, seat, sessions)
8575}
8576
8577/// The next entry in `roster` after `start`, never wrapping back to the
8578/// front, whose id is not in `tried` yet.
8579///
8580/// Starts one past `start` rather than at the front of `roster`: `start` is
8581/// the seat's own original position, and a seat whose candidate slot already
8582/// sits on the roster's second entry must fall through to the third next, not
8583/// restart at the first — which is very likely a different candidate's own
8584/// agent already. Never wraps back past `start`, for the same reason: an
8585/// entry earlier in the roster than the seat's own position is almost
8586/// certainly some *other* candidate slot's own agent, and once the tail of
8587/// the roster is exhausted there are no more untried agents for *this* seat
8588/// to fall through to — the caller's fallback chain ends there, exactly as
8589/// "no further untried agents remain in the list for that seat" asks for.
8590///
8591/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
8592/// the same id twice (an operator's `roles.implementers` typo, or a
8593/// `[[agents]]` list reused across roles) must not let
8594/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
8595/// over `roster` either finds an untried id or runs out, so this always
8596/// terminates regardless of duplicates.
8597fn next_untried_in_roster<'a>(
8598    roster: &'a [AgentSpec],
8599    start: usize,
8600    tried: &BTreeSet<String>,
8601) -> Option<&'a AgentSpec> {
8602    roster
8603        .get(start + 1..)?
8604        .iter()
8605        .find(|s| !tried.contains(&s.id))
8606}
8607
8608/// The successor for a seat, shared by all four seat kinds (implement, judge,
8609/// review, advise). `others` holds the ids that *currently* occupy the other
8610/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
8611/// sees them), so roster entries beyond the seat count act as spares: a failed
8612/// seat goes to an agent no other seat holds whenever the roster permits.
8613///
8614/// `carried` is `Some` only for the review loop's carried failure history.
8615/// Order: (1) forward from `start`, never wrapping, untried, not a carried
8616/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
8617/// whole roster: untried and not another seat's occupant; (3) the plain walk
8618/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
8619/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
8620/// a better outcome than an empty seat, and it keeps the answer to "is there
8621/// a successor at all" exactly what it was before occupants were considered,
8622/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
8623/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
8624/// carried failure is a cheaper bet than doubling an agent on the panel, and
8625/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
8626/// the same round are handled one at a time, so a seat later in the batch
8627/// sees an earlier one's new occupant but not yet its own freed agent.
8628fn pick_successor<'a>(
8629    roster: &'a [AgentSpec],
8630    start: usize,
8631    tried: &BTreeSet<String>,
8632    carried: Option<&BTreeSet<String>>,
8633    others: &BTreeSet<String>,
8634) -> Option<&'a AgentSpec> {
8635    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8636    roster
8637        .get(start + 1..)
8638        .and_then(|tail| {
8639            tail.iter()
8640                .filter(free)
8641                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8642        })
8643        .or_else(|| {
8644            carried
8645                .is_some()
8646                .then(|| roster.iter().find(free))
8647                .flatten()
8648        })
8649        .or_else(|| match carried {
8650            Some(c) => next_for_seat(roster, start, tried, c),
8651            None => next_untried_in_roster(roster, start, tried),
8652        })
8653}
8654
8655/// The next agent for a seat that carries its failure history across rounds
8656/// (the review loop). `round_tried` is this round's own bound and starts
8657/// empty every round; `carried_failed` only decides priority.
8658///
8659/// First: an id walking forward from `start`, never wrapping, that is neither
8660/// tried this round nor failed in an earlier one. Only when that is exhausted
8661/// does it rescue: the first roster id (in roster order, so this one *does*
8662/// look before `start`) not yet tried this round, which is by then a carried
8663/// failure. Each id is rescued at most once per round, so it cannot loop.
8664fn next_for_seat<'a>(
8665    roster: &'a [AgentSpec],
8666    start: usize,
8667    round_tried: &BTreeSet<String>,
8668    carried_failed: &BTreeSet<String>,
8669) -> Option<&'a AgentSpec> {
8670    roster
8671        .get(start + 1..)?
8672        .iter()
8673        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8674        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8675}
8676
8677/// Where a reviewer seat starts a round: the agent that last answered it when
8678/// it is still on the roster and not marked failed, else the spec's own agent
8679/// unless it failed, else the next roster agent that has not failed, else the
8680/// spec's own agent again (the whole roster failed). Ids no longer on the
8681/// roster are ignored. An empty roster has no handover, so the spec stands.
8682fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8683    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8684        return spec;
8685    };
8686    let ok = |id: &str| !h.failed.contains(id);
8687    if let Some(last) = h.last_ok.as_deref()
8688        && ok(last)
8689        && let Some(s) = roster.iter().find(|s| s.id == last)
8690    {
8691        return s.clone();
8692    }
8693    if ok(&spec.id) {
8694        return spec;
8695    }
8696    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8697    roster
8698        .get(from + 1..)
8699        .into_iter()
8700        .flatten()
8701        .chain(roster.iter())
8702        .find(|s| ok(&s.id))
8703        .cloned()
8704        .unwrap_or(spec)
8705}
8706
8707/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
8708/// seed so a CLI that mints its session id up front (`--session-id`) never
8709/// reuses the uuid the previous agent already opened under the same seat key.
8710pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8711    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8712}
8713
8714/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
8715/// so the classifier below and the code that builds the message cannot drift.
8716const TIMED_OUT: &str = "timed out";
8717
8718impl FailClass {
8719    /// `None` for an answer; otherwise how the turn failed.
8720    fn of(out: &AgentOutcome) -> Option<Self> {
8721        match out {
8722            AgentOutcome::Ok(_) => None,
8723            AgentOutcome::Quota(_) => Some(Self::Quota),
8724            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8725            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8726            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8727            // Not a failure of the agent. Callers check for it first; `None`
8728            // here means "no class to hand over on".
8729            AgentOutcome::ResourceBlocked(_) => None,
8730        }
8731    }
8732
8733    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
8734    fn stem_word(&self) -> &'static str {
8735        match self {
8736            Self::Quota => "quota",
8737            _ => "handover",
8738        }
8739    }
8740}
8741
8742/// The message's first line with its variable parts removed — digit runs and
8743/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
8744/// as one kind of failure.
8745fn failure_signature(msg: &str) -> String {
8746    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8747    let mut out = Vec::new();
8748    for word in line.split_whitespace() {
8749        if word.contains('/') || word.contains('\\') {
8750            out.push("<path>".to_owned());
8751            continue;
8752        }
8753        let mut w = String::new();
8754        let mut in_digits = false;
8755        for c in word.chars() {
8756            if c.is_ascii_digit() {
8757                if !in_digits {
8758                    w.push('#');
8759                }
8760                in_digits = true;
8761            } else {
8762                in_digits = false;
8763                w.push(c);
8764            }
8765        }
8766        out.push(w);
8767    }
8768    out.join(" ").chars().take(120).collect()
8769}
8770
8771/// Whether a seat that just failed with `cur` may go to the next roster agent.
8772/// A quota or a timeout always may. Any other failure may not when the agent
8773/// before it failed the same way: an error the prompt causes would otherwise
8774/// walk the whole roster. `prev` is the class of the immediately preceding
8775/// agent's failure, so a quota or timeout in between breaks the run of
8776/// identical failures by itself.
8777fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8778    match cur {
8779        FailClass::Quota | FailClass::Timeout => true,
8780        FailClass::Other(_) => prev != Some(cur),
8781    }
8782}
8783
8784/// A short human reason for a failed outcome, for the handover record.
8785fn fail_reason(out: &AgentOutcome) -> String {
8786    match out {
8787        AgentOutcome::Ok(_) => String::new(),
8788        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8789        AgentOutcome::Dropped(o) => format!(
8790            "the CLI dropped the stream ({})",
8791            o.dropped
8792                .as_ref()
8793                .map(|d| d.why.as_str())
8794                .unwrap_or("it ended without delivering its answer")
8795        ),
8796        AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8797            e.lines().next().unwrap_or("").chars().take(160).collect()
8798        }
8799    }
8800}
8801
8802/// Note one handover in the run: the structured record and, in the timeline,
8803/// the sentence a person reads. A quota keeps the wording it always had.
8804pub(crate) fn record_handover(
8805    state: &mut RunState,
8806    node: &str,
8807    seat: &str,
8808    from: &str,
8809    to: &str,
8810    class: &FailClass,
8811    reason: &str,
8812) {
8813    let message = if *class == FailClass::Quota {
8814        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8815    } else {
8816        format!("{seat}: handed over {from} -> {to} ({reason})")
8817    };
8818    state.event(node, message);
8819    state.handovers.push(Handover {
8820        at: Timestamp::now(),
8821        node: node.to_owned(),
8822        seat: seat.to_owned(),
8823        from: from.to_owned(),
8824        to: to.to_owned(),
8825        reason: reason.to_owned(),
8826    });
8827}
8828
8829/// Did this reply report running a command whose own CLI never confirmed an
8830/// exit status?
8831///
8832/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
8833/// command *finished* (see that type's own doc), so this can only be `true`
8834/// for a command whose completion event carried no readable exit code — not
8835/// for one that simply is not mentioned at all. That is the one signal this
8836/// crate can read, from the same record `state.jobs` renders, about a reply
8837/// standing next to work its own CLI cannot vouch for finishing; it is
8838/// deliberately not a check on the exit code's *value* (a fixer legitimately
8839/// runs a command that fails mid-iteration before it succeeds) and not a
8840/// guess at a command still running in the background (which emits no event
8841/// at all, and so leaves no evidence here to find).
8842fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8843    commands.iter().any(|c| c.exit_code.is_none())
8844}
8845
8846/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
8847/// trusted as a verified no-op — the adoption guard's own text-level half.
8848///
8849/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
8850/// timed out): a marker only earns the benefit of the doubt from a turn the
8851/// CLI itself vouches for finishing properly, the same house style
8852/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
8853/// *fix* report to for `commands`. A candidate that timed out, exited
8854/// non-zero, or left a command unconfirmed is read as the ordinary loss it
8855/// is, whatever prose it wrote — this returns `None` before it ever looks at
8856/// `text`. The remaining guards (the tree really is empty, the evidence is
8857/// non-empty) are the caller's: this only reads what the reply *claimed*.
8858fn verified_noop_claim(
8859    usable: bool,
8860    commands: &[agent::CommandEvidence],
8861    text: &str,
8862) -> Option<String> {
8863    (usable && !has_unconfirmed_command(commands))
8864        .then(|| verdict::verified_noop(text))
8865        .flatten()
8866}
8867
8868fn short(commit: &str) -> String {
8869    commit.chars().take(7).collect()
8870}
8871
8872fn make_executable(path: &Path) -> Result<()> {
8873    #[cfg(unix)]
8874    {
8875        use std::os::unix::fs::PermissionsExt as _;
8876        let mut perms = std::fs::metadata(path)?.permissions();
8877        perms.set_mode(0o755);
8878        std::fs::set_permissions(path, perms)?;
8879    }
8880    #[cfg(not(unix))]
8881    {
8882        let _ = path;
8883    }
8884    Ok(())
8885}
8886
8887/// What every seat in one batch shares: where the answers are attributed, the
8888/// prompt overlay they inherit, and the build cache they are told to use.
8889///
8890/// A struct rather than four more parameters: `wave` also needs the run's
8891/// state (to record who is answering right now) and the attempt number, and
8892/// eight positional arguments is both unreadable and a clippy error.
8893struct WaveCtx<'a> {
8894    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
8895    /// paid for it.
8896    run: &'a str,
8897    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
8898    node: &'a str,
8899    prompts: &'a Prompts,
8900    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
8901    cache: Option<&'a Path>,
8902    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
8903    /// `JobRecord::round`. `None` for every other node.
8904    round: Option<usize>,
8905    /// Carry each seat's failed-agent history across waves (the review loop
8906    /// only): start-of-round priority and handover choice read
8907    /// [`RunState::seat_history`], and every answer or failure writes it.
8908    carry_seats: bool,
8909    /// The run's park / interrupt requests, so a wait for a shared resource
8910    /// can be abandoned the moment the operator asks to park.
8911    pauses: Option<(&'a Pause, &'a Pause)>,
8912}
8913
8914/// Run one job, honouring the parallelism budget.
8915async fn run_one(
8916    job: SeatJob,
8917    sem: Arc<Semaphore>,
8918    ctx: &WaveCtx<'_>,
8919    state: &mut RunState,
8920    attempt: usize,
8921) -> (SeatState, AgentOutcome) {
8922    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8923        .await
8924        .pop()
8925        .expect("one job in, one result out");
8926    (seat, out)
8927}
8928
8929/// Run every job concurrently, capped by the semaphore, preserving order.
8930///
8931/// Every seat in the batch is recorded into [`RunState::active`] before the
8932/// wave starts and cleared as each answer lands, so the run's own record says
8933/// who is still being waited on rather than only who finished.
8934async fn wave(
8935    jobs: Vec<SeatJob>,
8936    sem: Arc<Semaphore>,
8937    ctx: &WaveCtx<'_>,
8938    state: &mut RunState,
8939    attempt: usize,
8940) -> Vec<(usize, SeatState, AgentOutcome)> {
8941    let WaveCtx {
8942        run,
8943        node,
8944        prompts,
8945        cache,
8946        round,
8947        carry_seats: _,
8948        pauses,
8949    } = *ctx;
8950    for job in &jobs {
8951        state.seat_started(node, &job.seat.key, job.timeout, attempt);
8952    }
8953    if let Err(e) = state.save() {
8954        // A failed persist of "who is answering right now" must not abort the
8955        // wave: the seats are already being asked, and the alternative is
8956        // losing the answers to save a status line nobody may even be
8957        // watching.
8958        tracing::warn!("could not persist in-progress seats: {e:#}");
8959    }
8960    // Hold the shared build cache's lease for the whole batch, not per job:
8961    // several candidates (an implement wave) or a fixer legitimately share
8962    // one cache concurrently within this run, and that stays untouched — a
8963    // single lease taken once for the whole wave and released once it is
8964    // done is what stops a *different* borrower (another run's own wave, its
8965    // e2e/gate, a human's `magi review`) from interleaving a build into the
8966    // same directory while this one is in flight. Best-effort, not
8967    // all-or-nothing: a wave that cannot get the lease within its own
8968    // longest job's budget still runs — an hour of paid implementer calls is
8969    // not thrown away over cache contention — but every write-allowed seat
8970    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
8971    // below), the same fallback a read-only seat always gets, rather than
8972    // building into a directory this run was never granted. The identity
8973    // record is still invalidated below either way, so the next tracked
8974    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
8975    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8976    // The wait for the lease has its own bound, `timeout_resource_wait`; it is
8977    // never borrowed from a job's agent budget (and never charged to it either,
8978    // see below). The agent's clock starts in `agent::invoke`, at spawn.
8979    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8980    let cache_guard = if let Some(cache_dir) = cache {
8981        if jobs_had_a_writer {
8982            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8983            let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8984            match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8985            {
8986                Ok(Some(g)) => Some(g),
8987                // Parking: leave without spawning anything. The run parks at
8988                // the next boundary and the seats are asked afresh on resume.
8989                Ok(None) => {
8990                    state.resource_block = Some(crate::run::ResourceBlock {
8991                        node: node.to_owned(),
8992                        kind: "shared build cache".to_owned(),
8993                        detail: "the run is parking".to_owned(),
8994                        at: Timestamp::now(),
8995                    });
8996                    return block_wave(
8997                        jobs,
8998                        state,
8999                        node,
9000                        attempt,
9001                        "parked while waiting for the shared build cache",
9002                    );
9003                }
9004                // An unreadable lease is not contention: keep the old
9005                // fallback (no shared cache for this wave's seats).
9006                Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
9007                Err(e) => {
9008                    // Writing seats may not build into a cache this run does
9009                    // not hold, and an agent that cannot be given one is not
9010                    // asked at all: the run stops resumably instead.
9011                    let why = format!("{e:#}");
9012                    state.resource_block = Some(crate::run::ResourceBlock {
9013                        node: node.to_owned(),
9014                        kind: "shared build cache".to_owned(),
9015                        detail: why.clone(),
9016                        at: Timestamp::now(),
9017                    });
9018                    state.event(
9019                        "resource",
9020                        format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
9021                    );
9022                    return block_wave(jobs, state, node, attempt, &why);
9023                }
9024            }
9025        } else {
9026            None
9027        }
9028    } else {
9029        None
9030    };
9031    // Every job keeps its whole budget: the wait above was not the agent's.
9032    // Restart the seats' clocks so "started" means "being spawned now".
9033    state.seats_spawning(node, attempt);
9034    if let Err(e) = state.save() {
9035        tracing::warn!("could not persist the start of a wave: {e:#}");
9036    }
9037    // Queueing behind this wave's own seats for a parallelism slot is normal
9038    // (each of them is bounded by its own agent timeout); only a wait beyond
9039    // all of them plus the resource limit means the slots never came back.
9040    let slot_limit = resource_limit
9041        + jobs
9042            .iter()
9043            .map(|j| j.timeout)
9044            .max()
9045            .unwrap_or_default()
9046            .saturating_mul(jobs.len() as u32);
9047    let mut set = tokio::task::JoinSet::new();
9048    // Seats announce themselves here the moment they hold a parallelism
9049    // permit, i.e. just before their CLI is spawned.
9050    let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9051    let overlay = prompts.overlay(node);
9052    for (i, mut job) in jobs.into_iter().enumerate() {
9053        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
9054        if cache.is_some() {
9055            job.prompt.push('\n');
9056            job.prompt
9057                .push_str(&prompt::build_cache_note(node, job.allow_write));
9058        }
9059        let sem = Arc::clone(&sem);
9060        let run = run.to_owned();
9061        let node = node.to_owned();
9062        // Only implementers were told about the task's attachments, so only
9063        // their seats get the directory widened for reading.
9064        let attachments = if node == "implement" {
9065            state.attachments.clone()
9066        } else {
9067            Vec::new()
9068        };
9069        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
9070        // `prompt::build_cache_note`'s doc for why setting it anyway is
9071        // exactly how a sandboxed reviewer's write refusal got reported as a
9072        // defect in the patch, not a property of its own seat. And a
9073        // write-allowed one is handed it only when the lease above was
9074        // actually acquired: a wave that could not get it (`cache_guard` is
9075        // `None`, see its own comment) must not send seats to build into a
9076        // directory this run does not hold - that is the exact concurrent,
9077        // unmanaged-write race this module exists to prevent, not something
9078        // "proceeding anyway" is allowed to reintroduce.
9079        let cache = cache
9080            .filter(|_| job.allow_write && cache_guard.is_some())
9081            .map(Path::to_path_buf);
9082        let spawned_tx = spawned_tx.clone();
9083        set.spawn(async move {
9084            // A wait for a parallelism slot is scheduling, not the agent: it
9085            // is bounded by its own limit and ends as a resource block, never
9086            // as a timeout of the agent that was not started.
9087            let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9088                Ok(p) => p,
9089                Err(_) => {
9090                    return (
9091                        i,
9092                        job.seat,
9093                        AgentOutcome::ResourceBlocked(format!(
9094                            "no parallel slot became free within {}s",
9095                            slot_limit.as_secs()
9096                        )),
9097                    );
9098                }
9099            };
9100            spawned_tx.send(job.seat.key.clone()).ok();
9101            let mut seat = job.seat;
9102            let out = agent::invoke(
9103                &job.spec,
9104                &mut seat,
9105                &Invocation {
9106                    cwd: &job.cwd,
9107                    prompt: &job.prompt,
9108                    timeout: job.timeout,
9109                    allow_write: job.allow_write,
9110                    unsandboxed: false,
9111                    sessions: job.sessions,
9112                    artifacts: &job.artifacts,
9113                    stem: &job.stem,
9114                    run: &run,
9115                    node: &node,
9116                    cache_dir: cache.as_deref(),
9117                    attachments: &attachments,
9118                    writable: &[],
9119                },
9120            )
9121            .await;
9122            let out = match out {
9123                Ok(o) if o.usable() => AgentOutcome::Ok(o),
9124                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9125                // Billed work the CLI failed to hand over is not an ordinary
9126                // failure, but its text is the CLI's raw error JSON, not an
9127                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
9128                // read it as one by forgetting to check. `usable()` is always
9129                // false here (dropped implies an empty response), so this has
9130                // to be checked before the catch-all `Failed` below or the
9131                // one shape this exists for is lost with the rest.
9132                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9133                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9134                Ok(o) => AgentOutcome::Failed(format!(
9135                    "exited with {:?} and no usable output",
9136                    o.exit_code
9137                )),
9138                Err(e) => AgentOutcome::Failed(e.to_string()),
9139            };
9140            (i, seat, out)
9141        });
9142    }
9143    drop(spawned_tx);
9144    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9145    loop {
9146        let joined = tokio::select! {
9147            Some(key) = spawned_rx.recv() => {
9148                // The clock of this seat starts now, not when it queued.
9149                state.seat_spawned(&key);
9150                if let Err(e) = state.save() {
9151                    tracing::warn!("could not persist a seat's start: {e:#}");
9152                }
9153                continue;
9154            }
9155            joined = set.join_next() => joined,
9156        };
9157        let Some(joined) = joined else {
9158            break;
9159        };
9160        let (i, seat, out) = match joined {
9161            Ok(v) => v,
9162            // No seat to clear: a panicked task never reported which one it
9163            // was. The defensive sweep below this loop is what stops that
9164            // seat's `active` entry from surviving forever.
9165            Err(e) => {
9166                tracing::error!("agent task panicked: {e}");
9167                continue;
9168            }
9169        };
9170        state.seat_finished(&seat.key);
9171        if let AgentOutcome::ResourceBlocked(why) = &out
9172            && state.resource_block.is_none()
9173        {
9174            state.resource_block = Some(crate::run::ResourceBlock {
9175                node: node.to_owned(),
9176                kind: "parallel slot".to_owned(),
9177                detail: why.clone(),
9178                at: Timestamp::now(),
9179            });
9180            state.event("resource", format!("{node}: no agent was started - {why}"));
9181        }
9182        record_jobs(state, node, round, &seat.key, &out);
9183        if let Err(e) = state.save() {
9184            tracing::warn!("could not persist a seat's completion: {e:#}");
9185        }
9186        if collected.len() <= i {
9187            collected.resize_with(i + 1, || None);
9188        }
9189        collected[i] = Some((i, seat, out));
9190    }
9191    // Belt-and-braces for the panic branch above: every seat this exact batch
9192    // started shares this `(node, attempt)` pair, and every seat that finished
9193    // normally already cleared itself, so anything left tagged with it here
9194    // can only be a panicked task's leftover. Cleared unconditionally rather
9195    // than left to read as still answering forever.
9196    if state
9197        .active
9198        .values()
9199        .any(|a| a.node == node && a.attempt == attempt)
9200    {
9201        state
9202            .active
9203            .retain(|_, a| !(a.node == node && a.attempt == attempt));
9204        if let Err(e) = state.save() {
9205            tracing::warn!("could not persist the end of a wave: {e:#}");
9206        }
9207    }
9208    // Whether or not the lease above was actually held, several worktrees
9209    // may just have built into the cache with nothing here able to name one
9210    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
9211    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
9212    // clean it might not have strictly needed; trusting a stale match would
9213    // cost it a wrong answer.
9214    if let Some(cache_dir) = cache
9215        && jobs_had_a_writer
9216    {
9217        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9218    }
9219    if let Some(guard) = cache_guard {
9220        guard.release();
9221    }
9222    collected.into_iter().flatten().collect()
9223}
9224
9225/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
9226/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
9227/// this is data collection, not the fix-specific completion contract in
9228/// [`Runner::continue_fix_report`], and applies regardless of which node
9229/// asked.
9230///
9231/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
9232/// evidence from; `Failed` does not, and correctly contributes nothing — a
9233/// timeout or crash is not itself evidence about a command the seat may have
9234/// started.
9235fn record_jobs(
9236    state: &mut RunState,
9237    node: &str,
9238    round: Option<usize>,
9239    seat: &str,
9240    out: &AgentOutcome,
9241) {
9242    let commands: &[agent::CommandEvidence] = match out {
9243        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9244        AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9245    };
9246    let checked_at = Timestamp::now();
9247    for c in commands {
9248        state.jobs.push(JobRecord {
9249            node: node.to_owned(),
9250            round,
9251            seat: seat.to_owned(),
9252            id: c.id.clone(),
9253            description: c.description.clone(),
9254            checked_at,
9255            status: match c.exit_code {
9256                Some(0) => JobStatus::Completed,
9257                Some(_) => JobStatus::Failed,
9258                None => JobStatus::Unknown,
9259            },
9260            exit_code: c.exit_code,
9261            result_summary: c.result_summary.clone(),
9262            source: c.source.clone(),
9263        });
9264    }
9265}
9266
9267/// Is a review round clean, given how many reviewer seats answered against
9268/// how many the round expected?
9269///
9270/// A seat that never answered (timeout, crash, unparsable output) is not a
9271/// seat that read the patch and found nothing — treating it as such is
9272/// exactly the bug this function exists to close. Under the default `block`
9273/// policy a missing seat can never be clean; `warn` still requires the seats
9274/// that *did* answer to have found nothing blocking and verification to be
9275/// green.
9276///
9277/// `quota_missing` narrows that `block` default for exactly one cause of
9278/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
9279/// a session limit lifts by the very next round buys nothing — the seat is
9280/// asked again with the same quota — so once every missing seat is accounted
9281/// for by a quota loss (and at least one seat *did* answer, so a decision has
9282/// something to rest on) the round is decided on the panel that could answer,
9283/// same as `warn` would. A panel that lost every seat to quota is not
9284/// decided here: `answered == 0` falls through to the existing `block`
9285/// fallback so a fully collapsed panel still waits rather than landing on no
9286/// review at all.
9287fn round_is_clean(
9288    blocking: usize,
9289    e2e_ok: bool,
9290    answered: usize,
9291    expected: usize,
9292    quota_missing: usize,
9293    policy: IncompleteReviewPolicy,
9294) -> bool {
9295    if blocking != 0 || !e2e_ok {
9296        return false;
9297    }
9298    if answered == expected || policy == IncompleteReviewPolicy::Warn {
9299        return true;
9300    }
9301    answered > 0 && expected - answered <= quota_missing
9302}
9303
9304/// The review loop's own conclusion, derived entirely from its persisted
9305/// round records and the round budget that produced them — never from
9306/// `status`, so a reentry (or `gate`/`merge` reading it independently)
9307/// recomputes the identical answer regardless of what an earlier node in the
9308/// same walk, or a previous walk, did to `status`.
9309///
9310/// `None` while more rounds remain to try, including when review never ran
9311/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
9312/// gone clean, or the budget is spent, or the tree has stopped moving (see
9313/// [`STAGNANT_LIMIT`]), the answer is one of two things:
9314///
9315/// - An incomplete panel that raised nothing is missing input, not a
9316///   verified tree — never a hand-off candidate, whatever verification said
9317///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
9318/// - Otherwise, green e2e on the last round hands off (see
9319///   [`Runner::stop_reviewing`]); red e2e blocks.
9320///
9321/// A last round whose own verification is still `ResourceBlocked` — magi
9322/// itself never got a command to run, not evidence the patch is broken —
9323/// is neither: this returns `None` for it too, the same as "more rounds
9324/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
9325/// handling of that shape) instead of this cheap recomputation guessing a
9326/// verdict a real attempt never produced.
9327fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9328    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9329        return Some(RunStatus::Gating);
9330    }
9331    let last = reviews.last()?;
9332    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9333    if reviews.len() < max_rounds && !stagnant {
9334        return None;
9335    }
9336    if last.incomplete() && last.blocking == 0 {
9337        return Some(RunStatus::Blocked);
9338    }
9339    if last.e2e_status() == E2eStatus::ResourceBlocked {
9340        return None;
9341    }
9342    Some(if last.e2e.iter().all(CommandOutcome::ok) {
9343        RunStatus::Gating
9344    } else {
9345        RunStatus::Blocked
9346    })
9347}
9348
9349/// How long a re-ask may take, given the budget the first attempt had.
9350///
9351/// A `nudged` retry is a request to restate an answer the seat has already
9352/// worked out: it carries no new work, so it does not deserve the original
9353/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
9354/// 133 seconds while a third sat for over ten minutes on a resumed session
9355/// holding 410 KB of prior output - and because the retry had inherited the
9356/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
9357/// judging round whose other seats were long finished.
9358///
9359/// A quarter of the budget, with a floor so that a deliberately short timeout
9360/// does not collapse to nothing. A retry that re-sends the whole prompt
9361/// (because the seat kept no context) is the original job again, and keeps the
9362/// original budget.
9363pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9364    if nudged {
9365        (full / 4).max(Duration::from_secs(120)).min(full)
9366    } else {
9367        full
9368    }
9369}
9370
9371/// Run a wave and parse each reply, re-asking the seats whose reply was
9372/// unusable.
9373///
9374/// The re-ask is a nudge rather than the whole prompt again when the seat still
9375/// holds its conversation, which is the difference between a cheap retry and
9376/// paying for the entire candidate set twice.
9377///
9378/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
9379/// successor in `roster` is handed to it instead of being re-asked: the
9380/// handover is the retry. A seat with no successor left (a single-agent
9381/// roster, the roster's tail) is nudged as before, up to `retries` times. So
9382/// the asks to one seat in one node number at most
9383/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
9384/// is asked once (a dropped stream is nudged first), and only the last agent
9385/// of the chain gets the `retries` same-agent nudges. Each roster agent is
9386/// tried at most once per seat, walking forward from the seat's own position and never wrapping
9387/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
9388/// other failure stops the chain when the previous agent failed the same way
9389/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
9390/// [`has_context`] is false and the job's own full prompt and full budget are
9391/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
9392/// intermediate ones are not losses) and is returned as a failure like any
9393/// other absent seat — the caller decides whether the panel still has a
9394/// quorum. An empty `roster` disables handover: failures are nudged as they
9395/// always were, and a quota is simply lost. A reply that fails to parse or
9396/// validate is the prompt's doing and is only ever nudged, never handed over.
9397///
9398/// The returned [`SeatState`] names the agent that answered (or tried last).
9399#[allow(clippy::too_many_arguments)]
9400async fn ask_json_wave<T>(
9401    jobs: Vec<SeatJob>,
9402    sem: Arc<Semaphore>,
9403    retries: usize,
9404    roster: &[AgentSpec],
9405    ctx: &WaveCtx<'_>,
9406    losses: &mut Vec<QuotaLoss>,
9407    state: &mut RunState,
9408    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9409) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9410where
9411    T: serde::de::DeserializeOwned + Send + 'static,
9412{
9413    ask_wave_with(
9414        jobs,
9415        sem,
9416        retries,
9417        roster,
9418        ctx,
9419        losses,
9420        state,
9421        &|text: &str| {
9422            let v = verdict::extract_json::<T>(text)?;
9423            validate(&v)?;
9424            Ok(v)
9425        },
9426    )
9427    .await
9428}
9429
9430/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
9431/// a node whose answer is prose (deliberation) shares the same handover,
9432/// failure classification, quota bookkeeping and bounds instead of a copy.
9433///
9434/// A seat handed to another roster agent is sent the job's `handover` prompt
9435/// (when it has one) rather than `prompt`: the new agent has no session, so a
9436/// resume-style prompt would be incomplete. That holds for the handover ask
9437/// and for every nudge to that agent whose `has_context` is false.
9438#[allow(clippy::too_many_arguments)]
9439async fn ask_wave_with<T>(
9440    jobs: Vec<SeatJob>,
9441    sem: Arc<Semaphore>,
9442    retries: usize,
9443    roster: &[AgentSpec],
9444    ctx: &WaveCtx<'_>,
9445    losses: &mut Vec<QuotaLoss>,
9446    state: &mut RunState,
9447    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9448) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9449where
9450    T: Send + 'static,
9451{
9452    let n = jobs.len();
9453    let originals: Vec<SeatJob> = jobs;
9454    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9455    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9456    // Nudges each seat's *current* agent has taken — 0 for a first-ask
9457    // answer, N once it has gone through N nudges. Read back once this
9458    // returns, so a caller building a history record (`ReviewRecord`) can
9459    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
9460    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
9461    // field's own doc.
9462    let mut nudges: Vec<usize> = vec![0; n];
9463    // The agent now occupying each seat, the ids it has already been through,
9464    // where in the roster the walk began, the class of the last failure, and
9465    // the stem word of a handover not yet asked (full prompt, full budget).
9466    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9467    let mut tried: Vec<BTreeSet<String>> = specs
9468        .iter()
9469        .map(|s| BTreeSet::from([s.id.clone()]))
9470        .collect();
9471    let starts: Vec<usize> = specs
9472        .iter()
9473        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9474        .collect();
9475    let carry = ctx.carry_seats && !roster.is_empty();
9476    // Carried across rounds: ids that failed the seat earlier, and how the
9477    // last failure went (so a repeat of it is not handed over again).
9478    let carried: Vec<BTreeSet<String>> = originals
9479        .iter()
9480        .map(|j| {
9481            state
9482                .seat_history
9483                .get(&j.seat.key)
9484                .filter(|_| carry)
9485                .map(|h| h.failed.clone())
9486                .unwrap_or_default()
9487        })
9488        .collect();
9489    let mut prev: Vec<Option<FailClass>> = originals
9490        .iter()
9491        .map(|j| {
9492            state
9493                .seat_history
9494                .get(&j.seat.key)
9495                .filter(|_| carry)
9496                .and_then(|h| h.last_fail.clone())
9497        })
9498        .collect();
9499    let next_agent =
9500        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9501            let others: BTreeSet<String> = specs
9502                .iter()
9503                .enumerate()
9504                .filter(|(j, _)| *j != i)
9505                .map(|(_, s)| s.id.clone())
9506                .collect();
9507            pick_successor(
9508                roster,
9509                starts[i],
9510                tried,
9511                carry.then(|| &carried[i]),
9512                &others,
9513            )
9514            .cloned()
9515        };
9516    let mut fresh: Vec<Option<String>> = vec![None; n];
9517    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9518    let mut pending: Vec<usize> = (0..n).collect();
9519
9520    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
9521    // asks: an agent with a successor is asked once and handed over, and only
9522    // a seat with no successor spends `retries` nudges on the same agent. This
9523    // only guarantees the loop's own termination whatever those say.
9524    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9525    for round in 0..max_rounds {
9526        if pending.is_empty() {
9527            break;
9528        }
9529        let mut batch = Vec::with_capacity(pending.len());
9530        let mut renudged: Vec<&str> = Vec::new();
9531        for &i in &pending {
9532            let src = &originals[i];
9533            // A seat now held by another agent than the job named has no
9534            // session of its own: it gets the full-context prompt whenever
9535            // it is asked in full (the handover ask, a nudge it cannot
9536            // resume).
9537            let full: &str = match &src.handover {
9538                Some(h) if specs[i].id != src.spec.id => h,
9539                _ => &src.prompt,
9540            };
9541            // The prompt and the budget are one decision: a nudge restates
9542            // finished work, a re-sent prompt redoes it.
9543            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9544                (
9545                    full.to_owned(),
9546                    src.timeout,
9547                    format!("{}-{word}-{}", src.stem, specs[i].id),
9548                )
9549            } else if nudges[i] == 0 {
9550                (full.to_owned(), src.timeout, src.stem.clone())
9551            } else {
9552                renudged.push(src.seat.key.as_str());
9553                let why = done[i]
9554                    .as_ref()
9555                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
9556                    .unwrap_or_else(|| "no parsable answer".to_owned());
9557                let nudge = prompt::nudge(&why);
9558                let nudged = has_context(&specs[i], &seats[i], src.sessions);
9559                let prompt = if nudged {
9560                    nudge
9561                } else {
9562                    format!("{full}\n\n---\n\n{nudge}")
9563                };
9564                (
9565                    prompt,
9566                    retry_budget(src.timeout, nudged),
9567                    format!("{}-retry{}", src.stem, nudges[i]),
9568                )
9569            };
9570            batch.push(SeatJob {
9571                spec: specs[i].clone(),
9572                seat: seats[i].clone(),
9573                cwd: src.cwd.clone(),
9574                prompt,
9575                timeout,
9576                allow_write: src.allow_write,
9577                sessions: src.sessions,
9578                artifacts: src.artifacts.clone(),
9579                stem,
9580                handover: None,
9581            });
9582        }
9583
9584        if !renudged.is_empty() {
9585            state.event(
9586                ctx.node,
9587                format!("retry {round}: re-asking {}", renudged.join(", ")),
9588            );
9589        }
9590        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9591        let mut still = Vec::new();
9592        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9593            seats[i] = seat;
9594            // No agent was asked: nothing to hand over, nudge, remember or
9595            // charge. The caller sees `resource_block` and stops the node.
9596            if let AgentOutcome::ResourceBlocked(why) = &out {
9597                done[i] = Some(Err(anyhow::anyhow!("{why}")));
9598                continue;
9599            }
9600            let class = FailClass::of(&out);
9601            // A dropped stream is nudged first (the conversation is still
9602            // there to pick up); only a seat whose nudges are spent hands over.
9603            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9604                && nudges[i] < retries
9605                && !roster.is_empty();
9606            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9607                let next = should_hand_over(prev[i].as_ref(), &cur)
9608                    .then(|| next_agent(i, &tried[i], &specs))
9609                    .flatten();
9610                if carry {
9611                    let h = state
9612                        .seat_history
9613                        .entry(originals[i].seat.key.clone())
9614                        .or_default();
9615                    h.failed.insert(specs[i].id.clone());
9616                    h.last_fail = Some(cur.clone());
9617                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9618                        h.last_ok = None;
9619                    }
9620                    // Saved before the next agent is asked, so a restart in
9621                    // between does not forget who failed.
9622                    if let Err(e) = state.save() {
9623                        tracing::warn!("could not persist a seat's failure history: {e:#}");
9624                    }
9625                }
9626                if let Some(next) = next {
9627                    record_handover(
9628                        state,
9629                        ctx.node,
9630                        &originals[i].seat.key,
9631                        &specs[i].id,
9632                        &next.id,
9633                        &cur,
9634                        &fail_reason(&out),
9635                    );
9636                    tried[i].insert(next.id.clone());
9637                    prev[i] = Some(cur.clone());
9638                    seats[i] =
9639                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9640                    specs[i] = next;
9641                    fresh[i] = Some(cur.stem_word().to_owned());
9642                    nudges[i] = 0;
9643                    done[i] = Some(Err(anyhow::anyhow!(
9644                        "handed over after: {}",
9645                        fail_reason(&out)
9646                    )));
9647                    still.push(i);
9648                    continue;
9649                }
9650            }
9651            if carry
9652                && !nudge_first
9653                && let Some(cur) = class.clone()
9654            {
9655                // The chain ended here (no successor, or a repeated failure).
9656                let h = state
9657                    .seat_history
9658                    .entry(originals[i].seat.key.clone())
9659                    .or_default();
9660                h.failed.insert(specs[i].id.clone());
9661                h.last_fail = Some(cur);
9662            }
9663            let parsed = match out {
9664                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9665                AgentOutcome::Quota(o) => {
9666                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9667                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9668                }
9669                // Not a parseable answer: the nudge loop re-asks it, which is
9670                // exactly what a dropped stream needs. Just don't hand its raw
9671                // error JSON to `extract_json`.
9672                AgentOutcome::Dropped(o) => {
9673                    let why = o
9674                        .dropped
9675                        .as_ref()
9676                        .map(|d| d.why.as_str())
9677                        .unwrap_or("the CLI ended the stream without delivering its answer");
9678                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9679                }
9680                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9681                    Err(anyhow::anyhow!(e))
9682                }
9683            };
9684            let quota = class == Some(FailClass::Quota);
9685            let failed = parsed.is_err();
9686            done[i] = Some(parsed);
9687            if carry && !failed {
9688                let h = state
9689                    .seat_history
9690                    .entry(originals[i].seat.key.clone())
9691                    .or_default();
9692                h.failed.remove(&specs[i].id);
9693                h.last_ok = Some(specs[i].id.clone());
9694                h.last_fail = None;
9695            }
9696            // Do not re-ask a rate-limited seat (quota) — a retry is known to
9697            // fail the same way; and never re-ask a seat that already parsed.
9698            // A failed agent that still has a successor is not re-asked
9699            // either: the handover was its remedy and has just been refused
9700            // (the chain stops on a repeated failure class). A seat with no
9701            // successor left (a single-agent roster, the roster's tail, or an
9702            // empty roster) keeps the same-agent nudge, bounded by `retries`.
9703            let agent_failure = class.is_some()
9704                && !nudge_first
9705                && !roster.is_empty()
9706                && next_agent(i, &tried[i], &specs).is_some();
9707            if failed && !quota && !agent_failure && nudges[i] < retries {
9708                nudges[i] += 1;
9709                still.push(i);
9710            }
9711        }
9712        pending = still;
9713    }
9714
9715    // One loss per seat whose chain ended on a quota: the intermediate ones
9716    // were absorbed by a handover and are not losses.
9717    for (i, q) in last_quota.into_iter().enumerate() {
9718        if let Some(reset) = q {
9719            losses.push(QuotaLoss {
9720                seat: originals[i].seat.key.clone(),
9721                node: ctx.node.to_owned(),
9722                at: Timestamp::now(),
9723                reset,
9724            });
9725        }
9726    }
9727
9728    seats
9729        .into_iter()
9730        .zip(done)
9731        .zip(nudges)
9732        .map(|((seat, res), attempts)| {
9733            (
9734                seat,
9735                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9736                attempts,
9737            )
9738        })
9739        .collect()
9740}
9741
9742/// Did the wave end without asking any agent (see [`block_wave`])?
9743fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9744    results
9745        .iter()
9746        .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9747}
9748
9749/// Settle a wave that never spawned anything: every job answers
9750/// [`AgentOutcome::ResourceBlocked`], its seat leaves `active`, and nothing
9751/// else about the seat (session, history, quota) is touched.
9752fn block_wave(
9753    jobs: Vec<SeatJob>,
9754    state: &mut RunState,
9755    node: &str,
9756    attempt: usize,
9757    why: &str,
9758) -> Vec<(usize, SeatState, AgentOutcome)> {
9759    state.resource_wait = None;
9760    state
9761        .active
9762        .retain(|_, a| !(a.node == node && a.attempt == attempt));
9763    if let Err(e) = state.save() {
9764        tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9765    }
9766    jobs.into_iter()
9767        .enumerate()
9768        .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9769        .collect()
9770}
9771
9772/// Acquire the shared build cache's lease, waiting out contention within
9773/// `budget` (never past it — see AGENTS.md's build-cache section on why an
9774/// unbounded wait is never acceptable).
9775///
9776/// A first, non-blocking check happens before ever waiting; if it finds the
9777/// lease busy, that fact is logged as a `verify` event *and* flushed with
9778/// [`RunState::save`] immediately — not only once the wait finally succeeds
9779/// or gives up — so a `magi show` run by a different process while this one
9780/// is still waiting reads a `run.json` that says so, rather than whatever it
9781/// looked like before the wait started. The same applies to the terminal
9782/// failure: logged and saved before this returns `Err`, so a caller that
9783/// could not get the lease at all still leaves a legible record of why.
9784async fn acquire_cache_lease(
9785    state: &mut RunState,
9786    cache_dir: &Path,
9787    owner: &crate::cache::Owner,
9788    budget: Duration,
9789    context: &str,
9790    cancel: &(dyn Fn() -> bool + Sync),
9791) -> Result<Option<crate::cache::Guard>> {
9792    let home = crate::run::home();
9793    let started = Instant::now();
9794    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9795        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9796        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9797        Err(e) => {
9798            state.event(
9799                "verify",
9800                format!("{context}: could not check the shared build cache: {e:#}"),
9801            );
9802            if let Err(e2) = state.save() {
9803                tracing::warn!("could not persist a cache-check failure: {e2:#}");
9804            }
9805            return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9806        }
9807    };
9808    state.event(
9809        "verify",
9810        format!(
9811            "{context}: waiting for the shared build cache at {} ({})",
9812            cache_dir.display(),
9813            busy.describe()
9814        ),
9815    );
9816    // Seats of this node are waiting, not running: say so, so the countdown a
9817    // reader sees only ever measures an agent that has been spawned.
9818    state.resource_wait = Some(crate::run::ResourceWait {
9819        node: context.to_owned(),
9820        kind: "shared build cache".to_owned(),
9821        holder: busy.describe(),
9822        since: Timestamp::now(),
9823        limit_secs: budget.as_secs(),
9824    });
9825    if let Err(e) = state.save() {
9826        tracing::warn!("could not persist a cache wait: {e:#}");
9827    }
9828    let remaining = budget.saturating_sub(started.elapsed());
9829    let waited = crate::cache::wait_for_unless(
9830        &home,
9831        cache_dir,
9832        owner,
9833        remaining,
9834        Duration::from_secs(5),
9835        cancel,
9836    )
9837    .await;
9838    state.resource_wait = None;
9839    match waited {
9840        Ok(g) => Ok(g),
9841        Err(e) => {
9842            state.event("verify", format!("{context}: {e:#}"));
9843            if let Err(e2) = state.save() {
9844                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9845            }
9846            Err(e)
9847        }
9848    }
9849}
9850
9851/// The lease state could not be read at all (unreadable lease file, a
9852/// permission problem). Unlike a busy lease that outlasts its bound, nothing is
9853/// known to be contended, so a wave degrades to running without a shared cache
9854/// instead of stopping on a hold that [`crate::triage`] could never release.
9855#[derive(Debug)]
9856struct CacheCheckError(String);
9857
9858impl std::fmt::Display for CacheCheckError {
9859    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9860        f.write_str(&self.0)
9861    }
9862}
9863
9864impl std::error::Error for CacheCheckError {}
9865
9866/// Run `body` — a verify command batch — while holding the shared build
9867/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
9868/// never interleave with another borrower's build against the same
9869/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
9870/// timeout, or a human's own `magi review`. See the `cache` module doc for
9871/// why this matters more than Cargo's own per-target locking covers — two
9872/// *different* worktrees building the same package name/version into one
9873/// cache directory is a staleness bug, not a lock contention one.
9874///
9875/// The wait for the lease is carved out of `budget`, never on top of it —
9876/// `body` is handed whatever is left, so a caller's own node timeout is the
9877/// only clock involved, exactly what AGENTS.md's build-cache section asks
9878/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
9879/// cache configured at all — this is a pass-through: `body` runs with the
9880/// full budget and nothing is leased.
9881///
9882/// A lease that cannot be acquired within `budget` is reported as a single
9883/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
9884/// verification — the same shape a spawn failure already takes in
9885/// [`run_commands`], so a caller need not special-case it.
9886#[allow(clippy::too_many_arguments)]
9887async fn with_cache_lease<'s, F, Fut>(
9888    state: &'s mut RunState,
9889    cache_dir: Option<&Path>,
9890    node: &str,
9891    seat: &str,
9892    worktree: &Path,
9893    head: &str,
9894    budget: Duration,
9895    context: &str,
9896    body: F,
9897) -> (Vec<CommandOutcome>, bool)
9898where
9899    F: FnOnce(&'s mut RunState, Duration) -> Fut,
9900    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9901{
9902    let Some(cache_dir) = cache_dir else {
9903        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9904        return (outcomes, retried);
9905    };
9906    let home = crate::run::home();
9907    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9908    let started = Instant::now();
9909    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9910        .await
9911        .and_then(|g| {
9912            g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9913        }) {
9914        Ok(g) => g,
9915        Err(e) => {
9916            return (
9917                vec![CommandOutcome {
9918                    command: "(waiting for the shared build cache)".to_owned(),
9919                    code: None,
9920                    output_tail: e.to_string(),
9921                    duration_ms: started.elapsed().as_millis() as u64,
9922                    resource_blocked: true,
9923                }],
9924                false,
9925            );
9926        }
9927    };
9928    let identity = crate::cache::Identity::new(worktree, head);
9929    if let Err(e) = crate::cache::ensure_fresh(
9930        &home,
9931        cache_dir,
9932        &identity,
9933        &state.config.verify.cargo_manifests(),
9934    ) {
9935        // A failed freshness check means this process cannot vouch for what
9936        // is sitting in the cache right now - on Windows this is exactly the
9937        // "a stale test executable is still locked, `cargo clean -p` cannot
9938        // remove it" case the evidence log records. Running verify anyway
9939        // and reporting whatever it says would let a result nobody can trust
9940        // stand for the tree it claims to have checked; fail the step
9941        // instead of the patch.
9942        state.event(
9943            "verify",
9944            format!(
9945                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9946                worktree.display(),
9947                short(head)
9948            ),
9949        );
9950        guard.release();
9951        return (
9952            vec![CommandOutcome {
9953                command: "(confirming the shared build cache is fresh)".to_owned(),
9954                code: None,
9955                output_tail: e.to_string(),
9956                duration_ms: started.elapsed().as_millis() as u64,
9957                resource_blocked: true,
9958            }],
9959            false,
9960        );
9961    }
9962    let remaining = budget.saturating_sub(started.elapsed());
9963    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9964    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
9965    // `start_kill`); confirm it actually has before handing the directory to
9966    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
9967    // for what this can and cannot see.
9968    if !timed_out_pids.is_empty() {
9969        wait_for_timed_out_children_to_die(&timed_out_pids).await;
9970    }
9971    guard.release();
9972    (outcomes, retried)
9973}
9974
9975/// Poll `pids` — commands [`run_commands`] reports as still running when its
9976/// own timeout elapsed — until every one is confirmed gone, or
9977/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
9978///
9979/// Real confirmation where confirmation is possible, not a substitute for
9980/// full process-tree observation: a grandchild the timed-out process spawned
9981/// and that survives independently of it is invisible to a pid check the
9982/// same way it always was, and continuing to observe and collect *that*
9983/// stays a different piece of work with its own owner. This only narrows a
9984/// fixed blind wait into an actual check of the pids this process does know
9985/// about.
9986async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9987    wait_for_pids_with(
9988        pids,
9989        crate::proc::pid_alive,
9990        LEASE_RELEASE_POLL,
9991        LEASE_RELEASE_MAX_WAIT,
9992    )
9993    .await;
9994}
9995
9996/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
9997/// interval and ceiling supplied by the caller, so the polling *logic* -
9998/// returns as soon as every pid reports dead, gives up at the ceiling
9999/// otherwise - is testable on millisecond durations without asking the real
10000/// OS about a pid at all.
10001async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10002    pids: &[u32],
10003    alive: F,
10004    poll: Duration,
10005    max_wait: Duration,
10006) {
10007    let deadline = Instant::now() + max_wait;
10008    loop {
10009        if pids.iter().all(|&pid| !alive(pid)) {
10010            return;
10011        }
10012        if Instant::now() >= deadline {
10013            return;
10014        }
10015        tokio::time::sleep(poll).await;
10016    }
10017}
10018
10019/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
10020/// admission that it could not even get a verify command to run, as opposed
10021/// to evidence the command actually produced? A caller that would otherwise
10022/// read a resource-blocked outcome as a red command must check this first:
10023/// see [`Runner::gate`], which retries rather than records `Blocked` when
10024/// this is true.
10025fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10026    outcomes.iter().any(|o| o.resource_blocked)
10027}
10028
10029/// What [`Runner::gate_fix_round`] decided.
10030enum GateFix {
10031    /// The tree changed and `verify.e2e` is still green: run the gate again.
10032    Retry,
10033    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
10034    /// last failure stands and the run ends blocked.
10035    Stop,
10036    /// `verify.e2e` could not run after the fix (magi's own contention):
10037    /// decide nothing now, a later reentry retries.
10038    Defer,
10039}
10040
10041/// Is every red command in `outcomes` an ordinary failure the code could
10042/// explain: it ran, exited non-zero, and said something?
10043///
10044/// A timeout, a spawn failure and a killed process all leave `code` `None`;
10045/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
10046/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
10047/// what the command is stays the gate's business.
10048fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10049    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10050    red.peek().is_some()
10051        && red.all(|o| {
10052            !o.resource_blocked
10053                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10054                && !o.output_tail.trim().is_empty()
10055        })
10056}
10057
10058/// Describe one verify command's outcome for the event log, distinguishing a
10059/// build/link failure — the toolchain never produced a binary to run — from
10060/// an actual test failure, since only the latter is a verdict on the patch.
10061fn e2e_outcome_label(o: &CommandOutcome) -> String {
10062    if o.ok() {
10063        return "pass".to_owned();
10064    }
10065    let reason = if o.build_failed() {
10066        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10067    } else {
10068        format!("FAIL ({:?})", o.code)
10069    };
10070    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10071}
10072
10073/// Run `verify.e2e`, retrying once if the first attempt could not build or
10074/// link — a build/link failure is frequently a race against a shared
10075/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
10076/// `verify` event per command, tagged with `context` (normally `"round N"`)
10077/// so the two call sites that need this — the ordinary per-round leg in
10078/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
10079/// it will ever call a round green — read identically in the event log.
10080async fn run_e2e_with_retry(
10081    state: &mut RunState,
10082    shell: &[String],
10083    commands: &[String],
10084    worktree: &Path,
10085    timeout: Duration,
10086    context: &str,
10087) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10088    let (mut e2e, mut timed_out_pids) = run_commands(
10089        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10090    )
10091    .await;
10092    for o in &e2e {
10093        state.event(
10094            "verify",
10095            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10096        );
10097    }
10098    // A build/link failure is not a verdict on the patch — it is frequently a
10099    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
10100    // one retry before letting a red like that decide the round.
10101    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10102    if verify_retried {
10103        state.event(
10104            "verify",
10105            format!(
10106                "{context}: verify could not build/link, not a test result — retrying once \
10107                 before concluding"
10108            ),
10109        );
10110        let retried = run_commands(
10111            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10112        )
10113        .await;
10114        e2e = retried.0;
10115        // Both attempts' timeouts matter, not just the last one: the first
10116        // attempt's descendants may still be alive alongside the retry's.
10117        timed_out_pids.extend(retried.1);
10118        for o in &e2e {
10119            state.event(
10120                "verify",
10121                format!(
10122                    "{context}: retry `{}` -> {}",
10123                    o.command,
10124                    e2e_outcome_label(o)
10125                ),
10126            );
10127        }
10128    }
10129    (e2e, verify_retried, timed_out_pids)
10130}
10131
10132/// Run configured shell commands in `cwd`, in order. The second element is
10133/// the pid of every command that hit `timeout` and was still running when
10134/// this stopped waiting on it (best-effort: `None` when the platform did not
10135/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
10136/// that releases a shared resource afterward needs to know.
10137///
10138/// Records `task` into [`RunState::active`] at every command boundary
10139/// (`RunState::task_command`) and clears it once the whole list has run
10140/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
10141/// for minutes with no seat and no output of its own to show for it (see
10142/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
10143/// apart from "not yet run" without this), and this is the only place that
10144/// knows which command is running right now and how many are left. Three
10145/// saves per command — start, not per second — matching the same "only at a
10146/// boundary" rule [`wave`] already follows for seats.
10147#[allow(clippy::too_many_arguments)]
10148async fn run_commands(
10149    state: &mut RunState,
10150    node: &str,
10151    task: &str,
10152    attempt: usize,
10153    shell: &[String],
10154    commands: &[String],
10155    cwd: &Path,
10156    timeout: Duration,
10157) -> (Vec<CommandOutcome>, Vec<u32>) {
10158    if commands.is_empty() {
10159        // Nothing to mark as running and nothing to clear — an empty list
10160        // means "not configured", and touching `active` (or the disk) over
10161        // that would be a write for every round of a repo with no
10162        // `verify.e2e` / `verify.gate` commands at all.
10163        return (Vec::new(), Vec::new());
10164    }
10165    let mut out = Vec::new();
10166    let mut timed_out_pids = Vec::new();
10167    let total = commands.len();
10168    for (idx, command) in commands.iter().enumerate() {
10169        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10170        if let Err(e) = state.save() {
10171            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10172        }
10173        let started = Instant::now();
10174        let mut cmd = tokio::process::Command::new(&shell[0]);
10175        cmd.quiet();
10176        cmd.args(&shell[1..])
10177            .arg(command)
10178            .current_dir(cwd)
10179            .stdin(std::process::Stdio::null())
10180            .stdout(std::process::Stdio::piped())
10181            .stderr(std::process::Stdio::piped())
10182            .kill_on_drop(true);
10183        let spawned = cmd.spawn();
10184        let (code, body) = match spawned {
10185            Ok(child) => {
10186                // Captured before the child is consumed below: `kill_on_drop`
10187                // only *asks* the process to die when the timeout branch
10188                // drops it, and the pid is the only way anyone downstream can
10189                // later check whether that request actually took.
10190                let pid = child.id();
10191                match tokio::time::timeout(timeout, child.wait_with_output()).await {
10192                    Ok(Ok(o)) => {
10193                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10194                        body.push_str(&String::from_utf8_lossy(&o.stderr));
10195                        (o.status.code(), body)
10196                    }
10197                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
10198                    Err(_) => {
10199                        if let Some(pid) = pid {
10200                            timed_out_pids.push(pid);
10201                        }
10202                        (None, format!("timed out after {}s", timeout.as_secs()))
10203                    }
10204                }
10205            }
10206            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10207        };
10208        out.push(CommandOutcome {
10209            command: command.clone(),
10210            code,
10211            output_tail: tail(&body, OUTPUT_TAIL),
10212            duration_ms: started.elapsed().as_millis() as u64,
10213            resource_blocked: false,
10214        });
10215    }
10216    state.task_finished(task);
10217    if let Err(e) = state.save() {
10218        tracing::warn!("could not persist the end of {task}: {e:#}");
10219    }
10220    (out, timed_out_pids)
10221}
10222
10223/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
10224/// section (`report::run`) and in the `merge` event this node records — for
10225/// the operator to run by hand.
10226///
10227/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
10228/// branch whose ruleset forbids merge commits (GitHub's "must not contain
10229/// merge commits", or "require linear history") rejects the push a `--no-ff`
10230/// merge would produce, which is exactly the guidance this function replaces.
10231/// `message`'s first line becomes the squash commit's subject, matching the
10232/// note `report::run` prints alongside this command — see that function for
10233/// why an explicit subject is not optional there.
10234fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10235    let repo = repo.display();
10236    match style {
10237        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10238        MergeStyle::Squash => {
10239            // The subject sits inside double quotes, and a title an agent
10240            // wrote may carry the characters that break out of them.
10241            let subject = message
10242                .lines()
10243                .next()
10244                .unwrap_or(branch)
10245                .replace(['\\', '"', '$', '`'], "");
10246            format!(
10247                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10248            )
10249        }
10250        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10251    }
10252}
10253
10254/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
10255/// under the hood, rejects a `title` over 256 characters and the whole
10256/// command fails — no PR at all, for a run whose body was otherwise fine
10257/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
10258/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
10259/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
10260/// this bound. It is a margin, not a guarantee — a title packed
10261/// with multi-unit characters could still in principle land close to the
10262/// edge, but a real task title's occasional emoji or accented letter fits
10263/// comfortably inside it.
10264const PR_TITLE_MAX: usize = 240;
10265
10266/// A pull request title from the opening line of `text`, or `None` when that
10267/// line is not English (GitHub text is) or has no letters.
10268///
10269/// A line within `max` is kept as is. A longer one is cut at the end of its
10270/// first sentence when that falls inside `max`, else at a word boundary with a
10271/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
10272/// make every merely-truncated title look non-English). The language check
10273/// runs on the kept text before any mark is added, so only what GitHub will
10274/// show is judged: an English opening followed by non-ASCII far past the cut
10275/// still passes.
10276fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10277    let line = queue::first_line(text)?;
10278    let chars: Vec<char> = line.chars().collect();
10279    let (kept, mark) = if chars.len() <= max {
10280        (line.to_owned(), "")
10281    } else if let Some(end) = sentence_end(&chars, max) {
10282        (chars[..end].iter().collect::<String>(), "")
10283    } else {
10284        let room = max.saturating_sub(3);
10285        // Cut at the last space inside the room; when the char just past the
10286        // room is a space the room already ends on a word.
10287        let cut = if chars[room].is_whitespace() {
10288            room
10289        } else {
10290            chars[..room]
10291                .iter()
10292                .rposition(|c| c.is_whitespace())
10293                .unwrap_or(room)
10294        };
10295        let head: String = chars[..cut].iter().collect();
10296        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10297        (head.to_owned(), "...")
10298    };
10299    if kept.is_empty() {
10300        return None;
10301    }
10302    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10303        return None;
10304    }
10305    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10306        return None;
10307    }
10308    Some(format!("{kept}{mark}"))
10309}
10310
10311/// The char length of the first sentence of `chars` when it ends within `max`
10312/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
10313/// abbreviations so `e.g. foo` does not end a title early.
10314fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10315    const MIN: usize = 20;
10316    for i in MIN..max.min(chars.len()) {
10317        if !matches!(chars[i], '.' | '!' | '?') {
10318            continue;
10319        }
10320        let Some(&next) = chars.get(i + 1) else {
10321            continue;
10322        };
10323        if !next.is_whitespace() {
10324            continue;
10325        }
10326        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10327        if after.is_some_and(|c| c.is_ascii_lowercase()) {
10328            continue;
10329        }
10330        let word: String = chars[..i]
10331            .iter()
10332            .rev()
10333            .take_while(|c| !c.is_whitespace())
10334            .collect::<Vec<_>>()
10335            .into_iter()
10336            .rev()
10337            .collect();
10338        let word = word.to_ascii_lowercase();
10339        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10340            continue;
10341        }
10342        let end = chars[..i]
10343            .iter()
10344            .rposition(|c| !c.is_whitespace())
10345            .map_or(i, |p| p + 1);
10346        return Some(end);
10347    }
10348    None
10349}
10350
10351/// What `merge = "pr"` (and the merge commit of the other modes) says about a
10352/// change: a title and a body describing what was *implemented*, not the task
10353/// that asked for it. A task reads as a request; a reader of the merged
10354/// history wants the change.
10355struct PrMessage {
10356    title: String,
10357    body: String,
10358}
10359
10360impl PrMessage {
10361    /// Title, blank line, body. The first line is the squash/merge commit
10362    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
10363    /// has to stay one sensible line.
10364    fn commit_message(&self) -> String {
10365        format!("{}\n\n{}", self.title, self.body)
10366    }
10367}
10368
10369/// The text after a leading `TITLE:` (any case) on `line`.
10370fn title_marker(line: &str) -> Option<&str> {
10371    let line = line.trim();
10372    let head = line.get(..6)?;
10373    head.eq_ignore_ascii_case("title:")
10374        .then(|| line[6..].trim())
10375}
10376
10377/// The implementer's own one-line title: the `TITLE:` line the implement
10378/// prompt asks for at the top of its SUMMARY. Candidate commits are all
10379/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
10380/// source, and a title that says as much is refused here too.
10381fn summary_title(summary: &str) -> Option<String> {
10382    let first = summary.lines().find(|l| !l.trim().is_empty())?;
10383    let raw = title_marker(first)?;
10384    if raw.is_empty() {
10385        return None;
10386    }
10387    let title = queue::title_from(raw, PR_TITLE_MAX);
10388    let lower = title.to_ascii_lowercase();
10389    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10390        return None;
10391    }
10392    Some(title)
10393}
10394
10395/// How `open_review`'s instruction begins; see [`landing_title`].
10396const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10397
10398/// Marker `open_review` gives a candidate that nothing in the roster wrote.
10399pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10400
10401/// Does this run review work that already existed, rather than implement a
10402/// task? Runs recorded before `reviewed_commits` existed carry only the
10403/// candidate marker.
10404fn is_review_run(state: &RunState) -> bool {
10405    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10406}
10407
10408/// The title of a review-only run: the subject of the oldest commit under
10409/// review. Later commits are usually fixups, and `instruction` is the review
10410/// prompt, which says nothing about the change. GitHub text is English, so a
10411/// non-ASCII or blank subject yields `None` and the caller's neutral title.
10412fn review_title(state: &RunState) -> Option<String> {
10413    english_subject(
10414        state.reviewed_commits.as_ref()?.first()?,
10415        state.config.graph.github_english(),
10416    )
10417}
10418
10419/// `raw` as a pull request title, or `None` when it is blank, not English
10420/// (GitHub text is), or one of magi's own candidate commit subjects.
10421fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10422    let raw = raw.trim();
10423    if raw.is_empty() {
10424        return None;
10425    }
10426    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10427        return None;
10428    }
10429    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10430        return None;
10431    }
10432    let title = queue::title_from(raw, PR_TITLE_MAX);
10433    let lower = title.to_ascii_lowercase();
10434    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10435        return None;
10436    }
10437    Some(title)
10438}
10439
10440/// What a review-only run's branch says about itself, read at the moment the
10441/// pull request is opened.
10442#[derive(Debug, Clone, PartialEq, Eq)]
10443struct BranchFacts {
10444    /// `(subject, body)` of each commit, oldest first.
10445    commits: Vec<(String, String)>,
10446    /// Trimmed `git diff --stat`.
10447    stat: String,
10448}
10449
10450/// Longest diff stat shown: this many file lines plus the summary line.
10451const STAT_FILE_LINES: usize = 25;
10452/// Cap on the commit list, well inside GitHub's 65536-character body limit.
10453const COMMITS_MAX_CHARS: usize = 20_000;
10454
10455/// Read the commits and diff stat of `base..branch`. `None` when git cannot
10456/// say or finds nothing, so the caller falls back to what the run recorded.
10457async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10458    let commits = git::commit_log(repo, base, branch).await.ok()?;
10459    if commits.is_empty() {
10460        return None;
10461    }
10462    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10463    let lines: Vec<&str> = stat.lines().collect();
10464    let stat = if lines.len() > STAT_FILE_LINES + 1 {
10465        let omitted = lines.len() - 1 - STAT_FILE_LINES;
10466        let more = format!(" ... {omitted} more file(s)");
10467        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10468        kept.push(&more);
10469        kept.push(lines[lines.len() - 1]);
10470        kept.join("\n")
10471    } else {
10472        lines.join("\n")
10473    };
10474    Some(BranchFacts { commits, stat })
10475}
10476
10477/// Defang what would break the surrounding markdown: a closing `</details>`
10478/// and a code fence.
10479fn markdown_safe(text: &str) -> String {
10480    text.replace("</details>", "&lt;/details&gt;")
10481        .replace("\x60\x60\x60", "~~~")
10482}
10483
10484fn neutral_title(state: &RunState, winner: char) -> String {
10485    format!(
10486        "chore: land candidate {} of run {}",
10487        winner.to_ascii_uppercase(),
10488        state.id
10489    )
10490}
10491
10492/// The pull request title to hand to `land::merge_subject`. A review-only run
10493/// opened by an earlier build titled its pull request with the review prompt;
10494/// that title is dropped (empty, so the fallback applies) rather than landed.
10495/// Any other title, including an operator's rename, passes through untouched,
10496/// and so does every title of a run that implements a task.
10497pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10498    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10499        ""
10500    } else {
10501        pr_title
10502    }
10503}
10504
10505/// What the squash subject falls back to when the pull request title is empty
10506/// or candidate-shaped: for a review-only run the derived title, never the
10507/// review prompt held in `instruction`.
10508pub fn landing_subject_source(state: &RunState) -> String {
10509    if is_review_run(state) {
10510        let winner = state.candidates.first().map_or('A', |c| c.label);
10511        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10512    }
10513    state.instruction.clone()
10514}
10515
10516/// `summary` without its `TITLE:` line, which the pull request title already
10517/// carries.
10518fn summary_without_title(summary: &str) -> String {
10519    let mut lines = summary.trim().lines().peekable();
10520    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10521        lines.next();
10522    }
10523    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10524}
10525
10526/// The pull request title and body for the winning candidate.
10527///
10528/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10529/// the task's own opening line via [`queue::title_from`] when there is none.
10530/// `state.instruction` can open with blank lines (`task_text` only rejects a
10531/// body that is blank *entirely*), which `title_from` skips.
10532///
10533/// Publishing uses an independent description generated from the final diff
10534/// and structured evidence. Internal summaries and reports remain local.
10535/// Non-publishing previews may display the original task and candidate summary.
10536/// The footer retains the run and candidate provenance.
10537#[cfg(test)]
10538fn pr_message(state: &RunState, winner: char) -> PrMessage {
10539    pr_message_with(state, winner, None)
10540}
10541
10542/// [`pr_message`] with what the branch of a review-only run says about itself.
10543/// `facts` is ignored for a run that implements a task.
10544#[cfg(test)]
10545fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10546    let raw = pr_message_raw(
10547        state,
10548        winner,
10549        facts,
10550        crate::pr_outcome::FinalHead::Unreported,
10551        ChangeNote::NotAsked,
10552    );
10553    let id = crate::scrub::Identity::current();
10554    PrMessage {
10555        title: crate::scrub::scrub(&raw.title, &id),
10556        body: crate::scrub::scrub(&raw.body, &id),
10557    }
10558}
10559
10560/// What the public description may say about the change itself.
10561#[derive(Debug, Clone, Copy)]
10562enum ChangeNote<'a> {
10563    /// A local preview or non-publishing merge mode: no public generation needed.
10564    NotAsked,
10565    /// A summary written from the final diff.
10566    Summary(&'a crate::run::ChangeSummary),
10567    /// A summary was asked for and could not be produced; the text says why.
10568    Unavailable(&'a str),
10569}
10570
10571fn pr_message_raw(
10572    state: &RunState,
10573    winner: char,
10574    facts: Option<&BranchFacts>,
10575    head: crate::pr_outcome::FinalHead<'_>,
10576    change: ChangeNote<'_>,
10577) -> PrMessage {
10578    if !matches!(change, ChangeNote::NotAsked) {
10579        let (title, mut body) = match change {
10580            ChangeNote::Summary(c) => (
10581                c.title.clone(),
10582                format!("## Summary\n\n{}\n\n", c.body.trim()),
10583            ),
10584            ChangeNote::Unavailable(why) => (
10585                neutral_title(state, winner),
10586                format!(
10587                    "## 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"
10588                ),
10589            ),
10590            ChangeNote::NotAsked => unreachable!(),
10591        };
10592        body.push_str(&public_pr_evidence(state, head));
10593        body.push_str(&format!(
10594            "\n---\nmagi:run/{} magi:candidate-{}\n",
10595            state.id,
10596            winner.to_ascii_lowercase()
10597        ));
10598        return PrMessage { title, body };
10599    }
10600    pr_preview(state, winner, facts, head)
10601}
10602
10603/// Append public facts from the run records after every prose generation,
10604/// including posting-gate rewrites. The agent cannot drop this provenance.
10605fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10606    let mut body = crate::pr_outcome::review_rounds(state);
10607    body.push_str(&crate::pr_outcome::final_verification(state, head));
10608    let open = state.last_round_findings();
10609    if !open.is_empty() {
10610        body.push_str("## Open review findings\n\n");
10611        for f in open {
10612            body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10613        }
10614        body.push('\n');
10615    }
10616    if let Some(c) = state.change_summary.as_ref()
10617        && head == crate::pr_outcome::FinalHead::At(&c.head)
10618    {
10619        body.push_str(&format!(
10620            "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10621            c.base, c.head
10622        ));
10623    }
10624    body
10625}
10626
10627/// Local-only preview of the operator's task and candidate summary.
10628fn pr_preview(
10629    state: &RunState,
10630    winner: char,
10631    facts: Option<&BranchFacts>,
10632    head: crate::pr_outcome::FinalHead<'_>,
10633) -> PrMessage {
10634    let summary = state
10635        .candidates
10636        .iter()
10637        .find(|c| c.label == winner)
10638        .map(|c| c.summary.as_str())
10639        .unwrap_or_default();
10640    // The fallback is the operator's own words and may not be English; GitHub
10641    // text always is, so a non-English task gets a neutral title instead.
10642    let review = is_review_run(state);
10643    let title = if review {
10644        facts
10645            .and_then(|f| {
10646                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10647            })
10648            .or_else(|| review_title(state))
10649            .or_else(|| {
10650                state
10651                    .candidates
10652                    .iter()
10653                    .find(|c| c.label == winner)
10654                    .filter(|c| !c.branch.starts_with("magi/"))
10655                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10656            })
10657            .unwrap_or_else(|| neutral_title(state, winner))
10658    } else {
10659        summary_title(summary).unwrap_or_else(|| {
10660            english_title(
10661                &state.instruction,
10662                PR_TITLE_MAX,
10663                state.config.graph.github_english(),
10664            )
10665            .unwrap_or_else(|| neutral_title(state, winner))
10666        })
10667    };
10668
10669    let mut body = String::new();
10670    let what = summary_without_title(summary);
10671    if !what.is_empty() {
10672        body.push_str("## Summary\n\n");
10673        body.push_str(&what);
10674        body.push_str("\n\n");
10675    }
10676
10677    // Review history uses structured facts across all rounds, never reports.
10678    body.push_str(&crate::pr_outcome::review_rounds(state));
10679    body.push_str(&crate::pr_outcome::final_verification(state, head));
10680    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10681
10682    let open = state.open_findings();
10683    if !open.is_empty() {
10684        body.push_str("## Open review findings\n\n");
10685        for f in &open {
10686            body.push_str(&format!(
10687                "- `{}` [{:?}] {}\n",
10688                f.id, f.severity, "Context retained in local review records."
10689            ));
10690        }
10691        body.push('\n');
10692    }
10693
10694    if let Some(fix) = fix
10695        && !fix.rejected.is_empty()
10696    {
10697        body.push_str("## Declined by the fixer\n\n");
10698        for r in &fix.rejected {
10699            body.push_str(&format!(
10700                "- `{}`: {}\n",
10701                r.id, "Reason retained in local review records."
10702            ));
10703        }
10704        body.push('\n');
10705    }
10706
10707    if review {
10708        // The review prompt is not the task; list what the branch carries.
10709        body.push_str("## Commits under review\n\n");
10710        if let Some(facts) = facts {
10711            let mut left = COMMITS_MAX_CHARS;
10712            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10713                let mut entry = format!("- {}\n", markdown_safe(subject));
10714                for l in markdown_safe(text).lines() {
10715                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10716                }
10717                if left == 0 {
10718                    body.push_str(&format!(
10719                        "- ... {} more commit(s)\n",
10720                        facts.commits.len() - i
10721                    ));
10722                    break;
10723                }
10724                if entry.len() > left {
10725                    // Even the first commit is cut: one huge body must not
10726                    // push the whole description past GitHub's limit.
10727                    let mut end = left;
10728                    while !entry.is_char_boundary(end) {
10729                        end -= 1;
10730                    }
10731                    entry.truncate(end);
10732                    entry.push_str("\n  ... (truncated)\n");
10733                    left = 0;
10734                } else {
10735                    left -= entry.len();
10736                }
10737                body.push_str(&entry);
10738            }
10739            if !facts.stat.trim().is_empty() {
10740                body.push_str(&format!(
10741                    "\n## Diff stat\n\n```\n{}\n```\n",
10742                    markdown_safe(facts.stat.trim())
10743                ));
10744            }
10745        } else {
10746            match &state.reviewed_commits {
10747                Some(subjects) => {
10748                    for s in subjects {
10749                        body.push_str(&format!("- {}\n", s.trim()));
10750                    }
10751                }
10752                None => {
10753                    // An older run kept only the prompt, with the commit list
10754                    // after its first paragraph.
10755                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10756                    body.push_str(rest.trim());
10757                    body.push('\n');
10758                }
10759            }
10760        }
10761    } else {
10762        let task = state.instruction.trim();
10763        let task = if task.is_empty() {
10764            "(empty task)"
10765        } else {
10766            task
10767        };
10768        body.push_str(&format!(
10769            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10770            task.replace("</details>", "&lt;/details&gt;")
10771        ));
10772    }
10773
10774    body.push_str(&format!(
10775        "\n---\nmagi:run/{} magi:candidate-{}\n",
10776        state.id,
10777        winner.to_ascii_lowercase()
10778    ));
10779
10780    PrMessage { title, body }
10781}
10782
10783/// The task with what the repository says about the existing work it names
10784/// appended, so an implementer knows what it started from and what it must
10785/// not redo. Unchanged when the task names nothing.
10786fn seeded_instruction(state: &RunState) -> String {
10787    match refs::describe(&state.seeds) {
10788        Some(facts) => format!(
10789            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10790             Candidates start from the unmerged branch named above, when there \
10791             is one, and carry any unmerged commit named by sha as a \
10792             cherry-pick. Check that this is what the task meant before \
10793             building on it.",
10794            state.instruction
10795        ),
10796        None => state.instruction.clone(),
10797    }
10798}
10799
10800/// Does the winner have no commits ahead of the base it would land on?
10801/// Any failure to find out reads as "not empty": the merge then behaves as it
10802/// always did rather than refusing on a guess.
10803async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10804    let base = &state.base_branch;
10805    let mut against = base.clone();
10806    if mode == MergeMode::Pr {
10807        // A pull request lands on the remote's base, never the local branch
10808        // of the same name: if that cannot be read, "not empty" is the safe
10809        // answer.
10810        let remote = &state.config.merge.remote;
10811        let tracking = format!("{remote}/{base}");
10812        let fetched = git::fetch(repo, remote, base).await;
10813        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10814            against = tracking;
10815        } else {
10816            return false;
10817        }
10818    }
10819    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10820}
10821
10822/// Why nothing was opened for an empty winner, with what the task's own
10823/// references resolved to.
10824fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10825    let mut detail = format!(
10826        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10827         nothing to open a pull request for"
10828    );
10829    match refs::describe(&state.seeds) {
10830        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10831        None => detail.push_str(
10832            "\nThe task names no existing branch or commit; if it means to land work \
10833             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10834        ),
10835    }
10836    detail
10837}
10838
10839/// What the `Pr` merge does once it knows whether the branch already has an
10840/// open pull request.
10841#[derive(Debug, PartialEq, Eq)]
10842enum PrPlan {
10843    Create,
10844    Adopt { url: String, title: String },
10845    Stop(String),
10846}
10847
10848/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
10849/// a failed lookup -> stop with the real reason. Never guesses.
10850fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10851    match found {
10852        Ok(land::OpenPr::None) => PrPlan::Create,
10853        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10854        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10855            "several open pull requests exist for this branch, not picking one: {}",
10856            urls.join(" ")
10857        )),
10858        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10859    }
10860}
10861
10862/// `gh pr create`, returning the PR url.
10863pub(crate) async fn gh_pr_create(
10864    cwd: &Path,
10865    base: &str,
10866    head: &str,
10867    title: &str,
10868    body: &str,
10869) -> Result<String> {
10870    let out = tokio::process::Command::new("gh")
10871        .args([
10872            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10873        ])
10874        .current_dir(cwd)
10875        .quiet()
10876        .stdin(std::process::Stdio::null())
10877        .output()
10878        .await
10879        .context("spawn gh")?;
10880    if out.status.success() {
10881        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10882    } else {
10883        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10884    }
10885}
10886
10887/// Tear a run's worktrees and branches down.
10888///
10889/// `home` is where the updated `run.json` is saved (via
10890/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
10891/// a housekeeping pass already has its own honest `home` handed to it, and
10892/// falling through to the global here would write back through whichever
10893/// directory some other process or test pinned into that `OnceLock` first,
10894/// not the one the caller actually resolved its `runs` and `state` from.
10895pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10896    if crate::pr_recovery::sources_retained(home, &state.id) {
10897        return Ok(Vec::new());
10898    }
10899    let repo = state.repo.clone();
10900    let root = state.worktree_root();
10901    let winner = state.tally.as_ref().map(|t| t.winner);
10902    let mut removed = Vec::new();
10903
10904    for i in 0..state.candidates.len() {
10905        let c = state.candidates[i].clone();
10906        let is_winner = Some(c.label) == winner;
10907        if is_winner && !drop_winner {
10908            continue;
10909        }
10910        if c.worktree.exists() {
10911            git::worktree_remove(&repo, &c.worktree).await.ok();
10912            removed.push(c.worktree.to_string_lossy().into_owned());
10913        }
10914        // A branch handed to a later run (and its pull request) is not this
10915        // run's to delete.
10916        let handed_over = state.released_branches.contains(&c.branch);
10917        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10918            git::branch_delete(&repo, &c.branch).await.ok();
10919            removed.push(c.branch.clone());
10920        }
10921        state.candidates[i].folded = true;
10922    }
10923
10924    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10925        let path = name.path();
10926        let keep = !drop_winner
10927            && winner.is_some_and(|w| {
10928                path.file_name()
10929                    .is_some_and(|n| n == format!("cand-{w}").as_str())
10930            });
10931        if keep {
10932            continue;
10933        }
10934        git::worktree_remove(&repo, &path).await.ok();
10935        removed.push(path.to_string_lossy().into_owned());
10936    }
10937
10938    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
10939    // judge worktrees, so once the loop above has cleared all of them out,
10940    // the parent is a bare directory nobody else was ever going to remove -
10941    // git only ever managed what was inside it. Left alone, one of these
10942    // accumulates per fully-folded run; the operator's own machine had 74.
10943    // `remove_if_empty` re-checks rather than assuming: a run whose winner
10944    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
10945    // so does anything a run never claimed that happens to share the bay.
10946    remove_if_empty(&root);
10947
10948    if state.enabled_worktree_config && drop_winner {
10949        // A release, not a raw disable: some sibling run in this repository
10950        // may still hold its own reference (see `git::acquire_worktree_config`),
10951        // and only the last release actually turns the setting back off.
10952        git::release_worktree_config(&repo).await.ok();
10953        state.enabled_worktree_config = false;
10954    }
10955    state.save_under(home)?;
10956    Ok(removed)
10957}
10958
10959/// Remove `dir` if it exists and has nothing in it.
10960///
10961/// Best-effort and silent by design: a directory that is not empty (a run
10962/// whose winner is still parked there, a stray file some other process left)
10963/// is exactly the case this must refuse, and a directory that is already gone
10964/// is not a failure worth reporting either. `std::fs::remove_dir` itself
10965/// already refuses a non-empty directory, so the emptiness check below is
10966/// belt, not suspenders - it is what keeps this from ever attempting the
10967/// removal in the case that matters, rather than trusting `remove_dir`'s
10968/// error path to have no side effects if it ever changed.
10969fn remove_if_empty(dir: &Path) {
10970    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10971        std::fs::remove_dir(dir).ok();
10972    }
10973}
10974
10975/// Severity of the worst open finding in the last review round, for reporting.
10976pub fn worst_open(state: &RunState) -> Option<Severity> {
10977    state
10978        .reviews
10979        .last()?
10980        .reviews
10981        .iter()
10982        .flat_map(|r| r.findings.iter())
10983        .map(|f| f.severity)
10984        .max()
10985}
10986
10987/// `[worktree] setup` hides its products per worktree, which needs
10988/// `extensions.worktreeConfig`. Taken once per run and released with the run's
10989/// worktrees, like the hook's; every place that runs setup calls this first.
10990async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10991    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10992        git::acquire_worktree_config(repo).await?;
10993        state.enabled_worktree_config = true;
10994    }
10995    Ok(())
10996}
10997
10998#[cfg(test)]
10999mod tests {
11000    #[test]
11001    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11002        let leaked = vec!["chore(deps): update a crate".to_owned()];
11003        let own = "fix(daemon): apply a chosen action";
11004        assert!(should_retitle("", own, &leaked));
11005        assert!(should_retitle(
11006            &format!("{REVIEW_PROMPT_OPENING} `x`"),
11007            own,
11008            &leaked
11009        ));
11010        assert!(should_retitle(
11011            "chore: land candidate A of run 1",
11012            own,
11013            &leaked
11014        ));
11015        assert!(should_retitle(
11016            "magi: candidate A (uncommitted work)",
11017            own,
11018            &leaked
11019        ));
11020        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11021        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11022        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11023    }
11024
11025    #[test]
11026    fn pr_merge_plan_creates_adopts_or_stops() {
11027        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11028        assert_eq!(
11029            pr_merge_plan(Ok(land::OpenPr::One {
11030                url: "u".into(),
11031                title: "t".into()
11032            })),
11033            PrPlan::Adopt {
11034                url: "u".into(),
11035                title: "t".into()
11036            }
11037        );
11038        let PrPlan::Stop(many) =
11039            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11040        else {
11041            panic!("many must stop");
11042        };
11043        assert!(many.contains('a') && many.contains('b'));
11044        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11045            panic!("a failed lookup must stop");
11046        };
11047        assert!(err.contains("bad token"));
11048    }
11049
11050    use super::*;
11051    use crate::run::GateStatus;
11052    use std::collections::BTreeMap;
11053    use std::time::Duration;
11054
11055    fn conductor() -> AgentSpec {
11056        AgentSpec {
11057            id: "conductor".to_owned(),
11058            kind: crate::config::AgentKind::Command,
11059            model: None,
11060            command: vec!["true".to_owned()],
11061            extra_args: Vec::new(),
11062            env: BTreeMap::new(),
11063            prompt_delivery: None,
11064        }
11065    }
11066
11067    fn spec(id: &str) -> AgentSpec {
11068        AgentSpec {
11069            id: id.to_owned(),
11070            kind: crate::config::AgentKind::Command,
11071            model: None,
11072            command: vec!["true".to_owned()],
11073            extra_args: Vec::new(),
11074            env: BTreeMap::new(),
11075            prompt_delivery: None,
11076        }
11077    }
11078
11079    fn ids(xs: &[&str]) -> BTreeSet<String> {
11080        xs.iter().map(|s| (*s).to_owned()).collect()
11081    }
11082
11083    #[test]
11084    fn pick_successor_skips_an_agent_another_seat_holds() {
11085        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
11086        let roster = [spec("a"), spec("b"), spec("c")];
11087        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11088        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11089    }
11090
11091    #[test]
11092    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11093        // The other seat started on c but was handed to d; c is free again.
11094        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11095        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11096        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11097        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11098        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11099    }
11100
11101    #[test]
11102    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11103        let roster = [spec("a"), spec("b")];
11104        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11105        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11106        let carried = ids(&["b"]);
11107        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11108        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11109    }
11110
11111    #[test]
11112    fn pick_successor_returns_none_without_any_untried_successor() {
11113        let roster = [spec("a"), spec("b")];
11114        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11115        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11116        let carried = ids(&["a"]);
11117        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11118    }
11119
11120    #[test]
11121    fn pick_successor_rescue_avoids_another_seats_occupant() {
11122        // b is a carried failure and free; a is held by the other seat.
11123        let roster = [spec("a"), spec("b"), spec("c")];
11124        let carried = ids(&["b", "c"]);
11125        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11126        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11127    }
11128
11129    #[test]
11130    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11131        let roster = [spec("a"), spec("b"), spec("c")];
11132        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11133        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11134    }
11135
11136    #[test]
11137    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11138        let roster = [spec("a"), spec("b"), spec("c")];
11139        let failed = ids(&["a", "b", "c"]);
11140        // Rescue looks at the whole roster, once per id, then runs out.
11141        let mut tried = ids(&["b"]);
11142        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11143        assert_eq!(first.id, "a");
11144        tried.insert(first.id.clone());
11145        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11146        assert_eq!(second.id, "c");
11147        tried.insert(second.id.clone());
11148        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11149    }
11150
11151    #[test]
11152    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11153        let roster = [spec("a"), spec("b")];
11154        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11155        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11156    }
11157
11158    #[test]
11159    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11160        let roster = [spec("a"), spec("b"), spec("c")];
11161        let h = SeatHistory {
11162            failed: ids(&["a"]),
11163            last_ok: Some("b".to_owned()),
11164            last_fail: None,
11165        };
11166        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11167        // A last answerer that left the roster, or later failed, is ignored.
11168        let gone = SeatHistory {
11169            last_ok: Some("zzz".to_owned()),
11170            ..h.clone()
11171        };
11172        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11173        let failed = SeatHistory {
11174            failed: ids(&["a", "b"]),
11175            last_ok: Some("b".to_owned()),
11176            last_fail: None,
11177        };
11178        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11179    }
11180
11181    #[test]
11182    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11183        let first = SeatState::new("review-1", "alpha", 7);
11184        let next = handover_seat("review-1", "gamma", 7);
11185        assert_ne!(first.claude_session, next.claude_session);
11186    }
11187
11188    #[test]
11189    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11190        let mut state = state_with_summary("x", "y");
11191        let a = handover_seat("review-1", "beta", state.next_seat_seed());
11192        let b = handover_seat("review-1", "beta", state.next_seat_seed());
11193        assert_ne!(a.claude_session, b.claude_session);
11194    }
11195
11196    #[test]
11197    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11198        let roster = [spec("a"), spec("b")];
11199        let h = SeatHistory {
11200            failed: ids(&["a", "b"]),
11201            ..SeatHistory::default()
11202        };
11203        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11204        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11205        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11206    }
11207
11208    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
11209    // fallback loop depends on to terminate: it must walk forward from the
11210    // seat's own position, never restart at the front of the roster, and it
11211    // must never hand back an id already tried, however many times that id
11212    // happens to appear.
11213
11214    #[test]
11215    fn failure_signature_ignores_numbers_and_paths() {
11216        assert_eq!(
11217            failure_signature("exited with Some(2) and no usable output"),
11218            failure_signature("exited with Some(137) and no usable output")
11219        );
11220        assert_eq!(
11221            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11222            failure_signature("cannot open /var/x.txt: denied")
11223        );
11224        assert_ne!(failure_signature("boom"), failure_signature("bang"));
11225    }
11226
11227    #[test]
11228    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11229        let other = FailClass::Other("x".into());
11230        assert!(should_hand_over(None, &FailClass::Quota));
11231        assert!(should_hand_over(Some(&other), &FailClass::Quota));
11232        assert!(should_hand_over(
11233            Some(&FailClass::Timeout),
11234            &FailClass::Timeout
11235        ));
11236        assert!(should_hand_over(None, &other));
11237        assert!(!should_hand_over(Some(&other), &other));
11238        assert!(should_hand_over(
11239            Some(&other),
11240            &FailClass::Other("y".into())
11241        ));
11242        // A quota or timeout in between ends the run of identical failures.
11243        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11244        assert!(should_hand_over(Some(&FailClass::Quota), &other));
11245    }
11246
11247    #[test]
11248    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11249        let a = SeatState::new("judge-1", "alpha", 7);
11250        let b = handover_seat("judge-1", "beta", 7);
11251        assert_ne!(a.claude_session, b.claude_session);
11252        assert_eq!(b.turns, 0);
11253    }
11254
11255    #[test]
11256    fn a_timeout_is_classified_apart_from_other_failures() {
11257        assert_eq!(
11258            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11259            Some(FailClass::Timeout)
11260        );
11261        assert!(matches!(
11262            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11263            Some(FailClass::Other(_))
11264        ));
11265    }
11266
11267    #[test]
11268    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11269        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11270        let tried = BTreeSet::from(["beta".to_owned()]);
11271        // beta sits at index 1; the next candidate is gamma, never alpha —
11272        // which is very likely a different candidate slot's own agent.
11273        let next = next_untried_in_roster(&roster, 1, &tried);
11274        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11275    }
11276
11277    #[test]
11278    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11279        let roster = vec![spec("alpha"), spec("beta")];
11280        let tried = BTreeSet::from(["beta".to_owned()]);
11281        // beta is the roster's last entry: nothing follows it, and alpha —
11282        // earlier in the roster, almost certainly a different candidate
11283        // slot's own agent — must not be reached by wrapping back to it.
11284        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11285    }
11286
11287    #[test]
11288    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11289        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11290        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11291        // beta (index 1) and gamma (index 2, the only entry after it) have
11292        // both been tried; alpha (index 0) never has, but it comes before
11293        // beta's own position, so there is nothing further for this seat.
11294        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11295    }
11296
11297    #[test]
11298    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11299        let roster = vec![spec("a"), spec("a"), spec("b")];
11300        let tried = BTreeSet::from(["a".to_owned()]);
11301        let next = next_untried_in_roster(&roster, 0, &tried);
11302        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11303    }
11304
11305    #[test]
11306    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11307        let roster = vec![spec("a"), spec("b")];
11308        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11309        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11310    }
11311
11312    #[test]
11313    fn remove_if_empty_only_ever_takes_a_bare_directory() {
11314        let dir = tempfile::tempdir().unwrap();
11315        let bay = dir.path().join("ffff");
11316
11317        // Not there yet: nothing to do, nothing to panic on.
11318        remove_if_empty(&bay);
11319        assert!(!bay.exists());
11320
11321        // Something still inside - the winner's worktree, or a stray file -
11322        // keeps the directory standing.
11323        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11324        remove_if_empty(&bay);
11325        assert!(bay.exists(), "non-empty directory must survive");
11326
11327        // Once the last entry is gone, so is the directory itself.
11328        std::fs::remove_dir(bay.join("cand-A")).unwrap();
11329        remove_if_empty(&bay);
11330        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11331    }
11332
11333    // `round_is_clean` is the exact decision this task fixed: a round with a
11334    // seat that never answered must not read the same as a round every seat
11335    // actually reviewed. These are deterministic and process-free by design —
11336    // the equivalent end-to-end check (a real reviewer timing out under a
11337    // live graph run) is a genuine race against wall-clock contention, and a
11338    // spawn slow enough to blow even a generous budget under a loaded test
11339    // run must not turn this specific regression check flaky.
11340
11341    #[test]
11342    fn a_full_panel_that_found_nothing_is_clean() {
11343        assert!(round_is_clean(
11344            0,
11345            true,
11346            2,
11347            2,
11348            0,
11349            IncompleteReviewPolicy::Block
11350        ));
11351    }
11352
11353    #[test]
11354    fn a_missing_seat_is_never_clean_under_the_default_policy() {
11355        assert!(!round_is_clean(
11356            0,
11357            true,
11358            1,
11359            2,
11360            0,
11361            IncompleteReviewPolicy::Block
11362        ));
11363    }
11364
11365    #[test]
11366    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11367        assert!(!round_is_clean(
11368            1,
11369            true,
11370            1,
11371            2,
11372            0,
11373            IncompleteReviewPolicy::Warn
11374        ));
11375    }
11376
11377    #[test]
11378    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11379        assert!(round_is_clean(
11380            0,
11381            true,
11382            1,
11383            2,
11384            0,
11385            IncompleteReviewPolicy::Warn
11386        ));
11387    }
11388
11389    #[test]
11390    fn a_full_panel_with_an_open_finding_is_not_clean() {
11391        assert!(!round_is_clean(
11392            1,
11393            true,
11394            2,
11395            2,
11396            0,
11397            IncompleteReviewPolicy::Block
11398        ));
11399    }
11400
11401    #[test]
11402    fn a_full_panel_with_a_red_e2e_is_not_clean() {
11403        assert!(!round_is_clean(
11404            0,
11405            false,
11406            2,
11407            2,
11408            0,
11409            IncompleteReviewPolicy::Block
11410        ));
11411    }
11412
11413    // The stall this task closes: under the default `block` policy, a seat
11414    // missing only because it was rate limited must not force a wait for a
11415    // session limit that will not lift by the next round. `round_is_clean`
11416    // is where that quorum carve-out lives; the review loop around it never
11417    // changes what a reviewer's vote or a finding's severity means.
11418
11419    #[test]
11420    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11421        // 1 of 2 answered, and the one missing was quota'd — the exact
11422        // "review-2 rate limited (quota)" shape from the field report.
11423        assert!(round_is_clean(
11424            0,
11425            true,
11426            1,
11427            2,
11428            1,
11429            IncompleteReviewPolicy::Block
11430        ));
11431    }
11432
11433    #[test]
11434    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11435        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
11436        // not a quota loss (`quota_missing` stays 0) — worth another try.
11437        assert!(!round_is_clean(
11438            0,
11439            true,
11440            1,
11441            2,
11442            0,
11443            IncompleteReviewPolicy::Block
11444        ));
11445    }
11446
11447    #[test]
11448    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11449        assert!(!round_is_clean(
11450            1,
11451            true,
11452            1,
11453            2,
11454            1,
11455            IncompleteReviewPolicy::Block
11456        ));
11457        assert!(!round_is_clean(
11458            0,
11459            false,
11460            1,
11461            2,
11462            1,
11463            IncompleteReviewPolicy::Block
11464        ));
11465    }
11466
11467    #[test]
11468    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11469        // Every seat quota'd, nobody answered: there is no panel to decide
11470        // on, so this must fall through to the existing block-and-retry
11471        // fallback rather than call an unreviewed patch clean.
11472        assert!(!round_is_clean(
11473            0,
11474            true,
11475            0,
11476            2,
11477            2,
11478            IncompleteReviewPolicy::Block
11479        ));
11480    }
11481
11482    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11483        CommandOutcome {
11484            command: "test".to_owned(),
11485            code,
11486            output_tail: String::new(),
11487            duration_ms: 0,
11488            resource_blocked,
11489        }
11490    }
11491
11492    #[test]
11493    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11494        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11495        assert!(
11496            !verify_inconclusive(&[outcome(Some(1), false)]),
11497            "an ordinary failure is still evidence about the patch"
11498        );
11499        assert!(verify_inconclusive(&[outcome(None, true)]));
11500        assert!(
11501            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11502            "one inconclusive outcome taints the whole batch"
11503        );
11504        assert!(!verify_inconclusive(&[]));
11505    }
11506
11507    #[tokio::test]
11508    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11509        // Alive for the first two checks, then dead - confirms the loop
11510        // actually re-polls rather than deciding once and sleeping out the
11511        // ceiling regardless.
11512        let calls = std::sync::atomic::AtomicUsize::new(0);
11513        let started = Instant::now();
11514        wait_for_pids_with(
11515            &[123],
11516            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11517            Duration::from_millis(5),
11518            Duration::from_secs(5),
11519        )
11520        .await;
11521        assert!(
11522            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11523            "must keep checking rather than deciding on the first answer"
11524        );
11525        assert!(
11526            started.elapsed() < Duration::from_secs(1),
11527            "must return the moment it is confirmed dead, not wait out the ceiling"
11528        );
11529    }
11530
11531    #[tokio::test]
11532    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11533        let started = Instant::now();
11534        wait_for_pids_with(
11535            &[123],
11536            |_| true, // never reports dead
11537            Duration::from_millis(5),
11538            Duration::from_millis(30),
11539        )
11540        .await;
11541        let elapsed = started.elapsed();
11542        assert!(
11543            elapsed >= Duration::from_millis(30),
11544            "must not give up before its own ceiling: {elapsed:?}"
11545        );
11546        assert!(
11547            elapsed < Duration::from_secs(1),
11548            "must not wait past its own ceiling either: {elapsed:?}"
11549        );
11550    }
11551
11552    #[tokio::test]
11553    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11554        let started = Instant::now();
11555        wait_for_pids_with(
11556            &[],
11557            |_| true,
11558            Duration::from_secs(5),
11559            Duration::from_secs(5),
11560        )
11561        .await;
11562        assert!(
11563            started.elapsed() < Duration::from_millis(200),
11564            "an empty pid list has nothing to confirm"
11565        );
11566    }
11567
11568    // `review_conclusion` is the exact decision the review hand-off task
11569    // fixed: a round budget spent (or a tree that stopped moving) must not
11570    // collapse into `Blocked` regardless of what verification actually
11571    // said. Deterministic and process-free for the same reason the
11572    // `round_is_clean` family above is.
11573    fn review_round(
11574        clean: bool,
11575        blocking: usize,
11576        answered: usize,
11577        expected: usize,
11578        progressed: bool,
11579        e2e_ok: bool,
11580    ) -> ReviewRound {
11581        ReviewRound {
11582            round: 1,
11583            head: "h".to_owned(),
11584            verified_head: None,
11585            verified_at: None,
11586            reviews: Vec::new(),
11587            e2e: vec![CommandOutcome {
11588                command: "test".to_owned(),
11589                code: Some(if e2e_ok { 0 } else { 1 }),
11590                output_tail: String::new(),
11591                duration_ms: 0,
11592                resource_blocked: false,
11593            }],
11594            verify_retried: false,
11595            e2e_deferred: false,
11596            e2e_defer_reason: None,
11597            fix: None,
11598            blocking,
11599            answered,
11600            expected,
11601            clean,
11602            progressed,
11603            vote_split: false,
11604            reconsideration: Vec::new(),
11605            verdict: None,
11606        }
11607    }
11608
11609    #[test]
11610    fn review_conclusion_is_none_when_nothing_has_run() {
11611        assert_eq!(review_conclusion(&[], 3), None);
11612    }
11613
11614    #[test]
11615    fn review_conclusion_is_none_while_rounds_remain() {
11616        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11617        assert_eq!(review_conclusion(&rounds, 3), None);
11618    }
11619
11620    #[test]
11621    fn review_conclusion_is_gating_once_a_round_is_clean() {
11622        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11623        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11624    }
11625
11626    #[test]
11627    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11628        let rounds = vec![
11629            review_round(false, 1, 2, 2, true, true),
11630            review_round(false, 1, 2, 2, true, true),
11631        ];
11632        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11633    }
11634
11635    #[test]
11636    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11637        let rounds = vec![
11638            review_round(false, 1, 2, 2, true, true),
11639            review_round(false, 1, 2, 2, true, false),
11640        ];
11641        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11642    }
11643
11644    #[test]
11645    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11646        // Magi never got a command to run against this round's own head — a
11647        // resource-blocked attempt, not a red one — so this must never
11648        // settle on `Blocked` the way a genuine e2e failure would. `None`
11649        // here is what tells `Runner::review_loop` to retry the check
11650        // itself rather than trust this cheap recomputation with a verdict
11651        // it cannot actually produce.
11652        let mut blocked = review_round(false, 1, 2, 2, true, false);
11653        blocked.e2e[0].resource_blocked = true;
11654        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11655        assert_eq!(review_conclusion(&rounds, 2), None);
11656    }
11657
11658    #[test]
11659    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11660        // Missing input, not a verified tree — never a hand-off candidate.
11661        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11662        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11663    }
11664
11665    #[test]
11666    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11667        let rounds = vec![
11668            review_round(false, 1, 2, 2, false, true),
11669            review_round(false, 1, 2, 2, false, true),
11670        ];
11671        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11672    }
11673
11674    fn secs(n: u64) -> Duration {
11675        Duration::from_secs(n)
11676    }
11677
11678    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11679    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11680    #[tokio::test]
11681    async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11682        ask_test_home();
11683        let tmp = tempfile::tempdir().unwrap();
11684        let repo = tmp.path().join("repo");
11685        std::fs::create_dir_all(&repo).unwrap();
11686        init_repo(&repo);
11687        let g = |args: &[&str]| {
11688            let out = std::process::Command::new("git")
11689                .args(args)
11690                .current_dir(&repo)
11691                .quiet()
11692                .output()
11693                .unwrap();
11694            assert!(
11695                out.status.success(),
11696                "{}",
11697                String::from_utf8_lossy(&out.stderr)
11698            );
11699        };
11700        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11701        g(&["checkout", "-b", "historical"]);
11702        std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11703        g(&["add", "."]);
11704        g(&["commit", "-m", "old alternative"]);
11705        let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11706        g(&["checkout", "-b", "magi/task-selected", &base]);
11707        std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11708        g(&["add", "."]);
11709        g(&["commit", "-m", "measurement infrastructure"]);
11710        let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11711        std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11712        g(&["add", "."]);
11713        g(&["commit", "-m", "dependent implementation"]);
11714        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11715        g(&["checkout", "main"]);
11716        std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11717        g(&["add", "."]);
11718        g(&["commit", "-m", "unrelated base change"]);
11719        let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11720        let origin = tmp.path().join("origin.git");
11721        g(&[
11722            "clone",
11723            "--bare",
11724            repo.to_str().unwrap(),
11725            origin.to_str().unwrap(),
11726        ]);
11727        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11728        g(&["fetch", "origin"]);
11729        let selection = crate::pr_recovery::Selection {
11730            url: "https://example.test/project/pull/1".into(),
11731            branch: "magi/task-selected".into(),
11732            head: head.clone(),
11733            instruction: "preserve measured behavior".into(),
11734            approved_at: jiff::Timestamp::now(),
11735        };
11736        let mut template = runner_at(RunStatus::Reviewing);
11737        template.state.repo = repo.clone();
11738        template.state.base_commit = current.clone();
11739        template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11740        template.state.origin = Some(crate::run::Origin::queue("selected"));
11741        template.state.pr_head = Some(selection.branch.clone());
11742        template.state.pr_recovery = Some(selection.clone());
11743        let mut runner =
11744            Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11745                .await
11746                .unwrap();
11747        let wt = runner.state.winner().unwrap().worktree.clone();
11748        assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11749        assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11750        assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11751        assert!(runner.state.seeds.is_empty());
11752        assert_eq!(runner.state.instruction, "preserve measured behavior");
11753        runner.sync_to_base().await.unwrap();
11754        assert_eq!(
11755            std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11756            "prerequisite"
11757        );
11758        assert_eq!(
11759            std::fs::read_to_string(wt.join("implementation")).unwrap(),
11760            "measured implementation"
11761        );
11762        assert_eq!(
11763            std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11764            "current main change"
11765        );
11766        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11767        assert_eq!(
11768            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11769            head
11770        );
11771
11772        // A failed selected-source setup stays inspectable, never resumable as a new competition.
11773        let mut partial = runner_at(RunStatus::Reviewing);
11774        partial.state.repo = repo.clone();
11775        partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11776        partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11777            run: Some("exit 1".into()),
11778            ..Default::default()
11779        }];
11780        let selection = runner.state.pr_recovery.clone().unwrap();
11781        partial.state.pr_recovery = Some(selection.clone());
11782        partial.state.pr_head = Some(selection.branch.clone());
11783        partial.state.origin = Some(crate::run::Origin::queue("selected"));
11784        let id = partial.state.id.clone();
11785        let error = match Runner::open_selected(
11786            &repo,
11787            partial.state,
11788            partial.roles,
11789            1,
11790            runner.state.base_commit.clone(),
11791            selection,
11792        )
11793        .await
11794        {
11795            Ok(_) => panic!("setup must fail"),
11796            Err(e) => e,
11797        };
11798        assert_eq!(
11799            error
11800                .downcast_ref::<crate::pr_recovery::StartupFailed>()
11801                .unwrap()
11802                .run,
11803            id
11804        );
11805        let failed = RunState::load(&id).unwrap();
11806        assert_eq!(failed.status, RunStatus::Blocked);
11807        assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11808        assert!(Runner::resume(&id).is_err());
11809        runner.state.status = RunStatus::Ready;
11810        runner.state.driver_exited = true;
11811        runner.state.save().unwrap();
11812        let mut retry = runner_at(RunStatus::Reviewing);
11813        retry.state.repo = repo.clone();
11814        retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11815        let mut reselected = runner.state.pr_recovery.clone().unwrap();
11816        reselected.approved_at = jiff::Timestamp::now();
11817        retry.state.origin = Some(crate::run::Origin::queue("selected"));
11818        retry.state.pr_head = Some(reselected.branch.clone());
11819        retry.state.pr_recovery = Some(reselected.clone());
11820        crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11821            .await
11822            .unwrap();
11823        let restarted = Runner::open_selected(
11824            &repo,
11825            retry.state,
11826            retry.roles,
11827            1,
11828            runner.state.base_commit.clone(),
11829            reselected,
11830        )
11831        .await
11832        .unwrap();
11833        assert_eq!(
11834            git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
11835                .await
11836                .unwrap(),
11837            head
11838        );
11839        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11840        assert_eq!(
11841            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11842            head
11843        );
11844    }
11845
11846    #[tokio::test]
11847    async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
11848        ask_test_home();
11849        let tmp = tempfile::tempdir().unwrap();
11850        let repo = tmp.path().join("repo");
11851        std::fs::create_dir_all(&repo).unwrap();
11852        init_repo(&repo);
11853        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11854        git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
11855        std::fs::write(repo.join("referenced"), "required commit").unwrap();
11856        git::git(&repo, &["add", "."]).await.unwrap();
11857        git::git(&repo, &["commit", "-m", "required seed"])
11858            .await
11859            .unwrap();
11860        let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
11861        git::git(&repo, &["checkout", "main"]).await.unwrap();
11862        let mut runner = runner_at(RunStatus::Prep);
11863        runner.state.repo = repo.clone();
11864        runner.state.base_commit = base.clone();
11865        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
11866        runner.state.config.graph.advise = false;
11867        runner.state.config.blind.commit_msg_hook = false;
11868        runner.state.seeds = vec![refs::Seed {
11869            token: seed.clone(),
11870            sha: seed.clone(),
11871            kind: refs::SeedKind::Unmerged,
11872            branch: false,
11873            detail: String::new(),
11874        }];
11875        runner.roles.implementers = vec![spec("one"), spec("two")];
11876        runner.roles.judges = vec![spec("judge")];
11877        let label = blind::assign_labels(2, runner.state.seed)[0];
11878        let branch = runner.state.branch_for(label);
11879        let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
11880        git::worktree_add_branch(&repo, &worktree, &branch, &base)
11881            .await
11882            .unwrap();
11883        runner.state.candidates.push(Candidate {
11884            index: 0,
11885            label,
11886            agent: "one".into(),
11887            branch,
11888            worktree,
11889            summary: String::new(),
11890            stat: String::new(),
11891            files: 0,
11892            commits: 0,
11893            empty: false,
11894            failed: None,
11895            verified_noop: None,
11896            duration_ms: 0,
11897            folded: false,
11898        });
11899        runner.state.save().unwrap();
11900        // Reload the checkpoint immediately before its first cherry-pick.
11901        runner.state = RunState::load(&runner.state.id).unwrap();
11902        assert_eq!(runner.state.prep_complete, Some(false));
11903        runner.prep().await.unwrap();
11904        assert_eq!(runner.state.prep_complete, Some(true));
11905        assert_eq!(runner.state.candidates.len(), 2);
11906        for candidate in &runner.state.candidates {
11907            assert_eq!(
11908                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11909                git::tree_of(&repo, &seed).await.unwrap()
11910            );
11911            assert_eq!(
11912                std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
11913                "required commit"
11914            );
11915        }
11916        assert!(runner.state.worktree_root().join("judge-1").exists());
11917        let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
11918        runner.prep().await.unwrap();
11919        assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
11920
11921        runner.state.prep_complete = Some(false);
11922        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
11923            run: Some(format!(
11924                "test -f '{}'",
11925                tmp.path().join("dependency-ready").display()
11926            )),
11927            ..Default::default()
11928        }];
11929        assert!(runner.prep().await.is_err());
11930        assert!(runner.state.prep_failure.is_some());
11931        std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
11932        runner.state = RunState::load(&runner.state.id).unwrap();
11933        assert!(
11934            runner.prep().await.is_err(),
11935            "correcting the cause alone must not auto-retry"
11936        );
11937        let failed_at = runner.state.events.last().unwrap().at;
11938        assert!(
11939            runner.retry_preparation(failed_at).is_err(),
11940            "old approvals cannot unlock the hold"
11941        );
11942        let mut task = crate::queue::Task::new(
11943            "retry".into(),
11944            "retry setup".into(),
11945            repo.clone(),
11946            crate::queue::Source::Human,
11947        );
11948        task.start(runner.state.id.clone());
11949        task.hold_machine(Some("setup failed".into()));
11950        task.release_operator("cli", None);
11951        runner
11952            .retry_preparation(task.resumption.as_ref().unwrap().at)
11953            .unwrap();
11954        runner.prep().await.unwrap();
11955        assert!(runner.state.prep_failure.is_none());
11956        assert_eq!(runner.state.prep_complete, Some(true));
11957        assert_eq!(runner.state.candidates.len(), 2);
11958        assert!(runner.state.worktree_root().join("judge-1").exists());
11959        for candidate in &runner.state.candidates {
11960            assert_eq!(
11961                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11962                git::tree_of(&repo, &seed).await.unwrap()
11963            );
11964        }
11965    }
11966
11967    async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
11968        let mut heads = Vec::new();
11969        for candidate in candidates {
11970            heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
11971        }
11972        heads
11973    }
11974
11975    fn init_repo(dir: &Path) {
11976        let run = |args: &[&str]| {
11977            let out = std::process::Command::new("git")
11978                .args(args)
11979                .current_dir(dir)
11980                .quiet()
11981                .output()
11982                .expect("spawn git");
11983            assert!(
11984                out.status.success(),
11985                "git {args:?} failed: {}",
11986                String::from_utf8_lossy(&out.stderr)
11987            );
11988        };
11989        run(&["init", "-b", "main"]);
11990        run(&["config", "user.name", "magi test"]);
11991        run(&["config", "user.email", "magi@example.com"]);
11992        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11993        run(&["add", "-A"]);
11994        run(&["commit", "-m", "init"]);
11995    }
11996
11997    // `settle_questions` is what closes the ghost the phone showed: a run's
11998    // seat asked something, the run then ended, and nothing was left to
11999    // abandon the question it left `open`. `HOME` is a process-wide
12000    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
12001    // first time it runs in the binary — every test below still reaches the
12002    // same directory whichever call won, and each gets its own run id from
12003    // `RunState::new`, so they never collide there.
12004    fn ask_test_home() {
12005        crate::run::pin_test_home();
12006    }
12007
12008    /// A minimal, git-free `Runner` at a given status — `settle_questions`
12009    /// reads nothing else off it.
12010    fn runner_at(status: RunStatus) -> Runner {
12011        let mut state = RunState::new(
12012            PathBuf::from("/nonexistent/repo"),
12013            "main".to_owned(),
12014            "deadbeef".to_owned(),
12015            "task".to_owned(),
12016            Config::default(),
12017        );
12018        // These fixtures share one home and save concurrently. Short clock
12019        // IDs can collide, making an ownership test read another test's run.
12020        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12021        state.id = format!(
12022            "20000101-000000-{:04x}",
12023            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
12024        );
12025        state.status = status;
12026        Runner {
12027            state,
12028            roles: ResolvedRoles {
12029                implementers: Vec::new(),
12030                judges: Vec::new(),
12031                reviewers: Vec::new(),
12032                fixer: None,
12033                conductor: conductor(),
12034                implementer_roster: Vec::new(),
12035                judge_roster: Vec::new(),
12036                reviewer_roster: Vec::new(),
12037            },
12038            sem: Arc::new(Semaphore::new(1)),
12039            pause: Pause::new(),
12040            interrupt: Pause::new(),
12041        }
12042    }
12043
12044    fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
12045        crate::run::MergeRepair {
12046            question: "repair-question".into(),
12047            head: "old-head".into(),
12048            pr: "https://github.com/o/r/pull/42".into(),
12049            reviews: vec![review_round(false, 1, 1, 1, false, true)],
12050            gate: vec![outcome(Some(0), false)],
12051            gate_head: Some("old-head".into()),
12052            gate_fixes: Vec::new(),
12053            pre_gate: Vec::new(),
12054            phase,
12055            failure: None,
12056        }
12057    }
12058
12059    #[tokio::test]
12060    async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
12061        crate::run::pin_test_home();
12062        let mut runner = runner_at(RunStatus::Landing);
12063        runner.state.merge_repairs.push(merge_repair_request(
12064            crate::run::MergeRepairPhase::Requested,
12065        ));
12066        runner.state.save().unwrap();
12067        let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
12068        assert!(
12069            runner
12070                .execute()
12071                .await
12072                .unwrap_err()
12073                .to_string()
12074                .contains("already running")
12075        );
12076        assert_eq!(
12077            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12078            crate::run::MergeRepairPhase::Requested
12079        );
12080        drop(claim);
12081        let mut disk = runner.state.clone();
12082        disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12083        disk.save().unwrap();
12084        assert!(
12085            runner
12086                .execute()
12087                .await
12088                .unwrap_err()
12089                .to_string()
12090                .contains("decision changed")
12091        );
12092        assert_eq!(
12093            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12094            crate::run::MergeRepairPhase::Complete
12095        );
12096    }
12097
12098    #[test]
12099    fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12100        crate::run::pin_test_home();
12101        let mut runner = runner_at(RunStatus::Landing);
12102        let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12103        runner.state.reviews = request.reviews.clone();
12104        runner.state.gate = request.gate.clone();
12105        runner.state.gate_ran = true;
12106        runner.state.land_approval = Some(crate::run::LandApproval {
12107            question: "old".into(),
12108            head: "old-head".into(),
12109        });
12110        runner.state.merge_repairs.push(request);
12111        runner.begin_merge_repair_review(0).unwrap();
12112        assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12113        assert!(!runner.state.gate_status().ok());
12114        assert!(runner.state.land_approval.is_none());
12115        let disk = RunState::load(&runner.state.id).unwrap();
12116        assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12117        assert!(disk.merge_repairs[0].gate[0].ok());
12118        assert_eq!(
12119            disk.merge_repairs[0].phase,
12120            crate::run::MergeRepairPhase::Reviewing
12121        );
12122        assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12123        assert!(disk.reviews.is_empty());
12124    }
12125
12126    #[tokio::test]
12127    async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12128        crate::run::pin_test_home();
12129        for phase in [
12130            crate::run::MergeRepairPhase::Fixing,
12131            crate::run::MergeRepairPhase::Requested,
12132        ] {
12133            let mut runner = runner_at(RunStatus::Landing);
12134            runner.state.config.graph.land_rounds = 0;
12135            runner.state.merge_repairs.push(merge_repair_request(phase));
12136            runner.state.save().unwrap();
12137            assert!(!runner.repair_merge_review().await.unwrap());
12138            assert_eq!(runner.state.status, RunStatus::Blocked);
12139            assert_eq!(
12140                runner.state.merge_repairs[0].phase,
12141                crate::run::MergeRepairPhase::Failed
12142            );
12143            let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12144            assert!(
12145                reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12146                    "interrupted"
12147                } else {
12148                    "budget exhausted"
12149                })
12150            );
12151            assert!(runner.state.merge.is_none());
12152        }
12153    }
12154
12155    /// `park_here` folding in the reason `Pause::park_because` recorded -
12156    /// this is what lets an operator reading a run's events tell an
12157    /// interrupt-driven park from an ordinary shutdown park.
12158    #[test]
12159    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12160        crate::run::pin_test_home();
12161        let mut runner = runner_at(RunStatus::Implementing);
12162        let interrupt = Pause::new();
12163        runner.watch_interrupt(interrupt.clone());
12164
12165        interrupt.park_because("task a1b2 asked to run first");
12166
12167        assert!(runner.park_here().expect("park_here"));
12168        assert!(runner.state.parked);
12169        let last = runner.state.events.last().expect("a park event");
12170        assert_eq!(last.node, "park");
12171        assert!(
12172            last.message.contains("task a1b2 asked to run first"),
12173            "expected the interrupt reason in {:?}",
12174            last.message
12175        );
12176    }
12177
12178    /// The cause is stored on the pass when it parks, and the first cause a
12179    /// shared pause was given is the one that stays.
12180    #[test]
12181    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12182        crate::run::pin_test_home();
12183        let mut runner = runner_at(RunStatus::Implementing);
12184        runner.state.begin_pass();
12185        let shutdown = Pause::new();
12186        runner.on_pause(shutdown.clone());
12187        let upgrade = ParkCause::Upgrade {
12188            from: "0.121.0".to_owned(),
12189            to: Some("0.121.1".to_owned()),
12190        };
12191        shutdown.park_for(upgrade.clone());
12192        shutdown.park_for(ParkCause::Operator);
12193
12194        assert!(runner.park_here().expect("park_here"));
12195        assert_eq!(
12196            runner.state.passes[0].end,
12197            Some(crate::run::PassEnd::Parked { cause: upgrade })
12198        );
12199        let last = runner.state.events.last().expect("a park event");
12200        assert!(last.message.contains("0.121.0"), "{}", last.message);
12201    }
12202
12203    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
12204    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
12205    /// cleared) must not make a *different* run - one only watching its own,
12206    /// unshared interrupt `Pause` - see itself as parked. If a future change
12207    /// ever collapsed these back into one handle, the interrupt scheduler
12208    /// would park every run for the rest of the daemon's life, not just the
12209    /// one it meant to interrupt.
12210    #[test]
12211    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12212        crate::run::pin_test_home();
12213        let mut runner = runner_at(RunStatus::Implementing);
12214        let shutdown = Pause::new();
12215        runner.on_pause(shutdown.clone());
12216        let interrupt = Pause::new();
12217        runner.watch_interrupt(interrupt.clone());
12218
12219        // Nobody has asked for anything yet.
12220        assert!(!runner.park_here().expect("park_here"));
12221        assert!(!runner.state.parked);
12222
12223        // Only the interrupt handle fires; the shutdown handle stays clear.
12224        interrupt.park_because("test");
12225        assert!(!shutdown.parked());
12226        assert!(runner.park_here().expect("park_here"));
12227    }
12228
12229    /// The property every prior attempt at this feature failed to pin down:
12230    /// asking a run to park while one of its nodes has a real, in-flight
12231    /// async operation running (an agent call, in production) must not cut
12232    /// that operation short. `park_here` is only ever consulted *between*
12233    /// `execute`'s node calls - see its own doc - so nothing inside a node
12234    /// can observe a park request until the node itself returns. This proves
12235    /// that structurally, with real `tokio` concurrency and a channel
12236    /// handshake (never a sleep, which would only prove "usually", not
12237    /// "cannot"): the "node" below reports that it has genuinely started,
12238    /// and only then is the park requested; the node still has to be told to
12239    /// finish before `park_here` is ever called, exactly mirroring every
12240    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
12241    /// in `execute`.
12242    #[tokio::test]
12243    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12244        crate::run::pin_test_home();
12245        let mut runner = runner_at(RunStatus::Implementing);
12246        let interrupt = Pause::new();
12247        runner.watch_interrupt(interrupt.clone());
12248
12249        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12250        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12251
12252        // Stands in for one node's in-flight agent call: it proves it has
12253        // genuinely started, then blocks - exactly as a spawned CLI process
12254        // does - until told to finish.
12255        let node = async move {
12256            started_tx.send(()).expect("send started");
12257            finish_rx.await.expect("recv finish");
12258            "node finished"
12259        };
12260
12261        let interrupter = async move {
12262            started_rx.await.expect("recv started");
12263            // The call is now genuinely in flight. Ask it to park.
12264            interrupt.park_because("higher-priority task waiting");
12265            // Nothing the node does can observe this yet - there is no
12266            // check inside it, by construction - so let the executor run
12267            // anything pending and then let the node finish on its own.
12268            tokio::task::yield_now().await;
12269            finish_tx.send(()).expect("send finish");
12270        };
12271
12272        let (node_result, ()) = tokio::join!(node, interrupter);
12273        assert_eq!(
12274            node_result, "node finished",
12275            "the in-flight call ran to completion"
12276        );
12277
12278        // Only now, at the boundary the real `execute` would check right
12279        // after this node, does the park take effect.
12280        assert!(runner.park_here().expect("park_here"));
12281        assert!(runner.state.parked);
12282    }
12283
12284    /// A run parked mid-competition carries every field it had accumulated
12285    /// through the exact same disk round-trip an ordinary resume uses -
12286    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
12287    /// Nothing about parking for an interrupt is a special case of that path;
12288    /// this is what proves it rather than assuming it.
12289    #[test]
12290    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12291        crate::run::pin_test_home();
12292        let mut runner = runner_at(RunStatus::Judging);
12293        // `Runner::resume` re-resolves roles from the saved config, which
12294        // refuses an empty roster - give it the same minimal one `conductor`
12295        // itself uses.
12296        runner.state.config.agents = vec![conductor()];
12297        runner.state.candidates = vec![Candidate {
12298            index: 0,
12299            label: 'A',
12300            agent: "alpha".to_owned(),
12301            branch: "magi/x/A".to_owned(),
12302            worktree: PathBuf::from("/nonexistent/worktree"),
12303            summary: "did the thing".to_owned(),
12304            stat: "1 file changed".to_owned(),
12305            files: 1,
12306            commits: 1,
12307            empty: false,
12308            failed: None,
12309            verified_noop: None,
12310            duration_ms: 1234,
12311            folded: false,
12312        }];
12313        let run_id = runner.state.id.clone();
12314
12315        let interrupt = Pause::new();
12316        runner.watch_interrupt(interrupt.clone());
12317        interrupt.park_because("task c3d4 asked to run first");
12318        assert!(runner.park_here().expect("park_here"));
12319
12320        let resumed = Runner::resume(&run_id).expect("resume");
12321        assert_eq!(resumed.state.candidates.len(), 1);
12322        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12323        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12324        assert_eq!(resumed.state.status, runner.state.status);
12325        assert!(
12326            resumed.state.parked,
12327            "still parked until `execute` actually walks the graph again"
12328        );
12329        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12330    }
12331
12332    /// A fresh open question on `run`, stored and handed back for assertions.
12333    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12334        let mut q = ask::Question::new(
12335            run.to_owned(),
12336            "implement".to_owned(),
12337            "impl-A".to_owned(),
12338            "Which storage backend should the cache use?".to_owned(),
12339            String::new(),
12340            vec!["SQLite".to_owned(), "Redis".to_owned()],
12341        );
12342        store.put(&mut q).unwrap();
12343        q
12344    }
12345
12346    #[test]
12347    fn a_failed_runs_open_question_is_abandoned() {
12348        ask_test_home();
12349        let store = ask::Questions::open();
12350        let mut runner = runner_at(RunStatus::Failed);
12351        let run = runner.state.id.clone();
12352        let q = ask_open_question(&store, &run);
12353
12354        runner.settle_questions();
12355
12356        let back = store.get(&q.id).unwrap();
12357        assert!(
12358            !back.status.open(),
12359            "the seat that asked died with the run; nobody is left to read an answer"
12360        );
12361        assert!(
12362            back.detail.contains(&run) && back.detail.contains("failed"),
12363            "the reason names what the run became, not just that it is gone: {}",
12364            back.detail
12365        );
12366    }
12367
12368    #[test]
12369    fn a_merged_runs_open_question_is_abandoned_too() {
12370        ask_test_home();
12371        let store = ask::Questions::open();
12372        // A run that finishes cleanly still leaves nobody to read an answer -
12373        // this is not only a failure-path cleanup.
12374        for status in [RunStatus::Merged, RunStatus::Ready] {
12375            let mut runner = runner_at(status);
12376            let run = runner.state.id.clone();
12377            let q = ask_open_question(&store, &run);
12378
12379            runner.settle_questions();
12380
12381            let back = store.get(&q.id).unwrap();
12382            assert!(
12383                !back.status.open(),
12384                "{status:?} run's question must not outlive the run"
12385            );
12386        }
12387    }
12388
12389    #[test]
12390    fn a_still_resumable_runs_open_question_is_left_alone() {
12391        ask_test_home();
12392        let store = ask::Questions::open();
12393        // `Blocked` and `Stalled` can still be resumed — the candidates, the
12394        // review round and the seat sessions are all still on disk — so a
12395        // question asked mid-round may yet get a real answer from a real
12396        // resume. Sweeping it here would be exactly the failure mode this
12397        // whole feature exists to avoid on the other side.
12398        for status in [RunStatus::Blocked, RunStatus::Stalled] {
12399            let mut runner = runner_at(status);
12400            let run = runner.state.id.clone();
12401            let q = ask_open_question(&store, &run);
12402
12403            runner.settle_questions();
12404
12405            let back = store.get(&q.id).unwrap();
12406            assert!(
12407                back.status.open(),
12408                "{status:?} is still alive; the question must still be waiting"
12409            );
12410        }
12411    }
12412
12413    #[test]
12414    fn settle_questions_never_touches_an_already_answered_question() {
12415        ask_test_home();
12416        let store = ask::Questions::open();
12417        let mut runner = runner_at(RunStatus::Failed);
12418        let run = runner.state.id.clone();
12419        let mut q = ask_open_question(&store, &run);
12420        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12421            .unwrap();
12422        store.put(&mut q).unwrap();
12423
12424        // Called twice, the way a crash-recovered daemon reclaim and the
12425        // graph's own cleanup both can for the same run — `abandon_for_run`
12426        // only ever touches what is still open, so this must be inert both
12427        // times, not merely the second.
12428        runner.settle_questions();
12429        runner.settle_questions();
12430
12431        let back = store.get(&q.id).unwrap();
12432        assert_eq!(
12433            back.status,
12434            ask::QuestionStatus::Answered,
12435            "a real answer is a decision on record, never overwritten by a sweep"
12436        );
12437    }
12438
12439    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
12440    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
12441    /// without merging, whose winner is still the operator's answer to read.
12442    /// Nothing previously called `fold_run` itself with a real `tally`, so
12443    /// this is the first test to pin down the one distinction the whole
12444    /// automatic-fold feature depends on: the winner's worktree and branch
12445    /// must survive, everything else sharing the run's worktree bay - a
12446    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
12447    /// second sweep treats every non-winner directory under the bay alike -
12448    /// must not.
12449    #[tokio::test]
12450    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12451        crate::run::pin_test_home();
12452        let tmp = tempfile::tempdir().expect("tempdir");
12453        let repo = tmp.path().join("repo");
12454        std::fs::create_dir_all(&repo).unwrap();
12455        init_repo(&repo);
12456
12457        let mut config = Config::default();
12458        config.graph.worktree_root = Some(tmp.path().join("wt"));
12459
12460        let mut state = RunState::new(
12461            repo.clone(),
12462            "main".to_owned(),
12463            "deadbeef".to_owned(),
12464            "task".to_owned(),
12465            config,
12466        );
12467        let root = state.worktree_root();
12468        let wt_a = root.join("cand-A");
12469        let wt_b = root.join("cand-B");
12470        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12471            .await
12472            .expect("worktree A");
12473        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12474            .await
12475            .expect("worktree B");
12476
12477        state.candidates = vec![
12478            Candidate {
12479                index: 0,
12480                label: 'A',
12481                agent: "alpha".to_owned(),
12482                branch: "magi/x/A".to_owned(),
12483                worktree: wt_a.clone(),
12484                summary: String::new(),
12485                stat: String::new(),
12486                files: 0,
12487                commits: 0,
12488                empty: false,
12489                failed: None,
12490                verified_noop: None,
12491                duration_ms: 0,
12492                folded: false,
12493            },
12494            Candidate {
12495                index: 1,
12496                label: 'B',
12497                agent: "beta".to_owned(),
12498                branch: "magi/x/B".to_owned(),
12499                worktree: wt_b.clone(),
12500                summary: String::new(),
12501                stat: String::new(),
12502                files: 0,
12503                commits: 0,
12504                empty: false,
12505                failed: None,
12506                verified_noop: None,
12507                duration_ms: 0,
12508                folded: false,
12509            },
12510        ];
12511        state.tally = Some(Tally {
12512            first_choice: BTreeMap::from([('A', 1)]),
12513            borda: BTreeMap::new(),
12514            winner: 'A',
12515            rankings: 1,
12516            unanimous_initial: true,
12517            deliberated: false,
12518            changed_votes: 0,
12519            unanimous_final: true,
12520            tie_break: None,
12521            judges: 1,
12522            present: 1,
12523            quorum: 1,
12524            met_quorum: true,
12525            uncontested: None,
12526        });
12527        state.status = RunStatus::Ready;
12528
12529        fold_run(&mut state, false, &crate::run::home())
12530            .await
12531            .expect("fold_run");
12532
12533        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12534        assert!(
12535            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12536            "the unmerged winner's branch survives"
12537        );
12538        assert!(
12539            !state.candidates[0].folded,
12540            "the winner is not marked folded"
12541        );
12542
12543        assert!(!wt_b.exists(), "the loser's worktree is removed");
12544        assert!(
12545            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12546            "the loser's branch is removed"
12547        );
12548        assert!(state.candidates[1].folded, "the loser is marked folded");
12549    }
12550
12551    /// A branch handed to a later run is that run's (and its pull request's):
12552    /// folding the run that released it must not delete it.
12553    #[tokio::test]
12554    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12555        let tmp = tempfile::tempdir().expect("tempdir");
12556        let repo = tmp.path().join("repo");
12557        std::fs::create_dir_all(&repo).unwrap();
12558        init_repo(&repo);
12559        let home = tmp.path().join("home");
12560
12561        let mut config = Config::default();
12562        config.graph.worktree_root = Some(tmp.path().join("wt"));
12563        let mut state = RunState::new(
12564            repo.clone(),
12565            "main".to_owned(),
12566            "deadbeef".to_owned(),
12567            "task".to_owned(),
12568            config,
12569        );
12570        // The worktree is already gone (released); the branch survives.
12571        git::git(&repo, &["branch", "magi/x/A", "main"])
12572            .await
12573            .expect("branch");
12574        state.candidates = vec![Candidate {
12575            index: 0,
12576            label: 'A',
12577            agent: "alpha".to_owned(),
12578            branch: "magi/x/A".to_owned(),
12579            worktree: state.worktree_root().join("cand-A"),
12580            summary: String::new(),
12581            stat: String::new(),
12582            files: 0,
12583            commits: 0,
12584            empty: false,
12585            failed: None,
12586            verified_noop: None,
12587            duration_ms: 0,
12588            folded: true,
12589        }];
12590        state.released_to = Some("20260901-000000-new1".to_owned());
12591        state.released_branches = vec!["magi/x/A".to_owned()];
12592
12593        fold_run(&mut state, true, &home).await.expect("fold_run");
12594
12595        assert!(
12596            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12597            "the handed-over branch survives a fold"
12598        );
12599    }
12600
12601    /// A winner with nothing ahead of the base is caught before `gh` is ever
12602    /// asked for a pull request, and the message carries what the task's
12603    /// references resolved to.
12604    #[tokio::test]
12605    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12606        let tmp = tempfile::tempdir().expect("tempdir");
12607        let repo = tmp.path().join("repo");
12608        std::fs::create_dir_all(&repo).unwrap();
12609        init_repo(&repo);
12610        let run = |args: &[&str]| {
12611            let out = std::process::Command::new("git")
12612                .quiet()
12613                .args(args)
12614                .current_dir(&repo)
12615                .output()
12616                .expect("spawn git");
12617            assert!(out.status.success(), "git {args:?}");
12618        };
12619        run(&["branch", "magi/x/A"]);
12620        run(&["checkout", "-q", "-b", "magi/x/B"]);
12621        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12622        run(&["add", "-A"]);
12623        run(&["commit", "-q", "-m", "work"]);
12624        run(&["checkout", "-q", "main"]);
12625
12626        // A pull request is compared against the remote's base, so the
12627        // fixture needs one. Before it exists nothing can be read, and the
12628        // answer must be "not empty".
12629        let probe = RunState::new(
12630            repo.clone(),
12631            "main".to_owned(),
12632            "deadbeef".to_owned(),
12633            "task".to_owned(),
12634            Config::default(),
12635        );
12636        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12637        let bare = tmp.path().join("origin.git");
12638        let out = std::process::Command::new("git")
12639            .quiet()
12640            .args(["init", "-q", "--bare"])
12641            .arg(&bare)
12642            .output()
12643            .expect("spawn git");
12644        assert!(out.status.success(), "git init --bare");
12645        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12646        run(&["push", "-q", "origin", "main"]);
12647
12648        let mut state = RunState::new(
12649            repo.clone(),
12650            "main".to_owned(),
12651            "deadbeef".to_owned(),
12652            "task".to_owned(),
12653            Config::default(),
12654        );
12655        state.seeds = vec![refs::Seed {
12656            token: "magi/27b2/A".to_owned(),
12657            kind: refs::SeedKind::Unresolved,
12658            sha: String::new(),
12659            branch: true,
12660            detail: "no branch or commit named magi/27b2/A".to_owned(),
12661        }];
12662
12663        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12664        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12665        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12666        let detail = empty_candidate_detail(&state, "main");
12667        assert!(detail.starts_with("empty candidate"), "{detail}");
12668        assert!(detail.contains("magi/27b2/A"), "{detail}");
12669    }
12670
12671    /// `status == Ready` used to be read as "this is the harmless
12672    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
12673    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
12674    /// run's PR was closed without merging — and reentering `merge` with
12675    /// `mode` still `Pr` does not know the difference, so it pushed and
12676    /// opened a second pull request. `mode == Local` reproduces the same
12677    /// blind spot without a network call: reentry must not attempt another
12678    /// git merge once this node has already recorded an outcome.
12679    #[tokio::test]
12680    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12681        let tmp = tempfile::tempdir().expect("tempdir");
12682        let repo = tmp.path().join("repo");
12683        std::fs::create_dir_all(&repo).unwrap();
12684        init_repo(&repo);
12685
12686        let mut config = Config::default();
12687        config.merge.mode = MergeMode::Local;
12688
12689        let mut state = RunState::new(
12690            repo.clone(),
12691            "main".to_owned(),
12692            "deadbeef".to_owned(),
12693            "task".to_owned(),
12694            config,
12695        );
12696        state.candidates = vec![Candidate {
12697            index: 0,
12698            label: 'A',
12699            agent: "alpha".to_owned(),
12700            branch: "does-not-exist".to_owned(),
12701            worktree: repo.clone(),
12702            summary: String::new(),
12703            stat: String::new(),
12704            files: 0,
12705            commits: 0,
12706            empty: false,
12707            failed: None,
12708            verified_noop: None,
12709            duration_ms: 0,
12710            folded: false,
12711        }];
12712        state.tally = Some(Tally {
12713            first_choice: BTreeMap::from([('A', 1)]),
12714            borda: BTreeMap::new(),
12715            winner: 'A',
12716            rankings: 1,
12717            unanimous_initial: true,
12718            deliberated: false,
12719            changed_votes: 0,
12720            unanimous_final: true,
12721            tie_break: None,
12722            judges: 0,
12723            present: 0,
12724            quorum: 0,
12725            met_quorum: true,
12726            uncontested: Some("only candidate A produced a change".to_owned()),
12727        });
12728        state.reviews = vec![ReviewRound {
12729            round: 1,
12730            head: "deadbeef".to_owned(),
12731            verified_head: None,
12732            verified_at: None,
12733            reviews: Vec::new(),
12734            e2e: Vec::new(),
12735            fix: None,
12736            blocking: 0,
12737            answered: 0,
12738            expected: 0,
12739            clean: true,
12740            verify_retried: false,
12741            e2e_deferred: false,
12742            e2e_defer_reason: None,
12743            progressed: false,
12744            vote_split: false,
12745            reconsideration: Vec::new(),
12746            verdict: None,
12747        }];
12748        state.gate = vec![CommandOutcome {
12749            command: "test".to_owned(),
12750            code: Some(0),
12751            output_tail: String::new(),
12752            duration_ms: 0,
12753            resource_blocked: false,
12754        }];
12755        state.gate_ran = true;
12756        // Reached its conclusion already — e.g. `land` closing the PR without
12757        // merging it, which (like the honest `MergeMode::None` path) leaves
12758        // `status` at `Ready`. The recorded outcome is what actually marks
12759        // this node done.
12760        state.status = RunStatus::Ready;
12761        state.merge = Some(MergeOutcome {
12762            mode: MergeMode::Local,
12763            ok: false,
12764            detail: "already concluded".to_owned(),
12765            empty: false,
12766        });
12767
12768        let mut runner = Runner {
12769            state,
12770            roles: ResolvedRoles {
12771                implementers: Vec::new(),
12772                judges: Vec::new(),
12773                reviewers: Vec::new(),
12774                fixer: None,
12775                conductor: conductor(),
12776                implementer_roster: Vec::new(),
12777                judge_roster: Vec::new(),
12778                reviewer_roster: Vec::new(),
12779            },
12780            sem: Arc::new(Semaphore::new(1)),
12781            pause: Pause::new(),
12782            interrupt: Pause::new(),
12783        };
12784
12785        runner.merge().await.expect("merge");
12786
12787        assert_eq!(
12788            runner.state.status,
12789            RunStatus::Ready,
12790            "a concluded run's status must not change on reentry"
12791        );
12792        assert_eq!(
12793            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12794            Some("already concluded"),
12795            "merge must not run again once the node already recorded an outcome"
12796        );
12797    }
12798
12799    /// `gate` leaves `state.gate_ran` false both before it has ever run and
12800    /// when its last attempt was resource-blocked (the shared build cache
12801    /// could not be acquired or confirmed fresh in time - see
12802    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
12803    /// `Vec` this also leaves behind used to read as "nothing failed" and let
12804    /// a run merge a tree the gate never actually checked - exactly the case
12805    /// a contended cache produces on every retry until it clears. `merge`
12806    /// must refuse until `gate` has actually recorded an attempt.
12807    #[tokio::test]
12808    async fn merge_refuses_a_gate_that_has_not_actually_run() {
12809        let tmp = tempfile::tempdir().expect("tempdir");
12810        let repo = tmp.path().join("repo");
12811        std::fs::create_dir_all(&repo).unwrap();
12812        init_repo(&repo);
12813
12814        let mut config = Config::default();
12815        config.merge.mode = MergeMode::Local;
12816
12817        let mut state = RunState::new(
12818            repo.clone(),
12819            "main".to_owned(),
12820            "deadbeef".to_owned(),
12821            "task".to_owned(),
12822            config,
12823        );
12824        state.candidates = vec![Candidate {
12825            index: 0,
12826            label: 'A',
12827            agent: "alpha".to_owned(),
12828            branch: "does-not-exist".to_owned(),
12829            worktree: repo.clone(),
12830            summary: String::new(),
12831            stat: String::new(),
12832            files: 0,
12833            commits: 0,
12834            empty: false,
12835            failed: None,
12836            verified_noop: None,
12837            duration_ms: 0,
12838            folded: false,
12839        }];
12840        state.tally = Some(Tally {
12841            first_choice: BTreeMap::from([('A', 1)]),
12842            borda: BTreeMap::new(),
12843            winner: 'A',
12844            rankings: 1,
12845            unanimous_initial: true,
12846            deliberated: false,
12847            changed_votes: 0,
12848            unanimous_final: true,
12849            tie_break: None,
12850            judges: 0,
12851            present: 0,
12852            quorum: 0,
12853            met_quorum: true,
12854            uncontested: Some("only candidate A produced a change".to_owned()),
12855        });
12856        state.reviews = vec![ReviewRound {
12857            round: 1,
12858            head: "deadbeef".to_owned(),
12859            verified_head: None,
12860            verified_at: None,
12861            reviews: Vec::new(),
12862            e2e: Vec::new(),
12863            fix: None,
12864            blocking: 0,
12865            answered: 0,
12866            expected: 0,
12867            clean: true,
12868            verify_retried: false,
12869            e2e_deferred: false,
12870            e2e_defer_reason: None,
12871            progressed: false,
12872            vote_split: false,
12873            reconsideration: Vec::new(),
12874            verdict: None,
12875        }];
12876        // The point: `gate` has not recorded anything yet.
12877        state.gate = Vec::new();
12878        state.gate_ran = false;
12879        state.status = RunStatus::Gating;
12880
12881        let mut runner = Runner {
12882            state,
12883            roles: ResolvedRoles {
12884                implementers: Vec::new(),
12885                judges: Vec::new(),
12886                reviewers: Vec::new(),
12887                fixer: None,
12888                conductor: conductor(),
12889                implementer_roster: Vec::new(),
12890                judge_roster: Vec::new(),
12891                reviewer_roster: Vec::new(),
12892            },
12893            sem: Arc::new(Semaphore::new(1)),
12894            pause: Pause::new(),
12895            interrupt: Pause::new(),
12896        };
12897
12898        runner.merge().await.expect("merge");
12899
12900        assert!(
12901            runner.state.merge.is_none(),
12902            "an empty gate must never be read as a passing one: {:?}",
12903            runner.state.merge
12904        );
12905    }
12906
12907    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
12908    /// commands configured and `merge.mode` is `none` (a review-only run).
12909    /// `gate` must still record a real attempt — zero commands, vacuously
12910    /// passed — rather than leaving `state.gate` empty in a way `merge`
12911    /// cannot tell apart from "never ran"; otherwise the run reaches
12912    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
12913    #[tokio::test]
12914    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12915        let tmp = tempfile::tempdir().expect("tempdir");
12916        let repo = tmp.path().join("repo");
12917        std::fs::create_dir_all(&repo).unwrap();
12918        init_repo(&repo);
12919
12920        // Default config: `verify.gate` empty, `merge.mode` is `none`.
12921        let config = Config::default();
12922
12923        let mut state = RunState::new(
12924            repo.clone(),
12925            "main".to_owned(),
12926            "deadbeef".to_owned(),
12927            "task".to_owned(),
12928            config,
12929        );
12930        state.candidates = vec![Candidate {
12931            index: 0,
12932            label: 'A',
12933            agent: "alpha".to_owned(),
12934            branch: "does-not-exist".to_owned(),
12935            worktree: repo.clone(),
12936            summary: String::new(),
12937            stat: String::new(),
12938            files: 0,
12939            commits: 0,
12940            empty: false,
12941            failed: None,
12942            verified_noop: None,
12943            duration_ms: 0,
12944            folded: false,
12945        }];
12946        state.tally = Some(Tally {
12947            first_choice: BTreeMap::from([('A', 1)]),
12948            borda: BTreeMap::new(),
12949            winner: 'A',
12950            rankings: 1,
12951            unanimous_initial: true,
12952            deliberated: false,
12953            changed_votes: 0,
12954            unanimous_final: true,
12955            tie_break: None,
12956            judges: 0,
12957            present: 0,
12958            quorum: 0,
12959            met_quorum: true,
12960            uncontested: Some("only candidate A produced a change".to_owned()),
12961        });
12962        state.reviews = vec![ReviewRound {
12963            round: 1,
12964            head: "deadbeef".to_owned(),
12965            verified_head: None,
12966            verified_at: None,
12967            reviews: Vec::new(),
12968            e2e: Vec::new(),
12969            fix: None,
12970            blocking: 0,
12971            answered: 0,
12972            expected: 0,
12973            clean: true,
12974            verify_retried: false,
12975            e2e_deferred: false,
12976            e2e_defer_reason: None,
12977            progressed: false,
12978            vote_split: false,
12979            reconsideration: Vec::new(),
12980            verdict: None,
12981        }];
12982
12983        let mut runner = Runner {
12984            state,
12985            roles: ResolvedRoles {
12986                implementers: Vec::new(),
12987                judges: Vec::new(),
12988                reviewers: Vec::new(),
12989                fixer: None,
12990                conductor: conductor(),
12991                implementer_roster: Vec::new(),
12992                judge_roster: Vec::new(),
12993                reviewer_roster: Vec::new(),
12994            },
12995            sem: Arc::new(Semaphore::new(1)),
12996            pause: Pause::new(),
12997            interrupt: Pause::new(),
12998        };
12999
13000        runner.gate().await.expect("gate");
13001        assert!(
13002            runner.state.gate_ran,
13003            "zero configured commands is still a real attempt, not an unrun gate"
13004        );
13005        assert!(runner.state.gate.is_empty());
13006        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
13007        assert_ne!(
13008            runner.state.status,
13009            RunStatus::Blocked,
13010            "a gate with nothing to check must not read as failed"
13011        );
13012
13013        runner.merge().await.expect("merge");
13014        assert_eq!(
13015            runner.state.status,
13016            RunStatus::Ready,
13017            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
13018        );
13019    }
13020
13021    /// `Config::cache_dir` is derived from `verify.e2e` as well as
13022    /// `verify.gate` (so the e2e leg and the final gate never build against
13023    /// different directories). With zero `verify.gate` commands but a
13024    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
13025    /// that lease before discovering it had nothing to run - so a repo with
13026    /// no gate commands could come back `resource_blocked` (and therefore
13027    /// still `gate_ran == false`) on nothing but an unrelated run holding the
13028    /// cache, exactly the contention this run's own zero commands could
13029    /// never have touched. `gate` must recognise there is nothing to check
13030    /// before it ever asks for the lease.
13031    #[tokio::test]
13032    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
13033        crate::run::pin_test_home();
13034        let home = crate::run::home();
13035
13036        let tmp = tempfile::tempdir().expect("tempdir");
13037        let repo = tmp.path().join("repo");
13038        std::fs::create_dir_all(&repo).unwrap();
13039        init_repo(&repo);
13040        // Unique to this test, so holding its lease cannot collide with
13041        // another test sharing the same process-wide `home`.
13042        let cache_dir = tmp.path().join("target");
13043
13044        let mut config = Config::default();
13045        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
13046        // `verify.gate` stays empty (the default). Bounded so a regression
13047        // that does start waiting fails the test in seconds, not hangs it.
13048        config.graph.timeout_verify = Some(2);
13049
13050        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13051        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13052            .expect("no io error acquiring directly")
13053        {
13054            crate::cache::AcquireOutcome::Acquired(g) => g,
13055            crate::cache::AcquireOutcome::Busy(b) => {
13056                panic!("expected the direct acquire to win the lease first: {b:?}")
13057            }
13058        };
13059
13060        let mut state = RunState::new(
13061            repo.clone(),
13062            "main".to_owned(),
13063            "deadbeef".to_owned(),
13064            "task".to_owned(),
13065            config,
13066        );
13067        state.candidates = vec![Candidate {
13068            index: 0,
13069            label: 'A',
13070            agent: "alpha".to_owned(),
13071            branch: "does-not-exist".to_owned(),
13072            worktree: repo.clone(),
13073            summary: String::new(),
13074            stat: String::new(),
13075            files: 0,
13076            commits: 0,
13077            empty: false,
13078            failed: None,
13079            verified_noop: None,
13080            duration_ms: 0,
13081            folded: false,
13082        }];
13083        state.tally = Some(Tally {
13084            first_choice: BTreeMap::from([('A', 1)]),
13085            borda: BTreeMap::new(),
13086            winner: 'A',
13087            rankings: 1,
13088            unanimous_initial: true,
13089            deliberated: false,
13090            changed_votes: 0,
13091            unanimous_final: true,
13092            tie_break: None,
13093            judges: 0,
13094            present: 0,
13095            quorum: 0,
13096            met_quorum: true,
13097            uncontested: Some("only candidate A produced a change".to_owned()),
13098        });
13099        state.reviews = vec![ReviewRound {
13100            round: 1,
13101            head: "deadbeef".to_owned(),
13102            verified_head: None,
13103            verified_at: None,
13104            reviews: Vec::new(),
13105            e2e: Vec::new(),
13106            fix: None,
13107            blocking: 0,
13108            answered: 0,
13109            expected: 0,
13110            clean: true,
13111            verify_retried: false,
13112            e2e_deferred: false,
13113            e2e_defer_reason: None,
13114            progressed: false,
13115            vote_split: false,
13116            reconsideration: Vec::new(),
13117            verdict: None,
13118        }];
13119
13120        let mut runner = Runner {
13121            state,
13122            roles: ResolvedRoles {
13123                implementers: Vec::new(),
13124                judges: Vec::new(),
13125                reviewers: Vec::new(),
13126                fixer: None,
13127                conductor: conductor(),
13128                implementer_roster: Vec::new(),
13129                judge_roster: Vec::new(),
13130                reviewer_roster: Vec::new(),
13131            },
13132            sem: Arc::new(Semaphore::new(1)),
13133            pause: Pause::new(),
13134            interrupt: Pause::new(),
13135        };
13136
13137        let started = std::time::Instant::now();
13138        runner.gate().await.expect("gate");
13139        assert!(
13140            started.elapsed() < Duration::from_secs(1),
13141            "a gate with nothing to run must never wait on a lease it never needed"
13142        );
13143        assert!(
13144            runner.state.gate_ran,
13145            "zero commands is still a real, immediate attempt"
13146        );
13147        assert!(runner.state.gate.is_empty());
13148        assert_ne!(
13149            runner.state.status,
13150            RunStatus::Blocked,
13151            "must not read as resource-blocked on a lease it never asked for"
13152        );
13153    }
13154
13155    /// The addendum's second gap: a `verify.gate` command running for real
13156    /// wall-clock time had nothing at all to show for it in `active` before
13157    /// `run_commands` learned to record it — a run could sit in `Gating` for
13158    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
13159    /// what was actually happening. Proven with a genuinely still-running
13160    /// command, not just a before/after check on the final state: a poller
13161    /// task reads the same `run.json` `gate()` is writing, the same way the
13162    /// phone or `magi show` would, while the shell command is still blocked
13163    /// on its own release marker.
13164    #[tokio::test]
13165    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
13166        crate::run::pin_test_home();
13167
13168        let tmp = tempfile::tempdir().expect("tempdir");
13169        let repo = tmp.path().join("repo");
13170        std::fs::create_dir_all(&repo).unwrap();
13171        init_repo(&repo);
13172
13173        let mut config = Config::default();
13174        config.verify.gate = vec![
13175            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
13176             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
13177                .to_owned(),
13178        ];
13179
13180        let mut state = RunState::new(
13181            repo.clone(),
13182            "main".to_owned(),
13183            "deadbeef".to_owned(),
13184            "task".to_owned(),
13185            config,
13186        );
13187        let run_id = state.id.clone();
13188        state.candidates = vec![Candidate {
13189            index: 0,
13190            label: 'A',
13191            agent: "alpha".to_owned(),
13192            branch: "does-not-exist".to_owned(),
13193            worktree: repo.clone(),
13194            summary: String::new(),
13195            stat: String::new(),
13196            files: 0,
13197            commits: 0,
13198            empty: false,
13199            failed: None,
13200            verified_noop: None,
13201            duration_ms: 0,
13202            folded: false,
13203        }];
13204        state.tally = Some(Tally {
13205            first_choice: BTreeMap::from([('A', 1)]),
13206            borda: BTreeMap::new(),
13207            winner: 'A',
13208            rankings: 1,
13209            unanimous_initial: true,
13210            deliberated: false,
13211            changed_votes: 0,
13212            unanimous_final: true,
13213            tie_break: None,
13214            judges: 0,
13215            present: 0,
13216            quorum: 0,
13217            met_quorum: true,
13218            uncontested: Some("only candidate A produced a change".to_owned()),
13219        });
13220        state.reviews = vec![ReviewRound {
13221            round: 1,
13222            head: "deadbeef".to_owned(),
13223            verified_head: None,
13224            verified_at: None,
13225            reviews: Vec::new(),
13226            e2e: Vec::new(),
13227            fix: None,
13228            blocking: 0,
13229            answered: 0,
13230            expected: 0,
13231            clean: true,
13232            verify_retried: false,
13233            e2e_deferred: false,
13234            e2e_defer_reason: None,
13235            progressed: false,
13236            vote_split: false,
13237            reconsideration: Vec::new(),
13238            verdict: None,
13239        }];
13240
13241        let mut runner = Runner {
13242            state,
13243            roles: ResolvedRoles {
13244                implementers: Vec::new(),
13245                judges: Vec::new(),
13246                reviewers: Vec::new(),
13247                fixer: None,
13248                conductor: conductor(),
13249                implementer_roster: Vec::new(),
13250                judge_roster: Vec::new(),
13251                reviewer_roster: Vec::new(),
13252            },
13253            sem: Arc::new(Semaphore::new(1)),
13254            pause: Pause::new(),
13255            interrupt: Pause::new(),
13256        };
13257
13258        let started_marker = repo.join("started.marker");
13259        let release_marker = repo.join("release.marker");
13260        let poller = tokio::spawn(async move {
13261            // Bounded so a regression that never records the task entry
13262            // fails this test in seconds instead of hanging the suite —
13263            // the same shape `a_park_requested_while_a_seat_is_mid_call_
13264            // does_not_cut_it_short` uses for the same reason.
13265            for _ in 0..100 {
13266                if started_marker.exists()
13267                    && let Ok(s) = crate::run::RunState::load(&run_id)
13268                    && let Some(a) = s.active.get("gate")
13269                {
13270                    std::fs::write(&release_marker, b"go").expect("release marker");
13271                    return Some(a.clone());
13272                }
13273                tokio::time::sleep(Duration::from_millis(50)).await;
13274            }
13275            None
13276        });
13277
13278        runner.gate().await.expect("gate");
13279        let captured = poller.await.expect("poller task");
13280        let captured = captured.expect(
13281            "the poller never saw a `gate` task entry in run.json while the command was \
13282             still blocked on its own release marker",
13283        );
13284
13285        assert_eq!(captured.task.as_deref(), Some("gate"));
13286        assert_eq!(captured.node, "gate");
13287        assert_eq!(captured.index, Some(1));
13288        assert_eq!(captured.total, Some(1));
13289        assert!(
13290            captured
13291                .command
13292                .as_deref()
13293                .is_some_and(|c| c.contains("started.marker")),
13294            "{captured:?}"
13295        );
13296
13297        assert!(
13298            runner.state.active.is_empty(),
13299            "the entry must be cleared once the command actually finished: {:?}",
13300            runner.state.active
13301        );
13302        assert!(runner.state.gate_ran);
13303        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
13304    }
13305
13306    /// The hand-off over a blocking finding a reviewer rejected on leaves a
13307    /// record for `land`; one with only a Minor, or no reject, leaves none.
13308    #[tokio::test]
13309    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
13310        use crate::verdict::{Finding, ReviewVote, Severity};
13311        crate::run::pin_test_home();
13312        let tmp = tempfile::tempdir().expect("tempdir");
13313        let repo = tmp.path().join("repo");
13314        std::fs::create_dir_all(&repo).unwrap();
13315        init_repo(&repo);
13316
13317        for (severity, vote, expect) in [
13318            (Severity::Major, ReviewVote::Reject, true),
13319            (Severity::Minor, ReviewVote::Reject, false),
13320            (Severity::Major, ReviewVote::Approve, false),
13321        ] {
13322            let mut round = review_round(false, 1, 1, 1, false, true);
13323            round.reviews = vec![ReviewRecord {
13324                reviewer: 1,
13325                agent: "alpha".to_owned(),
13326                summary: String::new(),
13327                findings: vec![Finding {
13328                    id: "R1-1-1".to_owned(),
13329                    severity,
13330                    file: None,
13331                    line: None,
13332                    title: "t".to_owned(),
13333                    detail: String::new(),
13334                }],
13335                vote: Some(vote),
13336                failed: None,
13337                duration_ms: 0,
13338                attempts: 0,
13339            }];
13340            let mut state = RunState::new(
13341                repo.clone(),
13342                "main".to_owned(),
13343                "deadbeef".to_owned(),
13344                "task".to_owned(),
13345                Config::default(),
13346            );
13347            state.reviews = vec![round];
13348            let mut runner = Runner {
13349                state,
13350                roles: ResolvedRoles {
13351                    implementers: Vec::new(),
13352                    judges: Vec::new(),
13353                    reviewers: Vec::new(),
13354                    fixer: None,
13355                    conductor: conductor(),
13356                    implementer_roster: Vec::new(),
13357                    judge_roster: Vec::new(),
13358                    reviewer_roster: Vec::new(),
13359                },
13360                sem: Arc::new(Semaphore::new(1)),
13361                pause: Pause::new(),
13362                interrupt: Pause::new(),
13363            };
13364            let shell = runner.state.config.shell();
13365            runner
13366                .stop_reviewing("round budget spent", &shell, &repo)
13367                .await
13368                .expect("stop_reviewing");
13369            assert_eq!(runner.state.status, RunStatus::Gating);
13370            assert_eq!(
13371                runner.state.contested_handoff.is_some(),
13372                expect,
13373                "{severity:?} + {vote:?}"
13374            );
13375        }
13376    }
13377
13378    /// The shape the incident this whole fix responds to actually had: the
13379    /// round budget spent, the last round's own e2e blocked on the shared
13380    /// build cache (held here by a live pid — this test process — exactly
13381    /// `cache`'s own unit tests' pattern for "another owner, still alive"
13382    /// without forking a process). `stop_reviewing` must retry it — not
13383    /// silently leave the round looking untouched (the catch-up-only half of
13384    /// the bug), and not read the contention as a red `e2e` and block the
13385    /// run on it (the other half). Called directly, the same way
13386    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
13387    /// above exercises `gate`, so this never needs a real cargo build to
13388    /// reach: the lease is never released, so `with_cache_lease` never gets
13389    /// past acquiring it into anything that would need a real workspace.
13390    #[tokio::test]
13391    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
13392        crate::run::pin_test_home();
13393        let home = crate::run::home();
13394
13395        let tmp = tempfile::tempdir().expect("tempdir");
13396        let repo = tmp.path().join("repo");
13397        std::fs::create_dir_all(&repo).unwrap();
13398        init_repo(&repo);
13399        let head = crate::git::rev_parse(&repo, "HEAD")
13400            .await
13401            .expect("rev-parse");
13402        // Unique to this test, so holding its lease cannot collide with
13403        // another test sharing the same process-wide `home`.
13404        let cache_dir = tmp.path().join("target");
13405
13406        let mut config = Config::default();
13407        config.verify.e2e = vec![format!(
13408            "CARGO_TARGET_DIR='{}' test -f README.md",
13409            cache_dir.display()
13410        )];
13411        config.graph.review_rounds = 1;
13412        // Bounded so a regression that does start waiting fails the test in
13413        // seconds, not hangs it.
13414        config.graph.timeout_verify = Some(2);
13415
13416        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13417        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13418            .expect("no io error acquiring directly")
13419        {
13420            crate::cache::AcquireOutcome::Acquired(g) => g,
13421            crate::cache::AcquireOutcome::Busy(b) => {
13422                panic!("expected the direct acquire to win the lease first: {b:?}")
13423            }
13424        };
13425
13426        let mut state = RunState::new(
13427            repo.clone(),
13428            "main".to_owned(),
13429            head.clone(),
13430            "task".to_owned(),
13431            config,
13432        );
13433        state.candidates = vec![Candidate {
13434            index: 0,
13435            label: 'A',
13436            agent: "alpha".to_owned(),
13437            branch: "does-not-exist".to_owned(),
13438            worktree: repo.clone(),
13439            summary: String::new(),
13440            stat: String::new(),
13441            files: 0,
13442            commits: 0,
13443            empty: false,
13444            failed: None,
13445            verified_noop: None,
13446            duration_ms: 0,
13447            folded: false,
13448        }];
13449        state.tally = Some(Tally {
13450            first_choice: BTreeMap::from([('A', 1)]),
13451            borda: BTreeMap::new(),
13452            winner: 'A',
13453            rankings: 1,
13454            unanimous_initial: true,
13455            deliberated: false,
13456            changed_votes: 0,
13457            unanimous_final: true,
13458            tie_break: None,
13459            judges: 0,
13460            present: 0,
13461            quorum: 0,
13462            met_quorum: true,
13463            uncontested: Some("only candidate A produced a change".to_owned()),
13464        });
13465        // The round budget's last round, deferred: `needs_catchup_run`'s
13466        // other trigger. `stop_reviewing`'s retry machinery must treat this
13467        // exactly like a resource-blocked attempt once it actually runs.
13468        state.reviews = vec![ReviewRound {
13469            round: 1,
13470            head: head.clone(),
13471            verified_head: None,
13472            verified_at: None,
13473            reviews: Vec::new(),
13474            e2e: Vec::new(),
13475            fix: None,
13476            blocking: 1,
13477            answered: 1,
13478            expected: 1,
13479            clean: false,
13480            verify_retried: false,
13481            e2e_deferred: true,
13482            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13483            progressed: false,
13484            vote_split: false,
13485            reconsideration: Vec::new(),
13486            verdict: None,
13487        }];
13488
13489        let mut runner = Runner {
13490            state,
13491            roles: ResolvedRoles {
13492                implementers: Vec::new(),
13493                judges: Vec::new(),
13494                reviewers: Vec::new(),
13495                fixer: None,
13496                conductor: conductor(),
13497                implementer_roster: Vec::new(),
13498                judge_roster: Vec::new(),
13499                reviewer_roster: Vec::new(),
13500            },
13501            sem: Arc::new(Semaphore::new(1)),
13502            pause: Pause::new(),
13503            interrupt: Pause::new(),
13504        };
13505
13506        let shell = runner.state.config.shell();
13507        runner
13508            .stop_reviewing("round budget spent", &shell, &repo)
13509            .await
13510            .expect("stop_reviewing");
13511
13512        let last = runner.state.reviews.last().expect("round record");
13513        assert_eq!(
13514            last.e2e_status(),
13515            E2eStatus::ResourceBlocked,
13516            "the shared cache is still held; the attempt must read as blocked, not deferred or \
13517             failed: {last:?}"
13518        );
13519        assert_eq!(
13520            last.verified_head.as_deref(),
13521            Some(head.as_str()),
13522            "which commit this attempt targeted is known even though nothing finished checking \
13523             it"
13524        );
13525        let first_attempt_at = last
13526            .verified_at
13527            .expect("when this attempt ran is known too");
13528        assert_ne!(
13529            runner.state.status,
13530            RunStatus::Blocked,
13531            "contention is evidence about the machine, not the patch — it must not settle the \
13532             run as blocked: {:?}",
13533            runner.state.status
13534        );
13535        assert!(
13536            !runner
13537                .state
13538                .events
13539                .iter()
13540                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13541            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13542            runner.state.events
13543        );
13544
13545        // The cache is still held: a later reentry must retry the same
13546        // round's verification again — not leave it looking exactly as
13547        // untouched as the first blocked attempt, which is indistinguishable
13548        // from never having tried again at all.
13549        runner
13550            .stop_reviewing("round budget spent", &shell, &repo)
13551            .await
13552            .expect("stop_reviewing retry");
13553        assert_eq!(
13554            runner.state.reviews.len(),
13555            1,
13556            "no new round was started: {:?}",
13557            runner.state.reviews
13558        );
13559        let last = runner.state.reviews.last().expect("round record");
13560        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13561        assert!(
13562            last.verified_at.expect("still known") > first_attempt_at,
13563            "a second reentry must be a fresh attempt, not a stale copy of the first"
13564        );
13565        assert_ne!(runner.state.status, RunStatus::Blocked);
13566
13567        held.release();
13568    }
13569
13570    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
13571    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
13572    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
13573    /// own top-of-function fast path (`review_conclusion`) correctly reads
13574    /// this shape as `None` rather than guessing `Blocked`, and the loop's
13575    /// own `for` range is empty once the round budget is spent, so
13576    /// `review_loop` must retry the check itself rather than silently doing
13577    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
13578    /// above exercises directly, but through `review_loop`'s own entry point
13579    /// this time, proving the wiring between the two rather than just the
13580    /// retry logic in isolation.
13581    #[tokio::test]
13582    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13583        crate::run::pin_test_home();
13584        let home = crate::run::home();
13585
13586        let tmp = tempfile::tempdir().expect("tempdir");
13587        let repo = tmp.path().join("repo");
13588        std::fs::create_dir_all(&repo).unwrap();
13589        init_repo(&repo);
13590        let head = crate::git::rev_parse(&repo, "HEAD")
13591            .await
13592            .expect("rev-parse");
13593        let cache_dir = tmp.path().join("target");
13594
13595        let mut config = Config::default();
13596        config.verify.e2e = vec![format!(
13597            "CARGO_TARGET_DIR='{}' test -f README.md",
13598            cache_dir.display()
13599        )];
13600        config.graph.review_rounds = 1;
13601        config.graph.timeout_verify = Some(2);
13602
13603        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13604        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13605            .expect("no io error acquiring directly")
13606        {
13607            crate::cache::AcquireOutcome::Acquired(g) => g,
13608            crate::cache::AcquireOutcome::Busy(b) => {
13609                panic!("expected the direct acquire to win the lease first: {b:?}")
13610            }
13611        };
13612
13613        let mut state = RunState::new(
13614            repo.clone(),
13615            "main".to_owned(),
13616            head.clone(),
13617            "task".to_owned(),
13618            config,
13619        );
13620        state.candidates = vec![Candidate {
13621            index: 0,
13622            label: 'A',
13623            agent: "alpha".to_owned(),
13624            branch: "does-not-exist".to_owned(),
13625            worktree: repo.clone(),
13626            summary: String::new(),
13627            stat: String::new(),
13628            files: 0,
13629            commits: 0,
13630            empty: false,
13631            failed: None,
13632            verified_noop: None,
13633            duration_ms: 0,
13634            folded: false,
13635        }];
13636        state.tally = Some(Tally {
13637            first_choice: BTreeMap::from([('A', 1)]),
13638            borda: BTreeMap::new(),
13639            winner: 'A',
13640            rankings: 1,
13641            unanimous_initial: true,
13642            deliberated: false,
13643            changed_votes: 0,
13644            unanimous_final: true,
13645            tie_break: None,
13646            judges: 0,
13647            present: 0,
13648            quorum: 0,
13649            met_quorum: true,
13650            uncontested: Some("only candidate A produced a change".to_owned()),
13651        });
13652        // The exact shape a prior process's `stop_reviewing` would have left
13653        // on disk: the round budget's last round, a real attempt already
13654        // made and already resource-blocked.
13655        state.reviews = vec![ReviewRound {
13656            round: 1,
13657            head: head.clone(),
13658            verified_head: Some(head.clone()),
13659            verified_at: Some(jiff::Timestamp::now()),
13660            reviews: Vec::new(),
13661            e2e: vec![CommandOutcome {
13662                command: format!(
13663                    "CARGO_TARGET_DIR='{}' test -f README.md",
13664                    cache_dir.display()
13665                ),
13666                code: None,
13667                output_tail: "waiting for the shared build cache".to_owned(),
13668                duration_ms: 0,
13669                resource_blocked: true,
13670            }],
13671            fix: None,
13672            blocking: 1,
13673            answered: 1,
13674            expected: 1,
13675            clean: false,
13676            verify_retried: false,
13677            e2e_deferred: false,
13678            e2e_defer_reason: None,
13679            progressed: false,
13680            vote_split: false,
13681            reconsideration: Vec::new(),
13682            verdict: None,
13683        }];
13684
13685        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13686        let mut runner = Runner {
13687            state,
13688            roles: ResolvedRoles {
13689                implementers: Vec::new(),
13690                judges: Vec::new(),
13691                reviewers: Vec::new(),
13692                fixer: None,
13693                conductor: conductor(),
13694                implementer_roster: Vec::new(),
13695                judge_roster: Vec::new(),
13696                reviewer_roster: Vec::new(),
13697            },
13698            sem: Arc::new(Semaphore::new(1)),
13699            pause: Pause::new(),
13700            interrupt: Pause::new(),
13701        };
13702
13703        // The lease is still held throughout, so this reentry's own retry is
13704        // also contended — proving `review_loop` actually tried again (not
13705        // that it happened to succeed) is what the timestamp comparison
13706        // below is for.
13707        runner.review_loop().await.expect("review_loop");
13708
13709        assert_eq!(
13710            runner.state.reviews.len(),
13711            1,
13712            "no new round was started on top of the unresolved one: {:?}",
13713            runner.state.reviews
13714        );
13715        let last = &runner.state.reviews[0];
13716        assert_eq!(
13717            last.e2e_status(),
13718            E2eStatus::ResourceBlocked,
13719            "still contended: {last:?}"
13720        );
13721        assert!(
13722            last.verified_at.expect("still known") > first_attempt_at,
13723            "review_loop must have actually retried the check, not left it exactly as found"
13724        );
13725        assert_ne!(
13726            runner.state.status,
13727            RunStatus::Blocked,
13728            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13729            runner.state.status
13730        );
13731
13732        held.release();
13733    }
13734
13735    #[tokio::test]
13736    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13737        crate::run::pin_test_home();
13738        let tmp = tempfile::tempdir().expect("tempdir");
13739        let repo = tmp.path().join("repo");
13740        std::fs::create_dir_all(&repo).unwrap();
13741        init_repo(&repo);
13742
13743        let mut config = Config::default();
13744        config.merge.mode = MergeMode::Pr;
13745        config.graph.land = true;
13746        config.graph.land_approval = false;
13747
13748        let mut state = RunState::new(
13749            repo.clone(),
13750            "main".to_owned(),
13751            "deadbeef".to_owned(),
13752            "task".to_owned(),
13753            config,
13754        );
13755        state.candidates = vec![Candidate {
13756            index: 0,
13757            label: 'A',
13758            agent: "alpha".to_owned(),
13759            branch: "does-not-exist".to_owned(),
13760            worktree: repo.clone(),
13761            summary: String::new(),
13762            stat: String::new(),
13763            files: 0,
13764            commits: 0,
13765            empty: false,
13766            failed: None,
13767            verified_noop: None,
13768            duration_ms: 0,
13769            folded: false,
13770        }];
13771        state.tally = Some(Tally {
13772            first_choice: BTreeMap::from([('A', 1)]),
13773            borda: BTreeMap::new(),
13774            winner: 'A',
13775            rankings: 1,
13776            unanimous_initial: true,
13777            deliberated: false,
13778            changed_votes: 0,
13779            unanimous_final: true,
13780            tie_break: None,
13781            judges: 0,
13782            present: 0,
13783            quorum: 0,
13784            met_quorum: true,
13785            uncontested: Some("only candidate A produced a change".to_owned()),
13786        });
13787        state.reviews = vec![ReviewRound {
13788            round: 1,
13789            head: "deadbeef".to_owned(),
13790            verified_head: None,
13791            verified_at: None,
13792            reviews: Vec::new(),
13793            e2e: Vec::new(),
13794            fix: None,
13795            blocking: 0,
13796            answered: 0,
13797            expected: 0,
13798            clean: true,
13799            verify_retried: false,
13800            e2e_deferred: false,
13801            e2e_defer_reason: None,
13802            progressed: false,
13803            vote_split: false,
13804            reconsideration: Vec::new(),
13805            verdict: None,
13806        }];
13807        state.gate = vec![CommandOutcome {
13808            command: "test".to_owned(),
13809            code: Some(0),
13810            output_tail: String::new(),
13811            duration_ms: 0,
13812            resource_blocked: false,
13813        }];
13814        state.gate_ran = true;
13815        // A first pass through `merge` already pushed and opened this pull
13816        // request; `status` is `Landing` because a previous call into `land`
13817        // parked or was interrupted before it reached a terminal outcome.
13818        state.status = RunStatus::Landing;
13819        state.merge = Some(MergeOutcome {
13820            mode: MergeMode::Pr,
13821            ok: true,
13822            detail: "https://example.invalid/x/y/pull/1".to_owned(),
13823            empty: false,
13824        });
13825
13826        // The Landing-resume shortcut calls `run_land` directly rather than
13827        // through `merge`, which is exactly the call site that used to skip
13828        // `settle_questions` - see the fixture below.
13829        ask_test_home();
13830        // A real resume loads a persisted run; driver ownership is checked
13831        // against that record before the Landing shortcut is entered.
13832        state.save().expect("save landing run");
13833        let store = ask::Questions::open();
13834        let q = ask_open_question(&store, &state.id);
13835
13836        let mut runner = Runner {
13837            state,
13838            roles: ResolvedRoles {
13839                implementers: Vec::new(),
13840                judges: Vec::new(),
13841                reviewers: Vec::new(),
13842                fixer: None,
13843                conductor: conductor(),
13844                implementer_roster: Vec::new(),
13845                judge_roster: Vec::new(),
13846                reviewer_roster: Vec::new(),
13847            },
13848            sem: Arc::new(Semaphore::new(1)),
13849            pause: Pause::new(),
13850            interrupt: Pause::new(),
13851        };
13852
13853        // `execute`, not `merge` directly: the Landing-resume shortcut lives
13854        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
13855        // exactly because `review_loop` would otherwise clobber the marker
13856        // first.
13857        runner.execute().await.expect("execute");
13858
13859        assert_eq!(
13860            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13861            Some("https://example.invalid/x/y/pull/1"),
13862            "reentry must not push again or open a second pull request over the \
13863             one `land` is already watching"
13864        );
13865        assert_ne!(
13866            runner.state.status,
13867            RunStatus::Landing,
13868            "land could not actually reach the fake pull request, so it must \
13869             have given up rather than left the run silently parked forever"
13870        );
13871        // `land` could not reach the fake pull request, so it gave up into
13872        // `Blocked` - still resumable, so the question must not have been
13873        // swept just because this branch now also calls `settle_questions`.
13874        assert_eq!(runner.state.status, RunStatus::Blocked);
13875        assert!(
13876            store.get(&q.id).unwrap().status.open(),
13877            "Blocked is still alive; settle_questions must have been a no-op here"
13878        );
13879    }
13880
13881    fn state_with_round(round: ReviewRound) -> RunState {
13882        let mut s = RunState::new(
13883            PathBuf::from("/repo"),
13884            "main".to_owned(),
13885            "abc1234".to_owned(),
13886            "add retries".to_owned(),
13887            Config::default(),
13888        );
13889        s.reviews = vec![round];
13890        s
13891    }
13892
13893    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13894        crate::verdict::Finding {
13895            id: id.to_owned(),
13896            severity,
13897            file: None,
13898            line: None,
13899            title: title.to_owned(),
13900            detail: String::new(),
13901        }
13902    }
13903
13904    #[test]
13905    fn pr_body_names_open_findings_and_declined_ones() {
13906        let round = ReviewRound {
13907            round: 2,
13908            head: "deadbee".to_owned(),
13909            verified_head: None,
13910            verified_at: None,
13911            reviews: vec![ReviewRecord {
13912                attempts: 0,
13913                reviewer: 1,
13914                agent: "alpha".to_owned(),
13915                summary: String::new(),
13916                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13917                vote: None,
13918                failed: None,
13919                duration_ms: 0,
13920            }],
13921            e2e: vec![CommandOutcome {
13922                command: "cargo test".to_owned(),
13923                code: Some(0),
13924                output_tail: String::new(),
13925                duration_ms: 0,
13926                resource_blocked: false,
13927            }],
13928            verify_retried: false,
13929            e2e_deferred: false,
13930            e2e_defer_reason: None,
13931            fix: Some(FixRecord {
13932                agent: "alpha".to_owned(),
13933                addressed: Vec::new(),
13934                rejected: vec![crate::verdict::Rejection {
13935                    id: "R1-1-1".to_owned(),
13936                    why: "not reachable from any caller".to_owned(),
13937                }],
13938                notes: String::new(),
13939                committed: true,
13940                failed: None,
13941                duration_ms: 0,
13942                continuation: None,
13943            }),
13944            blocking: 0,
13945            answered: 1,
13946            expected: 1,
13947            clean: false,
13948            progressed: true,
13949            vote_split: false,
13950            reconsideration: Vec::new(),
13951            verdict: None,
13952        };
13953        let state = state_with_round(round);
13954        let body = pr_message(&state, 'A').body;
13955
13956        assert!(body.contains("add retries"), "the task must still be there");
13957        assert!(body.contains("R2-1-1"), "{body}");
13958        assert!(!body.contains("unused import"), "{body}");
13959        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13960        assert!(
13961            !body.contains("not reachable from any caller"),
13962            "the reason it was declined: {body}"
13963        );
13964    }
13965
13966    #[test]
13967    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13968        let round = ReviewRound {
13969            round: 1,
13970            head: "deadbee".to_owned(),
13971            verified_head: None,
13972            verified_at: None,
13973            reviews: vec![ReviewRecord {
13974                attempts: 0,
13975                reviewer: 1,
13976                agent: "alpha".to_owned(),
13977                summary: String::new(),
13978                findings: Vec::new(),
13979                vote: None,
13980                failed: None,
13981                duration_ms: 0,
13982            }],
13983            e2e: Vec::new(),
13984            verify_retried: false,
13985            e2e_deferred: false,
13986            e2e_defer_reason: None,
13987            fix: None,
13988            blocking: 0,
13989            answered: 1,
13990            expected: 1,
13991            clean: true,
13992            progressed: false,
13993            vote_split: false,
13994            reconsideration: Vec::new(),
13995            verdict: None,
13996        };
13997        let state = state_with_round(round);
13998        let body = pr_message(&state, 'A').body;
13999        assert!(!body.contains("Open review findings"), "{body}");
14000        assert!(!body.contains("Declined"), "{body}");
14001    }
14002
14003    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
14004        let mut state = RunState::new(
14005            PathBuf::from("/repo"),
14006            "main".to_owned(),
14007            "abc1234".to_owned(),
14008            instruction.to_owned(),
14009            Config::default(),
14010        );
14011        state.candidates.push(Candidate {
14012            index: 0,
14013            label: 'A',
14014            agent: "alpha".to_owned(),
14015            branch: "magi/x/A".to_owned(),
14016            worktree: PathBuf::from("/wt"),
14017            summary: summary.to_owned(),
14018            stat: String::new(),
14019            files: 1,
14020            commits: 1,
14021            empty: false,
14022            failed: None,
14023            verified_noop: None,
14024            folded: false,
14025            duration_ms: 0,
14026        });
14027        state
14028    }
14029
14030    fn review_state(subjects: &[&str]) -> RunState {
14031        let mut state = state_with_summary(
14032            "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",
14033            "",
14034        );
14035        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14036        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
14037        state
14038    }
14039
14040    /// A branch rebased onto a main that moved past the recorded
14041    /// `base_commit` is titled from its own first commit, never main's.
14042    #[tokio::test]
14043    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
14044        ask_test_home();
14045        let tmp = tempfile::tempdir().unwrap();
14046        let repo = tmp.path().join("repo");
14047        std::fs::create_dir_all(&repo).unwrap();
14048        init_repo(&repo);
14049        let origin = tmp.path().join("origin.git");
14050        let g = |dir: &Path, args: &[&str]| {
14051            let out = std::process::Command::new("git")
14052                .args(args)
14053                .current_dir(dir)
14054                .quiet()
14055                .output()
14056                .expect("spawn git");
14057            assert!(
14058                out.status.success(),
14059                "git {args:?}: {}",
14060                String::from_utf8_lossy(&out.stderr)
14061            );
14062        };
14063        g(
14064            tmp.path(),
14065            &[
14066                "clone",
14067                "--bare",
14068                "-q",
14069                repo.to_str().unwrap(),
14070                origin.to_str().unwrap(),
14071            ],
14072        );
14073        g(
14074            &repo,
14075            &["remote", "add", "origin", origin.to_str().unwrap()],
14076        );
14077        let c1 = git::rev_parse(&repo, "main").await.unwrap();
14078
14079        // Main moves on; the branch is built on top of the new main.
14080        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14081        g(&repo, &["add", "-A"]);
14082        g(
14083            &repo,
14084            &["commit", "-q", "-m", "chore(deps): update a crate"],
14085        );
14086        g(&repo, &["push", "-q", "origin", "main"]);
14087        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14088        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14089        g(&repo, &["add", "-A"]);
14090        g(
14091            &repo,
14092            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14093        );
14094        g(&repo, &["checkout", "-q", "main"]);
14095
14096        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14097        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14098        // Without a readable tracking ref the recorded base's merge base is used.
14099        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14100        assert_eq!(fallback, c1);
14101
14102        let mut state = review_state(&[
14103            "chore(deps): update a crate",
14104            "fix(daemon): apply a chosen action",
14105        ]);
14106        state.repo = repo.clone();
14107        state.base_branch = "main".to_owned();
14108        state.base_commit = c1;
14109        refresh_reviewed_commits(&mut state, "feat/own").await;
14110        assert_eq!(
14111            state.reviewed_commits,
14112            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14113        );
14114        assert_eq!(
14115            review_title(&state).as_deref(),
14116            Some("fix(daemon): apply a chosen action")
14117        );
14118        assert_eq!(
14119            leaked_subjects(&state, "feat/own").await,
14120            Some(vec!["chore(deps): update a crate".to_owned()])
14121        );
14122        // A stale tracking ref is still used when the fetch fails, but the
14123        // leak list is withheld.
14124        state.config.merge.remote = "nowhere".to_owned();
14125        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14126    }
14127
14128    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
14129    /// with `origin/main` one commit behind it, and a summarizing agent that
14130    /// prints whatever `reply` holds.
14131    struct SummaryFixture {
14132        _tmp: tempfile::TempDir,
14133        repo: PathBuf,
14134        reply: PathBuf,
14135        runner: Runner,
14136    }
14137
14138    fn summary_fixture() -> SummaryFixture {
14139        ask_test_home();
14140        let tmp = tempfile::tempdir().unwrap();
14141        let repo = tmp.path().join("repo");
14142        std::fs::create_dir_all(&repo).unwrap();
14143        init_repo(&repo);
14144        let origin = tmp.path().join("origin.git");
14145        let g = |args: &[&str]| {
14146            let out = std::process::Command::new("git")
14147                .args(args)
14148                .current_dir(&repo)
14149                .quiet()
14150                .output()
14151                .expect("spawn git");
14152            assert!(
14153                out.status.success(),
14154                "git {args:?}: {}",
14155                String::from_utf8_lossy(&out.stderr)
14156            );
14157        };
14158        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14159        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14160        g(&["push", "-q", "origin", "main"]);
14161        g(&["checkout", "-q", "-b", "feat/own"]);
14162        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14163        g(&["add", "-A"]);
14164        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14165        let reply = tmp.path().join("reply.txt");
14166        std::fs::write(
14167            &reply,
14168            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14169        )
14170        .unwrap();
14171        let mut runner = runner_at(RunStatus::Gating);
14172        let mut state = review_state(&["feat(own): add the own file"]);
14173        state.repo = repo.clone();
14174        state.base_branch = "main".to_owned();
14175        state.candidates[0].branch = "feat/own".to_owned();
14176        state.candidates[0].worktree = repo.clone();
14177        let mut author = spec("alpha");
14178        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14179        author
14180            .env
14181            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14182        state.config.agents = vec![author];
14183        runner.state = state;
14184        SummaryFixture {
14185            _tmp: tmp,
14186            repo,
14187            reply,
14188            runner,
14189        }
14190    }
14191
14192    async fn posted(f: &mut SummaryFixture) -> PrMessage {
14193        let winner = f.runner.state.candidates[0].clone();
14194        f.runner
14195            .guarded_pr_message(&winner, None, true)
14196            .await
14197            .unwrap()
14198            .expect("an English description never parks")
14199    }
14200
14201    fn clean_round_with_minor_findings() -> ReviewRound {
14202        let mut round = passing_round(2, None);
14203        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14204        round.reviews = vec![ReviewRecord {
14205            attempts: 0,
14206            reviewer: 1,
14207            agent: "alpha".into(),
14208            summary: "内部レビュー".into(),
14209            findings: vec![
14210                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14211                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14212            ],
14213            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14214            failed: None,
14215            duration_ms: 0,
14216        }];
14217        round
14218    }
14219
14220    #[tokio::test]
14221    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14222        for review_only in [false, true] {
14223            let mut f = summary_fixture();
14224            if !review_only {
14225                f.runner.state.candidates[0].agent = "alpha".into();
14226            }
14227            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14228            assert!(f.runner.state.reviews[0].clean);
14229            assert!(f.runner.state.reviews[0].fix.is_none());
14230            assert!(f.runner.state.open_findings().is_empty());
14231            assert_eq!(
14232                crate::pr_outcome::required_finding_ids(&f.runner.state),
14233                ["R2-1-1", "R2-1-2"]
14234            );
14235            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14236            let evidence = crate::pr_outcome::summary_evidence(
14237                &f.runner.state,
14238                crate::pr_outcome::FinalHead::At(&head),
14239            );
14240            assert!(
14241                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14242            );
14243            // The ordinary reply omits both IDs: no cached description is adopted.
14244            let omitted = posted(&mut f).await;
14245            assert!(omitted.body.contains("No description"));
14246            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14247            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14248            assert!(f.runner.state.change_summary.is_none());
14249            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();
14250            let described = posted(&mut f).await;
14251            assert!(described.body.contains("improve documentation for callers"));
14252            assert!(described.body.contains("remaining edge case"));
14253            assert!(described.body.contains("## Open review findings"));
14254            assert!(!described.body.contains("未修正"));
14255        }
14256    }
14257
14258    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14259        let mut f = summary_fixture();
14260        if !review_only {
14261            f.runner.state.candidates[0].agent = "alpha".into();
14262        }
14263        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14264        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14265        fixed
14266            .fix
14267            .as_mut()
14268            .unwrap()
14269            .rejected
14270            .push(crate::verdict::Rejection {
14271                id: "R1-1-1".into(),
14272                why: "呼び出されないコードのため辞退".into(),
14273            });
14274        let mut clean = clean_round_with_minor_findings();
14275        clean.verified_head = Some(head.clone());
14276        f.runner.state.reviews = vec![fixed, clean];
14277        ok_gate(&mut f.runner.state, Some(&head));
14278        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14279        f.runner.state.config.agents.push(scripted("beta", reply));
14280        f.runner.state.config.roles.language_judge =
14281            Some(crate::config::AgentChoice::One("beta".into()));
14282        f
14283    }
14284
14285    #[tokio::test]
14286    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14287        for review_only in [false, true] {
14288            let mut f = rewrite_fixture_with_findings(review_only,
14289                "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;
14290            let m = posted(&mut f).await;
14291            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14292            let expected =
14293                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14294            assert!(m.body.contains(&expected));
14295            assert!(m.body.contains("## Review rounds"));
14296            assert!(
14297                m.body
14298                    .contains("Gate (`verify.gate`): passed on the final commit")
14299            );
14300            assert!(
14301                m.body
14302                    .contains("Tests (`verify.e2e`): passed on the final commit")
14303            );
14304            let c = f.runner.state.change_summary.as_ref().unwrap();
14305            assert!(
14306                m.body
14307                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14308            );
14309            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14310            assert!(crate::pr_outcome::covers_required_findings(
14311                &f.runner.state,
14312                &m.body
14313            ));
14314            assert!(m.body.contains("documentation remains incomplete"));
14315            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14316            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14317            assert!(f.runner.state.github_text.is_none());
14318        }
14319    }
14320
14321    #[tokio::test]
14322    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14323        for review_only in [false, true] {
14324            for reply in [
14325                "TITLE: fix: public description\nA short English description.",
14326                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14327            ] {
14328                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14329                let winner = f.runner.state.candidates[0].clone();
14330                let posted = f
14331                    .runner
14332                    .guarded_pr_message(&winner, None, true)
14333                    .await
14334                    .unwrap();
14335                assert!(posted.is_none());
14336                assert!(f.runner.state.parked);
14337                let withheld = f.runner.state.github_text.as_ref().unwrap();
14338                assert!(
14339                    withheld.rewrite.is_none(),
14340                    "an incomplete rewrite must not be selectable"
14341                );
14342                let q = ask::Questions::open()
14343                    .get(withheld.question.as_ref().unwrap())
14344                    .unwrap();
14345                assert!(
14346                    !q.choices
14347                        .contains(&crate::github_text::USE_REWRITE.to_owned())
14348                );
14349                assert!(
14350                    q.detail.contains("R1-1-1")
14351                        && q.detail.contains("R2-1-1")
14352                        && q.detail.contains("R2-1-2")
14353                );
14354            }
14355        }
14356    }
14357
14358    #[tokio::test]
14359    async fn japanese_reports_stay_local_in_both_public_run_modes() {
14360        for review_only in [false, true] {
14361            let mut f = summary_fixture();
14362            if !review_only {
14363                f.runner.state.candidates[0].agent = "alpha".into();
14364            }
14365            f.runner.state.instruction = "内部タスクの説明".into();
14366            f.runner.state.candidates[0].summary = "内部の候補報告".into();
14367            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14368            f.runner.state.reviews = vec![
14369                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14370                round_with_notes(2, Some("二回目の内部修正報告です。")),
14371                passing_round(3, Some(&head)),
14372            ];
14373            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14374            ok_gate(&mut f.runner.state, Some(&head));
14375            let captured = f.repo.join("public-prompt.txt");
14376            let author = &mut f.runner.state.config.agents[0];
14377            author.command = vec![
14378                "sh".into(),
14379                "-c".into(),
14380                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14381            ];
14382            author.env.insert(
14383                "PROMPT_FILE".into(),
14384                captured.to_string_lossy().into_owned(),
14385            );
14386            let m = posted(&mut f).await;
14387            assert!(m.body.contains("Adds `own.txt`"));
14388            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14389            assert!(m.body.contains("R1-1-1"));
14390            assert!(
14391                m.body
14392                    .contains("Gate (`verify.gate`): passed on the final commit")
14393            );
14394            assert!(
14395                m.body
14396                    .contains("Tests (`verify.e2e`): passed on the final commit")
14397            );
14398            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14399            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14400            let prompt = std::fs::read_to_string(captured).unwrap();
14401            assert!(prompt.contains(&format!(
14402                "git diff {} {head}",
14403                f.runner.state.change_summary.as_ref().unwrap().base
14404            )));
14405            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14406            assert_eq!(
14407                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14408                "二回目の内部修正報告です。"
14409            );
14410        }
14411    }
14412
14413    #[tokio::test]
14414    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14415        for review_only in [false, true] {
14416            let mut f = summary_fixture();
14417            if !review_only {
14418                f.runner.state.candidates[0].agent = "alpha".into();
14419            }
14420            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14421            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14422            std::fs::write(&f.reply, "no usable description").unwrap();
14423            let failed = posted(&mut f).await;
14424            assert!(
14425                failed
14426                    .body
14427                    .contains("No description of what this change does could be generated")
14428            );
14429            assert!(failed.body.is_ascii());
14430            assert!(f.runner.state.github_text.is_none());
14431            assert!(f.runner.state.change_summary.is_none());
14432            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14433            let evidence = crate::pr_outcome::summary_evidence(
14434                &f.runner.state,
14435                crate::pr_outcome::FinalHead::At(&head),
14436            );
14437            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14438            assert!(
14439                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14440                    .exists()
14441            );
14442            f.runner.state =
14443                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14444            std::fs::write(
14445                &f.reply,
14446                "TITLE: feat(own): add the own file\nAdds the committed own file.",
14447            )
14448            .unwrap();
14449            let retried = posted(&mut f).await;
14450            assert!(retried.body.contains("Adds the committed own file."));
14451            assert_eq!(
14452                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14453                "日本語の修正報告"
14454            );
14455        }
14456    }
14457
14458    #[tokio::test]
14459    async fn same_head_verification_and_findings_invalidate_public_prose() {
14460        let mut f = summary_fixture();
14461        posted(&mut f).await;
14462        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14463        let original = f
14464            .runner
14465            .state
14466            .change_summary
14467            .as_ref()
14468            .unwrap()
14469            .evidence
14470            .clone();
14471        ok_gate(&mut f.runner.state, Some(&head));
14472        std::fs::write(
14473            &f.reply,
14474            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14475        )
14476        .unwrap();
14477        let verified = posted(&mut f).await;
14478        assert!(verified.body.contains("now verified"));
14479        assert_ne!(
14480            f.runner.state.change_summary.as_ref().unwrap().evidence,
14481            original
14482        );
14483        let mut round = round_with_notes(1, Some("内部報告"));
14484        round.reviews.push(ReviewRecord {
14485            attempts: 0,
14486            reviewer: 1,
14487            agent: "alpha".into(),
14488            summary: "内部レビュー".into(),
14489            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14490            vote: None,
14491            failed: None,
14492            duration_ms: 0,
14493        });
14494        round
14495            .fix
14496            .as_mut()
14497            .unwrap()
14498            .rejected
14499            .push(crate::verdict::Rejection {
14500                id: "R1-1-1".into(),
14501                why: "呼び出し元から到達できない".into(),
14502            });
14503        f.runner.state.reviews = vec![round];
14504        // A description omitting the required ID is unusable, even with a title.
14505        let omitted = posted(&mut f).await;
14506        assert!(omitted.body.contains("No description"));
14507        assert!(omitted.body.contains("R1-1-1"));
14508        assert!(!omitted.body.contains("到達"));
14509        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();
14510        let described = posted(&mut f).await;
14511        assert!(described.body.contains("unreachable from callers"));
14512        assert!(described.body.contains("R1-1-1"));
14513        assert!(!described.body.contains("到達"));
14514        let evidence = f
14515            .runner
14516            .state
14517            .change_summary
14518            .as_ref()
14519            .unwrap()
14520            .evidence
14521            .clone();
14522        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14523        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14524        assert!(posted(&mut f).await.body.contains("rationale updated"));
14525        assert_ne!(
14526            f.runner.state.change_summary.as_ref().unwrap().evidence,
14527            evidence
14528        );
14529        // Old serialized summaries have no evidence fingerprint and must regenerate.
14530        f.runner
14531            .state
14532            .change_summary
14533            .as_mut()
14534            .unwrap()
14535            .evidence
14536            .clear();
14537        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14538        assert!(posted(&mut f).await.body.contains("current specification"));
14539    }
14540
14541    #[tokio::test]
14542    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14543        let mut f = summary_fixture();
14544        let m = posted(&mut f).await;
14545        assert_eq!(m.title, "feat(own): add the own file");
14546        assert!(
14547            m.body
14548                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14549            "{}",
14550            m.body
14551        );
14552        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14553        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14554        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14555        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14556        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14557        let kept = f.runner.state.change_summary.as_ref().unwrap();
14558        assert_eq!(
14559            (kept.head.as_str(), kept.base.as_str()),
14560            (head.as_str(), base.as_str())
14561        );
14562        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14563    }
14564
14565    #[tokio::test]
14566    async fn review_only_summary_receives_the_configured_github_language() {
14567        let mut f = summary_fixture();
14568        f.runner.state.config.graph.github_language = "ja".into();
14569        let captured = f.repo.join("summary-prompt.txt");
14570        let author = &mut f.runner.state.config.agents[0];
14571        author.command = vec![
14572            "sh".into(),
14573            "-c".into(),
14574            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14575        ];
14576        author.env.insert(
14577            "PROMPT_FILE".into(),
14578            captured.to_string_lossy().into_owned(),
14579        );
14580        std::fs::write(
14581            &f.reply,
14582            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14583        )
14584        .unwrap();
14585        let m = posted(&mut f).await;
14586        let prompt = std::fs::read_to_string(captured).unwrap();
14587        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14588        assert!(!prompt.contains("in English"));
14589        assert_eq!(m.title, "feat(own): ファイルを追加");
14590        assert!(m.body.contains("own.txt を追加します。"));
14591    }
14592
14593    #[tokio::test]
14594    async fn review_fixes_come_after_the_whole_change_summary() {
14595        let mut f = summary_fixture();
14596        f.runner.state.reviews = vec![
14597            round_with_notes(1, Some("handled the PATH case")),
14598            round_with_notes(2, None),
14599        ];
14600        let m = posted(&mut f).await;
14601        let summary = m.body.find("## Summary").expect("summary");
14602        let fixes = m.body.find("## Review rounds").expect("review rounds");
14603        assert!(summary < fixes, "{}", m.body);
14604        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14605        assert!(m.body.contains("## Final verification"), "{}", m.body);
14606    }
14607
14608    #[tokio::test]
14609    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14610        let mut f = summary_fixture();
14611        let first = posted(&mut f).await;
14612        let first_head = f.runner.state.change_summary.clone().unwrap().head;
14613        // The same head reuses the record: a different reply is not read.
14614        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14615        let again = posted(&mut f).await;
14616        assert_eq!((again.title, again.body), (first.title, first.body));
14617
14618        let g = |args: &[&str]| {
14619            let out = std::process::Command::new("git")
14620                .args(args)
14621                .current_dir(&f.repo)
14622                .quiet()
14623                .output()
14624                .expect("spawn git");
14625            assert!(out.status.success(), "git {args:?}");
14626        };
14627        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14628        g(&["add", "-A"]);
14629        g(&["commit", "-q", "-m", "fix(own): handle more"]);
14630        let second = posted(&mut f).await;
14631        let kept = f.runner.state.change_summary.clone().unwrap();
14632        assert_ne!(kept.head, first_head);
14633        assert!(second.body.contains("Not read yet."), "{}", second.body);
14634        assert!(!second.body.contains("so the first summary is read"));
14635        assert_eq!(second.title, "feat(own): other");
14636    }
14637
14638    #[tokio::test]
14639    async fn an_unavailable_summary_is_stated_and_never_invented() {
14640        let mut f = summary_fixture();
14641        std::fs::write(&f.reply, "no title line here").unwrap();
14642        let m = posted(&mut f).await;
14643        assert!(
14644            m.body
14645                .contains("No description of what this change does could be generated"),
14646            "{}",
14647            m.body
14648        );
14649        assert!(m.body.contains("internal reports remain in local records"));
14650        assert!(f.runner.state.change_summary.is_none());
14651        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14652        // A preview asks nobody and claims nothing.
14653        let winner = f.runner.state.candidates[0].clone();
14654        let preview = f
14655            .runner
14656            .guarded_pr_message(&winner, None, false)
14657            .await
14658            .unwrap()
14659            .unwrap();
14660        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14661    }
14662
14663    #[test]
14664    fn pr_message_review_single_commit_uses_its_subject() {
14665        let state = review_state(&["feat(nats): per-role user"]);
14666        let m = pr_message(&state, 'A');
14667        assert_eq!(m.title, "feat(nats): per-role user");
14668        assert!(!m.body.contains("Review the work already"), "{}", m.body);
14669        assert!(m.body.contains("## Commits under review"), "{}", m.body);
14670    }
14671
14672    #[test]
14673    fn pr_message_review_multi_commit_takes_the_oldest() {
14674        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14675        let m = pr_message(&state, 'A');
14676        assert_eq!(m.title, "feat: the change");
14677        for s in ["feat: the change", "fix: typo", "fix: again"] {
14678            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14679        }
14680    }
14681
14682    #[test]
14683    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14684        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14685            let mut state = review_state(&[first, "fix: later fixup"]);
14686            state.candidates[0].branch = "機能/ログイン".to_owned();
14687            let m = pr_message(&state, 'A');
14688            assert!(
14689                m.title.starts_with("chore: land candidate A of run"),
14690                "{}",
14691                m.title
14692            );
14693        }
14694    }
14695
14696    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14697        BranchFacts {
14698            commits: commits
14699                .iter()
14700                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14701                .collect(),
14702            stat: stat.to_owned(),
14703        }
14704    }
14705
14706    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14707        let mut r = review_round(true, 0, 1, 1, true, true);
14708        r.round = round;
14709        r.fix = notes.map(|n| FixRecord {
14710            agent: "fixer".to_owned(),
14711            addressed: Vec::new(),
14712            rejected: Vec::new(),
14713            notes: n.to_owned(),
14714            committed: true,
14715            failed: None,
14716            duration_ms: 0,
14717            continuation: None,
14718        });
14719        r
14720    }
14721
14722    #[test]
14723    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14724        let state = review_state(&["ignored"]);
14725        let f = facts(
14726            &[
14727                (
14728                    "fix(login): resolve PATH on macOS",
14729                    "Login shells skip rc files.",
14730                ),
14731                ("fix: address review", ""),
14732            ],
14733            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14734        );
14735        let m = pr_message_with(&state, 'A', Some(&f));
14736        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14737        assert!(
14738            m.body
14739                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
14740            "{}",
14741            m.body
14742        );
14743        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14744        assert!(m.body.contains("## Diff stat"), "{}", m.body);
14745        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14746        for banned in [
14747            "Review the work already",
14748            "no task statement",
14749            "Original task",
14750        ] {
14751            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14752        }
14753        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14754    }
14755
14756    #[test]
14757    fn pr_message_review_truncates_a_huge_first_commit_body() {
14758        let state = review_state(&["ignored"]);
14759        let f = facts(
14760            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14761            "s",
14762        );
14763        let m = pr_message_with(&state, 'A', Some(&f));
14764        assert!(m.body.len() < 30_000, "{}", m.body.len());
14765        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14766        assert!(
14767            m.body.contains("- ... 1 more commit(s)"),
14768            "{}",
14769            m.body.len()
14770        );
14771        assert!(m.body.ends_with("magi:candidate-a\n"));
14772    }
14773
14774    #[test]
14775    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14776        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14777        assert_eq!(m.title, "feat: x");
14778        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14779        assert!(!m.body.contains("Diff stat"), "{}", m.body);
14780        assert!(!m.body.contains("no task statement"), "{}", m.body);
14781    }
14782
14783    #[test]
14784    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14785        let mut state = review_state(&["日本語の件名"]);
14786        state.candidates[0].branch = "fix/macos-login-path".to_owned();
14787        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14788        state.candidates[0].branch = "機能/ログイン".to_owned();
14789        assert!(
14790            pr_message(&state, 'A')
14791                .title
14792                .starts_with("chore: land candidate A")
14793        );
14794    }
14795
14796    #[test]
14797    fn pr_message_review_fixes_survive_a_clean_final_round() {
14798        let mut state = review_state(&["feat: x"]);
14799        state.reviews = vec![
14800            round_with_notes(1, Some("handled the PATH case")),
14801            round_with_notes(2, None),
14802        ];
14803        let body = pr_message(&state, 'A').body;
14804        assert!(
14805            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14806            "{body}"
14807        );
14808        assert!(body.contains("### Round 1"), "{body}");
14809
14810        state.reviews = vec![
14811            round_with_notes(1, Some("first fix")),
14812            round_with_notes(2, Some("")),
14813            round_with_notes(3, Some("second fix")),
14814            round_with_notes(4, None),
14815        ];
14816        let body = pr_message(&state, 'A').body;
14817        assert!(
14818            body.contains("### Round 1") && !body.contains("first fix"),
14819            "{body}"
14820        );
14821        assert!(
14822            body.contains("### Round 3") && !body.contains("second fix"),
14823            "{body}"
14824        );
14825        assert!(body.contains("### Round 2"), "{body}");
14826    }
14827
14828    const FINAL: &str = "abcdef1234567890";
14829
14830    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14831        let mut r = round_with_notes(round, None);
14832        r.verified_head = verified.map(str::to_owned);
14833        r
14834    }
14835
14836    fn ok_gate(state: &mut RunState, head: Option<&str>) {
14837        state.gate = vec![CommandOutcome {
14838            command: "gate".to_owned(),
14839            code: Some(0),
14840            output_tail: String::new(),
14841            duration_ms: 0,
14842            resource_blocked: false,
14843        }];
14844        state.gate_ran = true;
14845        state.gate_head = head.map(str::to_owned);
14846    }
14847
14848    fn body_at(state: &RunState) -> String {
14849        pr_message_raw(
14850            state,
14851            'A',
14852            None,
14853            crate::pr_outcome::FinalHead::At(FINAL),
14854            ChangeNote::NotAsked,
14855        )
14856        .body
14857    }
14858
14859    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14860
14861    #[test]
14862    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14863        let mut state = review_state(&["feat: x"]);
14864        state.reviews = vec![
14865            round_with_notes(1, Some(SANDBOXED)),
14866            passing_round(2, Some(FINAL)),
14867        ];
14868        ok_gate(&mut state, Some(FINAL));
14869        let body = body_at(&state);
14870        assert!(!body.contains("Handled the PATH case."), "{body}");
14871        assert!(!body.contains("could not commit"), "{body}");
14872        assert!(!body.contains("could not run"), "{body}");
14873        assert!(
14874            body.contains("The fix is committed on the branch"),
14875            "{body}"
14876        );
14877        assert!(!body.contains("sandbox"), "{body}");
14878        assert!(
14879            body.contains("Tests (`verify.e2e`): passed on the final commit"),
14880            "{body}"
14881        );
14882        assert!(
14883            body.contains("Gate (`verify.gate`): passed on the final commit"),
14884            "{body}"
14885        );
14886        // The raw report is untouched in the record.
14887        assert!(
14888            state.reviews[0]
14889                .fix
14890                .as_ref()
14891                .unwrap()
14892                .notes
14893                .contains("could not commit")
14894        );
14895    }
14896
14897    #[test]
14898    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14899        let mut state = review_state(&["feat: x"]);
14900        let mut r1 = round_with_notes(1, Some(SANDBOXED));
14901        r1.fix.as_mut().unwrap().committed = false;
14902        let mut r2 = passing_round(2, Some(FINAL));
14903        r2.e2e[0].code = Some(1);
14904        state.reviews = vec![r1, r2];
14905        let body = body_at(&state);
14906        assert!(!body.contains("could not commit"), "{body}");
14907        assert!(!body.contains("could not run"), "{body}");
14908        assert!(body.contains("This round produced no commit"), "{body}");
14909        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14910        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14911        assert!(!body.contains("passed"), "{body}");
14912    }
14913
14914    #[test]
14915    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14916        let mut state = review_state(&["feat: x"]);
14917        state.reviews = vec![
14918            round_with_notes(1, Some(SANDBOXED)),
14919            passing_round(2, Some("0123456789abcdef")),
14920        ];
14921        ok_gate(&mut state, Some("0123456789abcdef"));
14922        let body = body_at(&state);
14923        assert!(!body.contains("HTTP tests could not run"), "{body}");
14924        // The commit claim is still settled by the structured record.
14925        assert!(!body.contains("could not commit"), "{body}");
14926        assert!(
14927            body.contains("passed on an earlier commit (`0123456`)"),
14928            "{body}"
14929        );
14930        assert!(!body.contains("passed on the final commit"), "{body}");
14931
14932        // A gate recorded before `gate_head` existed is "not recorded".
14933        ok_gate(&mut state, None);
14934        let body = body_at(&state);
14935        assert!(
14936            body.contains("passed on a commit that was not recorded"),
14937            "{body}"
14938        );
14939    }
14940
14941    #[test]
14942    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14943        let state = state_with_summary(
14944            "add retries to the client",
14945            "TITLE: feat: retries\n\nDid it.",
14946        );
14947        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14948        assert_eq!(m.title, "feat: retries");
14949        assert!(
14950            m.body.contains("<summary>Original task</summary>"),
14951            "{}",
14952            m.body
14953        );
14954        assert!(!m.body.contains("Commits under review"), "{}", m.body);
14955        assert!(
14956            m.body
14957                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14958            "{}",
14959            m.body
14960        );
14961    }
14962
14963    #[test]
14964    fn pr_message_review_bounds_a_long_english_subject() {
14965        let long = format!("feat: {}", "word ".repeat(100));
14966        let m = pr_message(&review_state(&[&long]), 'A');
14967        assert!(m.title.starts_with("feat: word"), "{}", m.title);
14968        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14969    }
14970
14971    #[test]
14972    fn pr_message_implementation_run_is_unchanged_by_review_support() {
14973        let state = state_with_summary("add retries\n\ndetails", "- did some things");
14974        let m = pr_message(&state, 'A');
14975        assert_eq!(m.title, "add retries");
14976        assert!(m.body.contains("<summary>Original task</summary>"));
14977        assert!(!m.body.contains("Commits under review"));
14978        assert_eq!(landing_subject_source(&state), state.instruction);
14979    }
14980
14981    #[test]
14982    fn review_run_squash_subject_is_the_change_not_the_prompt() {
14983        let state = review_state(&["feat: the change", "fix: typo"]);
14984        let source = landing_subject_source(&state);
14985        assert_eq!(land::merge_subject("", &source), "feat: the change");
14986        assert_eq!(
14987            land::merge_subject("magi: candidate A (uncommitted work)", &source),
14988            "feat: the change"
14989        );
14990        // An operator's rename still wins.
14991        assert_eq!(
14992            land::merge_subject("feat: renamed by hand", &source),
14993            "feat: renamed by hand"
14994        );
14995        let blank = review_state(&["日本語"]);
14996        assert!(
14997            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14998        );
14999    }
15000
15001    #[test]
15002    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
15003        let state = review_state(&["feat: the change"]);
15004        let source = landing_subject_source(&state);
15005        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
15006        assert_eq!(
15007            land::merge_subject(landing_title(&state, old), &source),
15008            "feat: the change"
15009        );
15010        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
15011        let task = state_with_summary("add retries", "");
15012        assert_eq!(landing_title(&task, old), old);
15013    }
15014
15015    #[test]
15016    fn pr_message_describes_the_change_not_the_task() {
15017        let state = state_with_summary(
15018            "今回やってほしいこと: results projector を直す",
15019            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
15020        );
15021        let m = pr_message(&state, 'A');
15022        assert_eq!(m.title, "fix(web): batch the runs list reads");
15023        assert!(
15024            m.body.starts_with("## Summary\n\n- reads run.json once"),
15025            "{}",
15026            m.body
15027        );
15028        assert!(!m.body.contains("TITLE:"), "{}", m.body);
15029        let task_at = m.body.find("今回やってほしいこと").unwrap();
15030        let details_at = m.body.find("<details>").unwrap();
15031        assert!(
15032            details_at < task_at,
15033            "the task lives inside <details>: {}",
15034            m.body
15035        );
15036        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
15037        assert!(m.body.contains("magi:candidate-a"));
15038    }
15039
15040    #[test]
15041    fn pr_message_falls_back_to_the_task_without_a_title_line() {
15042        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
15043        let m = pr_message(&state, 'A');
15044        assert_eq!(m.title, "add retries");
15045        assert!(
15046            m.body.contains("## Summary\n\n- did some things"),
15047            "{}",
15048            m.body
15049        );
15050
15051        let none = RunState::new(
15052            PathBuf::from("/repo"),
15053            "main".to_owned(),
15054            "abc1234".to_owned(),
15055            "add retries".to_owned(),
15056            Config::default(),
15057        );
15058        let m = pr_message(&none, 'A');
15059        assert_eq!(m.title, "add retries");
15060        assert!(!m.body.contains("## Summary"), "{}", m.body);
15061    }
15062
15063    #[test]
15064    fn pr_message_refuses_the_candidate_commit_subject() {
15065        for bad in [
15066            "TITLE: magi: candidate A (uncommitted work)",
15067            "TITLE: chore: stuff (uncommitted work)",
15068            "TITLE:   ",
15069        ] {
15070            let state = state_with_summary("add retries", bad);
15071            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15072        }
15073    }
15074
15075    #[test]
15076    fn pr_message_bounds_a_very_long_task_and_title() {
15077        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15078        let state = state_with_summary(&long, "- nothing");
15079        let m = pr_message(&state, 'A');
15080        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15081        assert!(!m.title.contains('\n'));
15082
15083        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15084        let m = pr_message(&state, 'A');
15085        assert!(m.title.starts_with("feat: "));
15086        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15087        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15088    }
15089
15090    fn long_title_of(instruction: &str) -> String {
15091        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15092    }
15093
15094    #[test]
15095    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15096        let first = "Make the landing path keep a readable title for long tasks";
15097        let line = format!(
15098            "{first}. {}",
15099            "And then keep going with more words ".repeat(20)
15100        );
15101        let t = long_title_of(&line);
15102        assert_eq!(t, first);
15103        assert!(!t.starts_with("chore: land"));
15104    }
15105
15106    #[test]
15107    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15108        let line = "word ".repeat(200);
15109        let t = long_title_of(&line);
15110        assert!(t.ends_with("word..."), "{t}");
15111        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15112    }
15113
15114    #[test]
15115    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15116        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15117            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15118        }
15119    }
15120
15121    #[test]
15122    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15123        let mut state = state_with_summary("再試行を追加する", "- nothing");
15124        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15125        state.config.graph.github_language = "ja".into();
15126        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15127    }
15128
15129    #[test]
15130    fn pr_message_title_limit_is_exact() {
15131        let at = "a".repeat(PR_TITLE_MAX);
15132        assert_eq!(long_title_of(&at), at);
15133        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15134        assert!(over.ends_with("..."), "{over}");
15135        assert_eq!(over.chars().count(), PR_TITLE_MAX);
15136    }
15137
15138    #[test]
15139    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15140        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15141        let t = long_title_of(&line);
15142        assert!(t.ends_with("..."), "{t}");
15143        assert!(t.is_ascii(), "{t}");
15144    }
15145
15146    #[test]
15147    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15148        let line = format!(
15149            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15150            "plus more filler words ".repeat(20)
15151        );
15152        let t = long_title_of(&line);
15153        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15154    }
15155
15156    #[test]
15157    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15158        let line = format!("\n\n# {}", "title words ".repeat(40));
15159        let state = state_with_summary(&line, "- nothing");
15160        let m = pr_message(&state, 'A');
15161        assert!(m.title.starts_with("title words"), "{}", m.title);
15162        assert_eq!(
15163            land::merge_subject(&m.title, &landing_subject_source(&state)),
15164            m.title
15165        );
15166        // An operator's rename wins untouched.
15167        assert_eq!(
15168            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15169            "feat: renamed by hand"
15170        );
15171    }
15172
15173    #[tokio::test]
15174    async fn github_text_rewrite_is_bounded_and_falls_back() {
15175        crate::run::pin_test_home();
15176        for (reply, accepted) in [
15177            (
15178                "TITLE: fix: retries\nAdd retries for failed requests.",
15179                true,
15180            ),
15181            (
15182                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15183                false,
15184            ),
15185            (
15186                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15187                true,
15188            ),
15189            (
15190                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15191                true,
15192            ),
15193            (
15194                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15195                true,
15196            ),
15197            (
15198                "TITLE: fix: retries\n日本語の説明です。 token=secret",
15199                false,
15200            ),
15201            ("TITLE: fix: retries\n", false),
15202        ] {
15203            let dir = tempfile::tempdir().unwrap();
15204            let mut runner = runner_at(RunStatus::Gating);
15205            runner.state = state_with_summary(
15206                "add retries",
15207                "TITLE: fix: retries\n日本語の説明を書きます。",
15208            );
15209            runner.state.repo = dir.path().to_owned();
15210            runner.state.candidates[0].worktree = dir.path().to_owned();
15211            let mut author = spec("alpha");
15212            author.command = vec![
15213                "sh".into(),
15214                "-c".into(),
15215                "printf '%s' \"$REWRITE_REPLY\"".into(),
15216            ];
15217            author.env.insert("REWRITE_REPLY".into(), reply.into());
15218            runner.state.config.agents = vec![author];
15219            seed_public_summary(&mut runner);
15220            let winner = runner.state.candidates[0].clone();
15221            let first = runner
15222                .guarded_pr_message(&winner, None, true)
15223                .await
15224                .unwrap();
15225            let message = if accepted {
15226                first.expect("a passing rewrite never asks")
15227            } else {
15228                // Withheld: the run parks on a question and posts nothing yet.
15229                assert!(first.is_none());
15230                assert!(runner.state.parked);
15231                let store = ask::Questions::open();
15232                let asked: Vec<_> = store
15233                    .list()
15234                    .into_iter()
15235                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15236                    .collect();
15237                assert_eq!(asked.len(), 1);
15238                assert!(!asked[0].detail.contains("secret"));
15239                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15240                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15241                assert_eq!(
15242                    asked[0]
15243                        .choices
15244                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
15245                    rewrite.is_some()
15246                );
15247                // Silence: the question lapses and the neutral text is used,
15248                // without a second rewrite call or a second question.
15249                store
15250                    .update(&asked[0].id, |q| {
15251                        q.abandon("test");
15252                        Ok(())
15253                    })
15254                    .unwrap();
15255                let again = runner
15256                    .guarded_pr_message(&winner, None, true)
15257                    .await
15258                    .unwrap()
15259                    .expect("an abandoned question falls back");
15260                let n = store
15261                    .list()
15262                    .into_iter()
15263                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15264                    .count();
15265                assert_eq!(n, 1, "asked once per rejected text");
15266                again
15267            };
15268            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15269            assert!(!message.body.contains("secret"));
15270            assert!(!message.body.contains("prefix"));
15271            assert!(!message.title.contains("secret"));
15272            assert_eq!(
15273                message.body.contains("Add retries for failed requests."),
15274                accepted
15275            );
15276            assert_eq!(
15277                runner.state.seats["github-text-rewrite"].turns, 1,
15278                "only one rewrite invocation"
15279            );
15280            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15281            if accepted {
15282                assert!(runner.state.github_text.is_none());
15283                assert!(!runner.state.parked);
15284                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15285                assert!(message.body.contains("## Final verification"));
15286                assert!(message.body.contains("Evidence: final diff"));
15287                assert!(
15288                    message
15289                        .body
15290                        .contains(&format!("magi:run/{}", runner.state.id))
15291                );
15292            }
15293            if !accepted {
15294                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15295                assert!(
15296                    message
15297                        .body
15298                        .contains(&format!("magi:run/{}", runner.state.id))
15299                );
15300            }
15301        }
15302    }
15303
15304    fn scripted(id: &str, reply: &str) -> AgentSpec {
15305        let mut a = spec(id);
15306        a.command = vec![
15307            "sh".into(),
15308            "-c".into(),
15309            "printf '%s' \"$REWRITE_REPLY\"".into(),
15310        ];
15311        a.env.insert("REWRITE_REPLY".into(), reply.into());
15312        a
15313    }
15314
15315    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15316    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15317
15318    /// Gate tests inject generated public output, not internal reports.
15319    fn seed_public_summary(runner: &mut Runner) {
15320        let repo = &runner.state.repo;
15321        let g = |args: &[&str]| {
15322            let out = std::process::Command::new("git")
15323                .args(args)
15324                .current_dir(repo)
15325                .quiet()
15326                .output()
15327                .unwrap();
15328            assert!(out.status.success(), "git {args:?}");
15329            String::from_utf8_lossy(&out.stdout).trim().to_owned()
15330        };
15331        g(&["init", "-q", "-b", "main"]);
15332        g(&[
15333            "-c",
15334            "user.name=Test",
15335            "-c",
15336            "user.email=test@example.invalid",
15337            "commit",
15338            "-q",
15339            "--allow-empty",
15340            "-m",
15341            "feat: fixture",
15342        ]);
15343        let head = g(&["rev-parse", "HEAD"]);
15344        g(&["update-ref", "refs/remotes/origin/main", &head]);
15345        let evidence = crate::pr_outcome::summary_evidence(
15346            &runner.state,
15347            crate::pr_outcome::FinalHead::At(&head),
15348        );
15349        let generated = &runner.state.candidates[0].summary;
15350        runner.state.change_summary = Some(crate::run::ChangeSummary {
15351            base: head.clone(),
15352            head,
15353            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15354            title: summary_title(generated).unwrap(),
15355            body: summary_without_title(generated),
15356        });
15357    }
15358
15359    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15360        let mut runner = runner_at(RunStatus::Gating);
15361        runner.state = state_with_summary("add retries", JA_SUMMARY);
15362        if review_only {
15363            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15364        }
15365        runner.state.repo = dir.to_owned();
15366        runner.state.candidates[0].worktree = dir.to_owned();
15367        runner.state.config.agents = agents;
15368        seed_public_summary(&mut runner);
15369        runner
15370    }
15371
15372    fn gate_questions(runner: &Runner) -> usize {
15373        ask::Questions::open()
15374            .list()
15375            .into_iter()
15376            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15377            .count()
15378    }
15379
15380    #[tokio::test]
15381    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15382        crate::run::pin_test_home();
15383        let dir = tempfile::tempdir().unwrap();
15384        let mut runner = rewrite_runner(
15385            dir.path(),
15386            true,
15387            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15388        );
15389        runner.state.config.roles.language_judge =
15390            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15391        let winner = runner.state.candidates[0].clone();
15392        let msg = runner
15393            .guarded_pr_message(&winner, None, true)
15394            .await
15395            .unwrap()
15396            .expect("an English rewrite posts without asking");
15397        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15398        assert!(msg.body.contains("<details>kept</details>"));
15399        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15400        assert_eq!(gate_questions(&runner), 0);
15401        assert!(runner.state.github_text.is_none());
15402        assert!(!runner.state.seats.contains_key("impl-A"));
15403        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15404    }
15405
15406    #[tokio::test]
15407    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15408        crate::run::pin_test_home();
15409        let dir = tempfile::tempdir().unwrap();
15410        let mut runner = rewrite_runner(
15411            dir.path(),
15412            false,
15413            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15414        );
15415        runner.state.config.roles.language_judge =
15416            Some(crate::config::AgentChoice::One("beta".into()));
15417        runner.state.seats.insert(
15418            "impl-A".into(),
15419            SeatState::new("impl-A", "alpha", runner.state.seed),
15420        );
15421        let winner = runner.state.candidates[0].clone();
15422        let msg = runner
15423            .guarded_pr_message(&winner, None, true)
15424            .await
15425            .unwrap()
15426            .expect("rewritten");
15427        assert!(msg.body.contains("<details>kept</details>"));
15428        assert_eq!(runner.state.seats["impl-A"].turns, 0);
15429        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15430        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15431    }
15432
15433    #[tokio::test]
15434    async fn rewrite_reuses_a_resumable_implementer_session() {
15435        crate::run::pin_test_home();
15436        let dir = tempfile::tempdir().unwrap();
15437        let mut runner = rewrite_runner(
15438            dir.path(),
15439            false,
15440            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15441        );
15442        runner.state.config.roles.language_judge =
15443            Some(crate::config::AgentChoice::One("beta".into()));
15444        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15445        seat.turns = 1;
15446        runner.state.seats.insert("impl-A".into(), seat);
15447        let winner = runner.state.candidates[0].clone();
15448        runner
15449            .guarded_pr_message(&winner, None, true)
15450            .await
15451            .unwrap()
15452            .expect("rewritten");
15453        assert_eq!(runner.state.seats["impl-A"].turns, 2);
15454        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15455    }
15456
15457    #[tokio::test]
15458    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15459        crate::run::pin_test_home();
15460        let dir = tempfile::tempdir().unwrap();
15461        let mut broken = spec("bad");
15462        broken.command = vec!["false".into()];
15463        let mut runner = rewrite_runner(
15464            dir.path(),
15465            true,
15466            vec![
15467                broken,
15468                scripted("noformat", "Add retries."),
15469                scripted("good", EN_REWRITE),
15470            ],
15471        );
15472        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15473            "bad".into(),
15474            "noformat".into(),
15475            "good".into(),
15476        ]));
15477        let winner = runner.state.candidates[0].clone();
15478        let msg = runner
15479            .guarded_pr_message(&winner, None, true)
15480            .await
15481            .unwrap()
15482            .expect("the third agent rewrites");
15483        assert!(msg.body.contains("`src/net.rs`"));
15484        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15485        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15486    }
15487
15488    #[tokio::test]
15489    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15490        crate::run::pin_test_home();
15491        // (agents, role): still Japanese; no TITLE; nothing resolvable.
15492        for (agents, role, saved) in [
15493            (
15494                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15495                Some("beta"),
15496                true,
15497            ),
15498            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15499            (Vec::new(), None, false),
15500        ] {
15501            let dir = tempfile::tempdir().unwrap();
15502            let mut runner = rewrite_runner(dir.path(), true, agents);
15503            runner.state.config.roles.language_judge =
15504                role.map(|r| crate::config::AgentChoice::One(r.into()));
15505            let winner = runner.state.candidates[0].clone();
15506            let first = runner
15507                .guarded_pr_message(&winner, None, true)
15508                .await
15509                .unwrap();
15510            assert!(first.is_none(), "withheld text parks the run");
15511            assert!(runner.state.parked);
15512            assert_eq!(gate_questions(&runner), 1);
15513            let g = runner.state.github_text.as_ref().unwrap();
15514            assert_eq!(g.rewrite.is_some(), saved);
15515            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15516        }
15517    }
15518
15519    #[tokio::test]
15520    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15521        crate::run::pin_test_home();
15522        let dir = tempfile::tempdir().unwrap();
15523        let mut runner = runner_at(RunStatus::Gating);
15524        runner.state =
15525            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15526        runner.state.repo = dir.path().to_owned();
15527        runner.state.candidates[0].worktree = dir.path().to_owned();
15528        let mut author = spec("alpha");
15529        author.command = vec![
15530            "sh".into(),
15531            "-c".into(),
15532            "printf '%s' \"$REWRITE_REPLY\"".into(),
15533        ];
15534        author.env.insert(
15535            "REWRITE_REPLY".into(),
15536            format!(
15537                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15538                "English sentence. ".repeat(600)
15539            ),
15540        );
15541        runner.state.config.agents = vec![author];
15542        seed_public_summary(&mut runner);
15543        let winner = runner.state.candidates[0].clone();
15544        assert!(
15545            runner
15546                .guarded_pr_message(&winner, None, true)
15547                .await
15548                .unwrap()
15549                .is_none()
15550        );
15551        let record = runner.state.github_text.clone().unwrap();
15552        let rewrite = record.rewrite.clone().unwrap();
15553        assert_eq!(rewrite.failed, ["body-language"]);
15554        assert!(
15555            rewrite
15556                .body
15557                .ends_with(&format!("magi:run/{}", runner.state.id))
15558        );
15559        let store = ask::Questions::open();
15560        let q = store.get(record.question.as_ref().unwrap()).unwrap();
15561        assert!(
15562            q.choices
15563                .contains(&crate::github_text::USE_REWRITE.to_owned())
15564        );
15565        for label in [
15566            "ORIGINAL",
15567            "FALLBACK",
15568            "REWRITE",
15569            "Rules still failed: body-language",
15570            "truncated",
15571        ] {
15572            assert!(q.detail.contains(label), "{label}");
15573        }
15574        let artifact = std::fs::read_to_string(crate::run::artifact_path(
15575            &runner.state,
15576            &crate::github_text::artifact_name(&record.fingerprint),
15577        ))
15578        .unwrap();
15579        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15580        // Restart from serialized state, then retry after a bad owner title.
15581        runner.state =
15582            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15583        store
15584            .update(&q.id, |q| {
15585                q.say("まだ日本語")?;
15586                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15587            })
15588            .unwrap();
15589        assert!(
15590            runner
15591                .guarded_pr_message(&winner, None, true)
15592                .await
15593                .unwrap()
15594                .is_none()
15595        );
15596        let retry = runner.state.github_text.as_ref().unwrap();
15597        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15598        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15599        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15600        assert!(
15601            q.posting_comparison
15602                .as_ref()
15603                .unwrap()
15604                .matches_detail(&q.detail)
15605        );
15606        store
15607            .update(&q.id, |q| {
15608                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15609            })
15610            .unwrap();
15611        let message = runner
15612            .guarded_pr_message(&winner, None, true)
15613            .await
15614            .unwrap()
15615            .unwrap();
15616        assert_eq!(message.title, rewrite.title);
15617        assert_eq!(message.body, rewrite.body);
15618        assert_eq!(
15619            runner.state.github_text.as_ref().unwrap().choice,
15620            Some(GithubTextChoice::Rewrite)
15621        );
15622        let again = runner
15623            .guarded_pr_message(&winner, None, true)
15624            .await
15625            .unwrap()
15626            .unwrap();
15627        assert_eq!(again.title, message.title);
15628        assert_eq!(again.body, message.body);
15629        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15630        assert!(
15631            !runner
15632                .state
15633                .events
15634                .iter()
15635                .any(|e| e.message.contains(&rewrite.title)
15636                    || e.message.contains("English sentence."))
15637        );
15638    }
15639
15640    #[tokio::test]
15641    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15642        crate::run::pin_test_home();
15643        let dir = tempfile::tempdir().unwrap();
15644        let mut runner = runner_at(RunStatus::Gating);
15645        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15646        runner.state.repo = dir.path().to_owned();
15647        runner.state.candidates[0].worktree = dir.path().to_owned();
15648        seed_public_summary(&mut runner);
15649        let winner = runner.state.candidates[0].clone();
15650        assert!(
15651            runner
15652                .guarded_pr_message(&winner, None, true)
15653                .await
15654                .unwrap()
15655                .is_none()
15656        );
15657        let record = runner.state.github_text.as_ref().unwrap();
15658        assert!(record.rewrite.is_none());
15659        // Records written before the additive field deserialize with no rewrite.
15660        let mut old = serde_json::to_value(record).unwrap();
15661        old.as_object_mut().unwrap().remove("rewrite");
15662        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15663        assert!(old.rewrite.is_none());
15664        let id = record.question.clone().unwrap();
15665        ask::Questions::open()
15666            .update(&id, |q| {
15667                q.choices.push(crate::github_text::USE_REWRITE.into());
15668                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15669            })
15670            .unwrap();
15671        let message = runner
15672            .guarded_pr_message(&winner, None, true)
15673            .await
15674            .unwrap()
15675            .unwrap();
15676        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15677        assert_eq!(
15678            runner.state.github_text.as_ref().unwrap().choice,
15679            Some(GithubTextChoice::Fallback)
15680        );
15681    }
15682
15683    #[tokio::test]
15684    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15685        crate::run::pin_test_home();
15686        let dir = tempfile::tempdir().unwrap();
15687        let mut runner = runner_at(RunStatus::Gating);
15688        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15689        runner.state.repo = dir.path().to_owned();
15690        runner.state.candidates[0].worktree = dir.path().to_owned();
15691        seed_public_summary(&mut runner);
15692        let winner = runner.state.candidates[0].clone();
15693        assert!(
15694            runner
15695                .guarded_pr_message(&winner, None, true)
15696                .await
15697                .unwrap()
15698                .is_none()
15699        );
15700        let store = ask::Questions::open();
15701        let find = |run: &str| {
15702            store
15703                .list()
15704                .into_iter()
15705                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15706                .unwrap()
15707        };
15708        let run = runner.state.id.clone();
15709        let q = find(&run);
15710        let fp = runner
15711            .state
15712            .github_text
15713            .as_ref()
15714            .unwrap()
15715            .fingerprint
15716            .clone();
15717        let name = crate::github_text::artifact_name(&fp);
15718        let path = crate::run::artifact_path(&runner.state, &name);
15719        assert!(q.detail.contains(&path.display().to_string()));
15720        assert!(q.detail.contains("title-language: title"));
15721        let saved = std::fs::read_to_string(&path).expect("artifact written");
15722        assert!(saved.contains("日本語のタイトル"));
15723        assert!(q.cwd.is_none());
15724        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15725        // A bad replacement is refused and asked about once more.
15726        let say = |id: &str, text: &str, choice: &str| {
15727            store
15728                .update(id, |q| {
15729                    q.thread.push(ask::Turn {
15730                        who: ask::Who::Operator,
15731                        body: text.to_owned(),
15732                        at: jiff::Timestamp::now(),
15733                        note: None,
15734                    });
15735                    q.answer(ask::Answer::Choice(choice.to_owned()))
15736                })
15737                .unwrap();
15738        };
15739        say(&q.id, "まだ日本語", "use my text");
15740        assert!(
15741            runner
15742                .guarded_pr_message(&winner, None, true)
15743                .await
15744                .unwrap()
15745                .is_none()
15746        );
15747        let second = find(&run);
15748        assert_ne!(second.id, q.id);
15749        assert!(second.detail.contains("did not pass"));
15750        // The retry still compares the original text; the rejected
15751        // replacement is named by category only.
15752        assert!(second.detail.contains("日本語のタイトル"));
15753        assert!(!second.detail.contains("まだ日本語"));
15754        assert!(second.detail.contains(&path.display().to_string()));
15755        say(&second.id, "fix: retry failed requests", "use my text");
15756        let message = runner
15757            .guarded_pr_message(&winner, None, true)
15758            .await
15759            .unwrap()
15760            .expect("a vetted replacement is posted");
15761        assert_eq!(message.title, "fix: retry failed requests");
15762        assert_eq!(
15763            runner
15764                .state
15765                .github_text
15766                .as_ref()
15767                .unwrap()
15768                .chosen_title
15769                .as_deref(),
15770            Some("fix: retry failed requests")
15771        );
15772        assert!(runner.state.github_text.as_ref().unwrap().resolved);
15773        // Resumed again: the saved decision is applied, nothing is re-asked.
15774        let again = runner
15775            .guarded_pr_message(&winner, None, true)
15776            .await
15777            .unwrap()
15778            .unwrap();
15779        assert_eq!(again.title, message.title);
15780        assert_eq!(
15781            store
15782                .list()
15783                .into_iter()
15784                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15785                .count(),
15786            2
15787        );
15788    }
15789
15790    #[test]
15791    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15792        // What magi itself writes stays English under any configured language,
15793        // so a future localisation of these headings fails here. (The agents'
15794        // own text is also checked by the posting gate.)
15795        let mut state = state_with_summary(
15796            "add retries",
15797            "TITLE: fix(web): batch reads\n- reads run.json once",
15798        );
15799        state.config.graph.language = "ja".to_owned();
15800        let m = pr_message(&state, 'A');
15801        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15802        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15803
15804        // The task is the operator's own text: it goes in untouched, and the
15805        // fallback title (no summary) may be in its language too.
15806        let task = "今回やってほしいこと: results projector を直す";
15807        let mut state = state_with_summary(task, "- no title line");
15808        state.config.graph.language = "ja".to_owned();
15809        let m = pr_message(&state, 'A');
15810        assert_eq!(
15811            m.title,
15812            format!("chore: land candidate A of run {}", state.id)
15813        );
15814        assert!(
15815            m.body.contains(&format!(
15816                "<summary>Original task</summary>\n\n{task}\n\n</details>"
15817            )),
15818            "{}",
15819            m.body
15820        );
15821    }
15822
15823    #[test]
15824    fn pr_message_scrubs_home_paths_and_addresses() {
15825        let state = state_with_summary(
15826            "fix it in /Users/someone/src/x",
15827            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15828        );
15829        let m = pr_message(&state, 'A');
15830        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15831            assert!(!m.body.contains(leak), "{}", m.body);
15832        }
15833        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15834    }
15835
15836    #[test]
15837    fn pr_message_survives_a_task_that_closes_details() {
15838        let state = state_with_summary("a </details> b", "TITLE: fix: x");
15839        let m = pr_message(&state, 'A');
15840        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15841    }
15842
15843    #[test]
15844    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15845        let cmd = manual_merge_command(
15846            MergeStyle::Squash,
15847            Path::new("/repo"),
15848            "b",
15849            "fix: \"quoted\" $(x) `y`\n\nbody",
15850        );
15851        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15852    }
15853
15854    #[test]
15855    fn manual_merge_command_matches_the_configured_style() {
15856        let repo = Path::new("/repo");
15857        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15858
15859        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15860        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15861
15862        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15863        assert_eq!(
15864            squash,
15865            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15866             \"Merge magi run 0832 (candidate A)\""
15867        );
15868
15869        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15870        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15871    }
15872
15873    #[test]
15874    fn a_nudge_gets_a_quarter_of_the_budget() {
15875        // The judge and implement budgets magi ships with.
15876        assert_eq!(retry_budget(secs(1200), true), secs(300));
15877        assert_eq!(retry_budget(secs(3600), true), secs(900));
15878    }
15879
15880    #[test]
15881    fn a_resent_prompt_keeps_the_whole_budget() {
15882        // The seat kept no context, so the retry is the original job again and
15883        // shortening it would only guarantee a second failure.
15884        assert_eq!(retry_budget(secs(1200), false), secs(1200));
15885        assert_eq!(retry_budget(secs(60), false), secs(60));
15886    }
15887
15888    #[test]
15889    fn the_floor_never_exceeds_the_original_budget() {
15890        // A short configured timeout must not be *raised* by the floor: the
15891        // operator asked for a bound, and a retry may not outlast the attempt
15892        // it is retrying.
15893        assert_eq!(retry_budget(secs(60), true), secs(60));
15894        assert_eq!(retry_budget(secs(480), true), secs(120));
15895        assert_eq!(retry_budget(secs(0), true), secs(0));
15896    }
15897
15898    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15899        agent::CommandEvidence {
15900            id: "item1".to_owned(),
15901            description: "cargo test".to_owned(),
15902            exit_code,
15903            result_summary: String::new(),
15904            source: "codex".to_owned(),
15905        }
15906    }
15907
15908    #[test]
15909    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15910        // No evidence is not the same fact as unconfirmed evidence: a
15911        // backend with no adapter, or a reply that ran no commands at all,
15912        // must not be misread as carrying a dangling job.
15913        assert!(!has_unconfirmed_command(&[]));
15914    }
15915
15916    #[test]
15917    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15918        // Deliberately not a check on the exit code's *value*: a fixer
15919        // legitimately runs something that fails mid-iteration before it
15920        // succeeds, and that must never by itself reopen a valid report.
15921        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15922        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15923        assert!(!has_unconfirmed_command(&[
15924            evidence(Some(0)),
15925            evidence(Some(101))
15926        ]));
15927    }
15928
15929    #[test]
15930    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15931        assert!(has_unconfirmed_command(&[
15932            evidence(Some(0)),
15933            evidence(None)
15934        ]));
15935    }
15936
15937    #[test]
15938    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15939        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15940        assert_eq!(
15941            verified_noop_claim(true, &[], text).as_deref(),
15942            Some("already fixed by b32cfc4, on main.")
15943        );
15944    }
15945
15946    #[test]
15947    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15948        // A timeout or a bad exit code reads as the ordinary loss it is,
15949        // whatever the reply's own prose claims.
15950        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15951        assert!(verified_noop_claim(false, &[], text).is_none());
15952    }
15953
15954    #[test]
15955    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15956        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15957        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15958        // A confirmed command alongside the marker is fine.
15959        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15960    }
15961
15962    #[test]
15963    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15964        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15965    }
15966
15967    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
15968    /// pair, in order, labelled A, B, C, ...
15969    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15970        runner.state.candidates = shape
15971            .iter()
15972            .enumerate()
15973            .map(|(i, &(empty, verified))| Candidate {
15974                index: i,
15975                label: (b'A' + i as u8) as char,
15976                agent: "sonnet".to_owned(),
15977                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15978                worktree: PathBuf::from(format!("/wt/{i}")),
15979                summary: String::new(),
15980                stat: String::new(),
15981                files: 0,
15982                commits: 0,
15983                empty,
15984                failed: None,
15985                verified_noop: verified.map(str::to_owned),
15986                duration_ms: 0,
15987                folded: false,
15988            })
15989            .collect();
15990    }
15991
15992    #[test]
15993    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15994        ask_test_home();
15995        let mut runner = runner_at(RunStatus::Implementing);
15996        set_candidates(
15997            &mut runner,
15998            &[
15999                (true, Some("already on main at b32cfc4")),
16000                (true, Some("same fix, see the existing test")),
16001            ],
16002        );
16003
16004        runner
16005            .after_implement()
16006            .expect("a verified no-op is not an error");
16007
16008        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
16009    }
16010
16011    #[test]
16012    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
16013        ask_test_home();
16014        let mut runner = runner_at(RunStatus::Implementing);
16015        // Candidate A declares a verified no-op; candidate B simply wrote
16016        // nothing and said nothing about why. One candidate's claim is not
16017        // the whole run's agreement.
16018        set_candidates(
16019            &mut runner,
16020            &[(true, Some("already on main at b32cfc4")), (true, None)],
16021        );
16022
16023        let err = runner
16024            .after_implement()
16025            .expect_err("an unverified empty candidate must still fail the run");
16026
16027        assert!(
16028            err.to_string().contains("no candidate produced a change"),
16029            "{err}"
16030        );
16031        assert_eq!(runner.state.status, RunStatus::Failed);
16032    }
16033
16034    #[test]
16035    fn after_implement_still_fails_an_ordinary_all_empty_run() {
16036        ask_test_home();
16037        let mut runner = runner_at(RunStatus::Implementing);
16038        set_candidates(&mut runner, &[(true, None), (true, None)]);
16039
16040        let err = runner
16041            .after_implement()
16042            .expect_err("no candidate declared anything; this is an ordinary failure");
16043
16044        assert!(
16045            err.to_string().contains("no candidate produced a change"),
16046            "{err}"
16047        );
16048        assert_eq!(runner.state.status, RunStatus::Failed);
16049    }
16050}
16051
16052/// A graft rollback whose restore could not be confirmed (see
16053/// [`Runner::graft`]).
16054#[derive(Debug)]
16055struct GraftRestoreFailed(String);
16056
16057impl std::fmt::Display for GraftRestoreFailed {
16058    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16059        write!(
16060            f,
16061            "could not restore the winner to its pre-graft commit: {}",
16062            self.0
16063        )
16064    }
16065}
16066
16067impl std::error::Error for GraftRestoreFailed {}
16068
16069/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
16070/// not the agent: they never draw on an agent's own timeout, and running out of
16071/// them never reads as an agent failure.
16072#[cfg(test)]
16073mod resource_wait_tests {
16074    use super::*;
16075    use std::collections::BTreeMap;
16076
16077    fn sh(id: &str, script: &str) -> AgentSpec {
16078        AgentSpec {
16079            id: id.to_owned(),
16080            kind: crate::config::AgentKind::Command,
16081            model: None,
16082            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16083            extra_args: Vec::new(),
16084            env: BTreeMap::new(),
16085            prompt_delivery: None,
16086        }
16087    }
16088
16089    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16090        SeatJob {
16091            seat: SeatState::new("impl-A", &spec.id, 1),
16092            spec,
16093            cwd: dir.to_path_buf(),
16094            prompt: "do it".to_owned(),
16095            timeout,
16096            allow_write: true,
16097            sessions: false,
16098            artifacts: dir.join("art"),
16099            stem: "impl-A".to_owned(),
16100            handover: None,
16101        }
16102    }
16103
16104    fn state(resource_wait_secs: u64) -> RunState {
16105        let mut config = Config::default();
16106        config.graph.timeout_resource_wait = resource_wait_secs;
16107        RunState::new(
16108            PathBuf::from("/repo"),
16109            "main".to_owned(),
16110            "abc1234".to_owned(),
16111            "task".to_owned(),
16112            config,
16113        )
16114    }
16115
16116    fn hold(cache: &Path) -> crate::cache::Guard {
16117        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16118        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16119            crate::cache::AcquireOutcome::Acquired(g) => g,
16120            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16121        }
16122    }
16123
16124    async fn run(
16125        jobs: Vec<SeatJob>,
16126        sem: Arc<Semaphore>,
16127        cache: Option<&Path>,
16128        st: &mut RunState,
16129    ) -> Vec<(usize, SeatState, AgentOutcome)> {
16130        let prompts = Prompts::default();
16131        let ctx = WaveCtx {
16132            run: "r",
16133            node: "implement",
16134            prompts: &prompts,
16135            cache,
16136            round: None,
16137            carry_seats: false,
16138            pauses: None,
16139        };
16140        wave(jobs, sem, &ctx, st, 0).await
16141    }
16142
16143    #[tokio::test]
16144    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16145        crate::run::pin_test_home();
16146        let tmp = tempfile::tempdir().unwrap();
16147        let cache = tmp.path().join("target-a");
16148        let guard = hold(&cache);
16149        // Held for longer than the agent's whole 1s budget.
16150        tokio::spawn(async move {
16151            tokio::time::sleep(Duration::from_millis(1500)).await;
16152            guard.release();
16153        });
16154        let mut st = state(60);
16155        let results = run(
16156            vec![job(
16157                sh("alpha", "echo ok"),
16158                tmp.path(),
16159                Duration::from_secs(1),
16160            )],
16161            Arc::new(Semaphore::new(1)),
16162            Some(&cache),
16163            &mut st,
16164        )
16165        .await;
16166        assert!(
16167            matches!(results[0].2, AgentOutcome::Ok(_)),
16168            "the wait must not be charged to the agent"
16169        );
16170        assert!(st.resource_block.is_none());
16171        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16172        assert!(st.resource_wait.is_none() && st.active.is_empty());
16173    }
16174
16175    #[tokio::test]
16176    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16177        crate::run::pin_test_home();
16178        let tmp = tempfile::tempdir().unwrap();
16179        let cache = tmp.path().join("target-b");
16180        let _held = hold(&cache);
16181        let mut st = state(1);
16182        let results = run(
16183            vec![job(
16184                sh("alpha", "echo ok"),
16185                tmp.path(),
16186                Duration::from_secs(1),
16187            )],
16188            Arc::new(Semaphore::new(1)),
16189            Some(&cache),
16190            &mut st,
16191        )
16192        .await;
16193        let (_, seat, out) = &results[0];
16194        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16195        assert!(wave_blocked(&results));
16196        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16197        assert_eq!(seat.turns, 0, "nothing was asked");
16198        assert!(
16199            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16200            "no agent was spawned"
16201        );
16202        assert!(FailClass::of(out).is_none(), "not a failure class");
16203        let block = st.resource_block.as_ref().expect("recorded for the resume");
16204        assert_eq!(block.kind, "shared build cache");
16205        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16206        assert!(st.active.is_empty());
16207    }
16208
16209    #[tokio::test]
16210    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16211        crate::run::pin_test_home();
16212        let tmp = tempfile::tempdir().unwrap();
16213        let mut st = state(1);
16214        let results = run(
16215            vec![job(
16216                sh("alpha", "echo ok"),
16217                tmp.path(),
16218                Duration::from_secs(1),
16219            )],
16220            Arc::new(Semaphore::new(0)),
16221            None,
16222            &mut st,
16223        )
16224        .await;
16225        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16226        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16227        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16228    }
16229
16230    #[tokio::test]
16231    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16232        crate::run::pin_test_home();
16233        let tmp = tempfile::tempdir().unwrap();
16234        let mut st = state(60);
16235        let results = run(
16236            vec![job(
16237                sh("alpha", "sleep 30"),
16238                tmp.path(),
16239                Duration::from_secs(1),
16240            )],
16241            Arc::new(Semaphore::new(1)),
16242            None,
16243            &mut st,
16244        )
16245        .await;
16246        let out = &results[0].2;
16247        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16248        let class = FailClass::of(out).expect("a failure");
16249        assert_eq!(class, FailClass::Timeout);
16250        assert!(should_hand_over(None, &class), "existing handover policy");
16251        assert!(st.resource_block.is_none());
16252    }
16253}