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/// Cache acquisition has its own bound, `timeout_resource_wait`, just as
9876/// agent waves do. Contention must not consume the verification budget:
9877/// `body` receives the full `budget` after the cache is acquired and fresh.
9878/// Without a shared cache this is a pass-through with the same full budget.
9879///
9880/// A lease that cannot be acquired within the resource wait is reported as a single
9881/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
9882/// verification — the same shape a spawn failure already takes in
9883/// [`run_commands`], so a caller need not special-case it.
9884#[allow(clippy::too_many_arguments)]
9885async fn with_cache_lease<'s, F, Fut>(
9886    state: &'s mut RunState,
9887    cache_dir: Option<&Path>,
9888    node: &str,
9889    seat: &str,
9890    worktree: &Path,
9891    head: &str,
9892    budget: Duration,
9893    context: &str,
9894    body: F,
9895) -> (Vec<CommandOutcome>, bool)
9896where
9897    F: FnOnce(&'s mut RunState, Duration) -> Fut,
9898    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9899{
9900    let Some(cache_dir) = cache_dir else {
9901        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9902        return (outcomes, retried);
9903    };
9904    let home = crate::run::home();
9905    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9906    let started = Instant::now();
9907    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
9908    let guard =
9909        match acquire_cache_lease(state, cache_dir, &owner, resource_limit, 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 (outcomes, retried, timed_out_pids) = body(state, budget).await;
9963    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
9964    // `start_kill`); confirm it actually has before handing the directory to
9965    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
9966    // for what this can and cannot see.
9967    if !timed_out_pids.is_empty() {
9968        wait_for_timed_out_children_to_die(&timed_out_pids).await;
9969    }
9970    guard.release();
9971    (outcomes, retried)
9972}
9973
9974/// Poll `pids` — commands [`run_commands`] reports as still running when its
9975/// own timeout elapsed — until every one is confirmed gone, or
9976/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
9977///
9978/// Real confirmation where confirmation is possible, not a substitute for
9979/// full process-tree observation: a grandchild the timed-out process spawned
9980/// and that survives independently of it is invisible to a pid check the
9981/// same way it always was, and continuing to observe and collect *that*
9982/// stays a different piece of work with its own owner. This only narrows a
9983/// fixed blind wait into an actual check of the pids this process does know
9984/// about.
9985async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9986    wait_for_pids_with(
9987        pids,
9988        crate::proc::pid_alive,
9989        LEASE_RELEASE_POLL,
9990        LEASE_RELEASE_MAX_WAIT,
9991    )
9992    .await;
9993}
9994
9995/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
9996/// interval and ceiling supplied by the caller, so the polling *logic* -
9997/// returns as soon as every pid reports dead, gives up at the ceiling
9998/// otherwise - is testable on millisecond durations without asking the real
9999/// OS about a pid at all.
10000async fn wait_for_pids_with<F: Fn(u32) -> bool>(
10001    pids: &[u32],
10002    alive: F,
10003    poll: Duration,
10004    max_wait: Duration,
10005) {
10006    let deadline = Instant::now() + max_wait;
10007    loop {
10008        if pids.iter().all(|&pid| !alive(pid)) {
10009            return;
10010        }
10011        if Instant::now() >= deadline {
10012            return;
10013        }
10014        tokio::time::sleep(poll).await;
10015    }
10016}
10017
10018/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
10019/// admission that it could not even get a verify command to run, as opposed
10020/// to evidence the command actually produced? A caller that would otherwise
10021/// read a resource-blocked outcome as a red command must check this first:
10022/// see [`Runner::gate`], which retries rather than records `Blocked` when
10023/// this is true.
10024fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
10025    outcomes.iter().any(|o| o.resource_blocked)
10026}
10027
10028/// What [`Runner::gate_fix_round`] decided.
10029enum GateFix {
10030    /// The tree changed and `verify.e2e` is still green: run the gate again.
10031    Retry,
10032    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
10033    /// last failure stands and the run ends blocked.
10034    Stop,
10035    /// `verify.e2e` could not run after the fix (magi's own contention):
10036    /// decide nothing now, a later reentry retries.
10037    Defer,
10038}
10039
10040/// Is every red command in `outcomes` an ordinary failure the code could
10041/// explain: it ran, exited non-zero, and said something?
10042///
10043/// A timeout, a spawn failure and a killed process all leave `code` `None`;
10044/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
10045/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
10046/// what the command is stays the gate's business.
10047fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
10048    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
10049    red.peek().is_some()
10050        && red.all(|o| {
10051            !o.resource_blocked
10052                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
10053                && !o.output_tail.trim().is_empty()
10054        })
10055}
10056
10057/// Describe one verify command's outcome for the event log, distinguishing a
10058/// build/link failure — the toolchain never produced a binary to run — from
10059/// an actual test failure, since only the latter is a verdict on the patch.
10060fn e2e_outcome_label(o: &CommandOutcome) -> String {
10061    if o.ok() {
10062        return "pass".to_owned();
10063    }
10064    let reason = if o.build_failed() {
10065        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10066    } else {
10067        format!("FAIL ({:?})", o.code)
10068    };
10069    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10070}
10071
10072/// Run `verify.e2e`, retrying once if the first attempt could not build or
10073/// link — a build/link failure is frequently a race against a shared
10074/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
10075/// `verify` event per command, tagged with `context` (normally `"round N"`)
10076/// so the two call sites that need this — the ordinary per-round leg in
10077/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
10078/// it will ever call a round green — read identically in the event log.
10079async fn run_e2e_with_retry(
10080    state: &mut RunState,
10081    shell: &[String],
10082    commands: &[String],
10083    worktree: &Path,
10084    timeout: Duration,
10085    context: &str,
10086) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10087    let (mut e2e, mut timed_out_pids) = run_commands(
10088        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10089    )
10090    .await;
10091    for o in &e2e {
10092        state.event(
10093            "verify",
10094            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10095        );
10096    }
10097    // A build/link failure is not a verdict on the patch — it is frequently a
10098    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
10099    // one retry before letting a red like that decide the round.
10100    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10101    if verify_retried {
10102        state.event(
10103            "verify",
10104            format!(
10105                "{context}: verify could not build/link, not a test result — retrying once \
10106                 before concluding"
10107            ),
10108        );
10109        let retried = run_commands(
10110            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10111        )
10112        .await;
10113        e2e = retried.0;
10114        // Both attempts' timeouts matter, not just the last one: the first
10115        // attempt's descendants may still be alive alongside the retry's.
10116        timed_out_pids.extend(retried.1);
10117        for o in &e2e {
10118            state.event(
10119                "verify",
10120                format!(
10121                    "{context}: retry `{}` -> {}",
10122                    o.command,
10123                    e2e_outcome_label(o)
10124                ),
10125            );
10126        }
10127    }
10128    (e2e, verify_retried, timed_out_pids)
10129}
10130
10131/// Run configured shell commands in `cwd`, in order. The second element is
10132/// the pid of every command that hit `timeout` and was still running when
10133/// this stopped waiting on it (best-effort: `None` when the platform did not
10134/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
10135/// that releases a shared resource afterward needs to know.
10136///
10137/// Records `task` into [`RunState::active`] at every command boundary
10138/// (`RunState::task_command`) and clears it once the whole list has run
10139/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
10140/// for minutes with no seat and no output of its own to show for it (see
10141/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
10142/// apart from "not yet run" without this), and this is the only place that
10143/// knows which command is running right now and how many are left. Three
10144/// saves per command — start, not per second — matching the same "only at a
10145/// boundary" rule [`wave`] already follows for seats.
10146#[allow(clippy::too_many_arguments)]
10147async fn run_commands(
10148    state: &mut RunState,
10149    node: &str,
10150    task: &str,
10151    attempt: usize,
10152    shell: &[String],
10153    commands: &[String],
10154    cwd: &Path,
10155    timeout: Duration,
10156) -> (Vec<CommandOutcome>, Vec<u32>) {
10157    if commands.is_empty() {
10158        // Nothing to mark as running and nothing to clear — an empty list
10159        // means "not configured", and touching `active` (or the disk) over
10160        // that would be a write for every round of a repo with no
10161        // `verify.e2e` / `verify.gate` commands at all.
10162        return (Vec::new(), Vec::new());
10163    }
10164    let mut out = Vec::new();
10165    let mut timed_out_pids = Vec::new();
10166    let total = commands.len();
10167    for (idx, command) in commands.iter().enumerate() {
10168        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10169        if let Err(e) = state.save() {
10170            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10171        }
10172        let started = Instant::now();
10173        let mut cmd = tokio::process::Command::new(&shell[0]);
10174        cmd.quiet();
10175        cmd.args(&shell[1..])
10176            .arg(command)
10177            .current_dir(cwd)
10178            .stdin(std::process::Stdio::null())
10179            .stdout(std::process::Stdio::piped())
10180            .stderr(std::process::Stdio::piped())
10181            .kill_on_drop(true);
10182        let spawned = cmd.spawn();
10183        let (code, body) = match spawned {
10184            Ok(child) => {
10185                // Captured before the child is consumed below: `kill_on_drop`
10186                // only *asks* the process to die when the timeout branch
10187                // drops it, and the pid is the only way anyone downstream can
10188                // later check whether that request actually took.
10189                let pid = child.id();
10190                match tokio::time::timeout(timeout, child.wait_with_output()).await {
10191                    Ok(Ok(o)) => {
10192                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10193                        body.push_str(&String::from_utf8_lossy(&o.stderr));
10194                        (o.status.code(), body)
10195                    }
10196                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
10197                    Err(_) => {
10198                        if let Some(pid) = pid {
10199                            timed_out_pids.push(pid);
10200                        }
10201                        (None, format!("timed out after {}s", timeout.as_secs()))
10202                    }
10203                }
10204            }
10205            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10206        };
10207        out.push(CommandOutcome {
10208            command: command.clone(),
10209            code,
10210            output_tail: tail(&body, OUTPUT_TAIL),
10211            duration_ms: started.elapsed().as_millis() as u64,
10212            resource_blocked: false,
10213        });
10214    }
10215    state.task_finished(task);
10216    if let Err(e) = state.save() {
10217        tracing::warn!("could not persist the end of {task}: {e:#}");
10218    }
10219    (out, timed_out_pids)
10220}
10221
10222/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
10223/// section (`report::run`) and in the `merge` event this node records — for
10224/// the operator to run by hand.
10225///
10226/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
10227/// branch whose ruleset forbids merge commits (GitHub's "must not contain
10228/// merge commits", or "require linear history") rejects the push a `--no-ff`
10229/// merge would produce, which is exactly the guidance this function replaces.
10230/// `message`'s first line becomes the squash commit's subject, matching the
10231/// note `report::run` prints alongside this command — see that function for
10232/// why an explicit subject is not optional there.
10233fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10234    let repo = repo.display();
10235    match style {
10236        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10237        MergeStyle::Squash => {
10238            // The subject sits inside double quotes, and a title an agent
10239            // wrote may carry the characters that break out of them.
10240            let subject = message
10241                .lines()
10242                .next()
10243                .unwrap_or(branch)
10244                .replace(['\\', '"', '$', '`'], "");
10245            format!(
10246                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10247            )
10248        }
10249        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10250    }
10251}
10252
10253/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
10254/// under the hood, rejects a `title` over 256 characters and the whole
10255/// command fails — no PR at all, for a run whose body was otherwise fine
10256/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
10257/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
10258/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
10259/// this bound. It is a margin, not a guarantee — a title packed
10260/// with multi-unit characters could still in principle land close to the
10261/// edge, but a real task title's occasional emoji or accented letter fits
10262/// comfortably inside it.
10263const PR_TITLE_MAX: usize = 240;
10264
10265/// A pull request title from the opening line of `text`, or `None` when that
10266/// line is not English (GitHub text is) or has no letters.
10267///
10268/// A line within `max` is kept as is. A longer one is cut at the end of its
10269/// first sentence when that falls inside `max`, else at a word boundary with a
10270/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
10271/// make every merely-truncated title look non-English). The language check
10272/// runs on the kept text before any mark is added, so only what GitHub will
10273/// show is judged: an English opening followed by non-ASCII far past the cut
10274/// still passes.
10275fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10276    let line = queue::first_line(text)?;
10277    let chars: Vec<char> = line.chars().collect();
10278    let (kept, mark) = if chars.len() <= max {
10279        (line.to_owned(), "")
10280    } else if let Some(end) = sentence_end(&chars, max) {
10281        (chars[..end].iter().collect::<String>(), "")
10282    } else {
10283        let room = max.saturating_sub(3);
10284        // Cut at the last space inside the room; when the char just past the
10285        // room is a space the room already ends on a word.
10286        let cut = if chars[room].is_whitespace() {
10287            room
10288        } else {
10289            chars[..room]
10290                .iter()
10291                .rposition(|c| c.is_whitespace())
10292                .unwrap_or(room)
10293        };
10294        let head: String = chars[..cut].iter().collect();
10295        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10296        (head.to_owned(), "...")
10297    };
10298    if kept.is_empty() {
10299        return None;
10300    }
10301    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10302        return None;
10303    }
10304    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10305        return None;
10306    }
10307    Some(format!("{kept}{mark}"))
10308}
10309
10310/// The char length of the first sentence of `chars` when it ends within `max`
10311/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
10312/// abbreviations so `e.g. foo` does not end a title early.
10313fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10314    const MIN: usize = 20;
10315    for i in MIN..max.min(chars.len()) {
10316        if !matches!(chars[i], '.' | '!' | '?') {
10317            continue;
10318        }
10319        let Some(&next) = chars.get(i + 1) else {
10320            continue;
10321        };
10322        if !next.is_whitespace() {
10323            continue;
10324        }
10325        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10326        if after.is_some_and(|c| c.is_ascii_lowercase()) {
10327            continue;
10328        }
10329        let word: String = chars[..i]
10330            .iter()
10331            .rev()
10332            .take_while(|c| !c.is_whitespace())
10333            .collect::<Vec<_>>()
10334            .into_iter()
10335            .rev()
10336            .collect();
10337        let word = word.to_ascii_lowercase();
10338        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10339            continue;
10340        }
10341        let end = chars[..i]
10342            .iter()
10343            .rposition(|c| !c.is_whitespace())
10344            .map_or(i, |p| p + 1);
10345        return Some(end);
10346    }
10347    None
10348}
10349
10350/// What `merge = "pr"` (and the merge commit of the other modes) says about a
10351/// change: a title and a body describing what was *implemented*, not the task
10352/// that asked for it. A task reads as a request; a reader of the merged
10353/// history wants the change.
10354struct PrMessage {
10355    title: String,
10356    body: String,
10357}
10358
10359impl PrMessage {
10360    /// Title, blank line, body. The first line is the squash/merge commit
10361    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
10362    /// has to stay one sensible line.
10363    fn commit_message(&self) -> String {
10364        format!("{}\n\n{}", self.title, self.body)
10365    }
10366}
10367
10368/// The text after a leading `TITLE:` (any case) on `line`.
10369fn title_marker(line: &str) -> Option<&str> {
10370    let line = line.trim();
10371    let head = line.get(..6)?;
10372    head.eq_ignore_ascii_case("title:")
10373        .then(|| line[6..].trim())
10374}
10375
10376/// The implementer's own one-line title: the `TITLE:` line the implement
10377/// prompt asks for at the top of its SUMMARY. Candidate commits are all
10378/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
10379/// source, and a title that says as much is refused here too.
10380fn summary_title(summary: &str) -> Option<String> {
10381    let first = summary.lines().find(|l| !l.trim().is_empty())?;
10382    let raw = title_marker(first)?;
10383    if raw.is_empty() {
10384        return None;
10385    }
10386    let title = queue::title_from(raw, PR_TITLE_MAX);
10387    let lower = title.to_ascii_lowercase();
10388    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10389        return None;
10390    }
10391    Some(title)
10392}
10393
10394/// How `open_review`'s instruction begins; see [`landing_title`].
10395const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10396
10397/// Marker `open_review` gives a candidate that nothing in the roster wrote.
10398pub(crate) const EXISTING_BRANCH: &str = "(existing branch)";
10399
10400/// Does this run review work that already existed, rather than implement a
10401/// task? Runs recorded before `reviewed_commits` existed carry only the
10402/// candidate marker.
10403fn is_review_run(state: &RunState) -> bool {
10404    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10405}
10406
10407/// The title of a review-only run: the subject of the oldest commit under
10408/// review. Later commits are usually fixups, and `instruction` is the review
10409/// prompt, which says nothing about the change. GitHub text is English, so a
10410/// non-ASCII or blank subject yields `None` and the caller's neutral title.
10411fn review_title(state: &RunState) -> Option<String> {
10412    english_subject(
10413        state.reviewed_commits.as_ref()?.first()?,
10414        state.config.graph.github_english(),
10415    )
10416}
10417
10418/// `raw` as a pull request title, or `None` when it is blank, not English
10419/// (GitHub text is), or one of magi's own candidate commit subjects.
10420fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10421    let raw = raw.trim();
10422    if raw.is_empty() {
10423        return None;
10424    }
10425    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10426        return None;
10427    }
10428    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10429        return None;
10430    }
10431    let title = queue::title_from(raw, PR_TITLE_MAX);
10432    let lower = title.to_ascii_lowercase();
10433    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10434        return None;
10435    }
10436    Some(title)
10437}
10438
10439/// What a review-only run's branch says about itself, read at the moment the
10440/// pull request is opened.
10441#[derive(Debug, Clone, PartialEq, Eq)]
10442struct BranchFacts {
10443    /// `(subject, body)` of each commit, oldest first.
10444    commits: Vec<(String, String)>,
10445    /// Trimmed `git diff --stat`.
10446    stat: String,
10447}
10448
10449/// Longest diff stat shown: this many file lines plus the summary line.
10450const STAT_FILE_LINES: usize = 25;
10451/// Cap on the commit list, well inside GitHub's 65536-character body limit.
10452const COMMITS_MAX_CHARS: usize = 20_000;
10453
10454/// Read the commits and diff stat of `base..branch`. `None` when git cannot
10455/// say or finds nothing, so the caller falls back to what the run recorded.
10456async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10457    let commits = git::commit_log(repo, base, branch).await.ok()?;
10458    if commits.is_empty() {
10459        return None;
10460    }
10461    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10462    let lines: Vec<&str> = stat.lines().collect();
10463    let stat = if lines.len() > STAT_FILE_LINES + 1 {
10464        let omitted = lines.len() - 1 - STAT_FILE_LINES;
10465        let more = format!(" ... {omitted} more file(s)");
10466        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10467        kept.push(&more);
10468        kept.push(lines[lines.len() - 1]);
10469        kept.join("\n")
10470    } else {
10471        lines.join("\n")
10472    };
10473    Some(BranchFacts { commits, stat })
10474}
10475
10476/// Defang what would break the surrounding markdown: a closing `</details>`
10477/// and a code fence.
10478fn markdown_safe(text: &str) -> String {
10479    text.replace("</details>", "&lt;/details&gt;")
10480        .replace("\x60\x60\x60", "~~~")
10481}
10482
10483fn neutral_title(state: &RunState, winner: char) -> String {
10484    format!(
10485        "chore: land candidate {} of run {}",
10486        winner.to_ascii_uppercase(),
10487        state.id
10488    )
10489}
10490
10491/// The pull request title to hand to `land::merge_subject`. A review-only run
10492/// opened by an earlier build titled its pull request with the review prompt;
10493/// that title is dropped (empty, so the fallback applies) rather than landed.
10494/// Any other title, including an operator's rename, passes through untouched,
10495/// and so does every title of a run that implements a task.
10496pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10497    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10498        ""
10499    } else {
10500        pr_title
10501    }
10502}
10503
10504/// What the squash subject falls back to when the pull request title is empty
10505/// or candidate-shaped: for a review-only run the derived title, never the
10506/// review prompt held in `instruction`.
10507pub fn landing_subject_source(state: &RunState) -> String {
10508    if is_review_run(state) {
10509        let winner = state.candidates.first().map_or('A', |c| c.label);
10510        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10511    }
10512    state.instruction.clone()
10513}
10514
10515/// `summary` without its `TITLE:` line, which the pull request title already
10516/// carries.
10517fn summary_without_title(summary: &str) -> String {
10518    let mut lines = summary.trim().lines().peekable();
10519    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10520        lines.next();
10521    }
10522    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10523}
10524
10525/// The pull request title and body for the winning candidate.
10526///
10527/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10528/// the task's own opening line via [`queue::title_from`] when there is none.
10529/// `state.instruction` can open with blank lines (`task_text` only rejects a
10530/// body that is blank *entirely*), which `title_from` skips.
10531///
10532/// Publishing uses an independent description generated from the final diff
10533/// and structured evidence. Internal summaries and reports remain local.
10534/// Non-publishing previews may display the original task and candidate summary.
10535/// The footer retains the run and candidate provenance.
10536#[cfg(test)]
10537fn pr_message(state: &RunState, winner: char) -> PrMessage {
10538    pr_message_with(state, winner, None)
10539}
10540
10541/// [`pr_message`] with what the branch of a review-only run says about itself.
10542/// `facts` is ignored for a run that implements a task.
10543#[cfg(test)]
10544fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10545    let raw = pr_message_raw(
10546        state,
10547        winner,
10548        facts,
10549        crate::pr_outcome::FinalHead::Unreported,
10550        ChangeNote::NotAsked,
10551    );
10552    let id = crate::scrub::Identity::current();
10553    PrMessage {
10554        title: crate::scrub::scrub(&raw.title, &id),
10555        body: crate::scrub::scrub(&raw.body, &id),
10556    }
10557}
10558
10559/// What the public description may say about the change itself.
10560#[derive(Debug, Clone, Copy)]
10561enum ChangeNote<'a> {
10562    /// A local preview or non-publishing merge mode: no public generation needed.
10563    NotAsked,
10564    /// A summary written from the final diff.
10565    Summary(&'a crate::run::ChangeSummary),
10566    /// A summary was asked for and could not be produced; the text says why.
10567    Unavailable(&'a str),
10568}
10569
10570fn pr_message_raw(
10571    state: &RunState,
10572    winner: char,
10573    facts: Option<&BranchFacts>,
10574    head: crate::pr_outcome::FinalHead<'_>,
10575    change: ChangeNote<'_>,
10576) -> PrMessage {
10577    if !matches!(change, ChangeNote::NotAsked) {
10578        let (title, mut body) = match change {
10579            ChangeNote::Summary(c) => (
10580                c.title.clone(),
10581                format!("## Summary\n\n{}\n\n", c.body.trim()),
10582            ),
10583            ChangeNote::Unavailable(why) => (
10584                neutral_title(state, winner),
10585                format!(
10586                    "## 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"
10587                ),
10588            ),
10589            ChangeNote::NotAsked => unreachable!(),
10590        };
10591        body.push_str(&public_pr_evidence(state, head));
10592        body.push_str(&format!(
10593            "\n---\nmagi:run/{} magi:candidate-{}\n",
10594            state.id,
10595            winner.to_ascii_lowercase()
10596        ));
10597        return PrMessage { title, body };
10598    }
10599    pr_preview(state, winner, facts, head)
10600}
10601
10602/// Append public facts from the run records after every prose generation,
10603/// including posting-gate rewrites. The agent cannot drop this provenance.
10604fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10605    let mut body = crate::pr_outcome::review_rounds(state);
10606    body.push_str(&crate::pr_outcome::final_verification(state, head));
10607    let open = state.last_round_findings();
10608    if !open.is_empty() {
10609        body.push_str("## Open review findings\n\n");
10610        for f in open {
10611            body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10612        }
10613        body.push('\n');
10614    }
10615    if let Some(c) = state.change_summary.as_ref()
10616        && head == crate::pr_outcome::FinalHead::At(&c.head)
10617    {
10618        body.push_str(&format!(
10619            "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10620            c.base, c.head
10621        ));
10622    }
10623    body
10624}
10625
10626/// Local-only preview of the operator's task and candidate summary.
10627fn pr_preview(
10628    state: &RunState,
10629    winner: char,
10630    facts: Option<&BranchFacts>,
10631    head: crate::pr_outcome::FinalHead<'_>,
10632) -> PrMessage {
10633    let summary = state
10634        .candidates
10635        .iter()
10636        .find(|c| c.label == winner)
10637        .map(|c| c.summary.as_str())
10638        .unwrap_or_default();
10639    // The fallback is the operator's own words and may not be English; GitHub
10640    // text always is, so a non-English task gets a neutral title instead.
10641    let review = is_review_run(state);
10642    let title = if review {
10643        facts
10644            .and_then(|f| {
10645                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10646            })
10647            .or_else(|| review_title(state))
10648            .or_else(|| {
10649                state
10650                    .candidates
10651                    .iter()
10652                    .find(|c| c.label == winner)
10653                    .filter(|c| !c.branch.starts_with("magi/"))
10654                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10655            })
10656            .unwrap_or_else(|| neutral_title(state, winner))
10657    } else {
10658        summary_title(summary).unwrap_or_else(|| {
10659            english_title(
10660                &state.instruction,
10661                PR_TITLE_MAX,
10662                state.config.graph.github_english(),
10663            )
10664            .unwrap_or_else(|| neutral_title(state, winner))
10665        })
10666    };
10667
10668    let mut body = String::new();
10669    let what = summary_without_title(summary);
10670    if !what.is_empty() {
10671        body.push_str("## Summary\n\n");
10672        body.push_str(&what);
10673        body.push_str("\n\n");
10674    }
10675
10676    // Review history uses structured facts across all rounds, never reports.
10677    body.push_str(&crate::pr_outcome::review_rounds(state));
10678    body.push_str(&crate::pr_outcome::final_verification(state, head));
10679    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10680
10681    let open = state.open_findings();
10682    if !open.is_empty() {
10683        body.push_str("## Open review findings\n\n");
10684        for f in &open {
10685            body.push_str(&format!(
10686                "- `{}` [{:?}] {}\n",
10687                f.id, f.severity, "Context retained in local review records."
10688            ));
10689        }
10690        body.push('\n');
10691    }
10692
10693    if let Some(fix) = fix
10694        && !fix.rejected.is_empty()
10695    {
10696        body.push_str("## Declined by the fixer\n\n");
10697        for r in &fix.rejected {
10698            body.push_str(&format!(
10699                "- `{}`: {}\n",
10700                r.id, "Reason retained in local review records."
10701            ));
10702        }
10703        body.push('\n');
10704    }
10705
10706    if review {
10707        // The review prompt is not the task; list what the branch carries.
10708        body.push_str("## Commits under review\n\n");
10709        if let Some(facts) = facts {
10710            let mut left = COMMITS_MAX_CHARS;
10711            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10712                let mut entry = format!("- {}\n", markdown_safe(subject));
10713                for l in markdown_safe(text).lines() {
10714                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10715                }
10716                if left == 0 {
10717                    body.push_str(&format!(
10718                        "- ... {} more commit(s)\n",
10719                        facts.commits.len() - i
10720                    ));
10721                    break;
10722                }
10723                if entry.len() > left {
10724                    // Even the first commit is cut: one huge body must not
10725                    // push the whole description past GitHub's limit.
10726                    let mut end = left;
10727                    while !entry.is_char_boundary(end) {
10728                        end -= 1;
10729                    }
10730                    entry.truncate(end);
10731                    entry.push_str("\n  ... (truncated)\n");
10732                    left = 0;
10733                } else {
10734                    left -= entry.len();
10735                }
10736                body.push_str(&entry);
10737            }
10738            if !facts.stat.trim().is_empty() {
10739                body.push_str(&format!(
10740                    "\n## Diff stat\n\n```\n{}\n```\n",
10741                    markdown_safe(facts.stat.trim())
10742                ));
10743            }
10744        } else {
10745            match &state.reviewed_commits {
10746                Some(subjects) => {
10747                    for s in subjects {
10748                        body.push_str(&format!("- {}\n", s.trim()));
10749                    }
10750                }
10751                None => {
10752                    // An older run kept only the prompt, with the commit list
10753                    // after its first paragraph.
10754                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10755                    body.push_str(rest.trim());
10756                    body.push('\n');
10757                }
10758            }
10759        }
10760    } else {
10761        let task = state.instruction.trim();
10762        let task = if task.is_empty() {
10763            "(empty task)"
10764        } else {
10765            task
10766        };
10767        body.push_str(&format!(
10768            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10769            task.replace("</details>", "&lt;/details&gt;")
10770        ));
10771    }
10772
10773    body.push_str(&format!(
10774        "\n---\nmagi:run/{} magi:candidate-{}\n",
10775        state.id,
10776        winner.to_ascii_lowercase()
10777    ));
10778
10779    PrMessage { title, body }
10780}
10781
10782/// The task with what the repository says about the existing work it names
10783/// appended, so an implementer knows what it started from and what it must
10784/// not redo. Unchanged when the task names nothing.
10785fn seeded_instruction(state: &RunState) -> String {
10786    match refs::describe(&state.seeds) {
10787        Some(facts) => format!(
10788            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10789             Candidates start from the unmerged branch named above, when there \
10790             is one, and carry any unmerged commit named by sha as a \
10791             cherry-pick. Check that this is what the task meant before \
10792             building on it.",
10793            state.instruction
10794        ),
10795        None => state.instruction.clone(),
10796    }
10797}
10798
10799/// Does the winner have no commits ahead of the base it would land on?
10800/// Any failure to find out reads as "not empty": the merge then behaves as it
10801/// always did rather than refusing on a guess.
10802async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10803    let base = &state.base_branch;
10804    let mut against = base.clone();
10805    if mode == MergeMode::Pr {
10806        // A pull request lands on the remote's base, never the local branch
10807        // of the same name: if that cannot be read, "not empty" is the safe
10808        // answer.
10809        let remote = &state.config.merge.remote;
10810        let tracking = format!("{remote}/{base}");
10811        let fetched = git::fetch(repo, remote, base).await;
10812        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10813            against = tracking;
10814        } else {
10815            return false;
10816        }
10817    }
10818    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10819}
10820
10821/// Why nothing was opened for an empty winner, with what the task's own
10822/// references resolved to.
10823fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10824    let mut detail = format!(
10825        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10826         nothing to open a pull request for"
10827    );
10828    match refs::describe(&state.seeds) {
10829        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10830        None => detail.push_str(
10831            "\nThe task names no existing branch or commit; if it means to land work \
10832             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10833        ),
10834    }
10835    detail
10836}
10837
10838/// What the `Pr` merge does once it knows whether the branch already has an
10839/// open pull request.
10840#[derive(Debug, PartialEq, Eq)]
10841enum PrPlan {
10842    Create,
10843    Adopt { url: String, title: String },
10844    Stop(String),
10845}
10846
10847/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
10848/// a failed lookup -> stop with the real reason. Never guesses.
10849fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10850    match found {
10851        Ok(land::OpenPr::None) => PrPlan::Create,
10852        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10853        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10854            "several open pull requests exist for this branch, not picking one: {}",
10855            urls.join(" ")
10856        )),
10857        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10858    }
10859}
10860
10861/// `gh pr create`, returning the PR url.
10862pub(crate) async fn gh_pr_create(
10863    cwd: &Path,
10864    base: &str,
10865    head: &str,
10866    title: &str,
10867    body: &str,
10868) -> Result<String> {
10869    let out = tokio::process::Command::new("gh")
10870        .args([
10871            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10872        ])
10873        .current_dir(cwd)
10874        .quiet()
10875        .stdin(std::process::Stdio::null())
10876        .output()
10877        .await
10878        .context("spawn gh")?;
10879    if out.status.success() {
10880        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10881    } else {
10882        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10883    }
10884}
10885
10886/// Tear a run's worktrees and branches down.
10887///
10888/// `home` is where the updated `run.json` is saved (via
10889/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
10890/// a housekeeping pass already has its own honest `home` handed to it, and
10891/// falling through to the global here would write back through whichever
10892/// directory some other process or test pinned into that `OnceLock` first,
10893/// not the one the caller actually resolved its `runs` and `state` from.
10894pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10895    if crate::pr_recovery::sources_retained(home, &state.id) {
10896        return Ok(Vec::new());
10897    }
10898    let repo = state.repo.clone();
10899    let root = state.worktree_root();
10900    let winner = state.tally.as_ref().map(|t| t.winner);
10901    let mut removed = Vec::new();
10902
10903    for i in 0..state.candidates.len() {
10904        let c = state.candidates[i].clone();
10905        let is_winner = Some(c.label) == winner;
10906        if is_winner && !drop_winner {
10907            continue;
10908        }
10909        if c.worktree.exists() {
10910            git::worktree_remove(&repo, &c.worktree).await.ok();
10911            removed.push(c.worktree.to_string_lossy().into_owned());
10912        }
10913        // A branch handed to a later run (and its pull request) is not this
10914        // run's to delete.
10915        let handed_over = state.released_branches.contains(&c.branch);
10916        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10917            git::branch_delete(&repo, &c.branch).await.ok();
10918            removed.push(c.branch.clone());
10919        }
10920        state.candidates[i].folded = true;
10921    }
10922
10923    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10924        let path = name.path();
10925        let keep = !drop_winner
10926            && winner.is_some_and(|w| {
10927                path.file_name()
10928                    .is_some_and(|n| n == format!("cand-{w}").as_str())
10929            });
10930        if keep {
10931            continue;
10932        }
10933        git::worktree_remove(&repo, &path).await.ok();
10934        removed.push(path.to_string_lossy().into_owned());
10935    }
10936
10937    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
10938    // judge worktrees, so once the loop above has cleared all of them out,
10939    // the parent is a bare directory nobody else was ever going to remove -
10940    // git only ever managed what was inside it. Left alone, one of these
10941    // accumulates per fully-folded run; the operator's own machine had 74.
10942    // `remove_if_empty` re-checks rather than assuming: a run whose winner
10943    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
10944    // so does anything a run never claimed that happens to share the bay.
10945    remove_if_empty(&root);
10946
10947    if state.enabled_worktree_config && drop_winner {
10948        // A release, not a raw disable: some sibling run in this repository
10949        // may still hold its own reference (see `git::acquire_worktree_config`),
10950        // and only the last release actually turns the setting back off.
10951        git::release_worktree_config(&repo).await.ok();
10952        state.enabled_worktree_config = false;
10953    }
10954    state.save_under(home)?;
10955    Ok(removed)
10956}
10957
10958/// Remove `dir` if it exists and has nothing in it.
10959///
10960/// Best-effort and silent by design: a directory that is not empty (a run
10961/// whose winner is still parked there, a stray file some other process left)
10962/// is exactly the case this must refuse, and a directory that is already gone
10963/// is not a failure worth reporting either. `std::fs::remove_dir` itself
10964/// already refuses a non-empty directory, so the emptiness check below is
10965/// belt, not suspenders - it is what keeps this from ever attempting the
10966/// removal in the case that matters, rather than trusting `remove_dir`'s
10967/// error path to have no side effects if it ever changed.
10968fn remove_if_empty(dir: &Path) {
10969    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10970        std::fs::remove_dir(dir).ok();
10971    }
10972}
10973
10974/// Severity of the worst open finding in the last review round, for reporting.
10975pub fn worst_open(state: &RunState) -> Option<Severity> {
10976    state
10977        .reviews
10978        .last()?
10979        .reviews
10980        .iter()
10981        .flat_map(|r| r.findings.iter())
10982        .map(|f| f.severity)
10983        .max()
10984}
10985
10986/// `[worktree] setup` hides its products per worktree, which needs
10987/// `extensions.worktreeConfig`. Taken once per run and released with the run's
10988/// worktrees, like the hook's; every place that runs setup calls this first.
10989async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10990    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10991        git::acquire_worktree_config(repo).await?;
10992        state.enabled_worktree_config = true;
10993    }
10994    Ok(())
10995}
10996
10997#[cfg(test)]
10998mod tests {
10999    #[test]
11000    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
11001        let leaked = vec!["chore(deps): update a crate".to_owned()];
11002        let own = "fix(daemon): apply a chosen action";
11003        assert!(should_retitle("", own, &leaked));
11004        assert!(should_retitle(
11005            &format!("{REVIEW_PROMPT_OPENING} `x`"),
11006            own,
11007            &leaked
11008        ));
11009        assert!(should_retitle(
11010            "chore: land candidate A of run 1",
11011            own,
11012            &leaked
11013        ));
11014        assert!(should_retitle(
11015            "magi: candidate A (uncommitted work)",
11016            own,
11017            &leaked
11018        ));
11019        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
11020        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
11021        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
11022    }
11023
11024    #[test]
11025    fn pr_merge_plan_creates_adopts_or_stops() {
11026        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
11027        assert_eq!(
11028            pr_merge_plan(Ok(land::OpenPr::One {
11029                url: "u".into(),
11030                title: "t".into()
11031            })),
11032            PrPlan::Adopt {
11033                url: "u".into(),
11034                title: "t".into()
11035            }
11036        );
11037        let PrPlan::Stop(many) =
11038            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
11039        else {
11040            panic!("many must stop");
11041        };
11042        assert!(many.contains('a') && many.contains('b'));
11043        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
11044            panic!("a failed lookup must stop");
11045        };
11046        assert!(err.contains("bad token"));
11047    }
11048
11049    use super::*;
11050    use crate::run::GateStatus;
11051    use std::collections::BTreeMap;
11052    use std::time::Duration;
11053
11054    fn conductor() -> AgentSpec {
11055        AgentSpec {
11056            id: "conductor".to_owned(),
11057            kind: crate::config::AgentKind::Command,
11058            model: None,
11059            command: vec!["true".to_owned()],
11060            extra_args: Vec::new(),
11061            env: BTreeMap::new(),
11062            prompt_delivery: None,
11063        }
11064    }
11065
11066    fn spec(id: &str) -> AgentSpec {
11067        AgentSpec {
11068            id: id.to_owned(),
11069            kind: crate::config::AgentKind::Command,
11070            model: None,
11071            command: vec!["true".to_owned()],
11072            extra_args: Vec::new(),
11073            env: BTreeMap::new(),
11074            prompt_delivery: None,
11075        }
11076    }
11077
11078    fn ids(xs: &[&str]) -> BTreeSet<String> {
11079        xs.iter().map(|s| (*s).to_owned()).collect()
11080    }
11081
11082    #[test]
11083    fn pick_successor_skips_an_agent_another_seat_holds() {
11084        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
11085        let roster = [spec("a"), spec("b"), spec("c")];
11086        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11087        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11088    }
11089
11090    #[test]
11091    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11092        // The other seat started on c but was handed to d; c is free again.
11093        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11094        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11095        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11096        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11097        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11098    }
11099
11100    #[test]
11101    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11102        let roster = [spec("a"), spec("b")];
11103        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11104        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11105        let carried = ids(&["b"]);
11106        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11107        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11108    }
11109
11110    #[test]
11111    fn pick_successor_returns_none_without_any_untried_successor() {
11112        let roster = [spec("a"), spec("b")];
11113        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11114        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11115        let carried = ids(&["a"]);
11116        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11117    }
11118
11119    #[test]
11120    fn pick_successor_rescue_avoids_another_seats_occupant() {
11121        // b is a carried failure and free; a is held by the other seat.
11122        let roster = [spec("a"), spec("b"), spec("c")];
11123        let carried = ids(&["b", "c"]);
11124        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11125        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11126    }
11127
11128    #[test]
11129    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11130        let roster = [spec("a"), spec("b"), spec("c")];
11131        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11132        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11133    }
11134
11135    #[test]
11136    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11137        let roster = [spec("a"), spec("b"), spec("c")];
11138        let failed = ids(&["a", "b", "c"]);
11139        // Rescue looks at the whole roster, once per id, then runs out.
11140        let mut tried = ids(&["b"]);
11141        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11142        assert_eq!(first.id, "a");
11143        tried.insert(first.id.clone());
11144        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11145        assert_eq!(second.id, "c");
11146        tried.insert(second.id.clone());
11147        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11148    }
11149
11150    #[test]
11151    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11152        let roster = [spec("a"), spec("b")];
11153        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11154        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11155    }
11156
11157    #[test]
11158    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11159        let roster = [spec("a"), spec("b"), spec("c")];
11160        let h = SeatHistory {
11161            failed: ids(&["a"]),
11162            last_ok: Some("b".to_owned()),
11163            last_fail: None,
11164        };
11165        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11166        // A last answerer that left the roster, or later failed, is ignored.
11167        let gone = SeatHistory {
11168            last_ok: Some("zzz".to_owned()),
11169            ..h.clone()
11170        };
11171        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11172        let failed = SeatHistory {
11173            failed: ids(&["a", "b"]),
11174            last_ok: Some("b".to_owned()),
11175            last_fail: None,
11176        };
11177        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11178    }
11179
11180    #[test]
11181    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11182        let first = SeatState::new("review-1", "alpha", 7);
11183        let next = handover_seat("review-1", "gamma", 7);
11184        assert_ne!(first.claude_session, next.claude_session);
11185    }
11186
11187    #[test]
11188    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11189        let mut state = state_with_summary("x", "y");
11190        let a = handover_seat("review-1", "beta", state.next_seat_seed());
11191        let b = handover_seat("review-1", "beta", state.next_seat_seed());
11192        assert_ne!(a.claude_session, b.claude_session);
11193    }
11194
11195    #[test]
11196    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11197        let roster = [spec("a"), spec("b")];
11198        let h = SeatHistory {
11199            failed: ids(&["a", "b"]),
11200            ..SeatHistory::default()
11201        };
11202        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11203        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11204        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11205    }
11206
11207    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
11208    // fallback loop depends on to terminate: it must walk forward from the
11209    // seat's own position, never restart at the front of the roster, and it
11210    // must never hand back an id already tried, however many times that id
11211    // happens to appear.
11212
11213    #[test]
11214    fn failure_signature_ignores_numbers_and_paths() {
11215        assert_eq!(
11216            failure_signature("exited with Some(2) and no usable output"),
11217            failure_signature("exited with Some(137) and no usable output")
11218        );
11219        assert_eq!(
11220            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11221            failure_signature("cannot open /var/x.txt: denied")
11222        );
11223        assert_ne!(failure_signature("boom"), failure_signature("bang"));
11224    }
11225
11226    #[test]
11227    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11228        let other = FailClass::Other("x".into());
11229        assert!(should_hand_over(None, &FailClass::Quota));
11230        assert!(should_hand_over(Some(&other), &FailClass::Quota));
11231        assert!(should_hand_over(
11232            Some(&FailClass::Timeout),
11233            &FailClass::Timeout
11234        ));
11235        assert!(should_hand_over(None, &other));
11236        assert!(!should_hand_over(Some(&other), &other));
11237        assert!(should_hand_over(
11238            Some(&other),
11239            &FailClass::Other("y".into())
11240        ));
11241        // A quota or timeout in between ends the run of identical failures.
11242        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11243        assert!(should_hand_over(Some(&FailClass::Quota), &other));
11244    }
11245
11246    #[test]
11247    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11248        let a = SeatState::new("judge-1", "alpha", 7);
11249        let b = handover_seat("judge-1", "beta", 7);
11250        assert_ne!(a.claude_session, b.claude_session);
11251        assert_eq!(b.turns, 0);
11252    }
11253
11254    #[test]
11255    fn a_timeout_is_classified_apart_from_other_failures() {
11256        assert_eq!(
11257            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11258            Some(FailClass::Timeout)
11259        );
11260        assert!(matches!(
11261            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11262            Some(FailClass::Other(_))
11263        ));
11264    }
11265
11266    #[test]
11267    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11268        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11269        let tried = BTreeSet::from(["beta".to_owned()]);
11270        // beta sits at index 1; the next candidate is gamma, never alpha —
11271        // which is very likely a different candidate slot's own agent.
11272        let next = next_untried_in_roster(&roster, 1, &tried);
11273        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11274    }
11275
11276    #[test]
11277    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11278        let roster = vec![spec("alpha"), spec("beta")];
11279        let tried = BTreeSet::from(["beta".to_owned()]);
11280        // beta is the roster's last entry: nothing follows it, and alpha —
11281        // earlier in the roster, almost certainly a different candidate
11282        // slot's own agent — must not be reached by wrapping back to it.
11283        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11284    }
11285
11286    #[test]
11287    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11288        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11289        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11290        // beta (index 1) and gamma (index 2, the only entry after it) have
11291        // both been tried; alpha (index 0) never has, but it comes before
11292        // beta's own position, so there is nothing further for this seat.
11293        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11294    }
11295
11296    #[test]
11297    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11298        let roster = vec![spec("a"), spec("a"), spec("b")];
11299        let tried = BTreeSet::from(["a".to_owned()]);
11300        let next = next_untried_in_roster(&roster, 0, &tried);
11301        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11302    }
11303
11304    #[test]
11305    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11306        let roster = vec![spec("a"), spec("b")];
11307        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11308        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11309    }
11310
11311    #[test]
11312    fn remove_if_empty_only_ever_takes_a_bare_directory() {
11313        let dir = tempfile::tempdir().unwrap();
11314        let bay = dir.path().join("ffff");
11315
11316        // Not there yet: nothing to do, nothing to panic on.
11317        remove_if_empty(&bay);
11318        assert!(!bay.exists());
11319
11320        // Something still inside - the winner's worktree, or a stray file -
11321        // keeps the directory standing.
11322        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11323        remove_if_empty(&bay);
11324        assert!(bay.exists(), "non-empty directory must survive");
11325
11326        // Once the last entry is gone, so is the directory itself.
11327        std::fs::remove_dir(bay.join("cand-A")).unwrap();
11328        remove_if_empty(&bay);
11329        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11330    }
11331
11332    // `round_is_clean` is the exact decision this task fixed: a round with a
11333    // seat that never answered must not read the same as a round every seat
11334    // actually reviewed. These are deterministic and process-free by design —
11335    // the equivalent end-to-end check (a real reviewer timing out under a
11336    // live graph run) is a genuine race against wall-clock contention, and a
11337    // spawn slow enough to blow even a generous budget under a loaded test
11338    // run must not turn this specific regression check flaky.
11339
11340    #[test]
11341    fn a_full_panel_that_found_nothing_is_clean() {
11342        assert!(round_is_clean(
11343            0,
11344            true,
11345            2,
11346            2,
11347            0,
11348            IncompleteReviewPolicy::Block
11349        ));
11350    }
11351
11352    #[test]
11353    fn a_missing_seat_is_never_clean_under_the_default_policy() {
11354        assert!(!round_is_clean(
11355            0,
11356            true,
11357            1,
11358            2,
11359            0,
11360            IncompleteReviewPolicy::Block
11361        ));
11362    }
11363
11364    #[test]
11365    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11366        assert!(!round_is_clean(
11367            1,
11368            true,
11369            1,
11370            2,
11371            0,
11372            IncompleteReviewPolicy::Warn
11373        ));
11374    }
11375
11376    #[test]
11377    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11378        assert!(round_is_clean(
11379            0,
11380            true,
11381            1,
11382            2,
11383            0,
11384            IncompleteReviewPolicy::Warn
11385        ));
11386    }
11387
11388    #[test]
11389    fn a_full_panel_with_an_open_finding_is_not_clean() {
11390        assert!(!round_is_clean(
11391            1,
11392            true,
11393            2,
11394            2,
11395            0,
11396            IncompleteReviewPolicy::Block
11397        ));
11398    }
11399
11400    #[test]
11401    fn a_full_panel_with_a_red_e2e_is_not_clean() {
11402        assert!(!round_is_clean(
11403            0,
11404            false,
11405            2,
11406            2,
11407            0,
11408            IncompleteReviewPolicy::Block
11409        ));
11410    }
11411
11412    // The stall this task closes: under the default `block` policy, a seat
11413    // missing only because it was rate limited must not force a wait for a
11414    // session limit that will not lift by the next round. `round_is_clean`
11415    // is where that quorum carve-out lives; the review loop around it never
11416    // changes what a reviewer's vote or a finding's severity means.
11417
11418    #[test]
11419    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11420        // 1 of 2 answered, and the one missing was quota'd — the exact
11421        // "review-2 rate limited (quota)" shape from the field report.
11422        assert!(round_is_clean(
11423            0,
11424            true,
11425            1,
11426            2,
11427            1,
11428            IncompleteReviewPolicy::Block
11429        ));
11430    }
11431
11432    #[test]
11433    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11434        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
11435        // not a quota loss (`quota_missing` stays 0) — worth another try.
11436        assert!(!round_is_clean(
11437            0,
11438            true,
11439            1,
11440            2,
11441            0,
11442            IncompleteReviewPolicy::Block
11443        ));
11444    }
11445
11446    #[test]
11447    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11448        assert!(!round_is_clean(
11449            1,
11450            true,
11451            1,
11452            2,
11453            1,
11454            IncompleteReviewPolicy::Block
11455        ));
11456        assert!(!round_is_clean(
11457            0,
11458            false,
11459            1,
11460            2,
11461            1,
11462            IncompleteReviewPolicy::Block
11463        ));
11464    }
11465
11466    #[test]
11467    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11468        // Every seat quota'd, nobody answered: there is no panel to decide
11469        // on, so this must fall through to the existing block-and-retry
11470        // fallback rather than call an unreviewed patch clean.
11471        assert!(!round_is_clean(
11472            0,
11473            true,
11474            0,
11475            2,
11476            2,
11477            IncompleteReviewPolicy::Block
11478        ));
11479    }
11480
11481    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11482        CommandOutcome {
11483            command: "test".to_owned(),
11484            code,
11485            output_tail: String::new(),
11486            duration_ms: 0,
11487            resource_blocked,
11488        }
11489    }
11490
11491    #[test]
11492    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11493        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11494        assert!(
11495            !verify_inconclusive(&[outcome(Some(1), false)]),
11496            "an ordinary failure is still evidence about the patch"
11497        );
11498        assert!(verify_inconclusive(&[outcome(None, true)]));
11499        assert!(
11500            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11501            "one inconclusive outcome taints the whole batch"
11502        );
11503        assert!(!verify_inconclusive(&[]));
11504    }
11505
11506    #[tokio::test]
11507    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11508        // Alive for the first two checks, then dead - confirms the loop
11509        // actually re-polls rather than deciding once and sleeping out the
11510        // ceiling regardless.
11511        let calls = std::sync::atomic::AtomicUsize::new(0);
11512        let started = Instant::now();
11513        wait_for_pids_with(
11514            &[123],
11515            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11516            Duration::from_millis(5),
11517            Duration::from_secs(5),
11518        )
11519        .await;
11520        assert!(
11521            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11522            "must keep checking rather than deciding on the first answer"
11523        );
11524        assert!(
11525            started.elapsed() < Duration::from_secs(1),
11526            "must return the moment it is confirmed dead, not wait out the ceiling"
11527        );
11528    }
11529
11530    #[tokio::test]
11531    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11532        let started = Instant::now();
11533        wait_for_pids_with(
11534            &[123],
11535            |_| true, // never reports dead
11536            Duration::from_millis(5),
11537            Duration::from_millis(30),
11538        )
11539        .await;
11540        let elapsed = started.elapsed();
11541        assert!(
11542            elapsed >= Duration::from_millis(30),
11543            "must not give up before its own ceiling: {elapsed:?}"
11544        );
11545        assert!(
11546            elapsed < Duration::from_secs(1),
11547            "must not wait past its own ceiling either: {elapsed:?}"
11548        );
11549    }
11550
11551    #[tokio::test]
11552    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11553        let started = Instant::now();
11554        wait_for_pids_with(
11555            &[],
11556            |_| true,
11557            Duration::from_secs(5),
11558            Duration::from_secs(5),
11559        )
11560        .await;
11561        assert!(
11562            started.elapsed() < Duration::from_millis(200),
11563            "an empty pid list has nothing to confirm"
11564        );
11565    }
11566
11567    // `review_conclusion` is the exact decision the review hand-off task
11568    // fixed: a round budget spent (or a tree that stopped moving) must not
11569    // collapse into `Blocked` regardless of what verification actually
11570    // said. Deterministic and process-free for the same reason the
11571    // `round_is_clean` family above is.
11572    fn review_round(
11573        clean: bool,
11574        blocking: usize,
11575        answered: usize,
11576        expected: usize,
11577        progressed: bool,
11578        e2e_ok: bool,
11579    ) -> ReviewRound {
11580        ReviewRound {
11581            round: 1,
11582            head: "h".to_owned(),
11583            verified_head: None,
11584            verified_at: None,
11585            reviews: Vec::new(),
11586            e2e: vec![CommandOutcome {
11587                command: "test".to_owned(),
11588                code: Some(if e2e_ok { 0 } else { 1 }),
11589                output_tail: String::new(),
11590                duration_ms: 0,
11591                resource_blocked: false,
11592            }],
11593            verify_retried: false,
11594            e2e_deferred: false,
11595            e2e_defer_reason: None,
11596            fix: None,
11597            blocking,
11598            answered,
11599            expected,
11600            clean,
11601            progressed,
11602            vote_split: false,
11603            reconsideration: Vec::new(),
11604            verdict: None,
11605        }
11606    }
11607
11608    #[test]
11609    fn review_conclusion_is_none_when_nothing_has_run() {
11610        assert_eq!(review_conclusion(&[], 3), None);
11611    }
11612
11613    #[test]
11614    fn review_conclusion_is_none_while_rounds_remain() {
11615        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11616        assert_eq!(review_conclusion(&rounds, 3), None);
11617    }
11618
11619    #[test]
11620    fn review_conclusion_is_gating_once_a_round_is_clean() {
11621        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11622        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11623    }
11624
11625    #[test]
11626    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11627        let rounds = vec![
11628            review_round(false, 1, 2, 2, true, true),
11629            review_round(false, 1, 2, 2, true, true),
11630        ];
11631        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11632    }
11633
11634    #[test]
11635    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11636        let rounds = vec![
11637            review_round(false, 1, 2, 2, true, true),
11638            review_round(false, 1, 2, 2, true, false),
11639        ];
11640        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11641    }
11642
11643    #[test]
11644    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11645        // Magi never got a command to run against this round's own head — a
11646        // resource-blocked attempt, not a red one — so this must never
11647        // settle on `Blocked` the way a genuine e2e failure would. `None`
11648        // here is what tells `Runner::review_loop` to retry the check
11649        // itself rather than trust this cheap recomputation with a verdict
11650        // it cannot actually produce.
11651        let mut blocked = review_round(false, 1, 2, 2, true, false);
11652        blocked.e2e[0].resource_blocked = true;
11653        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11654        assert_eq!(review_conclusion(&rounds, 2), None);
11655    }
11656
11657    #[test]
11658    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11659        // Missing input, not a verified tree — never a hand-off candidate.
11660        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11661        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11662    }
11663
11664    #[test]
11665    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11666        let rounds = vec![
11667            review_round(false, 1, 2, 2, false, true),
11668            review_round(false, 1, 2, 2, false, true),
11669        ];
11670        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11671    }
11672
11673    fn secs(n: u64) -> Duration {
11674        Duration::from_secs(n)
11675    }
11676
11677    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11678    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11679    #[tokio::test]
11680    async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11681        ask_test_home();
11682        let tmp = tempfile::tempdir().unwrap();
11683        let repo = tmp.path().join("repo");
11684        std::fs::create_dir_all(&repo).unwrap();
11685        init_repo(&repo);
11686        let g = |args: &[&str]| {
11687            let out = std::process::Command::new("git")
11688                .args(args)
11689                .current_dir(&repo)
11690                .quiet()
11691                .output()
11692                .unwrap();
11693            assert!(
11694                out.status.success(),
11695                "{}",
11696                String::from_utf8_lossy(&out.stderr)
11697            );
11698        };
11699        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11700        g(&["checkout", "-b", "historical"]);
11701        std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11702        g(&["add", "."]);
11703        g(&["commit", "-m", "old alternative"]);
11704        let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11705        g(&["checkout", "-b", "magi/task-selected", &base]);
11706        std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11707        g(&["add", "."]);
11708        g(&["commit", "-m", "measurement infrastructure"]);
11709        let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11710        std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11711        g(&["add", "."]);
11712        g(&["commit", "-m", "dependent implementation"]);
11713        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11714        g(&["checkout", "main"]);
11715        std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11716        g(&["add", "."]);
11717        g(&["commit", "-m", "unrelated base change"]);
11718        let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11719        let origin = tmp.path().join("origin.git");
11720        g(&[
11721            "clone",
11722            "--bare",
11723            repo.to_str().unwrap(),
11724            origin.to_str().unwrap(),
11725        ]);
11726        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11727        g(&["fetch", "origin"]);
11728        let selection = crate::pr_recovery::Selection {
11729            url: "https://example.test/project/pull/1".into(),
11730            branch: "magi/task-selected".into(),
11731            head: head.clone(),
11732            instruction: "preserve measured behavior".into(),
11733            approved_at: jiff::Timestamp::now(),
11734        };
11735        let mut template = runner_at(RunStatus::Reviewing);
11736        template.state.repo = repo.clone();
11737        template.state.base_commit = current.clone();
11738        template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11739        template.state.origin = Some(crate::run::Origin::queue("selected"));
11740        template.state.pr_head = Some(selection.branch.clone());
11741        template.state.pr_recovery = Some(selection.clone());
11742        let mut runner =
11743            Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11744                .await
11745                .unwrap();
11746        let wt = runner.state.winner().unwrap().worktree.clone();
11747        assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11748        assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11749        assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11750        assert!(runner.state.seeds.is_empty());
11751        assert_eq!(runner.state.instruction, "preserve measured behavior");
11752        runner.sync_to_base().await.unwrap();
11753        assert_eq!(
11754            std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11755            "prerequisite"
11756        );
11757        assert_eq!(
11758            std::fs::read_to_string(wt.join("implementation")).unwrap(),
11759            "measured implementation"
11760        );
11761        assert_eq!(
11762            std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11763            "current main change"
11764        );
11765        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11766        assert_eq!(
11767            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11768            head
11769        );
11770
11771        // A failed selected-source setup stays inspectable, never resumable as a new competition.
11772        let mut partial = runner_at(RunStatus::Reviewing);
11773        partial.state.repo = repo.clone();
11774        partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11775        partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11776            run: Some("exit 1".into()),
11777            ..Default::default()
11778        }];
11779        let selection = runner.state.pr_recovery.clone().unwrap();
11780        partial.state.pr_recovery = Some(selection.clone());
11781        partial.state.pr_head = Some(selection.branch.clone());
11782        partial.state.origin = Some(crate::run::Origin::queue("selected"));
11783        let id = partial.state.id.clone();
11784        let error = match Runner::open_selected(
11785            &repo,
11786            partial.state,
11787            partial.roles,
11788            1,
11789            runner.state.base_commit.clone(),
11790            selection,
11791        )
11792        .await
11793        {
11794            Ok(_) => panic!("setup must fail"),
11795            Err(e) => e,
11796        };
11797        assert_eq!(
11798            error
11799                .downcast_ref::<crate::pr_recovery::StartupFailed>()
11800                .unwrap()
11801                .run,
11802            id
11803        );
11804        let failed = RunState::load(&id).unwrap();
11805        assert_eq!(failed.status, RunStatus::Blocked);
11806        assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11807        assert!(Runner::resume(&id).is_err());
11808        runner.state.status = RunStatus::Ready;
11809        runner.state.driver_exited = true;
11810        runner.state.save().unwrap();
11811        let mut retry = runner_at(RunStatus::Reviewing);
11812        retry.state.repo = repo.clone();
11813        retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11814        let mut reselected = runner.state.pr_recovery.clone().unwrap();
11815        reselected.approved_at = jiff::Timestamp::now();
11816        retry.state.origin = Some(crate::run::Origin::queue("selected"));
11817        retry.state.pr_head = Some(reselected.branch.clone());
11818        retry.state.pr_recovery = Some(reselected.clone());
11819        crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11820            .await
11821            .unwrap();
11822        let restarted = Runner::open_selected(
11823            &repo,
11824            retry.state,
11825            retry.roles,
11826            1,
11827            runner.state.base_commit.clone(),
11828            reselected,
11829        )
11830        .await
11831        .unwrap();
11832        assert_eq!(
11833            git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
11834                .await
11835                .unwrap(),
11836            head
11837        );
11838        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11839        assert_eq!(
11840            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11841            head
11842        );
11843    }
11844
11845    #[tokio::test]
11846    async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
11847        ask_test_home();
11848        let tmp = tempfile::tempdir().unwrap();
11849        let repo = tmp.path().join("repo");
11850        std::fs::create_dir_all(&repo).unwrap();
11851        init_repo(&repo);
11852        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11853        git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
11854        std::fs::write(repo.join("referenced"), "required commit").unwrap();
11855        git::git(&repo, &["add", "."]).await.unwrap();
11856        git::git(&repo, &["commit", "-m", "required seed"])
11857            .await
11858            .unwrap();
11859        let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
11860        git::git(&repo, &["checkout", "main"]).await.unwrap();
11861        let mut runner = runner_at(RunStatus::Prep);
11862        runner.state.repo = repo.clone();
11863        runner.state.base_commit = base.clone();
11864        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
11865        runner.state.config.graph.advise = false;
11866        runner.state.config.blind.commit_msg_hook = false;
11867        runner.state.seeds = vec![refs::Seed {
11868            token: seed.clone(),
11869            sha: seed.clone(),
11870            kind: refs::SeedKind::Unmerged,
11871            branch: false,
11872            detail: String::new(),
11873        }];
11874        runner.roles.implementers = vec![spec("one"), spec("two")];
11875        runner.roles.judges = vec![spec("judge")];
11876        let label = blind::assign_labels(2, runner.state.seed)[0];
11877        let branch = runner.state.branch_for(label);
11878        let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
11879        git::worktree_add_branch(&repo, &worktree, &branch, &base)
11880            .await
11881            .unwrap();
11882        runner.state.candidates.push(Candidate {
11883            index: 0,
11884            label,
11885            agent: "one".into(),
11886            branch,
11887            worktree,
11888            summary: String::new(),
11889            stat: String::new(),
11890            files: 0,
11891            commits: 0,
11892            empty: false,
11893            failed: None,
11894            verified_noop: None,
11895            duration_ms: 0,
11896            folded: false,
11897        });
11898        runner.state.save().unwrap();
11899        // Reload the checkpoint immediately before its first cherry-pick.
11900        runner.state = RunState::load(&runner.state.id).unwrap();
11901        assert_eq!(runner.state.prep_complete, Some(false));
11902        runner.prep().await.unwrap();
11903        assert_eq!(runner.state.prep_complete, Some(true));
11904        assert_eq!(runner.state.candidates.len(), 2);
11905        for candidate in &runner.state.candidates {
11906            assert_eq!(
11907                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11908                git::tree_of(&repo, &seed).await.unwrap()
11909            );
11910            assert_eq!(
11911                std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
11912                "required commit"
11913            );
11914        }
11915        assert!(runner.state.worktree_root().join("judge-1").exists());
11916        let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
11917        runner.prep().await.unwrap();
11918        assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
11919
11920        runner.state.prep_complete = Some(false);
11921        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
11922            run: Some(format!(
11923                "test -f '{}'",
11924                tmp.path().join("dependency-ready").display()
11925            )),
11926            ..Default::default()
11927        }];
11928        assert!(runner.prep().await.is_err());
11929        assert!(runner.state.prep_failure.is_some());
11930        std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
11931        runner.state = RunState::load(&runner.state.id).unwrap();
11932        assert!(
11933            runner.prep().await.is_err(),
11934            "correcting the cause alone must not auto-retry"
11935        );
11936        let failed_at = runner.state.events.last().unwrap().at;
11937        assert!(
11938            runner.retry_preparation(failed_at).is_err(),
11939            "old approvals cannot unlock the hold"
11940        );
11941        let mut task = crate::queue::Task::new(
11942            "retry".into(),
11943            "retry setup".into(),
11944            repo.clone(),
11945            crate::queue::Source::Human,
11946        );
11947        task.start(runner.state.id.clone());
11948        task.hold_machine(Some("setup failed".into()));
11949        task.release_operator("cli", None);
11950        runner
11951            .retry_preparation(task.resumption.as_ref().unwrap().at)
11952            .unwrap();
11953        runner.prep().await.unwrap();
11954        assert!(runner.state.prep_failure.is_none());
11955        assert_eq!(runner.state.prep_complete, Some(true));
11956        assert_eq!(runner.state.candidates.len(), 2);
11957        assert!(runner.state.worktree_root().join("judge-1").exists());
11958        for candidate in &runner.state.candidates {
11959            assert_eq!(
11960                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11961                git::tree_of(&repo, &seed).await.unwrap()
11962            );
11963        }
11964    }
11965
11966    async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
11967        let mut heads = Vec::new();
11968        for candidate in candidates {
11969            heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
11970        }
11971        heads
11972    }
11973
11974    fn init_repo(dir: &Path) {
11975        let run = |args: &[&str]| {
11976            let out = std::process::Command::new("git")
11977                .args(args)
11978                .current_dir(dir)
11979                .quiet()
11980                .output()
11981                .expect("spawn git");
11982            assert!(
11983                out.status.success(),
11984                "git {args:?} failed: {}",
11985                String::from_utf8_lossy(&out.stderr)
11986            );
11987        };
11988        run(&["init", "-b", "main"]);
11989        run(&["config", "user.name", "magi test"]);
11990        run(&["config", "user.email", "magi@example.com"]);
11991        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11992        run(&["add", "-A"]);
11993        run(&["commit", "-m", "init"]);
11994    }
11995
11996    // `settle_questions` is what closes the ghost the phone showed: a run's
11997    // seat asked something, the run then ended, and nothing was left to
11998    // abandon the question it left `open`. `HOME` is a process-wide
11999    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
12000    // first time it runs in the binary — every test below still reaches the
12001    // same directory whichever call won, and each gets its own run id from
12002    // `RunState::new`, so they never collide there.
12003    fn ask_test_home() {
12004        crate::run::pin_test_home();
12005    }
12006
12007    /// A minimal, git-free `Runner` at a given status — `settle_questions`
12008    /// reads nothing else off it.
12009    fn runner_at(status: RunStatus) -> Runner {
12010        let mut state = RunState::new(
12011            PathBuf::from("/nonexistent/repo"),
12012            "main".to_owned(),
12013            "deadbeef".to_owned(),
12014            "task".to_owned(),
12015            Config::default(),
12016        );
12017        // These fixtures share one home and save concurrently. Short clock
12018        // IDs can collide, making an ownership test read another test's run.
12019        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12020        state.id = format!(
12021            "20000101-000000-{:04x}",
12022            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
12023        );
12024        state.status = status;
12025        Runner {
12026            state,
12027            roles: ResolvedRoles {
12028                implementers: Vec::new(),
12029                judges: Vec::new(),
12030                reviewers: Vec::new(),
12031                fixer: None,
12032                conductor: conductor(),
12033                implementer_roster: Vec::new(),
12034                judge_roster: Vec::new(),
12035                reviewer_roster: Vec::new(),
12036            },
12037            sem: Arc::new(Semaphore::new(1)),
12038            pause: Pause::new(),
12039            interrupt: Pause::new(),
12040        }
12041    }
12042
12043    fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
12044        crate::run::MergeRepair {
12045            question: "repair-question".into(),
12046            head: "old-head".into(),
12047            pr: "https://github.com/o/r/pull/42".into(),
12048            reviews: vec![review_round(false, 1, 1, 1, false, true)],
12049            gate: vec![outcome(Some(0), false)],
12050            gate_head: Some("old-head".into()),
12051            gate_fixes: Vec::new(),
12052            pre_gate: Vec::new(),
12053            phase,
12054            failure: None,
12055        }
12056    }
12057
12058    #[tokio::test]
12059    async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
12060        crate::run::pin_test_home();
12061        let mut runner = runner_at(RunStatus::Landing);
12062        runner.state.merge_repairs.push(merge_repair_request(
12063            crate::run::MergeRepairPhase::Requested,
12064        ));
12065        runner.state.save().unwrap();
12066        let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
12067        assert!(
12068            runner
12069                .execute()
12070                .await
12071                .unwrap_err()
12072                .to_string()
12073                .contains("already running")
12074        );
12075        assert_eq!(
12076            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12077            crate::run::MergeRepairPhase::Requested
12078        );
12079        drop(claim);
12080        let mut disk = runner.state.clone();
12081        disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12082        disk.save().unwrap();
12083        assert!(
12084            runner
12085                .execute()
12086                .await
12087                .unwrap_err()
12088                .to_string()
12089                .contains("decision changed")
12090        );
12091        assert_eq!(
12092            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12093            crate::run::MergeRepairPhase::Complete
12094        );
12095    }
12096
12097    #[test]
12098    fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12099        crate::run::pin_test_home();
12100        let mut runner = runner_at(RunStatus::Landing);
12101        let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12102        runner.state.reviews = request.reviews.clone();
12103        runner.state.gate = request.gate.clone();
12104        runner.state.gate_ran = true;
12105        runner.state.land_approval = Some(crate::run::LandApproval {
12106            question: "old".into(),
12107            head: "old-head".into(),
12108        });
12109        runner.state.merge_repairs.push(request);
12110        runner.begin_merge_repair_review(0).unwrap();
12111        assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12112        assert!(!runner.state.gate_status().ok());
12113        assert!(runner.state.land_approval.is_none());
12114        let disk = RunState::load(&runner.state.id).unwrap();
12115        assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12116        assert!(disk.merge_repairs[0].gate[0].ok());
12117        assert_eq!(
12118            disk.merge_repairs[0].phase,
12119            crate::run::MergeRepairPhase::Reviewing
12120        );
12121        assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12122        assert!(disk.reviews.is_empty());
12123    }
12124
12125    #[tokio::test]
12126    async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12127        crate::run::pin_test_home();
12128        for phase in [
12129            crate::run::MergeRepairPhase::Fixing,
12130            crate::run::MergeRepairPhase::Requested,
12131        ] {
12132            let mut runner = runner_at(RunStatus::Landing);
12133            runner.state.config.graph.land_rounds = 0;
12134            runner.state.merge_repairs.push(merge_repair_request(phase));
12135            runner.state.save().unwrap();
12136            assert!(!runner.repair_merge_review().await.unwrap());
12137            assert_eq!(runner.state.status, RunStatus::Blocked);
12138            assert_eq!(
12139                runner.state.merge_repairs[0].phase,
12140                crate::run::MergeRepairPhase::Failed
12141            );
12142            let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12143            assert!(
12144                reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12145                    "interrupted"
12146                } else {
12147                    "budget exhausted"
12148                })
12149            );
12150            assert!(runner.state.merge.is_none());
12151        }
12152    }
12153
12154    /// `park_here` folding in the reason `Pause::park_because` recorded -
12155    /// this is what lets an operator reading a run's events tell an
12156    /// interrupt-driven park from an ordinary shutdown park.
12157    #[test]
12158    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12159        crate::run::pin_test_home();
12160        let mut runner = runner_at(RunStatus::Implementing);
12161        let interrupt = Pause::new();
12162        runner.watch_interrupt(interrupt.clone());
12163
12164        interrupt.park_because("task a1b2 asked to run first");
12165
12166        assert!(runner.park_here().expect("park_here"));
12167        assert!(runner.state.parked);
12168        let last = runner.state.events.last().expect("a park event");
12169        assert_eq!(last.node, "park");
12170        assert!(
12171            last.message.contains("task a1b2 asked to run first"),
12172            "expected the interrupt reason in {:?}",
12173            last.message
12174        );
12175    }
12176
12177    /// The cause is stored on the pass when it parks, and the first cause a
12178    /// shared pause was given is the one that stays.
12179    #[test]
12180    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12181        crate::run::pin_test_home();
12182        let mut runner = runner_at(RunStatus::Implementing);
12183        runner.state.begin_pass();
12184        let shutdown = Pause::new();
12185        runner.on_pause(shutdown.clone());
12186        let upgrade = ParkCause::Upgrade {
12187            from: "0.121.0".to_owned(),
12188            to: Some("0.121.1".to_owned()),
12189        };
12190        shutdown.park_for(upgrade.clone());
12191        shutdown.park_for(ParkCause::Operator);
12192
12193        assert!(runner.park_here().expect("park_here"));
12194        assert_eq!(
12195            runner.state.passes[0].end,
12196            Some(crate::run::PassEnd::Parked { cause: upgrade })
12197        );
12198        let last = runner.state.events.last().expect("a park event");
12199        assert!(last.message.contains("0.121.0"), "{}", last.message);
12200    }
12201
12202    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
12203    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
12204    /// cleared) must not make a *different* run - one only watching its own,
12205    /// unshared interrupt `Pause` - see itself as parked. If a future change
12206    /// ever collapsed these back into one handle, the interrupt scheduler
12207    /// would park every run for the rest of the daemon's life, not just the
12208    /// one it meant to interrupt.
12209    #[test]
12210    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12211        crate::run::pin_test_home();
12212        let mut runner = runner_at(RunStatus::Implementing);
12213        let shutdown = Pause::new();
12214        runner.on_pause(shutdown.clone());
12215        let interrupt = Pause::new();
12216        runner.watch_interrupt(interrupt.clone());
12217
12218        // Nobody has asked for anything yet.
12219        assert!(!runner.park_here().expect("park_here"));
12220        assert!(!runner.state.parked);
12221
12222        // Only the interrupt handle fires; the shutdown handle stays clear.
12223        interrupt.park_because("test");
12224        assert!(!shutdown.parked());
12225        assert!(runner.park_here().expect("park_here"));
12226    }
12227
12228    /// The property every prior attempt at this feature failed to pin down:
12229    /// asking a run to park while one of its nodes has a real, in-flight
12230    /// async operation running (an agent call, in production) must not cut
12231    /// that operation short. `park_here` is only ever consulted *between*
12232    /// `execute`'s node calls - see its own doc - so nothing inside a node
12233    /// can observe a park request until the node itself returns. This proves
12234    /// that structurally, with real `tokio` concurrency and a channel
12235    /// handshake (never a sleep, which would only prove "usually", not
12236    /// "cannot"): the "node" below reports that it has genuinely started,
12237    /// and only then is the park requested; the node still has to be told to
12238    /// finish before `park_here` is ever called, exactly mirroring every
12239    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
12240    /// in `execute`.
12241    #[tokio::test]
12242    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12243        crate::run::pin_test_home();
12244        let mut runner = runner_at(RunStatus::Implementing);
12245        let interrupt = Pause::new();
12246        runner.watch_interrupt(interrupt.clone());
12247
12248        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12249        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12250
12251        // Stands in for one node's in-flight agent call: it proves it has
12252        // genuinely started, then blocks - exactly as a spawned CLI process
12253        // does - until told to finish.
12254        let node = async move {
12255            started_tx.send(()).expect("send started");
12256            finish_rx.await.expect("recv finish");
12257            "node finished"
12258        };
12259
12260        let interrupter = async move {
12261            started_rx.await.expect("recv started");
12262            // The call is now genuinely in flight. Ask it to park.
12263            interrupt.park_because("higher-priority task waiting");
12264            // Nothing the node does can observe this yet - there is no
12265            // check inside it, by construction - so let the executor run
12266            // anything pending and then let the node finish on its own.
12267            tokio::task::yield_now().await;
12268            finish_tx.send(()).expect("send finish");
12269        };
12270
12271        let (node_result, ()) = tokio::join!(node, interrupter);
12272        assert_eq!(
12273            node_result, "node finished",
12274            "the in-flight call ran to completion"
12275        );
12276
12277        // Only now, at the boundary the real `execute` would check right
12278        // after this node, does the park take effect.
12279        assert!(runner.park_here().expect("park_here"));
12280        assert!(runner.state.parked);
12281    }
12282
12283    /// A run parked mid-competition carries every field it had accumulated
12284    /// through the exact same disk round-trip an ordinary resume uses -
12285    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
12286    /// Nothing about parking for an interrupt is a special case of that path;
12287    /// this is what proves it rather than assuming it.
12288    #[test]
12289    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12290        crate::run::pin_test_home();
12291        let mut runner = runner_at(RunStatus::Judging);
12292        // `Runner::resume` re-resolves roles from the saved config, which
12293        // refuses an empty roster - give it the same minimal one `conductor`
12294        // itself uses.
12295        runner.state.config.agents = vec![conductor()];
12296        runner.state.candidates = vec![Candidate {
12297            index: 0,
12298            label: 'A',
12299            agent: "alpha".to_owned(),
12300            branch: "magi/x/A".to_owned(),
12301            worktree: PathBuf::from("/nonexistent/worktree"),
12302            summary: "did the thing".to_owned(),
12303            stat: "1 file changed".to_owned(),
12304            files: 1,
12305            commits: 1,
12306            empty: false,
12307            failed: None,
12308            verified_noop: None,
12309            duration_ms: 1234,
12310            folded: false,
12311        }];
12312        let run_id = runner.state.id.clone();
12313
12314        let interrupt = Pause::new();
12315        runner.watch_interrupt(interrupt.clone());
12316        interrupt.park_because("task c3d4 asked to run first");
12317        assert!(runner.park_here().expect("park_here"));
12318
12319        let resumed = Runner::resume(&run_id).expect("resume");
12320        assert_eq!(resumed.state.candidates.len(), 1);
12321        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12322        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12323        assert_eq!(resumed.state.status, runner.state.status);
12324        assert!(
12325            resumed.state.parked,
12326            "still parked until `execute` actually walks the graph again"
12327        );
12328        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12329    }
12330
12331    /// A fresh open question on `run`, stored and handed back for assertions.
12332    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12333        let mut q = ask::Question::new(
12334            run.to_owned(),
12335            "implement".to_owned(),
12336            "impl-A".to_owned(),
12337            "Which storage backend should the cache use?".to_owned(),
12338            String::new(),
12339            vec!["SQLite".to_owned(), "Redis".to_owned()],
12340        );
12341        store.put(&mut q).unwrap();
12342        q
12343    }
12344
12345    #[test]
12346    fn a_failed_runs_open_question_is_abandoned() {
12347        ask_test_home();
12348        let store = ask::Questions::open();
12349        let mut runner = runner_at(RunStatus::Failed);
12350        let run = runner.state.id.clone();
12351        let q = ask_open_question(&store, &run);
12352
12353        runner.settle_questions();
12354
12355        let back = store.get(&q.id).unwrap();
12356        assert!(
12357            !back.status.open(),
12358            "the seat that asked died with the run; nobody is left to read an answer"
12359        );
12360        assert!(
12361            back.detail.contains(&run) && back.detail.contains("failed"),
12362            "the reason names what the run became, not just that it is gone: {}",
12363            back.detail
12364        );
12365    }
12366
12367    #[test]
12368    fn a_merged_runs_open_question_is_abandoned_too() {
12369        ask_test_home();
12370        let store = ask::Questions::open();
12371        // A run that finishes cleanly still leaves nobody to read an answer -
12372        // this is not only a failure-path cleanup.
12373        for status in [RunStatus::Merged, RunStatus::Ready] {
12374            let mut runner = runner_at(status);
12375            let run = runner.state.id.clone();
12376            let q = ask_open_question(&store, &run);
12377
12378            runner.settle_questions();
12379
12380            let back = store.get(&q.id).unwrap();
12381            assert!(
12382                !back.status.open(),
12383                "{status:?} run's question must not outlive the run"
12384            );
12385        }
12386    }
12387
12388    #[test]
12389    fn a_still_resumable_runs_open_question_is_left_alone() {
12390        ask_test_home();
12391        let store = ask::Questions::open();
12392        // `Blocked` and `Stalled` can still be resumed — the candidates, the
12393        // review round and the seat sessions are all still on disk — so a
12394        // question asked mid-round may yet get a real answer from a real
12395        // resume. Sweeping it here would be exactly the failure mode this
12396        // whole feature exists to avoid on the other side.
12397        for status in [RunStatus::Blocked, RunStatus::Stalled] {
12398            let mut runner = runner_at(status);
12399            let run = runner.state.id.clone();
12400            let q = ask_open_question(&store, &run);
12401
12402            runner.settle_questions();
12403
12404            let back = store.get(&q.id).unwrap();
12405            assert!(
12406                back.status.open(),
12407                "{status:?} is still alive; the question must still be waiting"
12408            );
12409        }
12410    }
12411
12412    #[test]
12413    fn settle_questions_never_touches_an_already_answered_question() {
12414        ask_test_home();
12415        let store = ask::Questions::open();
12416        let mut runner = runner_at(RunStatus::Failed);
12417        let run = runner.state.id.clone();
12418        let mut q = ask_open_question(&store, &run);
12419        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12420            .unwrap();
12421        store.put(&mut q).unwrap();
12422
12423        // Called twice, the way a crash-recovered daemon reclaim and the
12424        // graph's own cleanup both can for the same run — `abandon_for_run`
12425        // only ever touches what is still open, so this must be inert both
12426        // times, not merely the second.
12427        runner.settle_questions();
12428        runner.settle_questions();
12429
12430        let back = store.get(&q.id).unwrap();
12431        assert_eq!(
12432            back.status,
12433            ask::QuestionStatus::Answered,
12434            "a real answer is a decision on record, never overwritten by a sweep"
12435        );
12436    }
12437
12438    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
12439    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
12440    /// without merging, whose winner is still the operator's answer to read.
12441    /// Nothing previously called `fold_run` itself with a real `tally`, so
12442    /// this is the first test to pin down the one distinction the whole
12443    /// automatic-fold feature depends on: the winner's worktree and branch
12444    /// must survive, everything else sharing the run's worktree bay - a
12445    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
12446    /// second sweep treats every non-winner directory under the bay alike -
12447    /// must not.
12448    #[tokio::test]
12449    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12450        crate::run::pin_test_home();
12451        let tmp = tempfile::tempdir().expect("tempdir");
12452        let repo = tmp.path().join("repo");
12453        std::fs::create_dir_all(&repo).unwrap();
12454        init_repo(&repo);
12455
12456        let mut config = Config::default();
12457        config.graph.worktree_root = Some(tmp.path().join("wt"));
12458
12459        let mut state = RunState::new(
12460            repo.clone(),
12461            "main".to_owned(),
12462            "deadbeef".to_owned(),
12463            "task".to_owned(),
12464            config,
12465        );
12466        let root = state.worktree_root();
12467        let wt_a = root.join("cand-A");
12468        let wt_b = root.join("cand-B");
12469        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12470            .await
12471            .expect("worktree A");
12472        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12473            .await
12474            .expect("worktree B");
12475
12476        state.candidates = vec![
12477            Candidate {
12478                index: 0,
12479                label: 'A',
12480                agent: "alpha".to_owned(),
12481                branch: "magi/x/A".to_owned(),
12482                worktree: wt_a.clone(),
12483                summary: String::new(),
12484                stat: String::new(),
12485                files: 0,
12486                commits: 0,
12487                empty: false,
12488                failed: None,
12489                verified_noop: None,
12490                duration_ms: 0,
12491                folded: false,
12492            },
12493            Candidate {
12494                index: 1,
12495                label: 'B',
12496                agent: "beta".to_owned(),
12497                branch: "magi/x/B".to_owned(),
12498                worktree: wt_b.clone(),
12499                summary: String::new(),
12500                stat: String::new(),
12501                files: 0,
12502                commits: 0,
12503                empty: false,
12504                failed: None,
12505                verified_noop: None,
12506                duration_ms: 0,
12507                folded: false,
12508            },
12509        ];
12510        state.tally = Some(Tally {
12511            first_choice: BTreeMap::from([('A', 1)]),
12512            borda: BTreeMap::new(),
12513            winner: 'A',
12514            rankings: 1,
12515            unanimous_initial: true,
12516            deliberated: false,
12517            changed_votes: 0,
12518            unanimous_final: true,
12519            tie_break: None,
12520            judges: 1,
12521            present: 1,
12522            quorum: 1,
12523            met_quorum: true,
12524            uncontested: None,
12525        });
12526        state.status = RunStatus::Ready;
12527
12528        fold_run(&mut state, false, &crate::run::home())
12529            .await
12530            .expect("fold_run");
12531
12532        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12533        assert!(
12534            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12535            "the unmerged winner's branch survives"
12536        );
12537        assert!(
12538            !state.candidates[0].folded,
12539            "the winner is not marked folded"
12540        );
12541
12542        assert!(!wt_b.exists(), "the loser's worktree is removed");
12543        assert!(
12544            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12545            "the loser's branch is removed"
12546        );
12547        assert!(state.candidates[1].folded, "the loser is marked folded");
12548    }
12549
12550    /// A branch handed to a later run is that run's (and its pull request's):
12551    /// folding the run that released it must not delete it.
12552    #[tokio::test]
12553    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12554        let tmp = tempfile::tempdir().expect("tempdir");
12555        let repo = tmp.path().join("repo");
12556        std::fs::create_dir_all(&repo).unwrap();
12557        init_repo(&repo);
12558        let home = tmp.path().join("home");
12559
12560        let mut config = Config::default();
12561        config.graph.worktree_root = Some(tmp.path().join("wt"));
12562        let mut state = RunState::new(
12563            repo.clone(),
12564            "main".to_owned(),
12565            "deadbeef".to_owned(),
12566            "task".to_owned(),
12567            config,
12568        );
12569        // The worktree is already gone (released); the branch survives.
12570        git::git(&repo, &["branch", "magi/x/A", "main"])
12571            .await
12572            .expect("branch");
12573        state.candidates = vec![Candidate {
12574            index: 0,
12575            label: 'A',
12576            agent: "alpha".to_owned(),
12577            branch: "magi/x/A".to_owned(),
12578            worktree: state.worktree_root().join("cand-A"),
12579            summary: String::new(),
12580            stat: String::new(),
12581            files: 0,
12582            commits: 0,
12583            empty: false,
12584            failed: None,
12585            verified_noop: None,
12586            duration_ms: 0,
12587            folded: true,
12588        }];
12589        state.released_to = Some("20260901-000000-new1".to_owned());
12590        state.released_branches = vec!["magi/x/A".to_owned()];
12591
12592        fold_run(&mut state, true, &home).await.expect("fold_run");
12593
12594        assert!(
12595            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12596            "the handed-over branch survives a fold"
12597        );
12598    }
12599
12600    /// A winner with nothing ahead of the base is caught before `gh` is ever
12601    /// asked for a pull request, and the message carries what the task's
12602    /// references resolved to.
12603    #[tokio::test]
12604    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12605        let tmp = tempfile::tempdir().expect("tempdir");
12606        let repo = tmp.path().join("repo");
12607        std::fs::create_dir_all(&repo).unwrap();
12608        init_repo(&repo);
12609        let run = |args: &[&str]| {
12610            let out = std::process::Command::new("git")
12611                .quiet()
12612                .args(args)
12613                .current_dir(&repo)
12614                .output()
12615                .expect("spawn git");
12616            assert!(out.status.success(), "git {args:?}");
12617        };
12618        run(&["branch", "magi/x/A"]);
12619        run(&["checkout", "-q", "-b", "magi/x/B"]);
12620        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12621        run(&["add", "-A"]);
12622        run(&["commit", "-q", "-m", "work"]);
12623        run(&["checkout", "-q", "main"]);
12624
12625        // A pull request is compared against the remote's base, so the
12626        // fixture needs one. Before it exists nothing can be read, and the
12627        // answer must be "not empty".
12628        let probe = RunState::new(
12629            repo.clone(),
12630            "main".to_owned(),
12631            "deadbeef".to_owned(),
12632            "task".to_owned(),
12633            Config::default(),
12634        );
12635        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12636        let bare = tmp.path().join("origin.git");
12637        let out = std::process::Command::new("git")
12638            .quiet()
12639            .args(["init", "-q", "--bare"])
12640            .arg(&bare)
12641            .output()
12642            .expect("spawn git");
12643        assert!(out.status.success(), "git init --bare");
12644        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12645        run(&["push", "-q", "origin", "main"]);
12646
12647        let mut state = RunState::new(
12648            repo.clone(),
12649            "main".to_owned(),
12650            "deadbeef".to_owned(),
12651            "task".to_owned(),
12652            Config::default(),
12653        );
12654        state.seeds = vec![refs::Seed {
12655            token: "magi/27b2/A".to_owned(),
12656            kind: refs::SeedKind::Unresolved,
12657            sha: String::new(),
12658            branch: true,
12659            detail: "no branch or commit named magi/27b2/A".to_owned(),
12660        }];
12661
12662        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12663        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12664        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12665        let detail = empty_candidate_detail(&state, "main");
12666        assert!(detail.starts_with("empty candidate"), "{detail}");
12667        assert!(detail.contains("magi/27b2/A"), "{detail}");
12668    }
12669
12670    /// `status == Ready` used to be read as "this is the harmless
12671    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
12672    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
12673    /// run's PR was closed without merging — and reentering `merge` with
12674    /// `mode` still `Pr` does not know the difference, so it pushed and
12675    /// opened a second pull request. `mode == Local` reproduces the same
12676    /// blind spot without a network call: reentry must not attempt another
12677    /// git merge once this node has already recorded an outcome.
12678    #[tokio::test]
12679    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12680        let tmp = tempfile::tempdir().expect("tempdir");
12681        let repo = tmp.path().join("repo");
12682        std::fs::create_dir_all(&repo).unwrap();
12683        init_repo(&repo);
12684
12685        let mut config = Config::default();
12686        config.merge.mode = MergeMode::Local;
12687
12688        let mut state = RunState::new(
12689            repo.clone(),
12690            "main".to_owned(),
12691            "deadbeef".to_owned(),
12692            "task".to_owned(),
12693            config,
12694        );
12695        state.candidates = vec![Candidate {
12696            index: 0,
12697            label: 'A',
12698            agent: "alpha".to_owned(),
12699            branch: "does-not-exist".to_owned(),
12700            worktree: repo.clone(),
12701            summary: String::new(),
12702            stat: String::new(),
12703            files: 0,
12704            commits: 0,
12705            empty: false,
12706            failed: None,
12707            verified_noop: None,
12708            duration_ms: 0,
12709            folded: false,
12710        }];
12711        state.tally = Some(Tally {
12712            first_choice: BTreeMap::from([('A', 1)]),
12713            borda: BTreeMap::new(),
12714            winner: 'A',
12715            rankings: 1,
12716            unanimous_initial: true,
12717            deliberated: false,
12718            changed_votes: 0,
12719            unanimous_final: true,
12720            tie_break: None,
12721            judges: 0,
12722            present: 0,
12723            quorum: 0,
12724            met_quorum: true,
12725            uncontested: Some("only candidate A produced a change".to_owned()),
12726        });
12727        state.reviews = vec![ReviewRound {
12728            round: 1,
12729            head: "deadbeef".to_owned(),
12730            verified_head: None,
12731            verified_at: None,
12732            reviews: Vec::new(),
12733            e2e: Vec::new(),
12734            fix: None,
12735            blocking: 0,
12736            answered: 0,
12737            expected: 0,
12738            clean: true,
12739            verify_retried: false,
12740            e2e_deferred: false,
12741            e2e_defer_reason: None,
12742            progressed: false,
12743            vote_split: false,
12744            reconsideration: Vec::new(),
12745            verdict: None,
12746        }];
12747        state.gate = vec![CommandOutcome {
12748            command: "test".to_owned(),
12749            code: Some(0),
12750            output_tail: String::new(),
12751            duration_ms: 0,
12752            resource_blocked: false,
12753        }];
12754        state.gate_ran = true;
12755        // Reached its conclusion already — e.g. `land` closing the PR without
12756        // merging it, which (like the honest `MergeMode::None` path) leaves
12757        // `status` at `Ready`. The recorded outcome is what actually marks
12758        // this node done.
12759        state.status = RunStatus::Ready;
12760        state.merge = Some(MergeOutcome {
12761            mode: MergeMode::Local,
12762            ok: false,
12763            detail: "already concluded".to_owned(),
12764            empty: false,
12765        });
12766
12767        let mut runner = Runner {
12768            state,
12769            roles: ResolvedRoles {
12770                implementers: Vec::new(),
12771                judges: Vec::new(),
12772                reviewers: Vec::new(),
12773                fixer: None,
12774                conductor: conductor(),
12775                implementer_roster: Vec::new(),
12776                judge_roster: Vec::new(),
12777                reviewer_roster: Vec::new(),
12778            },
12779            sem: Arc::new(Semaphore::new(1)),
12780            pause: Pause::new(),
12781            interrupt: Pause::new(),
12782        };
12783
12784        runner.merge().await.expect("merge");
12785
12786        assert_eq!(
12787            runner.state.status,
12788            RunStatus::Ready,
12789            "a concluded run's status must not change on reentry"
12790        );
12791        assert_eq!(
12792            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12793            Some("already concluded"),
12794            "merge must not run again once the node already recorded an outcome"
12795        );
12796    }
12797
12798    /// `gate` leaves `state.gate_ran` false both before it has ever run and
12799    /// when its last attempt was resource-blocked (the shared build cache
12800    /// could not be acquired or confirmed fresh in time - see
12801    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
12802    /// `Vec` this also leaves behind used to read as "nothing failed" and let
12803    /// a run merge a tree the gate never actually checked - exactly the case
12804    /// a contended cache produces on every retry until it clears. `merge`
12805    /// must refuse until `gate` has actually recorded an attempt.
12806    #[tokio::test]
12807    async fn merge_refuses_a_gate_that_has_not_actually_run() {
12808        let tmp = tempfile::tempdir().expect("tempdir");
12809        let repo = tmp.path().join("repo");
12810        std::fs::create_dir_all(&repo).unwrap();
12811        init_repo(&repo);
12812
12813        let mut config = Config::default();
12814        config.merge.mode = MergeMode::Local;
12815
12816        let mut state = RunState::new(
12817            repo.clone(),
12818            "main".to_owned(),
12819            "deadbeef".to_owned(),
12820            "task".to_owned(),
12821            config,
12822        );
12823        state.candidates = vec![Candidate {
12824            index: 0,
12825            label: 'A',
12826            agent: "alpha".to_owned(),
12827            branch: "does-not-exist".to_owned(),
12828            worktree: repo.clone(),
12829            summary: String::new(),
12830            stat: String::new(),
12831            files: 0,
12832            commits: 0,
12833            empty: false,
12834            failed: None,
12835            verified_noop: None,
12836            duration_ms: 0,
12837            folded: false,
12838        }];
12839        state.tally = Some(Tally {
12840            first_choice: BTreeMap::from([('A', 1)]),
12841            borda: BTreeMap::new(),
12842            winner: 'A',
12843            rankings: 1,
12844            unanimous_initial: true,
12845            deliberated: false,
12846            changed_votes: 0,
12847            unanimous_final: true,
12848            tie_break: None,
12849            judges: 0,
12850            present: 0,
12851            quorum: 0,
12852            met_quorum: true,
12853            uncontested: Some("only candidate A produced a change".to_owned()),
12854        });
12855        state.reviews = vec![ReviewRound {
12856            round: 1,
12857            head: "deadbeef".to_owned(),
12858            verified_head: None,
12859            verified_at: None,
12860            reviews: Vec::new(),
12861            e2e: Vec::new(),
12862            fix: None,
12863            blocking: 0,
12864            answered: 0,
12865            expected: 0,
12866            clean: true,
12867            verify_retried: false,
12868            e2e_deferred: false,
12869            e2e_defer_reason: None,
12870            progressed: false,
12871            vote_split: false,
12872            reconsideration: Vec::new(),
12873            verdict: None,
12874        }];
12875        // The point: `gate` has not recorded anything yet.
12876        state.gate = Vec::new();
12877        state.gate_ran = false;
12878        state.status = RunStatus::Gating;
12879
12880        let mut runner = Runner {
12881            state,
12882            roles: ResolvedRoles {
12883                implementers: Vec::new(),
12884                judges: Vec::new(),
12885                reviewers: Vec::new(),
12886                fixer: None,
12887                conductor: conductor(),
12888                implementer_roster: Vec::new(),
12889                judge_roster: Vec::new(),
12890                reviewer_roster: Vec::new(),
12891            },
12892            sem: Arc::new(Semaphore::new(1)),
12893            pause: Pause::new(),
12894            interrupt: Pause::new(),
12895        };
12896
12897        runner.merge().await.expect("merge");
12898
12899        assert!(
12900            runner.state.merge.is_none(),
12901            "an empty gate must never be read as a passing one: {:?}",
12902            runner.state.merge
12903        );
12904    }
12905
12906    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
12907    /// commands configured and `merge.mode` is `none` (a review-only run).
12908    /// `gate` must still record a real attempt — zero commands, vacuously
12909    /// passed — rather than leaving `state.gate` empty in a way `merge`
12910    /// cannot tell apart from "never ran"; otherwise the run reaches
12911    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
12912    #[tokio::test]
12913    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12914        let tmp = tempfile::tempdir().expect("tempdir");
12915        let repo = tmp.path().join("repo");
12916        std::fs::create_dir_all(&repo).unwrap();
12917        init_repo(&repo);
12918
12919        // Default config: `verify.gate` empty, `merge.mode` is `none`.
12920        let config = Config::default();
12921
12922        let mut state = RunState::new(
12923            repo.clone(),
12924            "main".to_owned(),
12925            "deadbeef".to_owned(),
12926            "task".to_owned(),
12927            config,
12928        );
12929        state.candidates = vec![Candidate {
12930            index: 0,
12931            label: 'A',
12932            agent: "alpha".to_owned(),
12933            branch: "does-not-exist".to_owned(),
12934            worktree: repo.clone(),
12935            summary: String::new(),
12936            stat: String::new(),
12937            files: 0,
12938            commits: 0,
12939            empty: false,
12940            failed: None,
12941            verified_noop: None,
12942            duration_ms: 0,
12943            folded: false,
12944        }];
12945        state.tally = Some(Tally {
12946            first_choice: BTreeMap::from([('A', 1)]),
12947            borda: BTreeMap::new(),
12948            winner: 'A',
12949            rankings: 1,
12950            unanimous_initial: true,
12951            deliberated: false,
12952            changed_votes: 0,
12953            unanimous_final: true,
12954            tie_break: None,
12955            judges: 0,
12956            present: 0,
12957            quorum: 0,
12958            met_quorum: true,
12959            uncontested: Some("only candidate A produced a change".to_owned()),
12960        });
12961        state.reviews = vec![ReviewRound {
12962            round: 1,
12963            head: "deadbeef".to_owned(),
12964            verified_head: None,
12965            verified_at: None,
12966            reviews: Vec::new(),
12967            e2e: Vec::new(),
12968            fix: None,
12969            blocking: 0,
12970            answered: 0,
12971            expected: 0,
12972            clean: true,
12973            verify_retried: false,
12974            e2e_deferred: false,
12975            e2e_defer_reason: None,
12976            progressed: false,
12977            vote_split: false,
12978            reconsideration: Vec::new(),
12979            verdict: None,
12980        }];
12981
12982        let mut runner = Runner {
12983            state,
12984            roles: ResolvedRoles {
12985                implementers: Vec::new(),
12986                judges: Vec::new(),
12987                reviewers: Vec::new(),
12988                fixer: None,
12989                conductor: conductor(),
12990                implementer_roster: Vec::new(),
12991                judge_roster: Vec::new(),
12992                reviewer_roster: Vec::new(),
12993            },
12994            sem: Arc::new(Semaphore::new(1)),
12995            pause: Pause::new(),
12996            interrupt: Pause::new(),
12997        };
12998
12999        runner.gate().await.expect("gate");
13000        assert!(
13001            runner.state.gate_ran,
13002            "zero configured commands is still a real attempt, not an unrun gate"
13003        );
13004        assert!(runner.state.gate.is_empty());
13005        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
13006        assert_ne!(
13007            runner.state.status,
13008            RunStatus::Blocked,
13009            "a gate with nothing to check must not read as failed"
13010        );
13011
13012        runner.merge().await.expect("merge");
13013        assert_eq!(
13014            runner.state.status,
13015            RunStatus::Ready,
13016            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
13017        );
13018    }
13019
13020    /// `Config::cache_dir` is derived from `verify.e2e` as well as
13021    /// `verify.gate` (so the e2e leg and the final gate never build against
13022    /// different directories). With zero `verify.gate` commands but a
13023    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
13024    /// that lease before discovering it had nothing to run - so a repo with
13025    /// no gate commands could come back `resource_blocked` (and therefore
13026    /// still `gate_ran == false`) on nothing but an unrelated run holding the
13027    /// cache, exactly the contention this run's own zero commands could
13028    /// never have touched. `gate` must recognise there is nothing to check
13029    /// before it ever asks for the lease.
13030    #[tokio::test]
13031    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
13032        crate::run::pin_test_home();
13033        let home = crate::run::home();
13034
13035        let tmp = tempfile::tempdir().expect("tempdir");
13036        let repo = tmp.path().join("repo");
13037        std::fs::create_dir_all(&repo).unwrap();
13038        init_repo(&repo);
13039        // Unique to this test, so holding its lease cannot collide with
13040        // another test sharing the same process-wide `home`.
13041        let cache_dir = tmp.path().join("target");
13042
13043        let mut config = Config::default();
13044        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
13045        // `verify.gate` stays empty (the default). Bounded so a regression
13046        // that does start waiting fails the test in seconds, not hangs it.
13047        config.graph.timeout_verify = Some(2);
13048        config.graph.timeout_resource_wait = 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        config.graph.timeout_resource_wait = 2;
13416
13417        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13418        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13419            .expect("no io error acquiring directly")
13420        {
13421            crate::cache::AcquireOutcome::Acquired(g) => g,
13422            crate::cache::AcquireOutcome::Busy(b) => {
13423                panic!("expected the direct acquire to win the lease first: {b:?}")
13424            }
13425        };
13426
13427        let mut state = RunState::new(
13428            repo.clone(),
13429            "main".to_owned(),
13430            head.clone(),
13431            "task".to_owned(),
13432            config,
13433        );
13434        state.candidates = vec![Candidate {
13435            index: 0,
13436            label: 'A',
13437            agent: "alpha".to_owned(),
13438            branch: "does-not-exist".to_owned(),
13439            worktree: repo.clone(),
13440            summary: String::new(),
13441            stat: String::new(),
13442            files: 0,
13443            commits: 0,
13444            empty: false,
13445            failed: None,
13446            verified_noop: None,
13447            duration_ms: 0,
13448            folded: false,
13449        }];
13450        state.tally = Some(Tally {
13451            first_choice: BTreeMap::from([('A', 1)]),
13452            borda: BTreeMap::new(),
13453            winner: 'A',
13454            rankings: 1,
13455            unanimous_initial: true,
13456            deliberated: false,
13457            changed_votes: 0,
13458            unanimous_final: true,
13459            tie_break: None,
13460            judges: 0,
13461            present: 0,
13462            quorum: 0,
13463            met_quorum: true,
13464            uncontested: Some("only candidate A produced a change".to_owned()),
13465        });
13466        // The round budget's last round, deferred: `needs_catchup_run`'s
13467        // other trigger. `stop_reviewing`'s retry machinery must treat this
13468        // exactly like a resource-blocked attempt once it actually runs.
13469        state.reviews = vec![ReviewRound {
13470            round: 1,
13471            head: head.clone(),
13472            verified_head: None,
13473            verified_at: None,
13474            reviews: Vec::new(),
13475            e2e: Vec::new(),
13476            fix: None,
13477            blocking: 1,
13478            answered: 1,
13479            expected: 1,
13480            clean: false,
13481            verify_retried: false,
13482            e2e_deferred: true,
13483            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13484            progressed: false,
13485            vote_split: false,
13486            reconsideration: Vec::new(),
13487            verdict: None,
13488        }];
13489
13490        let mut runner = Runner {
13491            state,
13492            roles: ResolvedRoles {
13493                implementers: Vec::new(),
13494                judges: Vec::new(),
13495                reviewers: Vec::new(),
13496                fixer: None,
13497                conductor: conductor(),
13498                implementer_roster: Vec::new(),
13499                judge_roster: Vec::new(),
13500                reviewer_roster: Vec::new(),
13501            },
13502            sem: Arc::new(Semaphore::new(1)),
13503            pause: Pause::new(),
13504            interrupt: Pause::new(),
13505        };
13506
13507        let shell = runner.state.config.shell();
13508        runner
13509            .stop_reviewing("round budget spent", &shell, &repo)
13510            .await
13511            .expect("stop_reviewing");
13512
13513        let last = runner.state.reviews.last().expect("round record");
13514        assert_eq!(
13515            last.e2e_status(),
13516            E2eStatus::ResourceBlocked,
13517            "the shared cache is still held; the attempt must read as blocked, not deferred or \
13518             failed: {last:?}"
13519        );
13520        assert_eq!(
13521            last.verified_head.as_deref(),
13522            Some(head.as_str()),
13523            "which commit this attempt targeted is known even though nothing finished checking \
13524             it"
13525        );
13526        let first_attempt_at = last
13527            .verified_at
13528            .expect("when this attempt ran is known too");
13529        assert_ne!(
13530            runner.state.status,
13531            RunStatus::Blocked,
13532            "contention is evidence about the machine, not the patch — it must not settle the \
13533             run as blocked: {:?}",
13534            runner.state.status
13535        );
13536        assert!(
13537            !runner
13538                .state
13539                .events
13540                .iter()
13541                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13542            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13543            runner.state.events
13544        );
13545
13546        // The cache is still held: a later reentry must retry the same
13547        // round's verification again — not leave it looking exactly as
13548        // untouched as the first blocked attempt, which is indistinguishable
13549        // from never having tried again at all.
13550        runner
13551            .stop_reviewing("round budget spent", &shell, &repo)
13552            .await
13553            .expect("stop_reviewing retry");
13554        assert_eq!(
13555            runner.state.reviews.len(),
13556            1,
13557            "no new round was started: {:?}",
13558            runner.state.reviews
13559        );
13560        let last = runner.state.reviews.last().expect("round record");
13561        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13562        assert!(
13563            last.verified_at.expect("still known") > first_attempt_at,
13564            "a second reentry must be a fresh attempt, not a stale copy of the first"
13565        );
13566        assert_ne!(runner.state.status, RunStatus::Blocked);
13567
13568        held.release();
13569    }
13570
13571    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
13572    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
13573    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
13574    /// own top-of-function fast path (`review_conclusion`) correctly reads
13575    /// this shape as `None` rather than guessing `Blocked`, and the loop's
13576    /// own `for` range is empty once the round budget is spent, so
13577    /// `review_loop` must retry the check itself rather than silently doing
13578    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
13579    /// above exercises directly, but through `review_loop`'s own entry point
13580    /// this time, proving the wiring between the two rather than just the
13581    /// retry logic in isolation.
13582    #[tokio::test]
13583    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13584        crate::run::pin_test_home();
13585        let home = crate::run::home();
13586
13587        let tmp = tempfile::tempdir().expect("tempdir");
13588        let repo = tmp.path().join("repo");
13589        std::fs::create_dir_all(&repo).unwrap();
13590        init_repo(&repo);
13591        let head = crate::git::rev_parse(&repo, "HEAD")
13592            .await
13593            .expect("rev-parse");
13594        let cache_dir = tmp.path().join("target");
13595
13596        let mut config = Config::default();
13597        config.verify.e2e = vec![format!(
13598            "CARGO_TARGET_DIR='{}' test -f README.md",
13599            cache_dir.display()
13600        )];
13601        config.graph.review_rounds = 1;
13602        config.graph.timeout_verify = Some(2);
13603        config.graph.timeout_resource_wait = 2;
13604
13605        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13606        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13607            .expect("no io error acquiring directly")
13608        {
13609            crate::cache::AcquireOutcome::Acquired(g) => g,
13610            crate::cache::AcquireOutcome::Busy(b) => {
13611                panic!("expected the direct acquire to win the lease first: {b:?}")
13612            }
13613        };
13614
13615        let mut state = RunState::new(
13616            repo.clone(),
13617            "main".to_owned(),
13618            head.clone(),
13619            "task".to_owned(),
13620            config,
13621        );
13622        state.candidates = vec![Candidate {
13623            index: 0,
13624            label: 'A',
13625            agent: "alpha".to_owned(),
13626            branch: "does-not-exist".to_owned(),
13627            worktree: repo.clone(),
13628            summary: String::new(),
13629            stat: String::new(),
13630            files: 0,
13631            commits: 0,
13632            empty: false,
13633            failed: None,
13634            verified_noop: None,
13635            duration_ms: 0,
13636            folded: false,
13637        }];
13638        state.tally = Some(Tally {
13639            first_choice: BTreeMap::from([('A', 1)]),
13640            borda: BTreeMap::new(),
13641            winner: 'A',
13642            rankings: 1,
13643            unanimous_initial: true,
13644            deliberated: false,
13645            changed_votes: 0,
13646            unanimous_final: true,
13647            tie_break: None,
13648            judges: 0,
13649            present: 0,
13650            quorum: 0,
13651            met_quorum: true,
13652            uncontested: Some("only candidate A produced a change".to_owned()),
13653        });
13654        // The exact shape a prior process's `stop_reviewing` would have left
13655        // on disk: the round budget's last round, a real attempt already
13656        // made and already resource-blocked.
13657        state.reviews = vec![ReviewRound {
13658            round: 1,
13659            head: head.clone(),
13660            verified_head: Some(head.clone()),
13661            verified_at: Some(jiff::Timestamp::now()),
13662            reviews: Vec::new(),
13663            e2e: vec![CommandOutcome {
13664                command: format!(
13665                    "CARGO_TARGET_DIR='{}' test -f README.md",
13666                    cache_dir.display()
13667                ),
13668                code: None,
13669                output_tail: "waiting for the shared build cache".to_owned(),
13670                duration_ms: 0,
13671                resource_blocked: true,
13672            }],
13673            fix: None,
13674            blocking: 1,
13675            answered: 1,
13676            expected: 1,
13677            clean: false,
13678            verify_retried: false,
13679            e2e_deferred: false,
13680            e2e_defer_reason: None,
13681            progressed: false,
13682            vote_split: false,
13683            reconsideration: Vec::new(),
13684            verdict: None,
13685        }];
13686
13687        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13688        let mut runner = Runner {
13689            state,
13690            roles: ResolvedRoles {
13691                implementers: Vec::new(),
13692                judges: Vec::new(),
13693                reviewers: Vec::new(),
13694                fixer: None,
13695                conductor: conductor(),
13696                implementer_roster: Vec::new(),
13697                judge_roster: Vec::new(),
13698                reviewer_roster: Vec::new(),
13699            },
13700            sem: Arc::new(Semaphore::new(1)),
13701            pause: Pause::new(),
13702            interrupt: Pause::new(),
13703        };
13704
13705        // The lease is still held throughout, so this reentry's own retry is
13706        // also contended — proving `review_loop` actually tried again (not
13707        // that it happened to succeed) is what the timestamp comparison
13708        // below is for.
13709        runner.review_loop().await.expect("review_loop");
13710
13711        assert_eq!(
13712            runner.state.reviews.len(),
13713            1,
13714            "no new round was started on top of the unresolved one: {:?}",
13715            runner.state.reviews
13716        );
13717        let last = &runner.state.reviews[0];
13718        assert_eq!(
13719            last.e2e_status(),
13720            E2eStatus::ResourceBlocked,
13721            "still contended: {last:?}"
13722        );
13723        assert!(
13724            last.verified_at.expect("still known") > first_attempt_at,
13725            "review_loop must have actually retried the check, not left it exactly as found"
13726        );
13727        assert_ne!(
13728            runner.state.status,
13729            RunStatus::Blocked,
13730            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13731            runner.state.status
13732        );
13733
13734        held.release();
13735    }
13736
13737    #[tokio::test]
13738    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13739        crate::run::pin_test_home();
13740        let tmp = tempfile::tempdir().expect("tempdir");
13741        let repo = tmp.path().join("repo");
13742        std::fs::create_dir_all(&repo).unwrap();
13743        init_repo(&repo);
13744
13745        let mut config = Config::default();
13746        config.merge.mode = MergeMode::Pr;
13747        config.graph.land = true;
13748        config.graph.land_approval = false;
13749
13750        let mut state = RunState::new(
13751            repo.clone(),
13752            "main".to_owned(),
13753            "deadbeef".to_owned(),
13754            "task".to_owned(),
13755            config,
13756        );
13757        state.candidates = vec![Candidate {
13758            index: 0,
13759            label: 'A',
13760            agent: "alpha".to_owned(),
13761            branch: "does-not-exist".to_owned(),
13762            worktree: repo.clone(),
13763            summary: String::new(),
13764            stat: String::new(),
13765            files: 0,
13766            commits: 0,
13767            empty: false,
13768            failed: None,
13769            verified_noop: None,
13770            duration_ms: 0,
13771            folded: false,
13772        }];
13773        state.tally = Some(Tally {
13774            first_choice: BTreeMap::from([('A', 1)]),
13775            borda: BTreeMap::new(),
13776            winner: 'A',
13777            rankings: 1,
13778            unanimous_initial: true,
13779            deliberated: false,
13780            changed_votes: 0,
13781            unanimous_final: true,
13782            tie_break: None,
13783            judges: 0,
13784            present: 0,
13785            quorum: 0,
13786            met_quorum: true,
13787            uncontested: Some("only candidate A produced a change".to_owned()),
13788        });
13789        state.reviews = vec![ReviewRound {
13790            round: 1,
13791            head: "deadbeef".to_owned(),
13792            verified_head: None,
13793            verified_at: None,
13794            reviews: Vec::new(),
13795            e2e: Vec::new(),
13796            fix: None,
13797            blocking: 0,
13798            answered: 0,
13799            expected: 0,
13800            clean: true,
13801            verify_retried: false,
13802            e2e_deferred: false,
13803            e2e_defer_reason: None,
13804            progressed: false,
13805            vote_split: false,
13806            reconsideration: Vec::new(),
13807            verdict: None,
13808        }];
13809        state.gate = vec![CommandOutcome {
13810            command: "test".to_owned(),
13811            code: Some(0),
13812            output_tail: String::new(),
13813            duration_ms: 0,
13814            resource_blocked: false,
13815        }];
13816        state.gate_ran = true;
13817        // A first pass through `merge` already pushed and opened this pull
13818        // request; `status` is `Landing` because a previous call into `land`
13819        // parked or was interrupted before it reached a terminal outcome.
13820        state.status = RunStatus::Landing;
13821        state.merge = Some(MergeOutcome {
13822            mode: MergeMode::Pr,
13823            ok: true,
13824            detail: "https://example.invalid/x/y/pull/1".to_owned(),
13825            empty: false,
13826        });
13827
13828        // The Landing-resume shortcut calls `run_land` directly rather than
13829        // through `merge`, which is exactly the call site that used to skip
13830        // `settle_questions` - see the fixture below.
13831        ask_test_home();
13832        // A real resume loads a persisted run; driver ownership is checked
13833        // against that record before the Landing shortcut is entered.
13834        state.save().expect("save landing run");
13835        let store = ask::Questions::open();
13836        let q = ask_open_question(&store, &state.id);
13837
13838        let mut runner = Runner {
13839            state,
13840            roles: ResolvedRoles {
13841                implementers: Vec::new(),
13842                judges: Vec::new(),
13843                reviewers: Vec::new(),
13844                fixer: None,
13845                conductor: conductor(),
13846                implementer_roster: Vec::new(),
13847                judge_roster: Vec::new(),
13848                reviewer_roster: Vec::new(),
13849            },
13850            sem: Arc::new(Semaphore::new(1)),
13851            pause: Pause::new(),
13852            interrupt: Pause::new(),
13853        };
13854
13855        // `execute`, not `merge` directly: the Landing-resume shortcut lives
13856        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
13857        // exactly because `review_loop` would otherwise clobber the marker
13858        // first.
13859        runner.execute().await.expect("execute");
13860
13861        assert_eq!(
13862            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13863            Some("https://example.invalid/x/y/pull/1"),
13864            "reentry must not push again or open a second pull request over the \
13865             one `land` is already watching"
13866        );
13867        assert_ne!(
13868            runner.state.status,
13869            RunStatus::Landing,
13870            "land could not actually reach the fake pull request, so it must \
13871             have given up rather than left the run silently parked forever"
13872        );
13873        // `land` could not reach the fake pull request, so it gave up into
13874        // `Blocked` - still resumable, so the question must not have been
13875        // swept just because this branch now also calls `settle_questions`.
13876        assert_eq!(runner.state.status, RunStatus::Blocked);
13877        assert!(
13878            store.get(&q.id).unwrap().status.open(),
13879            "Blocked is still alive; settle_questions must have been a no-op here"
13880        );
13881    }
13882
13883    fn state_with_round(round: ReviewRound) -> RunState {
13884        let mut s = RunState::new(
13885            PathBuf::from("/repo"),
13886            "main".to_owned(),
13887            "abc1234".to_owned(),
13888            "add retries".to_owned(),
13889            Config::default(),
13890        );
13891        s.reviews = vec![round];
13892        s
13893    }
13894
13895    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13896        crate::verdict::Finding {
13897            id: id.to_owned(),
13898            severity,
13899            file: None,
13900            line: None,
13901            title: title.to_owned(),
13902            detail: String::new(),
13903        }
13904    }
13905
13906    #[test]
13907    fn pr_body_names_open_findings_and_declined_ones() {
13908        let round = ReviewRound {
13909            round: 2,
13910            head: "deadbee".to_owned(),
13911            verified_head: None,
13912            verified_at: None,
13913            reviews: vec![ReviewRecord {
13914                attempts: 0,
13915                reviewer: 1,
13916                agent: "alpha".to_owned(),
13917                summary: String::new(),
13918                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13919                vote: None,
13920                failed: None,
13921                duration_ms: 0,
13922            }],
13923            e2e: vec![CommandOutcome {
13924                command: "cargo test".to_owned(),
13925                code: Some(0),
13926                output_tail: String::new(),
13927                duration_ms: 0,
13928                resource_blocked: false,
13929            }],
13930            verify_retried: false,
13931            e2e_deferred: false,
13932            e2e_defer_reason: None,
13933            fix: Some(FixRecord {
13934                agent: "alpha".to_owned(),
13935                addressed: Vec::new(),
13936                rejected: vec![crate::verdict::Rejection {
13937                    id: "R1-1-1".to_owned(),
13938                    why: "not reachable from any caller".to_owned(),
13939                }],
13940                notes: String::new(),
13941                committed: true,
13942                failed: None,
13943                duration_ms: 0,
13944                continuation: None,
13945            }),
13946            blocking: 0,
13947            answered: 1,
13948            expected: 1,
13949            clean: false,
13950            progressed: true,
13951            vote_split: false,
13952            reconsideration: Vec::new(),
13953            verdict: None,
13954        };
13955        let state = state_with_round(round);
13956        let body = pr_message(&state, 'A').body;
13957
13958        assert!(body.contains("add retries"), "the task must still be there");
13959        assert!(body.contains("R2-1-1"), "{body}");
13960        assert!(!body.contains("unused import"), "{body}");
13961        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13962        assert!(
13963            !body.contains("not reachable from any caller"),
13964            "the reason it was declined: {body}"
13965        );
13966    }
13967
13968    #[test]
13969    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13970        let round = ReviewRound {
13971            round: 1,
13972            head: "deadbee".to_owned(),
13973            verified_head: None,
13974            verified_at: None,
13975            reviews: vec![ReviewRecord {
13976                attempts: 0,
13977                reviewer: 1,
13978                agent: "alpha".to_owned(),
13979                summary: String::new(),
13980                findings: Vec::new(),
13981                vote: None,
13982                failed: None,
13983                duration_ms: 0,
13984            }],
13985            e2e: Vec::new(),
13986            verify_retried: false,
13987            e2e_deferred: false,
13988            e2e_defer_reason: None,
13989            fix: None,
13990            blocking: 0,
13991            answered: 1,
13992            expected: 1,
13993            clean: true,
13994            progressed: false,
13995            vote_split: false,
13996            reconsideration: Vec::new(),
13997            verdict: None,
13998        };
13999        let state = state_with_round(round);
14000        let body = pr_message(&state, 'A').body;
14001        assert!(!body.contains("Open review findings"), "{body}");
14002        assert!(!body.contains("Declined"), "{body}");
14003    }
14004
14005    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
14006        let mut state = RunState::new(
14007            PathBuf::from("/repo"),
14008            "main".to_owned(),
14009            "abc1234".to_owned(),
14010            instruction.to_owned(),
14011            Config::default(),
14012        );
14013        state.candidates.push(Candidate {
14014            index: 0,
14015            label: 'A',
14016            agent: "alpha".to_owned(),
14017            branch: "magi/x/A".to_owned(),
14018            worktree: PathBuf::from("/wt"),
14019            summary: summary.to_owned(),
14020            stat: String::new(),
14021            files: 1,
14022            commits: 1,
14023            empty: false,
14024            failed: None,
14025            verified_noop: None,
14026            folded: false,
14027            duration_ms: 0,
14028        });
14029        state
14030    }
14031
14032    fn review_state(subjects: &[&str]) -> RunState {
14033        let mut state = state_with_summary(
14034            "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",
14035            "",
14036        );
14037        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14038        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
14039        state
14040    }
14041
14042    /// A branch rebased onto a main that moved past the recorded
14043    /// `base_commit` is titled from its own first commit, never main's.
14044    #[tokio::test]
14045    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
14046        ask_test_home();
14047        let tmp = tempfile::tempdir().unwrap();
14048        let repo = tmp.path().join("repo");
14049        std::fs::create_dir_all(&repo).unwrap();
14050        init_repo(&repo);
14051        let origin = tmp.path().join("origin.git");
14052        let g = |dir: &Path, args: &[&str]| {
14053            let out = std::process::Command::new("git")
14054                .args(args)
14055                .current_dir(dir)
14056                .quiet()
14057                .output()
14058                .expect("spawn git");
14059            assert!(
14060                out.status.success(),
14061                "git {args:?}: {}",
14062                String::from_utf8_lossy(&out.stderr)
14063            );
14064        };
14065        g(
14066            tmp.path(),
14067            &[
14068                "clone",
14069                "--bare",
14070                "-q",
14071                repo.to_str().unwrap(),
14072                origin.to_str().unwrap(),
14073            ],
14074        );
14075        g(
14076            &repo,
14077            &["remote", "add", "origin", origin.to_str().unwrap()],
14078        );
14079        let c1 = git::rev_parse(&repo, "main").await.unwrap();
14080
14081        // Main moves on; the branch is built on top of the new main.
14082        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14083        g(&repo, &["add", "-A"]);
14084        g(
14085            &repo,
14086            &["commit", "-q", "-m", "chore(deps): update a crate"],
14087        );
14088        g(&repo, &["push", "-q", "origin", "main"]);
14089        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14090        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14091        g(&repo, &["add", "-A"]);
14092        g(
14093            &repo,
14094            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14095        );
14096        g(&repo, &["checkout", "-q", "main"]);
14097
14098        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14099        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14100        // Without a readable tracking ref the recorded base's merge base is used.
14101        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14102        assert_eq!(fallback, c1);
14103
14104        let mut state = review_state(&[
14105            "chore(deps): update a crate",
14106            "fix(daemon): apply a chosen action",
14107        ]);
14108        state.repo = repo.clone();
14109        state.base_branch = "main".to_owned();
14110        state.base_commit = c1;
14111        refresh_reviewed_commits(&mut state, "feat/own").await;
14112        assert_eq!(
14113            state.reviewed_commits,
14114            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14115        );
14116        assert_eq!(
14117            review_title(&state).as_deref(),
14118            Some("fix(daemon): apply a chosen action")
14119        );
14120        assert_eq!(
14121            leaked_subjects(&state, "feat/own").await,
14122            Some(vec!["chore(deps): update a crate".to_owned()])
14123        );
14124        // A stale tracking ref is still used when the fetch fails, but the
14125        // leak list is withheld.
14126        state.config.merge.remote = "nowhere".to_owned();
14127        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14128    }
14129
14130    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
14131    /// with `origin/main` one commit behind it, and a summarizing agent that
14132    /// prints whatever `reply` holds.
14133    struct SummaryFixture {
14134        _tmp: tempfile::TempDir,
14135        repo: PathBuf,
14136        reply: PathBuf,
14137        runner: Runner,
14138    }
14139
14140    fn summary_fixture() -> SummaryFixture {
14141        ask_test_home();
14142        let tmp = tempfile::tempdir().unwrap();
14143        let repo = tmp.path().join("repo");
14144        std::fs::create_dir_all(&repo).unwrap();
14145        init_repo(&repo);
14146        let origin = tmp.path().join("origin.git");
14147        let g = |args: &[&str]| {
14148            let out = std::process::Command::new("git")
14149                .args(args)
14150                .current_dir(&repo)
14151                .quiet()
14152                .output()
14153                .expect("spawn git");
14154            assert!(
14155                out.status.success(),
14156                "git {args:?}: {}",
14157                String::from_utf8_lossy(&out.stderr)
14158            );
14159        };
14160        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14161        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14162        g(&["push", "-q", "origin", "main"]);
14163        g(&["checkout", "-q", "-b", "feat/own"]);
14164        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14165        g(&["add", "-A"]);
14166        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14167        let reply = tmp.path().join("reply.txt");
14168        std::fs::write(
14169            &reply,
14170            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14171        )
14172        .unwrap();
14173        let mut runner = runner_at(RunStatus::Gating);
14174        let mut state = review_state(&["feat(own): add the own file"]);
14175        state.repo = repo.clone();
14176        state.base_branch = "main".to_owned();
14177        state.candidates[0].branch = "feat/own".to_owned();
14178        state.candidates[0].worktree = repo.clone();
14179        let mut author = spec("alpha");
14180        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14181        author
14182            .env
14183            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14184        state.config.agents = vec![author];
14185        runner.state = state;
14186        SummaryFixture {
14187            _tmp: tmp,
14188            repo,
14189            reply,
14190            runner,
14191        }
14192    }
14193
14194    async fn posted(f: &mut SummaryFixture) -> PrMessage {
14195        let winner = f.runner.state.candidates[0].clone();
14196        f.runner
14197            .guarded_pr_message(&winner, None, true)
14198            .await
14199            .unwrap()
14200            .expect("an English description never parks")
14201    }
14202
14203    fn clean_round_with_minor_findings() -> ReviewRound {
14204        let mut round = passing_round(2, None);
14205        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14206        round.reviews = vec![ReviewRecord {
14207            attempts: 0,
14208            reviewer: 1,
14209            agent: "alpha".into(),
14210            summary: "内部レビュー".into(),
14211            findings: vec![
14212                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14213                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14214            ],
14215            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14216            failed: None,
14217            duration_ms: 0,
14218        }];
14219        round
14220    }
14221
14222    #[tokio::test]
14223    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14224        for review_only in [false, true] {
14225            let mut f = summary_fixture();
14226            if !review_only {
14227                f.runner.state.candidates[0].agent = "alpha".into();
14228            }
14229            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14230            assert!(f.runner.state.reviews[0].clean);
14231            assert!(f.runner.state.reviews[0].fix.is_none());
14232            assert!(f.runner.state.open_findings().is_empty());
14233            assert_eq!(
14234                crate::pr_outcome::required_finding_ids(&f.runner.state),
14235                ["R2-1-1", "R2-1-2"]
14236            );
14237            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14238            let evidence = crate::pr_outcome::summary_evidence(
14239                &f.runner.state,
14240                crate::pr_outcome::FinalHead::At(&head),
14241            );
14242            assert!(
14243                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14244            );
14245            // The ordinary reply omits both IDs: no cached description is adopted.
14246            let omitted = posted(&mut f).await;
14247            assert!(omitted.body.contains("No description"));
14248            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14249            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14250            assert!(f.runner.state.change_summary.is_none());
14251            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();
14252            let described = posted(&mut f).await;
14253            assert!(described.body.contains("improve documentation for callers"));
14254            assert!(described.body.contains("remaining edge case"));
14255            assert!(described.body.contains("## Open review findings"));
14256            assert!(!described.body.contains("未修正"));
14257        }
14258    }
14259
14260    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14261        let mut f = summary_fixture();
14262        if !review_only {
14263            f.runner.state.candidates[0].agent = "alpha".into();
14264        }
14265        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14266        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14267        fixed
14268            .fix
14269            .as_mut()
14270            .unwrap()
14271            .rejected
14272            .push(crate::verdict::Rejection {
14273                id: "R1-1-1".into(),
14274                why: "呼び出されないコードのため辞退".into(),
14275            });
14276        let mut clean = clean_round_with_minor_findings();
14277        clean.verified_head = Some(head.clone());
14278        f.runner.state.reviews = vec![fixed, clean];
14279        ok_gate(&mut f.runner.state, Some(&head));
14280        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14281        f.runner.state.config.agents.push(scripted("beta", reply));
14282        f.runner.state.config.roles.language_judge =
14283            Some(crate::config::AgentChoice::One("beta".into()));
14284        f
14285    }
14286
14287    #[tokio::test]
14288    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14289        for review_only in [false, true] {
14290            let mut f = rewrite_fixture_with_findings(review_only,
14291                "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;
14292            let m = posted(&mut f).await;
14293            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14294            let expected =
14295                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14296            assert!(m.body.contains(&expected));
14297            assert!(m.body.contains("## Review rounds"));
14298            assert!(
14299                m.body
14300                    .contains("Gate (`verify.gate`): passed on the final commit")
14301            );
14302            assert!(
14303                m.body
14304                    .contains("Tests (`verify.e2e`): passed on the final commit")
14305            );
14306            let c = f.runner.state.change_summary.as_ref().unwrap();
14307            assert!(
14308                m.body
14309                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14310            );
14311            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14312            assert!(crate::pr_outcome::covers_required_findings(
14313                &f.runner.state,
14314                &m.body
14315            ));
14316            assert!(m.body.contains("documentation remains incomplete"));
14317            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14318            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14319            assert!(f.runner.state.github_text.is_none());
14320        }
14321    }
14322
14323    #[tokio::test]
14324    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14325        for review_only in [false, true] {
14326            for reply in [
14327                "TITLE: fix: public description\nA short English description.",
14328                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14329            ] {
14330                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14331                let winner = f.runner.state.candidates[0].clone();
14332                let posted = f
14333                    .runner
14334                    .guarded_pr_message(&winner, None, true)
14335                    .await
14336                    .unwrap();
14337                assert!(posted.is_none());
14338                assert!(f.runner.state.parked);
14339                let withheld = f.runner.state.github_text.as_ref().unwrap();
14340                assert!(
14341                    withheld.rewrite.is_none(),
14342                    "an incomplete rewrite must not be selectable"
14343                );
14344                let q = ask::Questions::open()
14345                    .get(withheld.question.as_ref().unwrap())
14346                    .unwrap();
14347                assert!(
14348                    !q.choices
14349                        .contains(&crate::github_text::USE_REWRITE.to_owned())
14350                );
14351                assert!(
14352                    q.detail.contains("R1-1-1")
14353                        && q.detail.contains("R2-1-1")
14354                        && q.detail.contains("R2-1-2")
14355                );
14356            }
14357        }
14358    }
14359
14360    #[tokio::test]
14361    async fn japanese_reports_stay_local_in_both_public_run_modes() {
14362        for review_only in [false, true] {
14363            let mut f = summary_fixture();
14364            if !review_only {
14365                f.runner.state.candidates[0].agent = "alpha".into();
14366            }
14367            f.runner.state.instruction = "内部タスクの説明".into();
14368            f.runner.state.candidates[0].summary = "内部の候補報告".into();
14369            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14370            f.runner.state.reviews = vec![
14371                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14372                round_with_notes(2, Some("二回目の内部修正報告です。")),
14373                passing_round(3, Some(&head)),
14374            ];
14375            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14376            ok_gate(&mut f.runner.state, Some(&head));
14377            let captured = f.repo.join("public-prompt.txt");
14378            let author = &mut f.runner.state.config.agents[0];
14379            author.command = vec![
14380                "sh".into(),
14381                "-c".into(),
14382                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14383            ];
14384            author.env.insert(
14385                "PROMPT_FILE".into(),
14386                captured.to_string_lossy().into_owned(),
14387            );
14388            let m = posted(&mut f).await;
14389            assert!(m.body.contains("Adds `own.txt`"));
14390            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14391            assert!(m.body.contains("R1-1-1"));
14392            assert!(
14393                m.body
14394                    .contains("Gate (`verify.gate`): passed on the final commit")
14395            );
14396            assert!(
14397                m.body
14398                    .contains("Tests (`verify.e2e`): passed on the final commit")
14399            );
14400            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14401            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14402            let prompt = std::fs::read_to_string(captured).unwrap();
14403            assert!(prompt.contains(&format!(
14404                "git diff {} {head}",
14405                f.runner.state.change_summary.as_ref().unwrap().base
14406            )));
14407            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14408            assert_eq!(
14409                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14410                "二回目の内部修正報告です。"
14411            );
14412        }
14413    }
14414
14415    #[tokio::test]
14416    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14417        for review_only in [false, true] {
14418            let mut f = summary_fixture();
14419            if !review_only {
14420                f.runner.state.candidates[0].agent = "alpha".into();
14421            }
14422            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14423            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14424            std::fs::write(&f.reply, "no usable description").unwrap();
14425            let failed = posted(&mut f).await;
14426            assert!(
14427                failed
14428                    .body
14429                    .contains("No description of what this change does could be generated")
14430            );
14431            assert!(failed.body.is_ascii());
14432            assert!(f.runner.state.github_text.is_none());
14433            assert!(f.runner.state.change_summary.is_none());
14434            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14435            let evidence = crate::pr_outcome::summary_evidence(
14436                &f.runner.state,
14437                crate::pr_outcome::FinalHead::At(&head),
14438            );
14439            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14440            assert!(
14441                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14442                    .exists()
14443            );
14444            f.runner.state =
14445                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14446            std::fs::write(
14447                &f.reply,
14448                "TITLE: feat(own): add the own file\nAdds the committed own file.",
14449            )
14450            .unwrap();
14451            let retried = posted(&mut f).await;
14452            assert!(retried.body.contains("Adds the committed own file."));
14453            assert_eq!(
14454                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14455                "日本語の修正報告"
14456            );
14457        }
14458    }
14459
14460    #[tokio::test]
14461    async fn same_head_verification_and_findings_invalidate_public_prose() {
14462        let mut f = summary_fixture();
14463        posted(&mut f).await;
14464        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14465        let original = f
14466            .runner
14467            .state
14468            .change_summary
14469            .as_ref()
14470            .unwrap()
14471            .evidence
14472            .clone();
14473        ok_gate(&mut f.runner.state, Some(&head));
14474        std::fs::write(
14475            &f.reply,
14476            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14477        )
14478        .unwrap();
14479        let verified = posted(&mut f).await;
14480        assert!(verified.body.contains("now verified"));
14481        assert_ne!(
14482            f.runner.state.change_summary.as_ref().unwrap().evidence,
14483            original
14484        );
14485        let mut round = round_with_notes(1, Some("内部報告"));
14486        round.reviews.push(ReviewRecord {
14487            attempts: 0,
14488            reviewer: 1,
14489            agent: "alpha".into(),
14490            summary: "内部レビュー".into(),
14491            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14492            vote: None,
14493            failed: None,
14494            duration_ms: 0,
14495        });
14496        round
14497            .fix
14498            .as_mut()
14499            .unwrap()
14500            .rejected
14501            .push(crate::verdict::Rejection {
14502                id: "R1-1-1".into(),
14503                why: "呼び出し元から到達できない".into(),
14504            });
14505        f.runner.state.reviews = vec![round];
14506        // A description omitting the required ID is unusable, even with a title.
14507        let omitted = posted(&mut f).await;
14508        assert!(omitted.body.contains("No description"));
14509        assert!(omitted.body.contains("R1-1-1"));
14510        assert!(!omitted.body.contains("到達"));
14511        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();
14512        let described = posted(&mut f).await;
14513        assert!(described.body.contains("unreachable from callers"));
14514        assert!(described.body.contains("R1-1-1"));
14515        assert!(!described.body.contains("到達"));
14516        let evidence = f
14517            .runner
14518            .state
14519            .change_summary
14520            .as_ref()
14521            .unwrap()
14522            .evidence
14523            .clone();
14524        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14525        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14526        assert!(posted(&mut f).await.body.contains("rationale updated"));
14527        assert_ne!(
14528            f.runner.state.change_summary.as_ref().unwrap().evidence,
14529            evidence
14530        );
14531        // Old serialized summaries have no evidence fingerprint and must regenerate.
14532        f.runner
14533            .state
14534            .change_summary
14535            .as_mut()
14536            .unwrap()
14537            .evidence
14538            .clear();
14539        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14540        assert!(posted(&mut f).await.body.contains("current specification"));
14541    }
14542
14543    #[tokio::test]
14544    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14545        let mut f = summary_fixture();
14546        let m = posted(&mut f).await;
14547        assert_eq!(m.title, "feat(own): add the own file");
14548        assert!(
14549            m.body
14550                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14551            "{}",
14552            m.body
14553        );
14554        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14555        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14556        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14557        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14558        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14559        let kept = f.runner.state.change_summary.as_ref().unwrap();
14560        assert_eq!(
14561            (kept.head.as_str(), kept.base.as_str()),
14562            (head.as_str(), base.as_str())
14563        );
14564        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14565    }
14566
14567    #[tokio::test]
14568    async fn review_only_summary_receives_the_configured_github_language() {
14569        let mut f = summary_fixture();
14570        f.runner.state.config.graph.github_language = "ja".into();
14571        let captured = f.repo.join("summary-prompt.txt");
14572        let author = &mut f.runner.state.config.agents[0];
14573        author.command = vec![
14574            "sh".into(),
14575            "-c".into(),
14576            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14577        ];
14578        author.env.insert(
14579            "PROMPT_FILE".into(),
14580            captured.to_string_lossy().into_owned(),
14581        );
14582        std::fs::write(
14583            &f.reply,
14584            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14585        )
14586        .unwrap();
14587        let m = posted(&mut f).await;
14588        let prompt = std::fs::read_to_string(captured).unwrap();
14589        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14590        assert!(!prompt.contains("in English"));
14591        assert_eq!(m.title, "feat(own): ファイルを追加");
14592        assert!(m.body.contains("own.txt を追加します。"));
14593    }
14594
14595    #[tokio::test]
14596    async fn review_fixes_come_after_the_whole_change_summary() {
14597        let mut f = summary_fixture();
14598        f.runner.state.reviews = vec![
14599            round_with_notes(1, Some("handled the PATH case")),
14600            round_with_notes(2, None),
14601        ];
14602        let m = posted(&mut f).await;
14603        let summary = m.body.find("## Summary").expect("summary");
14604        let fixes = m.body.find("## Review rounds").expect("review rounds");
14605        assert!(summary < fixes, "{}", m.body);
14606        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14607        assert!(m.body.contains("## Final verification"), "{}", m.body);
14608    }
14609
14610    #[tokio::test]
14611    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14612        let mut f = summary_fixture();
14613        let first = posted(&mut f).await;
14614        let first_head = f.runner.state.change_summary.clone().unwrap().head;
14615        // The same head reuses the record: a different reply is not read.
14616        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14617        let again = posted(&mut f).await;
14618        assert_eq!((again.title, again.body), (first.title, first.body));
14619
14620        let g = |args: &[&str]| {
14621            let out = std::process::Command::new("git")
14622                .args(args)
14623                .current_dir(&f.repo)
14624                .quiet()
14625                .output()
14626                .expect("spawn git");
14627            assert!(out.status.success(), "git {args:?}");
14628        };
14629        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14630        g(&["add", "-A"]);
14631        g(&["commit", "-q", "-m", "fix(own): handle more"]);
14632        let second = posted(&mut f).await;
14633        let kept = f.runner.state.change_summary.clone().unwrap();
14634        assert_ne!(kept.head, first_head);
14635        assert!(second.body.contains("Not read yet."), "{}", second.body);
14636        assert!(!second.body.contains("so the first summary is read"));
14637        assert_eq!(second.title, "feat(own): other");
14638    }
14639
14640    #[tokio::test]
14641    async fn an_unavailable_summary_is_stated_and_never_invented() {
14642        let mut f = summary_fixture();
14643        std::fs::write(&f.reply, "no title line here").unwrap();
14644        let m = posted(&mut f).await;
14645        assert!(
14646            m.body
14647                .contains("No description of what this change does could be generated"),
14648            "{}",
14649            m.body
14650        );
14651        assert!(m.body.contains("internal reports remain in local records"));
14652        assert!(f.runner.state.change_summary.is_none());
14653        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14654        // A preview asks nobody and claims nothing.
14655        let winner = f.runner.state.candidates[0].clone();
14656        let preview = f
14657            .runner
14658            .guarded_pr_message(&winner, None, false)
14659            .await
14660            .unwrap()
14661            .unwrap();
14662        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14663    }
14664
14665    #[test]
14666    fn pr_message_review_single_commit_uses_its_subject() {
14667        let state = review_state(&["feat(nats): per-role user"]);
14668        let m = pr_message(&state, 'A');
14669        assert_eq!(m.title, "feat(nats): per-role user");
14670        assert!(!m.body.contains("Review the work already"), "{}", m.body);
14671        assert!(m.body.contains("## Commits under review"), "{}", m.body);
14672    }
14673
14674    #[test]
14675    fn pr_message_review_multi_commit_takes_the_oldest() {
14676        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14677        let m = pr_message(&state, 'A');
14678        assert_eq!(m.title, "feat: the change");
14679        for s in ["feat: the change", "fix: typo", "fix: again"] {
14680            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14681        }
14682    }
14683
14684    #[test]
14685    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14686        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14687            let mut state = review_state(&[first, "fix: later fixup"]);
14688            state.candidates[0].branch = "機能/ログイン".to_owned();
14689            let m = pr_message(&state, 'A');
14690            assert!(
14691                m.title.starts_with("chore: land candidate A of run"),
14692                "{}",
14693                m.title
14694            );
14695        }
14696    }
14697
14698    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14699        BranchFacts {
14700            commits: commits
14701                .iter()
14702                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14703                .collect(),
14704            stat: stat.to_owned(),
14705        }
14706    }
14707
14708    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14709        let mut r = review_round(true, 0, 1, 1, true, true);
14710        r.round = round;
14711        r.fix = notes.map(|n| FixRecord {
14712            agent: "fixer".to_owned(),
14713            addressed: Vec::new(),
14714            rejected: Vec::new(),
14715            notes: n.to_owned(),
14716            committed: true,
14717            failed: None,
14718            duration_ms: 0,
14719            continuation: None,
14720        });
14721        r
14722    }
14723
14724    #[test]
14725    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14726        let state = review_state(&["ignored"]);
14727        let f = facts(
14728            &[
14729                (
14730                    "fix(login): resolve PATH on macOS",
14731                    "Login shells skip rc files.",
14732                ),
14733                ("fix: address review", ""),
14734            ],
14735            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14736        );
14737        let m = pr_message_with(&state, 'A', Some(&f));
14738        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14739        assert!(
14740            m.body
14741                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
14742            "{}",
14743            m.body
14744        );
14745        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14746        assert!(m.body.contains("## Diff stat"), "{}", m.body);
14747        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14748        for banned in [
14749            "Review the work already",
14750            "no task statement",
14751            "Original task",
14752        ] {
14753            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14754        }
14755        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14756    }
14757
14758    #[test]
14759    fn pr_message_review_truncates_a_huge_first_commit_body() {
14760        let state = review_state(&["ignored"]);
14761        let f = facts(
14762            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14763            "s",
14764        );
14765        let m = pr_message_with(&state, 'A', Some(&f));
14766        assert!(m.body.len() < 30_000, "{}", m.body.len());
14767        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14768        assert!(
14769            m.body.contains("- ... 1 more commit(s)"),
14770            "{}",
14771            m.body.len()
14772        );
14773        assert!(m.body.ends_with("magi:candidate-a\n"));
14774    }
14775
14776    #[test]
14777    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14778        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14779        assert_eq!(m.title, "feat: x");
14780        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14781        assert!(!m.body.contains("Diff stat"), "{}", m.body);
14782        assert!(!m.body.contains("no task statement"), "{}", m.body);
14783    }
14784
14785    #[test]
14786    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14787        let mut state = review_state(&["日本語の件名"]);
14788        state.candidates[0].branch = "fix/macos-login-path".to_owned();
14789        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14790        state.candidates[0].branch = "機能/ログイン".to_owned();
14791        assert!(
14792            pr_message(&state, 'A')
14793                .title
14794                .starts_with("chore: land candidate A")
14795        );
14796    }
14797
14798    #[test]
14799    fn pr_message_review_fixes_survive_a_clean_final_round() {
14800        let mut state = review_state(&["feat: x"]);
14801        state.reviews = vec![
14802            round_with_notes(1, Some("handled the PATH case")),
14803            round_with_notes(2, None),
14804        ];
14805        let body = pr_message(&state, 'A').body;
14806        assert!(
14807            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14808            "{body}"
14809        );
14810        assert!(body.contains("### Round 1"), "{body}");
14811
14812        state.reviews = vec![
14813            round_with_notes(1, Some("first fix")),
14814            round_with_notes(2, Some("")),
14815            round_with_notes(3, Some("second fix")),
14816            round_with_notes(4, None),
14817        ];
14818        let body = pr_message(&state, 'A').body;
14819        assert!(
14820            body.contains("### Round 1") && !body.contains("first fix"),
14821            "{body}"
14822        );
14823        assert!(
14824            body.contains("### Round 3") && !body.contains("second fix"),
14825            "{body}"
14826        );
14827        assert!(body.contains("### Round 2"), "{body}");
14828    }
14829
14830    const FINAL: &str = "abcdef1234567890";
14831
14832    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14833        let mut r = round_with_notes(round, None);
14834        r.verified_head = verified.map(str::to_owned);
14835        r
14836    }
14837
14838    fn ok_gate(state: &mut RunState, head: Option<&str>) {
14839        state.gate = vec![CommandOutcome {
14840            command: "gate".to_owned(),
14841            code: Some(0),
14842            output_tail: String::new(),
14843            duration_ms: 0,
14844            resource_blocked: false,
14845        }];
14846        state.gate_ran = true;
14847        state.gate_head = head.map(str::to_owned);
14848    }
14849
14850    fn body_at(state: &RunState) -> String {
14851        pr_message_raw(
14852            state,
14853            'A',
14854            None,
14855            crate::pr_outcome::FinalHead::At(FINAL),
14856            ChangeNote::NotAsked,
14857        )
14858        .body
14859    }
14860
14861    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14862
14863    #[test]
14864    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14865        let mut state = review_state(&["feat: x"]);
14866        state.reviews = vec![
14867            round_with_notes(1, Some(SANDBOXED)),
14868            passing_round(2, Some(FINAL)),
14869        ];
14870        ok_gate(&mut state, Some(FINAL));
14871        let body = body_at(&state);
14872        assert!(!body.contains("Handled the PATH case."), "{body}");
14873        assert!(!body.contains("could not commit"), "{body}");
14874        assert!(!body.contains("could not run"), "{body}");
14875        assert!(
14876            body.contains("The fix is committed on the branch"),
14877            "{body}"
14878        );
14879        assert!(!body.contains("sandbox"), "{body}");
14880        assert!(
14881            body.contains("Tests (`verify.e2e`): passed on the final commit"),
14882            "{body}"
14883        );
14884        assert!(
14885            body.contains("Gate (`verify.gate`): passed on the final commit"),
14886            "{body}"
14887        );
14888        // The raw report is untouched in the record.
14889        assert!(
14890            state.reviews[0]
14891                .fix
14892                .as_ref()
14893                .unwrap()
14894                .notes
14895                .contains("could not commit")
14896        );
14897    }
14898
14899    #[test]
14900    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14901        let mut state = review_state(&["feat: x"]);
14902        let mut r1 = round_with_notes(1, Some(SANDBOXED));
14903        r1.fix.as_mut().unwrap().committed = false;
14904        let mut r2 = passing_round(2, Some(FINAL));
14905        r2.e2e[0].code = Some(1);
14906        state.reviews = vec![r1, r2];
14907        let body = body_at(&state);
14908        assert!(!body.contains("could not commit"), "{body}");
14909        assert!(!body.contains("could not run"), "{body}");
14910        assert!(body.contains("This round produced no commit"), "{body}");
14911        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14912        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14913        assert!(!body.contains("passed"), "{body}");
14914    }
14915
14916    #[test]
14917    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14918        let mut state = review_state(&["feat: x"]);
14919        state.reviews = vec![
14920            round_with_notes(1, Some(SANDBOXED)),
14921            passing_round(2, Some("0123456789abcdef")),
14922        ];
14923        ok_gate(&mut state, Some("0123456789abcdef"));
14924        let body = body_at(&state);
14925        assert!(!body.contains("HTTP tests could not run"), "{body}");
14926        // The commit claim is still settled by the structured record.
14927        assert!(!body.contains("could not commit"), "{body}");
14928        assert!(
14929            body.contains("passed on an earlier commit (`0123456`)"),
14930            "{body}"
14931        );
14932        assert!(!body.contains("passed on the final commit"), "{body}");
14933
14934        // A gate recorded before `gate_head` existed is "not recorded".
14935        ok_gate(&mut state, None);
14936        let body = body_at(&state);
14937        assert!(
14938            body.contains("passed on a commit that was not recorded"),
14939            "{body}"
14940        );
14941    }
14942
14943    #[test]
14944    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14945        let state = state_with_summary(
14946            "add retries to the client",
14947            "TITLE: feat: retries\n\nDid it.",
14948        );
14949        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14950        assert_eq!(m.title, "feat: retries");
14951        assert!(
14952            m.body.contains("<summary>Original task</summary>"),
14953            "{}",
14954            m.body
14955        );
14956        assert!(!m.body.contains("Commits under review"), "{}", m.body);
14957        assert!(
14958            m.body
14959                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14960            "{}",
14961            m.body
14962        );
14963    }
14964
14965    #[test]
14966    fn pr_message_review_bounds_a_long_english_subject() {
14967        let long = format!("feat: {}", "word ".repeat(100));
14968        let m = pr_message(&review_state(&[&long]), 'A');
14969        assert!(m.title.starts_with("feat: word"), "{}", m.title);
14970        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14971    }
14972
14973    #[test]
14974    fn pr_message_implementation_run_is_unchanged_by_review_support() {
14975        let state = state_with_summary("add retries\n\ndetails", "- did some things");
14976        let m = pr_message(&state, 'A');
14977        assert_eq!(m.title, "add retries");
14978        assert!(m.body.contains("<summary>Original task</summary>"));
14979        assert!(!m.body.contains("Commits under review"));
14980        assert_eq!(landing_subject_source(&state), state.instruction);
14981    }
14982
14983    #[test]
14984    fn review_run_squash_subject_is_the_change_not_the_prompt() {
14985        let state = review_state(&["feat: the change", "fix: typo"]);
14986        let source = landing_subject_source(&state);
14987        assert_eq!(land::merge_subject("", &source), "feat: the change");
14988        assert_eq!(
14989            land::merge_subject("magi: candidate A (uncommitted work)", &source),
14990            "feat: the change"
14991        );
14992        // An operator's rename still wins.
14993        assert_eq!(
14994            land::merge_subject("feat: renamed by hand", &source),
14995            "feat: renamed by hand"
14996        );
14997        let blank = review_state(&["日本語"]);
14998        assert!(
14999            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
15000        );
15001    }
15002
15003    #[test]
15004    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
15005        let state = review_state(&["feat: the change"]);
15006        let source = landing_subject_source(&state);
15007        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
15008        assert_eq!(
15009            land::merge_subject(landing_title(&state, old), &source),
15010            "feat: the change"
15011        );
15012        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
15013        let task = state_with_summary("add retries", "");
15014        assert_eq!(landing_title(&task, old), old);
15015    }
15016
15017    #[test]
15018    fn pr_message_describes_the_change_not_the_task() {
15019        let state = state_with_summary(
15020            "今回やってほしいこと: results projector を直す",
15021            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
15022        );
15023        let m = pr_message(&state, 'A');
15024        assert_eq!(m.title, "fix(web): batch the runs list reads");
15025        assert!(
15026            m.body.starts_with("## Summary\n\n- reads run.json once"),
15027            "{}",
15028            m.body
15029        );
15030        assert!(!m.body.contains("TITLE:"), "{}", m.body);
15031        let task_at = m.body.find("今回やってほしいこと").unwrap();
15032        let details_at = m.body.find("<details>").unwrap();
15033        assert!(
15034            details_at < task_at,
15035            "the task lives inside <details>: {}",
15036            m.body
15037        );
15038        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
15039        assert!(m.body.contains("magi:candidate-a"));
15040    }
15041
15042    #[test]
15043    fn pr_message_falls_back_to_the_task_without_a_title_line() {
15044        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
15045        let m = pr_message(&state, 'A');
15046        assert_eq!(m.title, "add retries");
15047        assert!(
15048            m.body.contains("## Summary\n\n- did some things"),
15049            "{}",
15050            m.body
15051        );
15052
15053        let none = RunState::new(
15054            PathBuf::from("/repo"),
15055            "main".to_owned(),
15056            "abc1234".to_owned(),
15057            "add retries".to_owned(),
15058            Config::default(),
15059        );
15060        let m = pr_message(&none, 'A');
15061        assert_eq!(m.title, "add retries");
15062        assert!(!m.body.contains("## Summary"), "{}", m.body);
15063    }
15064
15065    #[test]
15066    fn pr_message_refuses_the_candidate_commit_subject() {
15067        for bad in [
15068            "TITLE: magi: candidate A (uncommitted work)",
15069            "TITLE: chore: stuff (uncommitted work)",
15070            "TITLE:   ",
15071        ] {
15072            let state = state_with_summary("add retries", bad);
15073            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15074        }
15075    }
15076
15077    #[test]
15078    fn pr_message_bounds_a_very_long_task_and_title() {
15079        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15080        let state = state_with_summary(&long, "- nothing");
15081        let m = pr_message(&state, 'A');
15082        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15083        assert!(!m.title.contains('\n'));
15084
15085        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15086        let m = pr_message(&state, 'A');
15087        assert!(m.title.starts_with("feat: "));
15088        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15089        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15090    }
15091
15092    fn long_title_of(instruction: &str) -> String {
15093        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15094    }
15095
15096    #[test]
15097    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15098        let first = "Make the landing path keep a readable title for long tasks";
15099        let line = format!(
15100            "{first}. {}",
15101            "And then keep going with more words ".repeat(20)
15102        );
15103        let t = long_title_of(&line);
15104        assert_eq!(t, first);
15105        assert!(!t.starts_with("chore: land"));
15106    }
15107
15108    #[test]
15109    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15110        let line = "word ".repeat(200);
15111        let t = long_title_of(&line);
15112        assert!(t.ends_with("word..."), "{t}");
15113        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15114    }
15115
15116    #[test]
15117    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15118        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15119            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15120        }
15121    }
15122
15123    #[test]
15124    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15125        let mut state = state_with_summary("再試行を追加する", "- nothing");
15126        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15127        state.config.graph.github_language = "ja".into();
15128        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15129    }
15130
15131    #[test]
15132    fn pr_message_title_limit_is_exact() {
15133        let at = "a".repeat(PR_TITLE_MAX);
15134        assert_eq!(long_title_of(&at), at);
15135        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15136        assert!(over.ends_with("..."), "{over}");
15137        assert_eq!(over.chars().count(), PR_TITLE_MAX);
15138    }
15139
15140    #[test]
15141    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15142        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15143        let t = long_title_of(&line);
15144        assert!(t.ends_with("..."), "{t}");
15145        assert!(t.is_ascii(), "{t}");
15146    }
15147
15148    #[test]
15149    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15150        let line = format!(
15151            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15152            "plus more filler words ".repeat(20)
15153        );
15154        let t = long_title_of(&line);
15155        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15156    }
15157
15158    #[test]
15159    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15160        let line = format!("\n\n# {}", "title words ".repeat(40));
15161        let state = state_with_summary(&line, "- nothing");
15162        let m = pr_message(&state, 'A');
15163        assert!(m.title.starts_with("title words"), "{}", m.title);
15164        assert_eq!(
15165            land::merge_subject(&m.title, &landing_subject_source(&state)),
15166            m.title
15167        );
15168        // An operator's rename wins untouched.
15169        assert_eq!(
15170            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15171            "feat: renamed by hand"
15172        );
15173    }
15174
15175    #[tokio::test]
15176    async fn github_text_rewrite_is_bounded_and_falls_back() {
15177        crate::run::pin_test_home();
15178        for (reply, accepted) in [
15179            (
15180                "TITLE: fix: retries\nAdd retries for failed requests.",
15181                true,
15182            ),
15183            (
15184                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15185                false,
15186            ),
15187            (
15188                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15189                true,
15190            ),
15191            (
15192                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15193                true,
15194            ),
15195            (
15196                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15197                true,
15198            ),
15199            (
15200                "TITLE: fix: retries\n日本語の説明です。 token=secret",
15201                false,
15202            ),
15203            ("TITLE: fix: retries\n", false),
15204        ] {
15205            let dir = tempfile::tempdir().unwrap();
15206            let mut runner = runner_at(RunStatus::Gating);
15207            runner.state = state_with_summary(
15208                "add retries",
15209                "TITLE: fix: retries\n日本語の説明を書きます。",
15210            );
15211            runner.state.repo = dir.path().to_owned();
15212            runner.state.candidates[0].worktree = dir.path().to_owned();
15213            let mut author = spec("alpha");
15214            author.command = vec![
15215                "sh".into(),
15216                "-c".into(),
15217                "printf '%s' \"$REWRITE_REPLY\"".into(),
15218            ];
15219            author.env.insert("REWRITE_REPLY".into(), reply.into());
15220            runner.state.config.agents = vec![author];
15221            seed_public_summary(&mut runner);
15222            let winner = runner.state.candidates[0].clone();
15223            let first = runner
15224                .guarded_pr_message(&winner, None, true)
15225                .await
15226                .unwrap();
15227            let message = if accepted {
15228                first.expect("a passing rewrite never asks")
15229            } else {
15230                // Withheld: the run parks on a question and posts nothing yet.
15231                assert!(first.is_none());
15232                assert!(runner.state.parked);
15233                let store = ask::Questions::open();
15234                let asked: Vec<_> = store
15235                    .list()
15236                    .into_iter()
15237                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15238                    .collect();
15239                assert_eq!(asked.len(), 1);
15240                assert!(!asked[0].detail.contains("secret"));
15241                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15242                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15243                assert_eq!(
15244                    asked[0]
15245                        .choices
15246                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
15247                    rewrite.is_some()
15248                );
15249                // Silence: the question lapses and the neutral text is used,
15250                // without a second rewrite call or a second question.
15251                store
15252                    .update(&asked[0].id, |q| {
15253                        q.abandon("test");
15254                        Ok(())
15255                    })
15256                    .unwrap();
15257                let again = runner
15258                    .guarded_pr_message(&winner, None, true)
15259                    .await
15260                    .unwrap()
15261                    .expect("an abandoned question falls back");
15262                let n = store
15263                    .list()
15264                    .into_iter()
15265                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15266                    .count();
15267                assert_eq!(n, 1, "asked once per rejected text");
15268                again
15269            };
15270            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15271            assert!(!message.body.contains("secret"));
15272            assert!(!message.body.contains("prefix"));
15273            assert!(!message.title.contains("secret"));
15274            assert_eq!(
15275                message.body.contains("Add retries for failed requests."),
15276                accepted
15277            );
15278            assert_eq!(
15279                runner.state.seats["github-text-rewrite"].turns, 1,
15280                "only one rewrite invocation"
15281            );
15282            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15283            if accepted {
15284                assert!(runner.state.github_text.is_none());
15285                assert!(!runner.state.parked);
15286                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15287                assert!(message.body.contains("## Final verification"));
15288                assert!(message.body.contains("Evidence: final diff"));
15289                assert!(
15290                    message
15291                        .body
15292                        .contains(&format!("magi:run/{}", runner.state.id))
15293                );
15294            }
15295            if !accepted {
15296                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15297                assert!(
15298                    message
15299                        .body
15300                        .contains(&format!("magi:run/{}", runner.state.id))
15301                );
15302            }
15303        }
15304    }
15305
15306    fn scripted(id: &str, reply: &str) -> AgentSpec {
15307        let mut a = spec(id);
15308        a.command = vec![
15309            "sh".into(),
15310            "-c".into(),
15311            "printf '%s' \"$REWRITE_REPLY\"".into(),
15312        ];
15313        a.env.insert("REWRITE_REPLY".into(), reply.into());
15314        a
15315    }
15316
15317    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15318    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15319
15320    /// Gate tests inject generated public output, not internal reports.
15321    fn seed_public_summary(runner: &mut Runner) {
15322        let repo = &runner.state.repo;
15323        let g = |args: &[&str]| {
15324            let out = std::process::Command::new("git")
15325                .args(args)
15326                .current_dir(repo)
15327                .quiet()
15328                .output()
15329                .unwrap();
15330            assert!(out.status.success(), "git {args:?}");
15331            String::from_utf8_lossy(&out.stdout).trim().to_owned()
15332        };
15333        g(&["init", "-q", "-b", "main"]);
15334        g(&[
15335            "-c",
15336            "user.name=Test",
15337            "-c",
15338            "user.email=test@example.invalid",
15339            "commit",
15340            "-q",
15341            "--allow-empty",
15342            "-m",
15343            "feat: fixture",
15344        ]);
15345        let head = g(&["rev-parse", "HEAD"]);
15346        g(&["update-ref", "refs/remotes/origin/main", &head]);
15347        let evidence = crate::pr_outcome::summary_evidence(
15348            &runner.state,
15349            crate::pr_outcome::FinalHead::At(&head),
15350        );
15351        let generated = &runner.state.candidates[0].summary;
15352        runner.state.change_summary = Some(crate::run::ChangeSummary {
15353            base: head.clone(),
15354            head,
15355            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15356            title: summary_title(generated).unwrap(),
15357            body: summary_without_title(generated),
15358        });
15359    }
15360
15361    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15362        let mut runner = runner_at(RunStatus::Gating);
15363        runner.state = state_with_summary("add retries", JA_SUMMARY);
15364        if review_only {
15365            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15366        }
15367        runner.state.repo = dir.to_owned();
15368        runner.state.candidates[0].worktree = dir.to_owned();
15369        runner.state.config.agents = agents;
15370        seed_public_summary(&mut runner);
15371        runner
15372    }
15373
15374    fn gate_questions(runner: &Runner) -> usize {
15375        ask::Questions::open()
15376            .list()
15377            .into_iter()
15378            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15379            .count()
15380    }
15381
15382    #[tokio::test]
15383    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15384        crate::run::pin_test_home();
15385        let dir = tempfile::tempdir().unwrap();
15386        let mut runner = rewrite_runner(
15387            dir.path(),
15388            true,
15389            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15390        );
15391        runner.state.config.roles.language_judge =
15392            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15393        let winner = runner.state.candidates[0].clone();
15394        let msg = runner
15395            .guarded_pr_message(&winner, None, true)
15396            .await
15397            .unwrap()
15398            .expect("an English rewrite posts without asking");
15399        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15400        assert!(msg.body.contains("<details>kept</details>"));
15401        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15402        assert_eq!(gate_questions(&runner), 0);
15403        assert!(runner.state.github_text.is_none());
15404        assert!(!runner.state.seats.contains_key("impl-A"));
15405        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15406    }
15407
15408    #[tokio::test]
15409    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15410        crate::run::pin_test_home();
15411        let dir = tempfile::tempdir().unwrap();
15412        let mut runner = rewrite_runner(
15413            dir.path(),
15414            false,
15415            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15416        );
15417        runner.state.config.roles.language_judge =
15418            Some(crate::config::AgentChoice::One("beta".into()));
15419        runner.state.seats.insert(
15420            "impl-A".into(),
15421            SeatState::new("impl-A", "alpha", runner.state.seed),
15422        );
15423        let winner = runner.state.candidates[0].clone();
15424        let msg = runner
15425            .guarded_pr_message(&winner, None, true)
15426            .await
15427            .unwrap()
15428            .expect("rewritten");
15429        assert!(msg.body.contains("<details>kept</details>"));
15430        assert_eq!(runner.state.seats["impl-A"].turns, 0);
15431        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15432        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15433    }
15434
15435    #[tokio::test]
15436    async fn rewrite_reuses_a_resumable_implementer_session() {
15437        crate::run::pin_test_home();
15438        let dir = tempfile::tempdir().unwrap();
15439        let mut runner = rewrite_runner(
15440            dir.path(),
15441            false,
15442            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15443        );
15444        runner.state.config.roles.language_judge =
15445            Some(crate::config::AgentChoice::One("beta".into()));
15446        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15447        seat.turns = 1;
15448        runner.state.seats.insert("impl-A".into(), seat);
15449        let winner = runner.state.candidates[0].clone();
15450        runner
15451            .guarded_pr_message(&winner, None, true)
15452            .await
15453            .unwrap()
15454            .expect("rewritten");
15455        assert_eq!(runner.state.seats["impl-A"].turns, 2);
15456        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15457    }
15458
15459    #[tokio::test]
15460    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15461        crate::run::pin_test_home();
15462        let dir = tempfile::tempdir().unwrap();
15463        let mut broken = spec("bad");
15464        broken.command = vec!["false".into()];
15465        let mut runner = rewrite_runner(
15466            dir.path(),
15467            true,
15468            vec![
15469                broken,
15470                scripted("noformat", "Add retries."),
15471                scripted("good", EN_REWRITE),
15472            ],
15473        );
15474        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15475            "bad".into(),
15476            "noformat".into(),
15477            "good".into(),
15478        ]));
15479        let winner = runner.state.candidates[0].clone();
15480        let msg = runner
15481            .guarded_pr_message(&winner, None, true)
15482            .await
15483            .unwrap()
15484            .expect("the third agent rewrites");
15485        assert!(msg.body.contains("`src/net.rs`"));
15486        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15487        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15488    }
15489
15490    #[tokio::test]
15491    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15492        crate::run::pin_test_home();
15493        // (agents, role): still Japanese; no TITLE; nothing resolvable.
15494        for (agents, role, saved) in [
15495            (
15496                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15497                Some("beta"),
15498                true,
15499            ),
15500            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15501            (Vec::new(), None, false),
15502        ] {
15503            let dir = tempfile::tempdir().unwrap();
15504            let mut runner = rewrite_runner(dir.path(), true, agents);
15505            runner.state.config.roles.language_judge =
15506                role.map(|r| crate::config::AgentChoice::One(r.into()));
15507            let winner = runner.state.candidates[0].clone();
15508            let first = runner
15509                .guarded_pr_message(&winner, None, true)
15510                .await
15511                .unwrap();
15512            assert!(first.is_none(), "withheld text parks the run");
15513            assert!(runner.state.parked);
15514            assert_eq!(gate_questions(&runner), 1);
15515            let g = runner.state.github_text.as_ref().unwrap();
15516            assert_eq!(g.rewrite.is_some(), saved);
15517            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15518        }
15519    }
15520
15521    #[tokio::test]
15522    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15523        crate::run::pin_test_home();
15524        let dir = tempfile::tempdir().unwrap();
15525        let mut runner = runner_at(RunStatus::Gating);
15526        runner.state =
15527            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15528        runner.state.repo = dir.path().to_owned();
15529        runner.state.candidates[0].worktree = dir.path().to_owned();
15530        let mut author = spec("alpha");
15531        author.command = vec![
15532            "sh".into(),
15533            "-c".into(),
15534            "printf '%s' \"$REWRITE_REPLY\"".into(),
15535        ];
15536        author.env.insert(
15537            "REWRITE_REPLY".into(),
15538            format!(
15539                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15540                "English sentence. ".repeat(600)
15541            ),
15542        );
15543        runner.state.config.agents = vec![author];
15544        seed_public_summary(&mut runner);
15545        let winner = runner.state.candidates[0].clone();
15546        assert!(
15547            runner
15548                .guarded_pr_message(&winner, None, true)
15549                .await
15550                .unwrap()
15551                .is_none()
15552        );
15553        let record = runner.state.github_text.clone().unwrap();
15554        let rewrite = record.rewrite.clone().unwrap();
15555        assert_eq!(rewrite.failed, ["body-language"]);
15556        assert!(
15557            rewrite
15558                .body
15559                .ends_with(&format!("magi:run/{}", runner.state.id))
15560        );
15561        let store = ask::Questions::open();
15562        let q = store.get(record.question.as_ref().unwrap()).unwrap();
15563        assert!(
15564            q.choices
15565                .contains(&crate::github_text::USE_REWRITE.to_owned())
15566        );
15567        for label in [
15568            "ORIGINAL",
15569            "FALLBACK",
15570            "REWRITE",
15571            "Rules still failed: body-language",
15572            "truncated",
15573        ] {
15574            assert!(q.detail.contains(label), "{label}");
15575        }
15576        let artifact = std::fs::read_to_string(crate::run::artifact_path(
15577            &runner.state,
15578            &crate::github_text::artifact_name(&record.fingerprint),
15579        ))
15580        .unwrap();
15581        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15582        // Restart from serialized state, then retry after a bad owner title.
15583        runner.state =
15584            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15585        store
15586            .update(&q.id, |q| {
15587                q.say("まだ日本語")?;
15588                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15589            })
15590            .unwrap();
15591        assert!(
15592            runner
15593                .guarded_pr_message(&winner, None, true)
15594                .await
15595                .unwrap()
15596                .is_none()
15597        );
15598        let retry = runner.state.github_text.as_ref().unwrap();
15599        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15600        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15601        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15602        assert!(
15603            q.posting_comparison
15604                .as_ref()
15605                .unwrap()
15606                .matches_detail(&q.detail)
15607        );
15608        store
15609            .update(&q.id, |q| {
15610                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15611            })
15612            .unwrap();
15613        let message = runner
15614            .guarded_pr_message(&winner, None, true)
15615            .await
15616            .unwrap()
15617            .unwrap();
15618        assert_eq!(message.title, rewrite.title);
15619        assert_eq!(message.body, rewrite.body);
15620        assert_eq!(
15621            runner.state.github_text.as_ref().unwrap().choice,
15622            Some(GithubTextChoice::Rewrite)
15623        );
15624        let again = runner
15625            .guarded_pr_message(&winner, None, true)
15626            .await
15627            .unwrap()
15628            .unwrap();
15629        assert_eq!(again.title, message.title);
15630        assert_eq!(again.body, message.body);
15631        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15632        assert!(
15633            !runner
15634                .state
15635                .events
15636                .iter()
15637                .any(|e| e.message.contains(&rewrite.title)
15638                    || e.message.contains("English sentence."))
15639        );
15640    }
15641
15642    #[tokio::test]
15643    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15644        crate::run::pin_test_home();
15645        let dir = tempfile::tempdir().unwrap();
15646        let mut runner = runner_at(RunStatus::Gating);
15647        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15648        runner.state.repo = dir.path().to_owned();
15649        runner.state.candidates[0].worktree = dir.path().to_owned();
15650        seed_public_summary(&mut runner);
15651        let winner = runner.state.candidates[0].clone();
15652        assert!(
15653            runner
15654                .guarded_pr_message(&winner, None, true)
15655                .await
15656                .unwrap()
15657                .is_none()
15658        );
15659        let record = runner.state.github_text.as_ref().unwrap();
15660        assert!(record.rewrite.is_none());
15661        // Records written before the additive field deserialize with no rewrite.
15662        let mut old = serde_json::to_value(record).unwrap();
15663        old.as_object_mut().unwrap().remove("rewrite");
15664        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15665        assert!(old.rewrite.is_none());
15666        let id = record.question.clone().unwrap();
15667        ask::Questions::open()
15668            .update(&id, |q| {
15669                q.choices.push(crate::github_text::USE_REWRITE.into());
15670                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15671            })
15672            .unwrap();
15673        let message = runner
15674            .guarded_pr_message(&winner, None, true)
15675            .await
15676            .unwrap()
15677            .unwrap();
15678        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15679        assert_eq!(
15680            runner.state.github_text.as_ref().unwrap().choice,
15681            Some(GithubTextChoice::Fallback)
15682        );
15683    }
15684
15685    #[tokio::test]
15686    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15687        crate::run::pin_test_home();
15688        let dir = tempfile::tempdir().unwrap();
15689        let mut runner = runner_at(RunStatus::Gating);
15690        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15691        runner.state.repo = dir.path().to_owned();
15692        runner.state.candidates[0].worktree = dir.path().to_owned();
15693        seed_public_summary(&mut runner);
15694        let winner = runner.state.candidates[0].clone();
15695        assert!(
15696            runner
15697                .guarded_pr_message(&winner, None, true)
15698                .await
15699                .unwrap()
15700                .is_none()
15701        );
15702        let store = ask::Questions::open();
15703        let find = |run: &str| {
15704            store
15705                .list()
15706                .into_iter()
15707                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15708                .unwrap()
15709        };
15710        let run = runner.state.id.clone();
15711        let q = find(&run);
15712        let fp = runner
15713            .state
15714            .github_text
15715            .as_ref()
15716            .unwrap()
15717            .fingerprint
15718            .clone();
15719        let name = crate::github_text::artifact_name(&fp);
15720        let path = crate::run::artifact_path(&runner.state, &name);
15721        assert!(q.detail.contains(&path.display().to_string()));
15722        assert!(q.detail.contains("title-language: title"));
15723        let saved = std::fs::read_to_string(&path).expect("artifact written");
15724        assert!(saved.contains("日本語のタイトル"));
15725        assert!(q.cwd.is_none());
15726        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15727        // A bad replacement is refused and asked about once more.
15728        let say = |id: &str, text: &str, choice: &str| {
15729            store
15730                .update(id, |q| {
15731                    q.thread.push(ask::Turn {
15732                        who: ask::Who::Operator,
15733                        body: text.to_owned(),
15734                        at: jiff::Timestamp::now(),
15735                        note: None,
15736                    });
15737                    q.answer(ask::Answer::Choice(choice.to_owned()))
15738                })
15739                .unwrap();
15740        };
15741        say(&q.id, "まだ日本語", "use my text");
15742        assert!(
15743            runner
15744                .guarded_pr_message(&winner, None, true)
15745                .await
15746                .unwrap()
15747                .is_none()
15748        );
15749        let second = find(&run);
15750        assert_ne!(second.id, q.id);
15751        assert!(second.detail.contains("did not pass"));
15752        // The retry still compares the original text; the rejected
15753        // replacement is named by category only.
15754        assert!(second.detail.contains("日本語のタイトル"));
15755        assert!(!second.detail.contains("まだ日本語"));
15756        assert!(second.detail.contains(&path.display().to_string()));
15757        say(&second.id, "fix: retry failed requests", "use my text");
15758        let message = runner
15759            .guarded_pr_message(&winner, None, true)
15760            .await
15761            .unwrap()
15762            .expect("a vetted replacement is posted");
15763        assert_eq!(message.title, "fix: retry failed requests");
15764        assert_eq!(
15765            runner
15766                .state
15767                .github_text
15768                .as_ref()
15769                .unwrap()
15770                .chosen_title
15771                .as_deref(),
15772            Some("fix: retry failed requests")
15773        );
15774        assert!(runner.state.github_text.as_ref().unwrap().resolved);
15775        // Resumed again: the saved decision is applied, nothing is re-asked.
15776        let again = runner
15777            .guarded_pr_message(&winner, None, true)
15778            .await
15779            .unwrap()
15780            .unwrap();
15781        assert_eq!(again.title, message.title);
15782        assert_eq!(
15783            store
15784                .list()
15785                .into_iter()
15786                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15787                .count(),
15788            2
15789        );
15790    }
15791
15792    #[test]
15793    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15794        // What magi itself writes stays English under any configured language,
15795        // so a future localisation of these headings fails here. (The agents'
15796        // own text is also checked by the posting gate.)
15797        let mut state = state_with_summary(
15798            "add retries",
15799            "TITLE: fix(web): batch reads\n- reads run.json once",
15800        );
15801        state.config.graph.language = "ja".to_owned();
15802        let m = pr_message(&state, 'A');
15803        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15804        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15805
15806        // The task is the operator's own text: it goes in untouched, and the
15807        // fallback title (no summary) may be in its language too.
15808        let task = "今回やってほしいこと: results projector を直す";
15809        let mut state = state_with_summary(task, "- no title line");
15810        state.config.graph.language = "ja".to_owned();
15811        let m = pr_message(&state, 'A');
15812        assert_eq!(
15813            m.title,
15814            format!("chore: land candidate A of run {}", state.id)
15815        );
15816        assert!(
15817            m.body.contains(&format!(
15818                "<summary>Original task</summary>\n\n{task}\n\n</details>"
15819            )),
15820            "{}",
15821            m.body
15822        );
15823    }
15824
15825    #[test]
15826    fn pr_message_scrubs_home_paths_and_addresses() {
15827        let state = state_with_summary(
15828            "fix it in /Users/someone/src/x",
15829            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15830        );
15831        let m = pr_message(&state, 'A');
15832        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15833            assert!(!m.body.contains(leak), "{}", m.body);
15834        }
15835        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15836    }
15837
15838    #[test]
15839    fn pr_message_survives_a_task_that_closes_details() {
15840        let state = state_with_summary("a </details> b", "TITLE: fix: x");
15841        let m = pr_message(&state, 'A');
15842        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15843    }
15844
15845    #[test]
15846    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15847        let cmd = manual_merge_command(
15848            MergeStyle::Squash,
15849            Path::new("/repo"),
15850            "b",
15851            "fix: \"quoted\" $(x) `y`\n\nbody",
15852        );
15853        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15854    }
15855
15856    #[test]
15857    fn manual_merge_command_matches_the_configured_style() {
15858        let repo = Path::new("/repo");
15859        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15860
15861        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15862        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15863
15864        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15865        assert_eq!(
15866            squash,
15867            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15868             \"Merge magi run 0832 (candidate A)\""
15869        );
15870
15871        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15872        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15873    }
15874
15875    #[test]
15876    fn a_nudge_gets_a_quarter_of_the_budget() {
15877        // The judge and implement budgets magi ships with.
15878        assert_eq!(retry_budget(secs(1200), true), secs(300));
15879        assert_eq!(retry_budget(secs(3600), true), secs(900));
15880    }
15881
15882    #[test]
15883    fn a_resent_prompt_keeps_the_whole_budget() {
15884        // The seat kept no context, so the retry is the original job again and
15885        // shortening it would only guarantee a second failure.
15886        assert_eq!(retry_budget(secs(1200), false), secs(1200));
15887        assert_eq!(retry_budget(secs(60), false), secs(60));
15888    }
15889
15890    #[test]
15891    fn the_floor_never_exceeds_the_original_budget() {
15892        // A short configured timeout must not be *raised* by the floor: the
15893        // operator asked for a bound, and a retry may not outlast the attempt
15894        // it is retrying.
15895        assert_eq!(retry_budget(secs(60), true), secs(60));
15896        assert_eq!(retry_budget(secs(480), true), secs(120));
15897        assert_eq!(retry_budget(secs(0), true), secs(0));
15898    }
15899
15900    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15901        agent::CommandEvidence {
15902            id: "item1".to_owned(),
15903            description: "cargo test".to_owned(),
15904            exit_code,
15905            result_summary: String::new(),
15906            source: "codex".to_owned(),
15907        }
15908    }
15909
15910    #[test]
15911    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15912        // No evidence is not the same fact as unconfirmed evidence: a
15913        // backend with no adapter, or a reply that ran no commands at all,
15914        // must not be misread as carrying a dangling job.
15915        assert!(!has_unconfirmed_command(&[]));
15916    }
15917
15918    #[test]
15919    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15920        // Deliberately not a check on the exit code's *value*: a fixer
15921        // legitimately runs something that fails mid-iteration before it
15922        // succeeds, and that must never by itself reopen a valid report.
15923        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15924        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15925        assert!(!has_unconfirmed_command(&[
15926            evidence(Some(0)),
15927            evidence(Some(101))
15928        ]));
15929    }
15930
15931    #[test]
15932    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15933        assert!(has_unconfirmed_command(&[
15934            evidence(Some(0)),
15935            evidence(None)
15936        ]));
15937    }
15938
15939    #[test]
15940    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15941        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15942        assert_eq!(
15943            verified_noop_claim(true, &[], text).as_deref(),
15944            Some("already fixed by b32cfc4, on main.")
15945        );
15946    }
15947
15948    #[test]
15949    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15950        // A timeout or a bad exit code reads as the ordinary loss it is,
15951        // whatever the reply's own prose claims.
15952        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15953        assert!(verified_noop_claim(false, &[], text).is_none());
15954    }
15955
15956    #[test]
15957    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15958        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15959        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15960        // A confirmed command alongside the marker is fine.
15961        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15962    }
15963
15964    #[test]
15965    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15966        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15967    }
15968
15969    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
15970    /// pair, in order, labelled A, B, C, ...
15971    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15972        runner.state.candidates = shape
15973            .iter()
15974            .enumerate()
15975            .map(|(i, &(empty, verified))| Candidate {
15976                index: i,
15977                label: (b'A' + i as u8) as char,
15978                agent: "sonnet".to_owned(),
15979                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15980                worktree: PathBuf::from(format!("/wt/{i}")),
15981                summary: String::new(),
15982                stat: String::new(),
15983                files: 0,
15984                commits: 0,
15985                empty,
15986                failed: None,
15987                verified_noop: verified.map(str::to_owned),
15988                duration_ms: 0,
15989                folded: false,
15990            })
15991            .collect();
15992    }
15993
15994    #[test]
15995    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15996        ask_test_home();
15997        let mut runner = runner_at(RunStatus::Implementing);
15998        set_candidates(
15999            &mut runner,
16000            &[
16001                (true, Some("already on main at b32cfc4")),
16002                (true, Some("same fix, see the existing test")),
16003            ],
16004        );
16005
16006        runner
16007            .after_implement()
16008            .expect("a verified no-op is not an error");
16009
16010        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
16011    }
16012
16013    #[test]
16014    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
16015        ask_test_home();
16016        let mut runner = runner_at(RunStatus::Implementing);
16017        // Candidate A declares a verified no-op; candidate B simply wrote
16018        // nothing and said nothing about why. One candidate's claim is not
16019        // the whole run's agreement.
16020        set_candidates(
16021            &mut runner,
16022            &[(true, Some("already on main at b32cfc4")), (true, None)],
16023        );
16024
16025        let err = runner
16026            .after_implement()
16027            .expect_err("an unverified empty candidate must still fail the run");
16028
16029        assert!(
16030            err.to_string().contains("no candidate produced a change"),
16031            "{err}"
16032        );
16033        assert_eq!(runner.state.status, RunStatus::Failed);
16034    }
16035
16036    #[test]
16037    fn after_implement_still_fails_an_ordinary_all_empty_run() {
16038        ask_test_home();
16039        let mut runner = runner_at(RunStatus::Implementing);
16040        set_candidates(&mut runner, &[(true, None), (true, None)]);
16041
16042        let err = runner
16043            .after_implement()
16044            .expect_err("no candidate declared anything; this is an ordinary failure");
16045
16046        assert!(
16047            err.to_string().contains("no candidate produced a change"),
16048            "{err}"
16049        );
16050        assert_eq!(runner.state.status, RunStatus::Failed);
16051    }
16052}
16053
16054/// A graft rollback whose restore could not be confirmed (see
16055/// [`Runner::graft`]).
16056#[derive(Debug)]
16057struct GraftRestoreFailed(String);
16058
16059impl std::fmt::Display for GraftRestoreFailed {
16060    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16061        write!(
16062            f,
16063            "could not restore the winner to its pre-graft commit: {}",
16064            self.0
16065        )
16066    }
16067}
16068
16069impl std::error::Error for GraftRestoreFailed {}
16070
16071/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
16072/// not the agent: they never draw on an agent's own timeout, and running out of
16073/// them never reads as an agent failure.
16074#[cfg(test)]
16075mod resource_wait_tests {
16076    use super::*;
16077    use std::collections::BTreeMap;
16078
16079    fn sh(id: &str, script: &str) -> AgentSpec {
16080        AgentSpec {
16081            id: id.to_owned(),
16082            kind: crate::config::AgentKind::Command,
16083            model: None,
16084            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16085            extra_args: Vec::new(),
16086            env: BTreeMap::new(),
16087            prompt_delivery: None,
16088        }
16089    }
16090
16091    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16092        SeatJob {
16093            seat: SeatState::new("impl-A", &spec.id, 1),
16094            spec,
16095            cwd: dir.to_path_buf(),
16096            prompt: "do it".to_owned(),
16097            timeout,
16098            allow_write: true,
16099            sessions: false,
16100            artifacts: dir.join("art"),
16101            stem: "impl-A".to_owned(),
16102            handover: None,
16103        }
16104    }
16105
16106    fn state(resource_wait_secs: u64) -> RunState {
16107        let mut config = Config::default();
16108        config.graph.timeout_resource_wait = resource_wait_secs;
16109        RunState::new(
16110            PathBuf::from("/repo"),
16111            "main".to_owned(),
16112            "abc1234".to_owned(),
16113            "task".to_owned(),
16114            config,
16115        )
16116    }
16117
16118    fn hold(cache: &Path) -> crate::cache::Guard {
16119        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16120        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16121            crate::cache::AcquireOutcome::Acquired(g) => g,
16122            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16123        }
16124    }
16125
16126    async fn run(
16127        jobs: Vec<SeatJob>,
16128        sem: Arc<Semaphore>,
16129        cache: Option<&Path>,
16130        st: &mut RunState,
16131    ) -> Vec<(usize, SeatState, AgentOutcome)> {
16132        let prompts = Prompts::default();
16133        let ctx = WaveCtx {
16134            run: "r",
16135            node: "implement",
16136            prompts: &prompts,
16137            cache,
16138            round: None,
16139            carry_seats: false,
16140            pauses: None,
16141        };
16142        wave(jobs, sem, &ctx, st, 0).await
16143    }
16144
16145    #[tokio::test]
16146    async fn verification_cache_wait_preserves_the_command_budget() {
16147        crate::run::pin_test_home();
16148        let tmp = tempfile::tempdir().unwrap();
16149        let cache = tmp.path().join("target");
16150        let guard = hold(&cache);
16151        let release = tokio::spawn(async move {
16152            tokio::time::sleep(Duration::from_millis(100)).await;
16153            guard.release();
16154        });
16155        let mut st = state(10);
16156        let budget = Duration::from_millis(50);
16157        let (outcomes, _) = with_cache_lease(
16158            &mut st,
16159            Some(&cache),
16160            "gate",
16161            "gate",
16162            tmp.path(),
16163            "abc1234",
16164            budget,
16165            "test gate",
16166            |_, available| async move {
16167                assert_eq!(
16168                    available, budget,
16169                    "contention must not shorten verification"
16170                );
16171                (Vec::new(), false, Vec::new())
16172            },
16173        )
16174        .await;
16175        release.await.unwrap();
16176        assert!(outcomes.is_empty(), "{outcomes:?}");
16177        assert!(
16178            st.events
16179                .iter()
16180                .any(|event| event.message.contains("waiting for"))
16181        );
16182        assert!(st.resource_wait.is_none());
16183        // The verification body no longer owns the lease after returning.
16184        hold(&cache).release();
16185    }
16186
16187    #[tokio::test]
16188    async fn verification_cache_wait_expires_without_starting_commands() {
16189        crate::run::pin_test_home();
16190        let tmp = tempfile::tempdir().unwrap();
16191        let cache = tmp.path().join("target");
16192        let _held = hold(&cache);
16193        let mut st = state(0);
16194        let (outcomes, _) = with_cache_lease(
16195            &mut st,
16196            Some(&cache),
16197            "gate",
16198            "gate",
16199            tmp.path(),
16200            "abc1234",
16201            Duration::from_secs(60),
16202            "test gate",
16203            |_, _| async { panic!("commands must not start without the cache lease") },
16204        )
16205        .await;
16206        assert_eq!(outcomes.len(), 1);
16207        assert!(outcomes[0].resource_blocked);
16208        assert!(st.resource_wait.is_none());
16209    }
16210
16211    #[tokio::test]
16212    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16213        crate::run::pin_test_home();
16214        let tmp = tempfile::tempdir().unwrap();
16215        let cache = tmp.path().join("target-a");
16216        let guard = hold(&cache);
16217        // Held for longer than the agent's whole 1s budget.
16218        tokio::spawn(async move {
16219            tokio::time::sleep(Duration::from_millis(1500)).await;
16220            guard.release();
16221        });
16222        let mut st = state(60);
16223        let results = run(
16224            vec![job(
16225                sh("alpha", "echo ok"),
16226                tmp.path(),
16227                Duration::from_secs(1),
16228            )],
16229            Arc::new(Semaphore::new(1)),
16230            Some(&cache),
16231            &mut st,
16232        )
16233        .await;
16234        assert!(
16235            matches!(results[0].2, AgentOutcome::Ok(_)),
16236            "the wait must not be charged to the agent"
16237        );
16238        assert!(st.resource_block.is_none());
16239        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16240        assert!(st.resource_wait.is_none() && st.active.is_empty());
16241    }
16242
16243    #[tokio::test]
16244    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16245        crate::run::pin_test_home();
16246        let tmp = tempfile::tempdir().unwrap();
16247        let cache = tmp.path().join("target-b");
16248        let _held = hold(&cache);
16249        let mut st = state(1);
16250        let results = run(
16251            vec![job(
16252                sh("alpha", "echo ok"),
16253                tmp.path(),
16254                Duration::from_secs(1),
16255            )],
16256            Arc::new(Semaphore::new(1)),
16257            Some(&cache),
16258            &mut st,
16259        )
16260        .await;
16261        let (_, seat, out) = &results[0];
16262        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16263        assert!(wave_blocked(&results));
16264        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16265        assert_eq!(seat.turns, 0, "nothing was asked");
16266        assert!(
16267            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16268            "no agent was spawned"
16269        );
16270        assert!(FailClass::of(out).is_none(), "not a failure class");
16271        let block = st.resource_block.as_ref().expect("recorded for the resume");
16272        assert_eq!(block.kind, "shared build cache");
16273        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16274        assert!(st.active.is_empty());
16275    }
16276
16277    #[tokio::test]
16278    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16279        crate::run::pin_test_home();
16280        let tmp = tempfile::tempdir().unwrap();
16281        let mut st = state(1);
16282        let results = run(
16283            vec![job(
16284                sh("alpha", "echo ok"),
16285                tmp.path(),
16286                Duration::from_secs(1),
16287            )],
16288            Arc::new(Semaphore::new(0)),
16289            None,
16290            &mut st,
16291        )
16292        .await;
16293        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16294        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16295        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16296    }
16297
16298    #[tokio::test]
16299    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16300        crate::run::pin_test_home();
16301        let tmp = tempfile::tempdir().unwrap();
16302        let mut st = state(60);
16303        let results = run(
16304            vec![job(
16305                sh("alpha", "sleep 30"),
16306                tmp.path(),
16307                Duration::from_secs(1),
16308            )],
16309            Arc::new(Semaphore::new(1)),
16310            None,
16311            &mut st,
16312        )
16313        .await;
16314        let out = &results[0].2;
16315        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16316        let class = FailClass::of(out).expect("a failure");
16317        assert_eq!(class, FailClass::Timeout);
16318        assert!(should_hand_over(None, &class), "existing handover policy");
16319        assert!(st.resource_block.is_none());
16320    }
16321}