Skip to main content

magi/
graph.rs

1//! The competition graph.
2//!
3//! ```text
4//! prep ──► implement ×N ──► judge ×M (blind) ──► split? ──► deliberate ──► vote (private)
5//!                                                   │                          │
6//!                                                   └──── unanimous ───────────┤
7//!                                                                              ▼
8//!   merge ◄── gate ◄── review ×R + E2E, fix, repeat ◄── fold losers ◄──────── tally
9//! ```
10//!
11//! Every node persists before the next one starts, so a run can be resumed
12//! after a crash, a rate limit, or a reboot without re-spending the work that
13//! already landed.
14//!
15//! The design decision that matters most is *where the facilitator lives*.
16//! There is no moderator agent: magi assigns the labels, decides the
17//! presentation order, relays the transcript, and collects the final votes
18//! one-to-one. A moderator that never learns an author cannot leak one.
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36    ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51    GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52    MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, ParkCause,
53    QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory,
54    Tally, VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58    ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62/// How much verification output is kept and fed back to the fixer.
63const OUTPUT_TAIL: usize = 8_000;
64
65/// Bytes of a failing command's output kept in an event, so the reason a run
66/// stopped is readable from the report without opening `run.json`.
67const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
70/// command's pid before releasing the build cache's lease.
71const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
74/// command's pid to actually exit before giving up and releasing anyway.
75///
76/// A timeout means the process was asked to die (`kill_on_drop`,
77/// `start_kill`), not that it already has — on Windows in particular that can
78/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
79/// the instant the command returns would let the very next acquirer (this
80/// run's own next round, another run's verification, the janitor's prune)
81/// start touching the same directory while it might still be writing to it,
82/// so this polls the actual pid — real confirmation, not a fixed guess —
83/// until it is gone or this ceiling is reached. It is still not full
84/// process-tree reaping: a grandchild the timed-out process spawned and that
85/// outlives it independently is invisible to a pid check, and continuing to
86/// observe and collect *that* stays a different piece of work with its own
87/// owner. Set generously because the common case returns early the moment
88/// the pid is confirmed gone, not because every timeout pays this in full.
89const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91/// Consecutive review rounds with no tree progress (see
92/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
93/// instead of spending the rest of the round budget.
94///
95/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
96/// legitimately finds nothing left to change (its previous round's fix already
97/// covered it, and this round's reviewers re-raised only nits) looks the same
98/// as one that is spinning, for exactly one round. Two in a row is where the
99/// two stop being distinguishable, and a review round on this workload has
100/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
101/// third attempt at a tree that has not moved twice running is pure cost.
102/// This does not touch `review_rounds` itself, which stays the operator's
103/// call.
104pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
107/// a base that moved before giving up and leaving the run `Blocked` for a
108/// person.
109///
110/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
111/// that keeps moving faster than a run can catch it is not something more
112/// rebasing fixes, it is a person's call. Not the same *number as*
113/// `land_rounds` - this budget is spent before a pull request exists, land's
114/// after - but bounded for the identical reason, so it uses the same
115/// default. Counted across both call sites in [`Runner::finish_after_tally`]
116/// (once before review, once before the gate), because either one finding
117/// the base still moving is the same signal.
118const BASE_SYNC_ROUNDS: usize = 4;
119
120/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
121/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
122/// reply held no [`FixReport`].
123///
124/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
125/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
126/// "I'll pause here until the `cargo make check` background run reports
127/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
128/// No `FixReport` was ever collected from that seat, and the run moved on to
129/// the next review round regardless.
130///
131/// Bounded independently of `review_rounds` and `graph.retries`: this
132/// recovers one seat's missing report mid-round, not a new round of review or
133/// an ordinary parse retry, and must not itself become the unbounded wait the
134/// rest of this module exists to avoid.
135const MAX_FIX_CONTINUATIONS: usize = 2;
136
137/// One queued agent invocation.
138///
139/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
140/// CLI hung up on its own stream is asked again from the same job rather than
141/// rebuilt from scratch. See [`Runner::resume_undelivered`].
142#[derive(Clone)]
143struct SeatJob {
144    spec: AgentSpec,
145    seat: SeatState,
146    cwd: PathBuf,
147    prompt: String,
148    timeout: Duration,
149    allow_write: bool,
150    sessions: bool,
151    artifacts: PathBuf,
152    stem: String,
153    /// The prompt for a seat that has been handed to another roster agent
154    /// (a fresh session): everything the original seat would have
155    /// remembered. `None` when `prompt` already carries it, as the first
156    /// ranking and the implement prompt do. Never a resume-style prompt.
157    handover: Option<String>,
158}
159
160/// How the graph reads one agent invocation.
161///
162/// Quota is split out from an ordinary failure on purpose: a rate-limited call
163/// is known to fail again if retried now, so the retry loop must not spend an
164/// attempt on it. `Dropped` is split out for the opposite reason: unlike
165/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
166/// error JSON, never the agent's answer — a caller that matched only
167/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
168/// left to read that JSON as if it were usable output. `resume_undelivered`
169/// is the only caller that acts on it; everywhere else it is reported like an
170/// ordinary failure.
171enum AgentOutcome {
172    /// A usable output.
173    Ok(AgentOutput),
174    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
175    Quota(AgentOutput),
176    /// The CLI hung up on its own stream after billed work. See
177    /// [`agent::AgentOutput::work_undelivered`].
178    Dropped(AgentOutput),
179    /// Any other failure: a timeout, a bad exit code, an empty reply.
180    Failed(String),
181    /// The agent was never spawned: a shared resource it needs (the build
182    /// cache's lease) stayed busy past `[graph] timeout_resource_wait`, or the
183    /// wait was abandoned because the run is parking. Not a failure of the
184    /// agent: it must not advance a fallback chain, enter seat history, cost
185    /// quota or spend an attempt. [`RunState::resource_block`] says why.
186    ResourceBlocked(String),
187}
188
189/// A request to park the run at its next node boundary.
190///
191/// Cloning is how the request travels: the loop keeps one handle and hands a
192/// clone to each [`Runner`], and every clone points at the same flag. There
193/// is no channel because there is nothing to send - the only message is
194/// "park", it is idempotent, and a flag cannot be missed by a receiver that
195/// was not listening yet.
196///
197/// The boundary is what makes this cheap. Every node writes the run's state
198/// before the next one starts, and every node skips what is already recorded:
199/// `prep` returns early once candidates exist, `implement` asks only the seats
200/// with nothing on disk, `judge` returns early once judgements exist. So a
201/// parked run resumes into exactly the node it stopped before, and no agent
202/// work is thrown away. Killing the process mid-node, by contrast, loses
203/// whatever the seats in flight had not yet written - which for an implement
204/// wave is an hour of paid work.
205///
206/// A [`Runner`] watches two independent handles of this type - see
207/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
208/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
209/// clone covering the whole daemon's lifetime and is never asked to un-park,
210/// which is correct exactly because nothing is dispatched after it fires.
211/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
212/// that parks for an interrupted task must go on to run other tasks
213/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
214/// reusing the daemon-wide one.
215#[derive(Debug, Clone, Default)]
216pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<ParkCause>>>);
217
218impl Pause {
219    /// A pause nobody has asked for yet.
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Ask the run to park at its next node boundary. Idempotent.
226    pub fn park(&self) {
227        self.0.store(true, Ordering::SeqCst);
228    }
229
230    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
231    /// fold into the run's own `park` event - so an operator reading the run
232    /// later knows this was a deliberate interrupt rather than a shutdown or
233    /// a binary swap. The first reason recorded wins; a park already in
234    /// flight is not relabelled by a second, unrelated request.
235    pub fn park_because(&self, reason: impl Into<String>) {
236        self.park_for(ParkCause::Interrupt {
237            reason: reason.into(),
238        });
239    }
240
241    /// [`Pause::park`], recording the structured cause. The first cause
242    /// recorded wins, like [`Pause::park_because`].
243    pub fn park_for(&self, cause: ParkCause) {
244        let mut reason_guard = self
245            .1
246            .lock()
247            .unwrap_or_else(std::sync::PoisonError::into_inner);
248        if reason_guard.is_none() {
249            *reason_guard = Some(cause);
250        }
251        drop(reason_guard);
252        self.park();
253    }
254
255    /// Has a park been asked for?
256    #[must_use]
257    pub fn parked(&self) -> bool {
258        self.0.load(Ordering::SeqCst)
259    }
260
261    /// Why the park was asked for, when the caller used [`Pause::park_because`].
262    #[must_use]
263    pub fn reason(&self) -> Option<String> {
264        self.cause().map(|c| c.label())
265    }
266
267    /// The structured cause, when one was recorded.
268    #[must_use]
269    pub fn cause(&self) -> Option<ParkCause> {
270        self.1
271            .lock()
272            .unwrap_or_else(std::sync::PoisonError::into_inner)
273            .clone()
274    }
275}
276
277/// Drives one run.
278pub struct Runner {
279    /// Run state; public so the CLI can report on it.
280    pub state: RunState,
281    roles: ResolvedRoles,
282    sem: Arc<Semaphore>,
283    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
284    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
285    /// this is never the same handle as `interrupt`.
286    pause: Pause,
287    /// Set when `magi serve`'s interrupt scheduler wants this specific run
288    /// parked at its next node boundary, to let a task marked
289    /// [`crate::queue::Task::interrupt`] run alone before this one carries
290    /// on. Unlike `pause`, a fresh, unshared handle per run - see
291    /// [`Runner::watch_interrupt`].
292    interrupt: Pause,
293}
294
295/// Where the branch's own commits start: its merge base with the base branch
296/// as the remote has it now, else with the recorded `base_commit`. A branch
297/// rebased onto a base that moved past `base_commit` would otherwise count
298/// the base's commits as its own under `base_commit..branch`.
299async fn review_base(
300    repo: &Path,
301    remote: &str,
302    base_branch: &str,
303    base_commit: &str,
304    branch: &str,
305) -> String {
306    review_base_checked(repo, remote, base_branch, base_commit, branch)
307        .await
308        .0
309}
310
311/// [`review_base`] plus whether the base was read from a freshly fetched
312/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
313/// never older than `base_commit`'s view of the base), but the answer is then
314/// not trusted to rewrite a pull request's title.
315async fn review_base_checked(
316    repo: &Path,
317    remote: &str,
318    base_branch: &str,
319    base_commit: &str,
320    branch: &str,
321) -> (String, bool) {
322    let tracking = format!("{remote}/{base_branch}");
323    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
324    if git::rev_exists(repo, &tracking).await
325        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
326        && !mb.is_empty()
327    {
328        return (mb, fresh);
329    }
330    let mb = git::merge_base(repo, base_commit, branch)
331        .await
332        .ok()
333        .filter(|mb| !mb.is_empty())
334        .unwrap_or_else(|| base_commit.to_owned());
335    (mb, false)
336}
337
338/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
339/// when git cannot say or finds nothing: a stale list is better than a wrong
340/// or empty one.
341pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
342    if !is_review_run(state) {
343        return;
344    }
345    let base = review_base(
346        &state.repo,
347        &state.config.merge.remote,
348        &state.base_branch,
349        &state.base_commit,
350        branch,
351    )
352    .await;
353    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
354        && !subjects.is_empty()
355        && state.reviewed_commits.as_ref() != Some(&subjects)
356    {
357        state.reviewed_commits = Some(subjects);
358        state.save().ok();
359    }
360}
361
362/// Subjects of the base's commits between the recorded start and the branch's
363/// merge base: what a stale `base_commit..branch` would have mistaken for the
364/// branch's own work.
365async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
366    let (base, trusted) = review_base_checked(
367        &state.repo,
368        &state.config.merge.remote,
369        &state.base_branch,
370        &state.base_commit,
371        branch,
372    )
373    .await;
374    if !trusted {
375        return None;
376    }
377    git::subjects(&state.repo, &state.base_commit, &base)
378        .await
379        .ok()
380}
381
382/// May an adopted pull request's title be replaced with `computed`? Only when
383/// it is empty, magi's own shape, or a base commit's subject that leaked in;
384/// a title a person wrote stays. Never when `computed` is itself a leak.
385pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
386    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
387    if is_leak(computed) {
388        return false;
389    }
390    let cur = current.trim();
391    cur.is_empty()
392        || cur.starts_with(REVIEW_PROMPT_OPENING)
393        || cur.starts_with("chore: land candidate")
394        || cur.starts_with("magi: candidate")
395        || is_leak(cur)
396}
397
398/// The commit a run branches from: the base branch as the remote has it.
399///
400/// Two failures this replaces. A run used to branch off `HEAD` and so refused
401/// to start on a dirty tree, which made `magi serve` decline every task for as
402/// long as the operator had work in progress - most of the time. Branching off
403/// the *local* base branch fixed that and introduced a worse one: `land` merges
404/// the winner on GitHub, nothing updates the local ref, and the next run
405/// branches off a base missing everything the previous runs landed. Two tasks
406/// in a row from a phone would have had the second silently re-implementing
407/// against stale code and opening a pull request that reverted the first.
408///
409/// Only refs move here - no checkout, no local branch, no merge - so it is safe
410/// with uncommitted work in the tree. A machine with no network still starts:
411/// the fetch may fail and the local tip is used with a warning, because
412/// refusing to run offline is a worse failure than running against a base the
413/// operator can see for themselves.
414///
415/// One function, called by both entry points. Two answers to "where does a run
416/// branch from" is the kind of drift nobody notices until a diff is wrong.
417/// Bring the local `branch` in line with `<remote>/<branch>` before a review
418/// checks it out.
419///
420/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
421/// anything other than plain `git push` from this checkout (a jj colocated
422/// workspace, another clone) moves only the remote-tracking ref - so the local
423/// one can be a stale placeholder. It moves only when local is behind the remote or is an
424/// empty placeholder that diverged from it; unpushed local work is kept, and a real
425/// divergence is refused rather than guessed at.
426async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
427    let tracking = format!("{remote}/{branch}");
428    let fetched = git::fetch(repo, remote, branch).await;
429    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
430    let local_exists = git::branch_exists(repo, branch).await?;
431    if !fresh {
432        if !local_exists {
433            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
434        }
435        tracing::warn!(
436            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
437        );
438        return Ok(());
439    }
440    let remote_sha = git::rev_parse(repo, &tracking).await?;
441    if !local_exists {
442        git::git(repo, &["branch", branch, &tracking]).await?;
443        return Ok(());
444    }
445    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
446    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
447        return Ok(());
448    }
449    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
450        // Diverged. `reconcile` settles it only when it can prove nothing is
451        // lost: a local tip that is the remote's change rebased is pushed over
452        // it (lease pinned to the tip read here), a tip whose every extra
453        // commit is empty is a placeholder the remote's work replaced, and
454        // anything else is two different changes - a question for a person.
455        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
456            .await?
457        {
458            crate::reconcile::Reconciliation::Pushed => {
459                tracing::warn!(
460                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
461                    short(&local_sha),
462                    short(&remote_sha)
463                );
464                return Ok(());
465            }
466            crate::reconcile::Reconciliation::Placeholder => {}
467            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
468        }
469    }
470    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
471    if !out.ok() {
472        bail!(
473            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
474            short(&local_sha),
475            short(&remote_sha),
476            out.stderr
477        );
478    }
479    tracing::warn!(
480        "local `{branch}` was stale: fast-forwarded {} -> {}",
481        short(&local_sha),
482        short(&remote_sha)
483    );
484    Ok(())
485}
486
487async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
488    let tracking = format!("{remote}/{base_branch}");
489    let fetched = git::fetch(repo, remote, base_branch).await;
490    if let Ok(out) = &fetched
491        && out.ok()
492        && git::rev_exists(repo, &tracking).await
493    {
494        return git::rev_parse(repo, &tracking).await;
495    }
496    let why = match &fetched {
497        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
498        Ok(_) => format!("{remote} has no {base_branch}"),
499        Err(e) => e.to_string(),
500    };
501    // No fallback to the local branch: it may be behind, and branching off an
502    // old commit is the stale-checkout bug this check exists to prevent.
503    bail!(
504        "cannot read {tracking} ({why}); refusing to branch off the local \
505         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
506         base / remote in magi.toml"
507    )
508}
509
510/// Exclusive claim on one run's `magi fix` step, released on drop — including
511/// on an early return or a panic.
512///
513/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
514/// manual `magi fix` invocations against the same run are otherwise
515/// invisible to each other and would race to remove and recreate the same
516/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
517/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
518/// which is only ever reclaimed later, out of band, by
519/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
520/// `magi fix` invocation is not necessarily running under either of those, so
521/// nothing would ever sweep a lock a killed or crashed process left behind.
522/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
523/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
524/// lease use: an unreadable or unparsable pid, or a liveness query the
525/// platform cannot answer, reads as alive and the lock is left in place.
526struct FixClaim {
527    path: PathBuf,
528}
529
530impl FixClaim {
531    fn acquire(dir: &Path) -> Result<Self> {
532        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
533        let path = dir.join("fix.lock");
534        match Self::create(&path) {
535            Ok(claim) => Ok(claim),
536            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
537                if Self::reclaim_if_dead(&path) {
538                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
539                } else {
540                    bail!(
541                        "another `magi fix` is already running for this run ({} exists)",
542                        path.display()
543                    )
544                }
545            }
546            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
547        }
548    }
549
550    fn create(path: &Path) -> std::io::Result<Self> {
551        let mut f = std::fs::OpenOptions::new()
552            .write(true)
553            .create_new(true)
554            .open(path)?;
555        use std::io::Write as _;
556        // Read back by `reclaim_if_dead` on a later, stuck invocation.
557        writeln!(f, "{}", std::process::id())?;
558        Ok(Self {
559            path: path.to_owned(),
560        })
561    }
562
563    /// True if the lock named a process confirmed dead, in which case it was
564    /// also removed. Never true on an unreadable file, an unparsable pid, or
565    /// a liveness query the platform cannot answer — see this type's own doc.
566    fn reclaim_if_dead(path: &Path) -> bool {
567        let dead = std::fs::read_to_string(path)
568            .ok()
569            .and_then(|body| body.trim().parse::<u32>().ok())
570            .is_some_and(|pid| !crate::proc::pid_alive(pid));
571        dead && std::fs::remove_file(path).is_ok()
572    }
573}
574
575impl Drop for FixClaim {
576    fn drop(&mut self) {
577        let _ = std::fs::remove_file(&self.path);
578    }
579}
580
581impl Runner {
582    /// Start a fresh run against `repo`.
583    pub async fn start(
584        repo: &Path,
585        instruction: String,
586        config: Config,
587        origin: Origin,
588    ) -> Result<Self> {
589        Self::start_naming(repo, instruction, "", config, origin).await
590    }
591
592    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
593    /// searched for branch and commit references along with the instruction,
594    /// since a task may name the work it is about only in its title.
595    pub async fn start_naming(
596        repo: &Path,
597        instruction: String,
598        also_scan: &str,
599        config: Config,
600        origin: Origin,
601    ) -> Result<Self> {
602        let repo = git::toplevel(repo).await?;
603        let missing = agent::missing_programs(&config.agents);
604        if !missing.is_empty() {
605            bail!(
606                "these agent programs are not on PATH: {}. Fix the roster in \
607                 magi.toml or install them.",
608                missing.join(", ")
609            );
610        }
611        let base_branch =
612            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613                .await?;
614        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615        let roles = config.resolve_roles()?;
616        let max_parallel = config.graph.max_parallel.max(1);
617        // A task that points at work already in the repository starts from
618        // it; what the repository says about each reference is recorded.
619        let seeds = refs::resolve(
620            &repo,
621            &base_commit,
622            &config.merge.remote,
623            &format!("{also_scan}\n{instruction}"),
624        )
625        .await;
626        refs::plan(&repo, &seeds).await?;
627        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628        // Recorded before the first save, so a crash right after minting
629        // cannot leave a run with no origin. Legibility only: nothing reads it
630        // to decide anything.
631        state.origin = Some(origin);
632        for seed in &seeds {
633            state.event(
634                "seed",
635                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636            );
637        }
638        state.seeds = seeds;
639        state.event("start", format!("run {} created", state.id));
640        state.save()?;
641        Ok(Self {
642            state,
643            roles,
644            sem: Arc::new(Semaphore::new(max_parallel)),
645            pause: Pause::new(),
646            interrupt: Pause::new(),
647        })
648    }
649
650    /// Open a review-only run against work that already exists on `branch`.
651    ///
652    /// The expensive half of the graph is the implement wave — measured at
653    /// 111 and 134 internal tool-loop turns on this repository, against a
654    /// handful for a judge or a reviewer. The cheap half is worth running on
655    /// hand-written work too, and there was no way to reach it.
656    ///
657    /// No new state and no schema change are needed: a run with **one** viable
658    /// candidate and a tally already decided degrades `execute` to exactly
659    /// review → gate → merge, because `judge` skips a single-candidate field,
660    /// `deliberate` has fewer than two first choices to reconcile, `vote`
661    /// returns early, `tally` is already present and `fold_losers` has no
662    /// losers. Resuming such a run therefore does the right thing as well.
663    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664        Self::review_taking_over(repo, branch, config, None, origin).await
665    }
666
667    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
668    /// at the same task still has `branch` checked out, its worktree is
669    /// released first if that is safe (see [`crate::handover`]), and the
670    /// review refuses with the reason if it is not. `None` is a hand-run
671    /// review: it has no earlier attempts, so only a worktree of a dead run
672    /// magi recorded itself can be released.
673    pub async fn review_taking_over(
674        repo: &Path,
675        branch: &str,
676        config: Config,
677        takeover: Option<crate::handover::Takeover>,
678        origin: Origin,
679    ) -> Result<Self> {
680        let repo = git::toplevel(repo).await?;
681        let missing = agent::missing_programs(&config.agents);
682        if !missing.is_empty() {
683            bail!(
684                "these agent programs are not on PATH: {}. Fix the roster in \
685                 magi.toml or install them.",
686                missing.join(", ")
687            );
688        }
689        let base_branch =
690            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691                .await?;
692        if base_branch == branch {
693            bail!("`{branch}` is the base branch; there is nothing to review against");
694        }
695        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697        let roles = config.resolve_roles()?;
698        let max_parallel = config.graph.max_parallel.max(1);
699        let mut state = RunState::new(
700            repo.clone(),
701            base_branch,
702            base_commit.clone(),
703            String::new(),
704            config,
705        );
706        state.origin = Some(origin);
707
708        // Released before anything else touches the branch: a stale local
709        // branch is moved with `git branch -f`, which git refuses while an
710        // earlier attempt's worktree still has it checked out. Everything
711        // after this point that can fail puts the old run back.
712        // A hand-run review has no task, hence no earlier attempts, but a
713        // worktree of a dead run magi made may still be released.
714        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715            earlier: Vec::new(),
716            home: crate::run::home(),
717            choice: None,
718            task_instruction: None,
719        });
720        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
721        if let Some(released) = &released {
722            state.event(
723                "release",
724                format!(
725                    "took `{branch}` over from run {}: its worktree was released: {}",
726                    crate::run::short_of(&released.old_id),
727                    released.audit
728                ),
729            );
730        }
731        // The owner's answer to an earlier divergence question is applied
732        // here: after the release (git will not move a checked-out branch)
733        // and before the sync that would otherwise ask again.
734        if let Some(choice) = takeover.choice.as_ref()
735            && let Err(e) =
736                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
737                    .await
738        {
739            if let Some(released) = &released {
740                released.restore(&repo, branch).await;
741            }
742            return Err(e.context("applying the owner's answer about the diverged branch"));
743        }
744        // An earlier attempt of this task that published the task head, and
745        // whose work is the branch under review, hands that head over: the
746        // review run updates the same pull request. Without one (a legacy
747        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
748        // own head and its own open pull request is adopted as before.
749        for id in takeover.earlier.iter().rev() {
750            if let Ok(old) = RunState::load_under(id, &takeover.home)
751                && let Some(head) = old.pr_head.clone()
752                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
753            {
754                state.pr_head = Some(head);
755                break;
756            }
757        }
758        let opened = Self::open_review(
759            &repo,
760            branch,
761            state,
762            roles,
763            max_parallel,
764            base_commit,
765            &takeover,
766        )
767        .await;
768        if opened.is_err()
769            && let Some(released) = &released
770        {
771            released.restore(&repo, branch).await;
772        }
773        opened
774    }
775
776    /// What the newest earlier attempt that reviewed `branch` leaves for this
777    /// run (see [`crate::recovery`]). `None` for a hand-run review, which has
778    /// no earlier attempts. Reads the earlier record only: ownership of its
779    /// worktree and branch is [`crate::handover`]'s business.
780    async fn inherit(
781        repo: &Path,
782        branch: &str,
783        base_commit: &str,
784        takeover: &crate::handover::Takeover,
785    ) -> Option<crate::recovery::RecoveryRecord> {
786        use crate::recovery::{Relation, assess};
787        let newest = takeover.earlier.last()?;
788        let mut chosen = None;
789        for id in takeover.earlier.iter().rev() {
790            if let Ok(old) = RunState::load_under(id, &takeover.home)
791                && !old.reviews.is_empty()
792                && (old.winner().is_some_and(|w| w.branch == branch)
793                    || old.pr_head.as_deref() == Some(branch))
794            {
795                chosen = Some(old);
796                break;
797            }
798        }
799        let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
800            .await
801            .unwrap_or_default();
802        let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
803            Some(last) if last.head == tip => Relation::Same,
804            Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
805            _ => Relation::Diverged,
806        };
807        let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
808        Some(assess(from, chosen.as_ref(), base_commit, relation))
809    }
810
811    /// The half of [`Runner::review_taking_over`] that can fail after an
812    /// earlier attempt's worktree was released.
813    async fn open_review(
814        repo: &Path,
815        branch: &str,
816        mut state: RunState,
817        roles: ResolvedRoles,
818        max_parallel: usize,
819        base_commit: String,
820        takeover: &crate::handover::Takeover,
821    ) -> Result<Self> {
822        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
823        // The commit subjects are the closest thing to a task statement that
824        // existing work carries, and the reviewers are told as much.
825        let start = review_base(
826            repo,
827            &state.config.merge.remote,
828            &state.base_branch,
829            &base_commit,
830            branch,
831        )
832        .await;
833        let log = git::log_oneline(repo, &start, branch)
834            .await
835            .unwrap_or_default();
836        let log_text = if log.trim().is_empty() {
837            "(no commit messages)"
838        } else {
839            log.trim()
840        };
841        let mut instruction = format!(
842            "Review the work already on branch `{branch}`. There is no task \
843             statement: what the change claims to do is whatever its commits \
844             say.\n\n{log_text}"
845        );
846        // A retry of a task whose earlier attempt left reviews behind carries
847        // that attempt's evidence, and the task's own statement with it: the
848        // completion criteria are the task's, not whatever the commits claim.
849        if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
850            if let Some(task) = takeover
851                .task_instruction
852                .as_deref()
853                .filter(|t| !t.trim().is_empty())
854                && rec.reviewed_head.is_some()
855            {
856                instruction = format!(
857                    "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
858                    task.trim()
859                );
860                rec.criteria_inherited = true;
861            }
862            state.event("recovery", rec.summary());
863            state.recovery = Some(rec);
864        }
865        state.instruction = instruction;
866        state.reviewed_commits = Some(
867            git::subjects(repo, &start, branch)
868                .await
869                .unwrap_or_default(),
870        );
871
872        // An attached worktree, so the fixer's commits land on the branch under
873        // review rather than on a detached head nobody will look at again.
874        let worktree = state.worktree_root().join("under-review");
875        if let Some(parent) = worktree.parent() {
876            tokio::fs::create_dir_all(parent).await.ok();
877        }
878        let path = worktree.to_string_lossy().to_string();
879        git::git(repo, &["worktree", "add", &path, branch])
880            .await
881            .with_context(|| {
882                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
883            })?;
884
885        let setup = match ensure_setup_config(&mut state, repo).await {
886            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
887            Err(e) => Err(e),
888        };
889        if let Err(e) = setup {
890            git::worktree_remove(repo, &worktree).await.ok();
891            return Err(e);
892        }
893        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
894            .await
895            .unwrap_or(0);
896        if commits == 0 {
897            git::worktree_remove(repo, &worktree).await.ok();
898            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
899        }
900        let files = git::changed_files(&worktree, &base_commit, "HEAD")
901            .await
902            .map(|f| f.len())
903            .unwrap_or(0);
904        if files == 0
905            && let (Ok(head_tree), Ok(base_tree)) = (
906                git::tree_of(&worktree, "HEAD").await,
907                git::tree_of(&worktree, &base_commit).await,
908            )
909            && head_tree == base_tree
910        {
911            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
912            git::worktree_remove(repo, &worktree).await.ok();
913            bail!(
914                "`{branch}` at {} has a tree identical to base {}; this usually means \
915                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
916                 against `{}/{branch}`) rather than an empty change",
917                short(&head),
918                short(&base_commit),
919                state.config.merge.remote
920            );
921        }
922        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
923            .await
924            .unwrap_or_default();
925
926        state.candidates.push(Candidate {
927            index: 0,
928            label: 'A',
929            // Not an agent id on purpose: nothing in the roster wrote this, and
930            // the stats tables must not credit anyone with a win for it.
931            agent: EXISTING_BRANCH.to_owned(),
932            branch: branch.to_owned(),
933            worktree,
934            summary: String::new(),
935            stat,
936            files,
937            commits,
938            empty: false,
939            failed: None,
940            verified_noop: None,
941            duration_ms: 0,
942            folded: false,
943        });
944        state.tally = Some(Tally {
945            first_choice: BTreeMap::from([('A', 0)]),
946            borda: BTreeMap::new(),
947            winner: 'A',
948            rankings: 0,
949            unanimous_initial: false,
950            deliberated: false,
951            changed_votes: 0,
952            unanimous_final: false,
953            tie_break: None,
954            // No panel sat, so no quorum applies. Zero judges is the correct
955            // number for work that never competed, and must not be reported as
956            // a collapsed panel.
957            judges: 0,
958            present: 0,
959            quorum: 0,
960            met_quorum: true,
961            uncontested: Some("review-only run: nothing competed".to_owned()),
962        });
963        state.status = RunStatus::Reviewing;
964        state.event(
965            "start",
966            format!(
967                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
968                state.id
969            ),
970        );
971        state.save()?;
972        Ok(Self {
973            state,
974            roles,
975            sem: Arc::new(Semaphore::new(max_parallel)),
976            pause: Pause::new(),
977            interrupt: Pause::new(),
978        })
979    }
980
981    /// Reopen an existing run.
982    pub fn resume(id: &str) -> Result<Self> {
983        let state = RunState::load(id)?;
984        if let Some(to) = &state.released_to {
985            bail!(
986                "run {} cannot be resumed: its worktree was released to run {}",
987                state.short(),
988                crate::run::short_of(to)
989            );
990        }
991        let roles = state.config.resolve_roles()?;
992        let max_parallel = state.config.graph.max_parallel.max(1);
993        Ok(Self {
994            state,
995            roles,
996            sem: Arc::new(Semaphore::new(max_parallel)),
997            pause: Pause::new(),
998            interrupt: Pause::new(),
999        })
1000    }
1001
1002    /// Walk the graph to a terminal state, skipping nodes already recorded.
1003    ///
1004    /// Every way a run is driven - the queue loop, `magi run`, a resume from
1005    /// the phone - ends here, so this is the one place a run that ended
1006    /// Blocked / Stalled / Failed, or died with an error, is announced to the
1007    /// notification centre. Best-effort: see [`crate::notices::raise`].
1008    pub async fn execute(&mut self) -> Result<()> {
1009        let result = self.execute_graph().await;
1010        self.mark_driver_exited();
1011        let ended = if result.is_err() {
1012            Some(crate::notices::run_stopped(&self.state.id, &self.state))
1013        } else {
1014            crate::notices::run_ended(&self.state)
1015        };
1016        if let Some(notice) = ended {
1017            crate::notices::raise(notice);
1018        }
1019        result
1020    }
1021
1022    /// Record that this process no longer drives the run, so its pid (a
1023    /// daemon's outlives the run) is not read as a live driver.
1024    ///
1025    /// Written onto the record as it is on disk, never this copy: another
1026    /// process may have resumed the run (recording its own pid and clearing
1027    /// the flag) or released its worktree since this copy was read, and
1028    /// saving over that would mark a running driver dead. Only a record still
1029    /// naming this process as the driver is touched.
1030    fn mark_driver_exited(&mut self) {
1031        self.state.driver_exited = true;
1032        let pid = std::process::id();
1033        let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1034            return;
1035        };
1036        let Ok(mut disk) = RunState::load(&self.state.id) else {
1037            return;
1038        };
1039        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1040            return;
1041        }
1042        disk.driver_exited = true;
1043        if let Err(e) = disk.save() {
1044            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1045        }
1046    }
1047
1048    /// Validate ownership and persist the driver claim in one critical section.
1049    fn claim_driver(&mut self) -> Result<()> {
1050        let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1051        let disk = RunState::load(&self.state.id)?;
1052        crate::pr_head::ensure_owner(&disk)?;
1053        // Moving again, so it is no longer parked. Set before the walk rather
1054        // than in `resume`, so every way of re-entering the graph clears it
1055        // and a card cannot claim a run is waiting to be resumed while the
1056        // agents are already working.
1057        self.state.parked = false;
1058        self.state.resource_wait = None;
1059        self.state.resource_block = None;
1060        // Any seat this state still lists as answering belongs to whatever
1061        // process last drove this run — this one included, if it crashed
1062        // mid-wave. Cleared and flushed immediately, before anything else
1063        // runs, so a resume can never show a seat as live when nothing is
1064        // asking it anything yet; the node that actually dispatches the next
1065        // wave repopulates it.
1066        self.state.clear_active();
1067        // Recorded in the same spot, and flushed together with the clear
1068        // above: this is the pid a reader checks (`RunState::liveness`) when
1069        // no daemon claim exists to answer "is a process still driving this
1070        // run" — a plain `magi run` / `magi review` typed into a terminal
1071        // claims nothing there. Always overwritten, never only-if-absent, so
1072        // a resumed run's stale pid from a previous, possibly-dead process
1073        // can never survive into this one's own report. Unlike
1074        // `clear_active`, this changes on every single `execute()` call, so
1075        // the save below is now unconditional rather than only-if-cleared.
1076        //
1077        // `driver_started_at` is recorded in the same breath, from this same
1078        // pid, so `liveness` can tell a live pid that is genuinely still us
1079        // apart from one the OS has since handed to an unrelated process —
1080        // see that field's own doc for why the pid alone is not enough.
1081        // A resume that raced a takeover: the record on disk says the worktree
1082        // was handed to a later run after this copy was read. Saving over it
1083        // would erase that and drive a run with nothing to run in.
1084        if let Some(to) = &disk.released_to {
1085            bail!(
1086                "run {} cannot continue: its worktree was released to run {}",
1087                self.state.short(),
1088                crate::run::short_of(to)
1089            );
1090        }
1091        let pid = std::process::id();
1092        self.state.driver_pid = Some(pid);
1093        self.state.driver_started_at = crate::proc::process_started_at(pid);
1094        self.state.driver_exited = false;
1095        self.state.begin_pass();
1096        self.state.save()?;
1097        Ok(())
1098    }
1099
1100    async fn execute_graph(&mut self) -> Result<()> {
1101        self.claim_driver()?;
1102        // Publication already passed review and gate. Resume only that node:
1103        // syncing to a newer base here could change the verified commit.
1104        if self.state.pr_publication_pending {
1105            self.merge().await?;
1106            if self.resource_halt()? {
1107                return Ok(());
1108            }
1109            self.state.save()?;
1110            self.settle_questions();
1111            return Ok(());
1112        }
1113        // A run that already lost its quorum never resumes into the verdict
1114        // machinery: `deliberate` and `vote` would otherwise clobber the
1115        // stalled marker back to Voting and the run would keep going past a
1116        // verdict that is no longer trustworthy. Everything already recorded is
1117        // kept, so the run stays resumable (or foldable) for a human to pick up.
1118        //
1119        // On --resume the run gets one chance to repair itself: the seats a
1120        // rate limit took out are re-asked. If their quota has since reset and
1121        // the quorum is restored, the run picks up and finishes; otherwise it
1122        // stays stale and still-resumable for a later retry. If it does not
1123        // recover, the returned status stays `Stalled` and nothing was
1124        // clobbered (the recovery only mutates entries for the lost seats).
1125        if self.state.status == RunStatus::Stalled {
1126            if self.recover_stall().await? {
1127                self.finish_after_tally().await?;
1128            } else {
1129                // Still below quorum: persist the marker and stay resumable.
1130                self.state.save()?;
1131            }
1132            return Ok(());
1133        }
1134        // A run parked inside `land` - watching CI, mid fix-round, or
1135        // waiting on the owner's merge approval - resumes directly into it,
1136        // never back through `prep`. Everything before `merge` already
1137        // concluded; that is the only way `status` reaches `Landing` in the
1138        // first place. Re-walking `review_loop` first would also be actively
1139        // wrong: its own status recomputation (see its doc) treats any
1140        // clean round as reason to set `status` to `Gating`, which would
1141        // clobber this marker before `merge` ever ran, and this run would
1142        // never find its way back into `land` at all.
1143        if self.state.status == RunStatus::Landing {
1144            self.run_land().await?;
1145            // `run_land` may have settled the run right here - CI came back
1146            // green and the PR merged, say - without ever passing back
1147            // through `merge`'s own trailing call. Whatever it left `status`
1148            // as is what this has to read.
1149            self.settle_questions();
1150            return Ok(());
1151        }
1152        // A run parked at `merge` on the owner's word about a withheld PR text
1153        // resumes straight into `merge`. Re-walking the tail would sync to the
1154        // base again (possibly rebasing, possibly spending the fixer) after the
1155        // gate had passed, and then push a tree nobody reviewed or gated.
1156        if self.state.github_text.is_some()
1157            && self.state.merge.is_none()
1158            && self.state.status == RunStatus::Gating
1159        {
1160            self.merge().await?;
1161            if self.resource_halt()? {
1162                return Ok(());
1163            }
1164            self.state.save()?;
1165            return Ok(());
1166        }
1167        self.prep().await?;
1168        if self.park_here()? {
1169            return Ok(());
1170        }
1171        self.advise().await?;
1172        if self.park_here()? {
1173            return Ok(());
1174        }
1175        self.implement().await?;
1176        if self.park_here()? {
1177            return Ok(());
1178        }
1179        // `after_implement` already saved the state and settled any open
1180        // questions when it set this; nothing later in the graph has
1181        // anything to judge.
1182        if self.state.status == RunStatus::VerifiedNoop {
1183            return Ok(());
1184        }
1185        self.judge().await?;
1186        if self.park_here()? {
1187            return Ok(());
1188        }
1189        self.deliberate().await?;
1190        if self.park_here()? {
1191            return Ok(());
1192        }
1193        self.vote().await?;
1194        if self.park_here()? {
1195            return Ok(());
1196        }
1197        self.tally()?;
1198        // A verdict that lost its quorum is not trustworthy: do not review,
1199        // gate, or merge on it. Everything already done is kept, so the run
1200        // stays resumable (or foldable); the human can replace the agent that
1201        // ran out of quota and pick it up.
1202        if self.state.status == RunStatus::Stalled {
1203            // Persist the stalled marker now — the normal end-of-execute save
1204            // below is below this early return, and without it a resumed run
1205            // would reload a pre-tally status and keep going.
1206            self.state.save()?;
1207            return Ok(());
1208        }
1209        self.finish_after_tally().await?;
1210        Ok(())
1211    }
1212
1213    /// A wave could not start its agents because a shared resource stayed busy
1214    /// (see [`RunState::resource_block`]): end the node without recording any
1215    /// result, leaving the run `Blocked` and resumable. No seat history,
1216    /// handover, quota loss or candidate state was written for it.
1217    fn stop_for_resource(&mut self) -> Result<()> {
1218        // A wait abandoned because the run is parking stays a park.
1219        if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1220        {
1221            self.state.status = RunStatus::Blocked;
1222        }
1223        self.state.save()
1224    }
1225
1226    /// Stop walking the graph if a node ended on a resource block.
1227    fn resource_halt(&mut self) -> Result<bool> {
1228        if self.state.resource_block.is_none() {
1229            return Ok(false);
1230        }
1231        self.stop_for_resource()?;
1232        Ok(true)
1233    }
1234
1235    /// Park here if asked to, recording it in the run's own timeline.
1236    ///
1237    /// Returns whether the caller should stop walking the graph. The state is
1238    /// saved either way by the node that just finished; this adds the event so
1239    /// the operator's card says why a run that is neither finished nor moving
1240    /// is sitting where it is.
1241    fn park_here(&mut self) -> Result<bool> {
1242        let parking = self.pause.parked() || self.interrupt.parked();
1243        if !parking && self.state.resource_block.is_some() {
1244            self.stop_for_resource()?;
1245            return Ok(true);
1246        }
1247        // Either handle asking is enough - see `Pause`'s own doc for why
1248        // they are never the same one. `interrupt` is checked second so a
1249        // reason it carries is preferred in the message below over a plain
1250        // shutdown park racing it at the same boundary.
1251        if !self.pause.parked() && !self.interrupt.parked() {
1252            return Ok(false);
1253        }
1254        let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1255        let why = match cause.as_ref().map(ParkCause::label) {
1256            Some(reason) => format!(
1257                "parked after `{}` ({reason}) — resume to carry on from here",
1258                self.state.status.as_str()
1259            ),
1260            None => format!(
1261                "parked after `{}` — resume to carry on from here",
1262                self.state.status.as_str()
1263            ),
1264        };
1265        self.state.event("park", why);
1266        self.state
1267            .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1268        self.state.parked = true;
1269        self.state.save()?;
1270        Ok(true)
1271    }
1272
1273    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1274    pub fn on_pause(&mut self, pause: Pause) {
1275        self.pause = pause;
1276    }
1277
1278    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1279    /// scheduler asking this one run - and no other - to park so a task
1280    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1281    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1282    /// handle.
1283    pub fn watch_interrupt(&mut self, pause: Pause) {
1284        self.interrupt = pause;
1285    }
1286
1287    /// Abandon this run's own open questions, once `status` has actually
1288    /// settled rather than merely paused.
1289    ///
1290    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1291    /// either back up with the candidates, the review round and the seat
1292    /// sessions already on disk, so a question an implementer asked mid-round
1293    /// may still get a real answer read by a real resume. Only the statuses
1294    /// `resumable` excludes are actually final: the run merged, it reached
1295    /// `Ready` with nothing left to do, it failed outright with no
1296    /// established point to continue from, or every candidate agreed, with
1297    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1298    /// every one of those the seat that asked is gone for good, exactly like
1299    /// the run being deleted under `magi run rm` - so the same cleanup
1300    /// applies, worded for what actually happened instead of "the run was
1301    /// deleted".
1302    ///
1303    /// Best-effort and silent on success: called from every place `status`
1304    /// can land on one of those three, including ones a resumed run revisits,
1305    /// so it must cost nothing when there was nothing open to begin with.
1306    fn settle_questions(&mut self) {
1307        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1308            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1309        }
1310    }
1311
1312    /// The tail of the graph after a trustworthy tally: fold losers, review,
1313    /// gate, merge, and persist.
1314    async fn finish_after_tally(&mut self) -> Result<()> {
1315        // Before the losers are folded: their branches are what the graft
1316        // reads from.
1317        self.graft().await?;
1318        if self.resource_halt()? {
1319            return Ok(());
1320        }
1321        self.fold_losers().await?;
1322        // Before review starts, and again right before the gate: a run's
1323        // review rounds can themselves take long enough for the base to move
1324        // a second time, and the gate is the one node whose "green" gets
1325        // acted on.
1326        self.sync_to_base().await?;
1327        if self.state.status == RunStatus::AlreadyInBase {
1328            return Ok(());
1329        }
1330        self.review_loop().await?;
1331        if self.resource_halt()? {
1332            return Ok(());
1333        }
1334        self.sync_to_base().await?;
1335        if self.state.status == RunStatus::AlreadyInBase {
1336            return Ok(());
1337        }
1338        self.gate().await?;
1339        self.merge().await?;
1340        if self.resource_halt()? {
1341            return Ok(());
1342        }
1343        self.state.save()?;
1344        Ok(())
1345    }
1346
1347    // ---------------------------------------------------------------- prep
1348
1349    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1350    /// run with the step and its output in the event; it is never swallowed.
1351    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1352    /// resume starts over instead of finding candidates and missing seats.
1353    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1354        let result = match ensure_setup_config(&mut self.state, repo).await {
1355            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1356            Err(e) => Err(e),
1357        };
1358        if let Err(e) = result {
1359            git::worktree_remove(repo, wt).await.ok();
1360            if in_prep {
1361                self.rollback_prep(repo).await;
1362            }
1363            self.state.status = RunStatus::Blocked;
1364            self.state.event(
1365                "setup",
1366                format!("worktree setup failed in {}: {e:#}", wt.display()),
1367            );
1368            self.state.save()?;
1369            return Err(e);
1370        }
1371        Ok(())
1372    }
1373
1374    /// Undo everything `prep` created, so a resume re-runs it whole.
1375    async fn rollback_prep(&mut self, repo: &Path) {
1376        let root = self.state.worktree_root();
1377        for c in std::mem::take(&mut self.state.candidates) {
1378            git::worktree_remove(repo, &c.worktree).await.ok();
1379            git::branch_delete(repo, &c.branch).await.ok();
1380        }
1381        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1382        for j in 1..=seats {
1383            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1384                .await
1385                .ok();
1386        }
1387        for k in 1..=self.state.config.graph.advisors {
1388            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1389                .await
1390                .ok();
1391        }
1392    }
1393
1394    async fn prep(&mut self) -> Result<()> {
1395        if !self.state.candidates.is_empty() {
1396            return Ok(());
1397        }
1398        self.state.status = RunStatus::Prep;
1399        let repo = self.state.repo.clone();
1400        let base = self.state.base_commit.clone();
1401        let plan = refs::plan(&repo, &self.state.seeds).await?;
1402        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1403        let root = self.state.worktree_root();
1404        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1405
1406        // The hook is the write-time half of the blindness contract; the
1407        // presentation filter in `blind` is the half that cannot be bypassed.
1408        let hooks_dir = self.state.dir().join("hooks");
1409        if self.state.config.blind.commit_msg_hook {
1410            std::fs::create_dir_all(&hooks_dir)
1411                .with_context(|| format!("create {}", hooks_dir.display()))?;
1412            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1413            let path = hooks_dir.join("commit-msg");
1414            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1415            make_executable(&path)?;
1416            // Ref-counted rather than a plain idempotent set: with more than
1417            // one run able to be in flight in the same repository at once
1418            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1419            // true?" check cannot tell "another run of mine still needs
1420            // this" from "nobody does", and the run that happens to finish
1421            // first would disable the hook out from under a sibling still
1422            // relying on it.
1423            git::acquire_worktree_config(&repo).await?;
1424            self.state.enabled_worktree_config = true;
1425        }
1426
1427        // `[worktree] setup` hides its products per worktree, which needs the
1428        // same per-worktree config the hook does. Held until fold, like it.
1429        ensure_setup_config(&mut self.state, &repo).await?;
1430
1431        for (index, (spec, label)) in self
1432            .roles
1433            .implementers
1434            .clone()
1435            .into_iter()
1436            .zip(labels)
1437            .enumerate()
1438        {
1439            let branch = self.state.branch_for(label);
1440            let worktree = root.join(format!("cand-{label}"));
1441            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1442            if self.state.config.blind.commit_msg_hook {
1443                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1444            }
1445            git::local_exclude(&worktree, "/.magi/").await?;
1446            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1447                // Nothing of this prep may survive: a resume must start over,
1448                // not find half the candidates and skip the rest.
1449                git::worktree_remove(&repo, &worktree).await.ok();
1450                git::branch_delete(&repo, &branch).await.ok();
1451                self.rollback_prep(&repo).await;
1452                self.state.status = RunStatus::Blocked;
1453                self.state
1454                    .event("prep", format!("worktree setup failed: {e:#}"));
1455                self.state.save()?;
1456                return Err(e);
1457            }
1458            for pick in &plan.picks {
1459                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1460                    self.state.status = RunStatus::Blocked;
1461                    self.state
1462                        .event("prep", format!("cannot apply referenced commit: {e}"));
1463                    self.state.save()?;
1464                    return Err(e);
1465                }
1466            }
1467            self.state.candidates.push(Candidate {
1468                index,
1469                label,
1470                agent: spec.id.clone(),
1471                branch,
1472                worktree,
1473                summary: String::new(),
1474                stat: String::new(),
1475                files: 0,
1476                commits: 0,
1477                empty: false,
1478                failed: None,
1479                verified_noop: None,
1480                duration_ms: 0,
1481                folded: false,
1482            });
1483        }
1484
1485        for j in 1..=self.roles.judges.len() {
1486            let wt = root.join(format!("judge-{j}"));
1487            if !wt.exists() {
1488                git::worktree_add_detached(&repo, &wt, &base).await?;
1489                self.setup_seat_worktree(&repo, &wt, true).await?;
1490            }
1491        }
1492
1493        // Disposable, detached checkouts for the design-deliberation stage's
1494        // advisor seats — the same shape as the judges' above, at the same
1495        // base commit, since advisors also only ever read. Sized off the
1496        // configured count directly rather than a resolved roster: unlike
1497        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1498        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1499        // `prep` has no `ResolvedRoles` field to read a count from here.
1500        if self.state.config.graph.advise {
1501            for k in 1..=self.state.config.graph.advisors {
1502                let wt = root.join(format!("advisor-{k}"));
1503                if !wt.exists() {
1504                    git::worktree_add_detached(&repo, &wt, &base).await?;
1505                    self.setup_seat_worktree(&repo, &wt, true).await?;
1506                }
1507            }
1508        }
1509
1510        // A judge cannot tell it is looking at its own patch — the seats keep
1511        // separate conversations — but a panel that shares agents with the
1512        // field is less independent than it looks, and that is worth saying out
1513        // loud once per run rather than leaving it in the config.
1514        let authors: Vec<&str> = self
1515            .roles
1516            .implementers
1517            .iter()
1518            .map(|a| a.id.as_str())
1519            .collect();
1520        let overlap: Vec<String> = self
1521            .roles
1522            .judges
1523            .iter()
1524            .enumerate()
1525            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1526            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1527            .collect();
1528        if !overlap.is_empty() {
1529            let note = format!(
1530                "{} also authored a candidate; blind, but the panel is less \
1531                 independent than {} distinct agents would be",
1532                overlap.join(", "),
1533                self.roles.judges.len()
1534            );
1535            self.state.event("prep", note);
1536        }
1537
1538        self.state.event(
1539            "prep",
1540            format!(
1541                "{} candidates, {} judges, base {} ({})",
1542                self.state.candidates.len(),
1543                self.roles.judges.len(),
1544                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1545                self.state.base_branch
1546            ),
1547        );
1548        self.state.status = RunStatus::Implementing;
1549        self.state.save()?;
1550        Ok(())
1551    }
1552
1553    // -------------------------------------------------------------- advise
1554
1555    /// The design-deliberation stage: independent, read-only advisor seats
1556    /// each sketch a design before any implementer touches the repository,
1557    /// and (when at least one produced a usable proposal) a synthesis seat
1558    /// blends them into a brief `implement` carries in every candidate's
1559    /// prompt.
1560    ///
1561    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1562    /// advisors` is the proposal count. Everything here is best-effort and
1563    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1564    /// that cannot reach quota, or a synthesis seat that produced nothing
1565    /// usable all leave `implement` exactly as it was before this stage
1566    /// existed — the task instruction alone — rather than failing the whole
1567    /// competition over an enrichment stage. Every outcome is still recorded
1568    /// as an event, so a run that got nothing from this stage says why.
1569    ///
1570    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1571    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1572    /// resumed run whose stage failed would re-run it, and re-spend the
1573    /// agent calls, on every reentry before `implement`.
1574    ///
1575    /// Also skipped once any candidate shows implementation progress — the
1576    /// exact predicate `implement` itself uses to decide a candidate is no
1577    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1578    /// is not enough: a run created by an older binary that predates this
1579    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1580    /// an already-`Implementing`-or-later run under this build would
1581    /// otherwise walk straight back through `prep` (a no-op once candidates
1582    /// exist) into this node and spawn every advisor seat against worktrees
1583    /// `prep` never recreated — after implementation has already started,
1584    /// which is exactly the invariant this stage exists to guarantee.
1585    async fn advise(&mut self) -> Result<()> {
1586        let implement_untouched = self
1587            .state
1588            .candidates
1589            .iter()
1590            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1591        if !self.state.config.graph.advise || self.state.advise_attempted {
1592            return Ok(());
1593        }
1594        if !implement_untouched {
1595            self.state.event(
1596                "advise",
1597                "skipping the design-deliberation stage: at least one \
1598                 candidate already shows implementation progress, so this \
1599                 run is past the point the stage exists to run before"
1600                    .to_owned(),
1601            );
1602            self.state.advise_attempted = true;
1603            self.state.save()?;
1604            return Ok(());
1605        }
1606        let run_id = self.state.id.clone();
1607        let prompts = self.state.config.prompts.clone();
1608        let instruction = self.state.instruction.clone();
1609        let language = self.state.config.graph.language.clone();
1610        let root = self.state.worktree_root();
1611        let n = self.state.config.graph.advisors;
1612        let where_recorded = self.state.dir().join("run.json");
1613
1614        let seats = match self.state.config.advisors() {
1615            Ok(seats) if !seats.is_empty() => seats,
1616            Ok(_) => {
1617                self.state.event(
1618                    "advise",
1619                    format!(
1620                        "[graph] advisors is 0; skipping the design-deliberation \
1621                         stage and continuing without a synthesis brief (see {})",
1622                        where_recorded.display()
1623                    ),
1624                );
1625                self.state.advise_attempted = true;
1626                self.state.save()?;
1627                return Ok(());
1628            }
1629            Err(e) => {
1630                self.state.event(
1631                    "advise",
1632                    format!(
1633                        "could not resolve advisor seats ({e:#}); continuing \
1634                         without a design-deliberation brief (see {})",
1635                        where_recorded.display()
1636                    ),
1637                );
1638                self.state.advise_attempted = true;
1639                self.state.save()?;
1640                return Ok(());
1641            }
1642        };
1643
1644        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1645        let artifacts = agent::artifacts_dir(&self.state.dir());
1646        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1647
1648        let mut jobs = Vec::new();
1649        for (i, spec) in seats.iter().cloned().enumerate() {
1650            let seat_key = format!("advisor-{}", i + 1);
1651            let seat = self.seat(&seat_key, &spec.id);
1652            jobs.push(SeatJob {
1653                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1654                spec,
1655                seat,
1656                cwd: worktrees[i % worktrees.len()].clone(),
1657                timeout,
1658                allow_write: false,
1659                sessions: false,
1660                artifacts: artifacts.clone(),
1661                stem: seat_key,
1662                handover: None,
1663            });
1664        }
1665
1666        self.state.event(
1667            "advise",
1668            format!(
1669                "{} advisor seat(s) sketching a design in parallel",
1670                jobs.len()
1671            ),
1672        );
1673        let mut quota_losses = Vec::new();
1674        let cache = self.state.config.cache_dir();
1675        let ctx = WaveCtx {
1676            carry_seats: false,
1677            pauses: Some((&self.pause, &self.interrupt)),
1678            run: &run_id,
1679            node: "advise",
1680            prompts: &prompts,
1681            cache: cache.as_deref(),
1682            round: None,
1683        };
1684        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1685        let results = ask_json_wave::<Proposal>(
1686            jobs,
1687            Arc::clone(&self.sem),
1688            self.state.config.graph.retries,
1689            &advisor_roster,
1690            &ctx,
1691            &mut quota_losses,
1692            &mut self.state,
1693            &|p: &Proposal| p.validate(),
1694        )
1695        .await;
1696        if self.state.resource_block.is_some() {
1697            return self.stop_for_resource();
1698        }
1699        self.state.quota.extend(quota_losses);
1700
1701        let mut records = Vec::with_capacity(results.len());
1702        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1703            let agent_id = seat.agent.clone();
1704            self.state.seats.insert(seat.key.clone(), seat);
1705            match res {
1706                Ok((proposal, out)) => {
1707                    self.state
1708                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1709                    records.push(advise::AdvisorRecord::proposed(
1710                        i + 1,
1711                        agent_id,
1712                        proposal,
1713                        out.duration_ms,
1714                    ));
1715                }
1716                Err(e) => {
1717                    self.state.event(
1718                        "advise",
1719                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1720                    );
1721                    records.push(advise::AdvisorRecord::failed(
1722                        i + 1,
1723                        agent_id,
1724                        e.to_string(),
1725                    ));
1726                }
1727            }
1728        }
1729
1730        let mut advice = advise::Advice {
1731            records,
1732            synthesis: None,
1733        };
1734        if advice.proposals().is_empty() {
1735            self.state.event(
1736                "advise",
1737                "no advisor produced a usable proposal; continuing without a \
1738                 synthesis brief"
1739                    .to_owned(),
1740            );
1741        } else {
1742            match self
1743                .synthesize_brief(
1744                    &advice,
1745                    &instruction,
1746                    &language,
1747                    &worktrees[0],
1748                    &artifacts,
1749                    &run_id,
1750                    &prompts,
1751                    cache.as_deref(),
1752                )
1753                .await
1754            {
1755                Ok(Some(text)) => {
1756                    self.state.event(
1757                        "advise",
1758                        "synthesized a design brief for the implementer".to_owned(),
1759                    );
1760                    advice.synthesis = Some(text);
1761                }
1762                Ok(None) => {
1763                    self.state.event(
1764                        "advise",
1765                        "the synthesis seat produced nothing usable; continuing \
1766                         without a design brief"
1767                            .to_owned(),
1768                    );
1769                }
1770                Err(e) => {
1771                    self.state.event(
1772                        "advise",
1773                        format!("could not synthesize a design brief: {e:#}"),
1774                    );
1775                }
1776            }
1777        }
1778        advise::apply_reflection(&mut advice);
1779
1780        self.state.advice = Some(advice);
1781        self.state.advise_attempted = true;
1782        self.state.save()?;
1783        Ok(())
1784    }
1785
1786    /// The synthesis seat: reads every advisor's proposal and blends them
1787    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1788    /// out of [`Runner::advise`] only for readability — it is not called
1789    /// anywhere else.
1790    ///
1791    /// Picked the same way [`crate::talk`]'s standing conversation and
1792    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1793    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1794    /// order (a claude seat, else the first runnable agent in roster order)
1795    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1796    /// in [`crate::config`] for why a dedicated field exists here at all.
1797    #[allow(clippy::too_many_arguments)]
1798    async fn synthesize_brief(
1799        &mut self,
1800        advice: &advise::Advice,
1801        instruction: &str,
1802        language: &str,
1803        cwd: &Path,
1804        artifacts: &Path,
1805        run_id: &str,
1806        prompts: &Prompts,
1807        cache: Option<&Path>,
1808    ) -> Result<Option<String>> {
1809        let chain = agent::pick_chain(
1810            &self.state.config.agents,
1811            self.state.config.roles.synthesizer.as_ref(),
1812            &agent::installed,
1813            "synthesizer",
1814        )?;
1815        let proposals = advice.proposals();
1816        let mut prompt = prompt::with_overlay(
1817            prompt::synthesize_brief(instruction, &proposals, language),
1818            prompts.overlay("advise"),
1819        );
1820        if cache.is_some() {
1821            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1822            // below — see `prompt::build_cache_note`'s doc for why telling a
1823            // read-only seat to build through the shared cache is exactly how
1824            // a sandbox's write refusal gets misread as a defect.
1825            prompt.push('\n');
1826            prompt.push_str(&prompt::build_cache_note("advise", false));
1827        }
1828        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1829        // Each id is tried once, in order; a quota hit, error or unusable
1830        // answer moves to the next. The seat is single-turn (`sessions:
1831        // false`) and the prompt is the whole context, so a fallback agent
1832        // needs nothing carried over.
1833        let mut last = None;
1834        for (n, spec) in chain.iter().enumerate() {
1835            if n > 0 {
1836                self.state
1837                    .event("advise", format!("synthesis falling back to {}", spec.id));
1838            }
1839            let mut seat = self.seat("advise-synthesis", &spec.id);
1840            let outcome = agent::invoke(
1841                spec,
1842                &mut seat,
1843                &Invocation {
1844                    cwd,
1845                    prompt: &prompt,
1846                    timeout,
1847                    allow_write: false,
1848                    unsandboxed: false,
1849                    sessions: false,
1850                    artifacts,
1851                    stem: &if n == 0 {
1852                        "advise-synthesis".to_owned()
1853                    } else {
1854                        format!("advise-synthesis-{}", spec.id)
1855                    },
1856                    run: run_id,
1857                    node: "advise",
1858                    cache_dir: None,
1859                    attachments: &[],
1860                    writable: &[],
1861                },
1862            )
1863            .await;
1864            if outcome.is_ok() {
1865                self.state.seats.insert(seat.key.clone(), seat);
1866            }
1867            let advance = agent::chain_advances(&outcome);
1868            last = Some(outcome);
1869            if !advance {
1870                break;
1871            }
1872        }
1873        // Exhausted: the last attempt's result is what a single failed seat
1874        // would have produced.
1875        let out = last.expect("a chain holds at least one agent")?;
1876        if !out.usable() {
1877            return Ok(None);
1878        }
1879        let text =
1880            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1881        Ok((!text.trim().is_empty()).then_some(text))
1882    }
1883
1884    // ----------------------------------------------------------- implement
1885
1886    async fn implement(&mut self) -> Result<()> {
1887        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1888        // agent files with `magi task add` name the run that paid for it. The
1889        // prompt overlay is cloned alongside it because the waves borrow it
1890        // while `self` is mutably borrowed by the node's own bookkeeping.
1891        let run_id = self.state.id.clone();
1892        let prompts = self.state.config.prompts.clone();
1893        let todo: Vec<usize> = self
1894            .state
1895            .candidates
1896            .iter()
1897            .enumerate()
1898            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1899            .map(|(i, _)| i)
1900            .collect();
1901        if todo.is_empty() {
1902            return self.after_implement();
1903        }
1904        self.state.status = RunStatus::Implementing;
1905
1906        let language = self.state.config.graph.language.clone();
1907        let github_language = self.state.config.graph.github_language.clone();
1908        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1909        let sessions = self.state.config.graph.sessions;
1910        let artifacts = agent::artifacts_dir(&self.state.dir());
1911        // The design-deliberation stage's blended brief, when `advise` found
1912        // one — carried into every implementer's prompt the same way
1913        // regardless of which candidate it is.
1914        let brief = self
1915            .state
1916            .advice
1917            .as_ref()
1918            .and_then(|a| a.synthesis.as_deref())
1919            .map(str::to_owned);
1920        let attachments = self.state.attachments.clone();
1921
1922        let mut jobs = Vec::new();
1923        for &i in &todo {
1924            let (index, label, worktree) = {
1925                let c = &self.state.candidates[i];
1926                (c.index, c.label, c.worktree.clone())
1927            };
1928            let spec = self.roles.implementers[index].clone();
1929            let seat_key = format!("impl-{label}");
1930            let seat = self.seat(&seat_key, &spec.id);
1931            let instruction = seeded_instruction(&self.state);
1932            jobs.push(SeatJob {
1933                spec,
1934                seat,
1935                prompt: prompt::implement_in(
1936                    &instruction,
1937                    &worktree.to_string_lossy(),
1938                    &language,
1939                    &github_language,
1940                    brief.as_deref(),
1941                    &attachments,
1942                ),
1943                cwd: worktree,
1944                timeout,
1945                allow_write: true,
1946                sessions,
1947                artifacts: artifacts.clone(),
1948                stem: format!("impl-{label}"),
1949                handover: None,
1950            });
1951        }
1952
1953        self.state.event(
1954            "implement",
1955            format!("{} candidates in parallel", jobs.len()),
1956        );
1957        // Kept so a seat whose CLI hung up can be asked again from the same
1958        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1959        // can update a seat's own entry once a fallback agent takes it over —
1960        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1961        // whichever agent actually answered, not the one that quota'd out.
1962        let mut sent = jobs.clone();
1963        let cache = self.state.config.cache_dir();
1964        let ctx = WaveCtx {
1965            carry_seats: false,
1966            pauses: Some((&self.pause, &self.interrupt)),
1967            run: &run_id,
1968            node: "implement",
1969            prompts: &prompts,
1970            cache: cache.as_deref(),
1971            round: None,
1972        };
1973        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1974        if wave_blocked(&results) {
1975            return self.stop_for_resource();
1976        }
1977        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1978            .await;
1979        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1980            .await;
1981        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1982            .await;
1983        if self.state.resource_block.is_some() {
1984            return self.stop_for_resource();
1985        }
1986
1987        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1988            let seat_key = seat.key.clone();
1989            // A quota fallback (`resume_seat_handovers`) may have handed this
1990            // seat to a different agent than the one `prep` recorded on the
1991            // candidate; the stats tables and any later fixer-defaults-to-
1992            // winner's-author lookup must credit whoever actually answered —
1993            // unless every fallback also quota'd out, in which case nobody
1994            // actually answered and crediting the last agent tried would
1995            // erase every earlier agent's own quota loss from the stats
1996            // tables instead of just this one seat's.
1997            let agent = seat.agent.clone();
1998            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1999            self.state.seats.insert(seat.key.clone(), seat);
2000            let label = self.state.candidates[i].label;
2001            let worktree = self.state.candidates[i].worktree.clone();
2002            let base = self.state.base_commit.clone();
2003
2004            let (summary, duration, failed, verified_claim) = match out {
2005                AgentOutcome::Ok(o) => {
2006                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2007                    let failed = (!o.usable()).then(|| {
2008                        if o.timed_out {
2009                            "agent timed out".to_owned()
2010                        } else {
2011                            format!("agent exited with {:?}", o.exit_code)
2012                        }
2013                    });
2014                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2015                    (text, o.duration_ms, failed, verified_claim)
2016                }
2017                // Left un-resumed by `resume_undelivered` (a dirty tree
2018                // already rescues the work, or there was no session left to
2019                // resume into) — reported like the ordinary failure it is,
2020                // never as if `o.text` (the CLI's raw error JSON) were an
2021                // answer.
2022                AgentOutcome::Dropped(o) => {
2023                    let why = o
2024                        .dropped
2025                        .as_ref()
2026                        .map(|d| d.why.as_str())
2027                        .unwrap_or("the CLI ended the stream without delivering its answer");
2028                    (
2029                        String::new(),
2030                        o.duration_ms,
2031                        Some(format!("the CLI dropped the stream ({why})")),
2032                        None,
2033                    )
2034                }
2035                AgentOutcome::Quota(o) => {
2036                    self.state.quota.push(QuotaLoss {
2037                        seat: seat_key,
2038                        node: "implement".to_owned(),
2039                        at: Timestamp::now(),
2040                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2041                    });
2042                    (
2043                        String::new(),
2044                        o.duration_ms,
2045                        Some("rate limited (quota); produced no change".to_owned()),
2046                        None,
2047                    )
2048                }
2049                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2050                    (String::new(), 0, Some(e), None)
2051                }
2052            };
2053
2054            // Rescue anything the agent edited but never committed: an
2055            // uncommitted candidate would silently be an empty one.
2056            let rescued = match git::rescue_commit(
2057                &worktree,
2058                &format!("magi: candidate {label} (uncommitted work)"),
2059            )
2060            .await
2061            {
2062                Ok(r) => {
2063                    self.state.note_withheld("implement", &r.withheld);
2064                    r.committed
2065                }
2066                Err(_) => false,
2067            };
2068            let commits = git::commits_ahead(&worktree, &base, "HEAD")
2069                .await
2070                .unwrap_or(0);
2071            let patch = git::diff(&worktree, &base, "HEAD")
2072                .await
2073                .unwrap_or_default();
2074            let stat = git::diff_stat(&worktree, &base, "HEAD")
2075                .await
2076                .unwrap_or_default();
2077            let files = git::changed_files(&worktree, &base, "HEAD")
2078                .await
2079                .map(|f| f.len())
2080                .unwrap_or(0);
2081            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2082
2083            let c = &mut self.state.candidates[i];
2084            if !exhausted_the_fallback_chain {
2085                c.agent = agent;
2086            }
2087            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2088            c.stat = stat;
2089            c.files = files;
2090            c.commits = commits;
2091            c.duration_ms = duration;
2092            c.empty = commits == 0 || patch.trim().is_empty();
2093            // An agent that failed but still produced a committed change stays
2094            // in the running: the patch is what gets judged, not the exit code.
2095            c.failed = match failed {
2096                Some(_) if c.empty => failed,
2097                _ => None,
2098            };
2099            // Only an empty candidate can be a verified no-op: a claim next
2100            // to a real patch is not what the marker is for, and `c.failed`
2101            // being `Some` here already implies `verified_claim` was never
2102            // set (see the guard above the match that produced it).
2103            c.verified_noop = if c.empty { verified_claim } else { None };
2104            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2105                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2106                (None, true, Some(_), _) => {
2107                    format!("candidate {label}: no change produced (agent-verified no-op)")
2108                }
2109                (None, true, None, _) => format!("candidate {label}: no change produced"),
2110                (None, false, _, true) => {
2111                    format!(
2112                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2113                    )
2114                }
2115                (None, false, _, false) => {
2116                    format!("candidate {label}: {files} files, {commits} commits")
2117                }
2118            };
2119            self.state.event("implement", note);
2120            self.state.save()?;
2121        }
2122
2123        self.after_implement()
2124    }
2125
2126    /// Ask again, once, for work a CLI did and then failed to hand over.
2127    ///
2128    /// [`agent::dropped_stream`] recognises the one shape observed: an error
2129    /// status with an empty response and a usage report showing output tokens,
2130    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
2131    /// seven minutes and 14,267 output tokens that arrived as an empty
2132    /// candidate, because `agy`'s own subscriber fell behind and hung up.
2133    ///
2134    /// Two conditions, and both matter:
2135    ///
2136    /// - **Only when the tree is untouched.** Often the agent has already
2137    ///   written its files and only the closing message was lost; the rescue
2138    ///   commit below picks that up and there is nothing to ask for. Re-asking
2139    ///   then would pay for a second implementation of work already on disk.
2140    /// - **Once.** A CLI that drops one stream can drop the next, and this
2141    ///   node is the most expensive in the graph.
2142    ///
2143    /// The re-ask is a resume, not a re-run: `has_context` is true because the
2144    /// dropped reply still carried its `conversation_id`, so the seat is asked
2145    /// to finish what it was doing rather than sent the whole task again. It
2146    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2147    /// node's - for the same reason a re-ranked judge does: restating finished
2148    /// work is not the work.
2149    ///
2150    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2151    /// same way until it resets, while an abandoned conversation is still
2152    /// there to be picked up.
2153    async fn resume_undelivered(
2154        &mut self,
2155        results: &mut [(usize, SeatState, AgentOutcome)],
2156        sent: &[SeatJob],
2157        prompts: &Prompts,
2158        run_id: &str,
2159    ) {
2160        for (wi, seat, out) in results.iter_mut() {
2161            let Some(dropped) = (match &*out {
2162                AgentOutcome::Dropped(o) => o.dropped.clone(),
2163                _ => None,
2164            }) else {
2165                continue;
2166            };
2167            let Some(job) = sent.get(*wi) else { continue };
2168            // Already on disk? Then only the closing message was lost.
2169            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2170                self.state.event(
2171                    "implement",
2172                    format!(
2173                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2174                         work is in the tree",
2175                        seat.key, dropped.output_tokens, dropped.why
2176                    ),
2177                );
2178                continue;
2179            }
2180            // The re-ask only makes sense as a resume: `resume_after_drop`
2181            // says nothing about the task, trusting the seat to still hold it.
2182            // Without a session to resume — sessions disabled, or this CLI's
2183            // drop shape happened not to carry a session id — that prompt
2184            // would open a brand-new conversation with no context at all,
2185            // which is worse than leaving this as the ordinary failure it
2186            // already is.
2187            if !has_context(&job.spec, seat, job.sessions) {
2188                self.state.event(
2189                    "implement",
2190                    format!(
2191                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2192                         is no session left to resume",
2193                        seat.key, dropped.output_tokens, dropped.why
2194                    ),
2195                );
2196                continue;
2197            }
2198            self.state.event(
2199                "implement",
2200                format!(
2201                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2202                     conversation",
2203                    seat.key, dropped.output_tokens, dropped.why
2204                ),
2205            );
2206            let mut retry = job.clone();
2207            retry.seat = seat.clone();
2208            retry.prompt = prompt::resume_after_drop(&dropped.why);
2209            retry.timeout = retry_budget(job.timeout, true);
2210            retry.stem = format!("{}-resume", job.stem);
2211            let cache = self.state.config.cache_dir();
2212            let ctx = WaveCtx {
2213                carry_seats: false,
2214                pauses: Some((&self.pause, &self.interrupt)),
2215                run: run_id,
2216                node: "implement",
2217                prompts,
2218                cache: cache.as_deref(),
2219                round: None,
2220            };
2221            let (resumed_seat, resumed) =
2222                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2223            *seat = resumed_seat;
2224            *out = resumed;
2225        }
2226    }
2227
2228    /// Fall an implement seat through to the next untried agent in the
2229    /// implementer roster when it lost to quota — or, since the handover was
2230    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2231    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2232    /// quota path itself being unchanged — instead of leaving the
2233    /// seat's loss final the moment one agent's account runs dry.
2234    ///
2235    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2236    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2237    /// truncates to the single slot rotation picked, so a solo task whose one
2238    /// implementer hits quota mid-run used to have nothing else to try. This
2239    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2240    /// unrotated roster — which is the only place the *other* candidates in
2241    /// the machine's roster still exist once `implementers` has been cut down
2242    /// to size.
2243    ///
2244    /// Walks forward from just past the seat's own original position in the
2245    /// roster, never wrapping back to the front: a later candidate slot (say
2246    /// `beta`, the roster's second entry) must fall through to the *next*
2247    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2248    /// almost certainly a different candidate's own agent already — and once
2249    /// the roster's tail is exhausted there is nothing left to fall through
2250    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2251    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2252    /// let this retry that id forever. The loop keeps falling through until
2253    /// an attempt lands something other than `Quota` or the roster's tail
2254    /// runs out of untried ids, at which point the seat is left exactly as
2255    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2256    /// `QuotaLoss` recorded, the candidate failed/empty.
2257    ///
2258    /// `sent` is taken mutably and updated with the fallback agent's spec:
2259    /// `resume_unconfirmed_commands`, which runs after this and also reads
2260    /// `sent`, must see whichever agent actually ended up answering the seat
2261    /// — reading the stale, original spec there would check session
2262    /// eligibility against the wrong CLI and could hand a fallback agent's
2263    /// session id to the agent that just lost the seat to quota.
2264    ///
2265    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2266    /// — `self.seat` only reuses state when the agent id is unchanged, so
2267    /// handing it a different id already gets this for free. Reusing the old
2268    /// seat would resume a different CLI's session as if it were a
2269    /// continuation of this one.
2270    ///
2271    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2272    /// a quota loss cuts an agent off mid-turn, so anything already in the
2273    /// tree is unfinished work, not a completed candidate a re-ask would pay
2274    /// for twice. A dirty tree is rescued into a commit first (the same
2275    /// neutral-identity rescue `implement`'s own outcome loop gives every
2276    /// candidate) so the next agent starts clean.
2277    ///
2278    /// The new agent gets the implementer's full prompt and full
2279    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2280    /// it has no session and no context, and is implementing the task from
2281    /// nothing, unlike a resumed drop which is only restating work already
2282    /// done.
2283    ///
2284    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2285    /// `implement` event, never into `self.state.quota` — that is what
2286    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2287    /// go unspent, and a seat that ultimately recovered on its second or
2288    /// third agent is not the stalled panel that check exists to catch. Only
2289    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2290    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2291    /// outcome loop already has — this helper never pushes to it itself.
2292    async fn resume_seat_handovers(
2293        &mut self,
2294        results: &mut [(usize, SeatState, AgentOutcome)],
2295        sent: &mut [SeatJob],
2296        prompts: &Prompts,
2297        run_id: &str,
2298    ) {
2299        let instruction = seeded_instruction(&self.state);
2300        let language = self.state.config.graph.language.clone();
2301        let github_language = self.state.config.graph.github_language.clone();
2302        let brief = self
2303            .state
2304            .advice
2305            .as_ref()
2306            .and_then(|a| a.synthesis.as_deref())
2307            .map(str::to_owned);
2308        let attachments = self.state.attachments.clone();
2309        for (wi, seat, out) in results.iter_mut() {
2310            // Who holds the other candidate seats of this wave right now
2311            // (earlier handovers already written back to `sent`).
2312            let others: BTreeSet<String> = sent
2313                .iter()
2314                .enumerate()
2315                .filter(|(j, _)| j != wi)
2316                .map(|(_, j)| j.spec.id.clone())
2317                .collect();
2318            let Some(job) = sent.get_mut(*wi) else {
2319                continue;
2320            };
2321            // Where the seat's own original agent sits in the roster — the
2322            // fallback walk starts just past here, never at the front, so a
2323            // later candidate slot's quota loss does not fall back onto an
2324            // earlier slot's own agent.
2325            let start = self
2326                .roles
2327                .implementer_roster
2328                .iter()
2329                .position(|s| s.id == job.spec.id)
2330                .unwrap_or(0);
2331            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2332            let mut fallback_attempt = 0usize;
2333            let mut prev: Option<FailClass> = None;
2334            while let Some(cur) = FailClass::of(&*out) {
2335                if !should_hand_over(prev.as_ref(), &cur) {
2336                    break;
2337                }
2338                let Some(next) =
2339                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2340                        .cloned()
2341                else {
2342                    break;
2343                };
2344                tried.insert(next.id.clone());
2345                fallback_attempt += 1;
2346
2347                if let Ok(r) = git::rescue_commit(
2348                    &job.cwd,
2349                    &format!(
2350                        "magi: candidate {} (uncommitted work before {} fallback)",
2351                        seat.key,
2352                        if cur == FailClass::Quota {
2353                            "quota"
2354                        } else {
2355                            "handover"
2356                        }
2357                    ),
2358                )
2359                .await
2360                {
2361                    self.state.note_withheld("implement", &r.withheld);
2362                }
2363
2364                record_handover(
2365                    &mut self.state,
2366                    "implement",
2367                    &seat.key,
2368                    &seat.agent,
2369                    &next.id,
2370                    &cur,
2371                    &fail_reason(&*out),
2372                );
2373                prev = Some(cur.clone());
2374
2375                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2376                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2377                // Kept in sync on `sent` itself, not just the local retry: a
2378                // later helper (`resume_unconfirmed_commands`) reads `sent`
2379                // after this one returns and must see whichever agent is now
2380                // occupying the seat, not the one that just quota'd out —
2381                // otherwise it would judge session/continuation eligibility
2382                // by the wrong CLI and could resend a fallback's session id
2383                // to the agent that lost it the seat in the first place.
2384                job.spec = next.clone();
2385                let mut retry = job.clone();
2386                retry.seat = new_seat;
2387                retry.prompt = prompt::implement_in(
2388                    &instruction,
2389                    &job.cwd.to_string_lossy(),
2390                    &language,
2391                    &github_language,
2392                    brief.as_deref(),
2393                    &attachments,
2394                );
2395                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2396                let cache = self.state.config.cache_dir();
2397                let ctx = WaveCtx {
2398                    carry_seats: false,
2399                    pauses: Some((&self.pause, &self.interrupt)),
2400                    run: run_id,
2401                    node: "implement",
2402                    prompts,
2403                    cache: cache.as_deref(),
2404                    round: None,
2405                };
2406                let (fallback_seat, fallback_out) = run_one(
2407                    retry,
2408                    Arc::clone(&self.sem),
2409                    &ctx,
2410                    &mut self.state,
2411                    fallback_attempt,
2412                )
2413                .await;
2414                *seat = fallback_seat;
2415                *out = fallback_out;
2416            }
2417        }
2418    }
2419
2420    /// Ask an implement seat's own CLI to confirm what it started, once, when
2421    /// its reply reported a command whose completion status it never
2422    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2423    /// that does and does not mean.
2424    ///
2425    /// The completion contract this task asks for, extended to `implement`
2426    /// with the same signal `continue_fix_report` reads for the fixer,
2427    /// rather than a keyword search over the reply or a hard requirement on
2428    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2429    /// a candidate's CLI turn ended cleanly while a test run it had started
2430    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2431    /// named in it at all is untouched by this: `commands` is empty, so
2432    /// there is nothing to be unconfirmed.
2433    ///
2434    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2435    /// this is not about recovering edits that might already be on disk, it
2436    /// is about a result the seat itself never vouched for, which resuming
2437    /// asks for regardless of what the tree already holds. Bounded to one
2438    /// attempt for the same reason `resume_undelivered` is — this is the
2439    /// most expensive node in the graph — and a seat that still cannot
2440    /// confirm on that attempt is left as whatever its (possibly still
2441    /// unconfirmed) reply says; this does not invent a new "failed" reason
2442    /// for a candidate that otherwise produced a real, committed change.
2443    async fn resume_unconfirmed_commands(
2444        &mut self,
2445        results: &mut [(usize, SeatState, AgentOutcome)],
2446        sent: &[SeatJob],
2447        prompts: &Prompts,
2448        run_id: &str,
2449    ) {
2450        for (wi, seat, out) in results.iter_mut() {
2451            let AgentOutcome::Ok(o) = &*out else {
2452                continue;
2453            };
2454            if !has_unconfirmed_command(&o.commands) {
2455                continue;
2456            }
2457            let Some(job) = sent.get(*wi) else { continue };
2458            if !has_context(&job.spec, seat, job.sessions) {
2459                self.state.event(
2460                    "implement",
2461                    format!(
2462                        "{}: the reply named a command whose own CLI never confirmed the exit \
2463                         status of, but there is no session left to resume",
2464                        seat.key
2465                    ),
2466                );
2467                continue;
2468            }
2469            self.state.event(
2470                "implement",
2471                format!(
2472                    "{}: the reply named a command whose own CLI never confirmed the exit \
2473                     status of; resuming the conversation",
2474                    seat.key
2475                ),
2476            );
2477            let mut retry = job.clone();
2478            retry.seat = seat.clone();
2479            retry.prompt = prompt::resume_incomplete(
2480                "a command in your last reply had no confirmed exit status",
2481            );
2482            retry.timeout = retry_budget(job.timeout, true);
2483            retry.stem = format!("{}-confirm", job.stem);
2484            let cache = self.state.config.cache_dir();
2485            let ctx = WaveCtx {
2486                carry_seats: false,
2487                pauses: Some((&self.pause, &self.interrupt)),
2488                run: run_id,
2489                node: "implement",
2490                prompts,
2491                cache: cache.as_deref(),
2492                round: None,
2493            };
2494            let (resumed_seat, resumed) =
2495                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2496            *seat = resumed_seat;
2497            *out = resumed;
2498        }
2499    }
2500
2501    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2502    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2503    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2504    /// that motivated this.
2505    ///
2506    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2507    /// own nudge loop already covers for judge/review/vote seats, and not a
2508    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2509    /// implement seats: here the CLI turn genuinely finished while the node's
2510    /// own work — the fixer's account of what it did — had not. Gated purely
2511    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2512    /// reply, never on any wording in it, so a fixer whose valid, first-try
2513    /// `FixReport` happens to mention having waited on a background test is
2514    /// never resumed — the `Ok(report)` branch at the call site returns
2515    /// before this is ever invoked.
2516    ///
2517    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2518    /// attempt ([`retry_budget`]), nothing attempted once the session is
2519    /// gone, and a quota hit ends the loop immediately rather than retrying a
2520    /// rate limit that fails the same way again.
2521    async fn continue_fix_report(
2522        &mut self,
2523        mut seat: SeatState,
2524        parse_err: String,
2525        job: &SeatJob,
2526        prompts: &Prompts,
2527        run_id: &str,
2528        round: usize,
2529    ) -> (
2530        SeatState,
2531        Option<FixReport>,
2532        Option<String>,
2533        ContinuationRecord,
2534    ) {
2535        let mut last_err = parse_err;
2536        let mut cumulative_wait_ms = 0u64;
2537        let mut attempts = 0usize;
2538        loop {
2539            if !has_context(&job.spec, &seat, job.sessions) {
2540                self.state.event(
2541                    "fix",
2542                    format!(
2543                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2544                         session left to resume into"
2545                    ),
2546                );
2547                let outcome = if attempts == 0 {
2548                    ContinuationOutcome::NoSession
2549                } else {
2550                    ContinuationOutcome::Exhausted
2551                };
2552                return (
2553                    seat,
2554                    None,
2555                    Some(format!("unparsable fix report: {last_err}")),
2556                    ContinuationRecord {
2557                        attempts,
2558                        cumulative_wait_ms,
2559                        outcome,
2560                    },
2561                );
2562            }
2563            if attempts >= MAX_FIX_CONTINUATIONS {
2564                self.state.event(
2565                    "fix",
2566                    format!(
2567                        "round {round}: fixer's reply still had no adoption report after \
2568                         {attempts} continuation(s) ({last_err}); giving up"
2569                    ),
2570                );
2571                return (
2572                    seat,
2573                    None,
2574                    Some(format!(
2575                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2576                    )),
2577                    ContinuationRecord {
2578                        attempts,
2579                        cumulative_wait_ms,
2580                        outcome: ContinuationOutcome::Exhausted,
2581                    },
2582                );
2583            }
2584            attempts += 1;
2585            self.state.event(
2586                "fix",
2587                format!(
2588                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2589                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2590                ),
2591            );
2592            let mut retry = job.clone();
2593            retry.seat = seat.clone();
2594            retry.prompt = prompt::resume_incomplete(&last_err);
2595            retry.timeout = retry_budget(job.timeout, true);
2596            retry.stem = format!("{}-continue{attempts}", job.stem);
2597            let cache = self.state.config.cache_dir();
2598            let ctx = WaveCtx {
2599                carry_seats: false,
2600                pauses: Some((&self.pause, &self.interrupt)),
2601                run: run_id,
2602                node: "fix",
2603                prompts,
2604                cache: cache.as_deref(),
2605                round: Some(round),
2606            };
2607            let (resumed_seat, resumed_out) = run_one(
2608                retry,
2609                Arc::clone(&self.sem),
2610                &ctx,
2611                &mut self.state,
2612                attempts,
2613            )
2614            .await;
2615            seat = resumed_seat;
2616            match resumed_out {
2617                AgentOutcome::Ok(o) => {
2618                    cumulative_wait_ms += o.duration_ms;
2619                    match verdict::extract_json::<FixReport>(&o.text) {
2620                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2621                            self.state.event(
2622                                "fix",
2623                                format!(
2624                                    "round {round}: fixer's adoption report recovered after \
2625                                     {attempts} continuation(s)"
2626                                ),
2627                            );
2628                            return (
2629                                seat,
2630                                Some(report),
2631                                None,
2632                                ContinuationRecord {
2633                                    attempts,
2634                                    cumulative_wait_ms,
2635                                    outcome: ContinuationOutcome::Resumed,
2636                                },
2637                            );
2638                        }
2639                        // The report parsed, but this same reply's own
2640                        // CommandEvidence — the identical record `state.jobs`
2641                        // renders — names a command whose CLI never
2642                        // confirmed an exit status. Read together, that is
2643                        // not a resolved answer: keep nudging rather than
2644                        // accept a report standing next to a command the
2645                        // seat's own CLI cannot vouch for.
2646                        Ok(_) => {
2647                            last_err = "the reply parsed, but it reported a command whose own CLI \
2648                                 never confirmed an exit status"
2649                                .to_owned();
2650                        }
2651                        Err(e) => last_err = e.to_string(),
2652                    }
2653                }
2654                AgentOutcome::Quota(o) => {
2655                    cumulative_wait_ms += o.duration_ms;
2656                    self.state.quota.push(QuotaLoss {
2657                        seat: seat.key.clone(),
2658                        node: "fix".to_owned(),
2659                        at: Timestamp::now(),
2660                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2661                    });
2662                    self.state.event(
2663                        "fix",
2664                        format!(
2665                            "round {round}: continuation rate limited (quota); not retrying now"
2666                        ),
2667                    );
2668                    return (
2669                        seat,
2670                        None,
2671                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2672                        ContinuationRecord {
2673                            attempts,
2674                            cumulative_wait_ms,
2675                            outcome: ContinuationOutcome::QuotaLost,
2676                        },
2677                    );
2678                }
2679                AgentOutcome::Dropped(o) => {
2680                    cumulative_wait_ms += o.duration_ms;
2681                    let why = o
2682                        .dropped
2683                        .as_ref()
2684                        .map(|d| d.why.as_str())
2685                        .unwrap_or("the CLI ended the stream without delivering its answer");
2686                    last_err = format!("the CLI dropped the stream ({why})");
2687                }
2688                AgentOutcome::Failed(e) => last_err = e,
2689                // The continuation was never started; the report stays lost
2690                // and the caller records that, without any handover.
2691                AgentOutcome::ResourceBlocked(e) => {
2692                    return (
2693                        seat,
2694                        None,
2695                        Some(format!("the continuation was not started: {e}")),
2696                        ContinuationRecord {
2697                            attempts: attempts.saturating_sub(1),
2698                            cumulative_wait_ms,
2699                            outcome: ContinuationOutcome::NoSession,
2700                        },
2701                    );
2702                }
2703            }
2704        }
2705    }
2706
2707    fn after_implement(&mut self) -> Result<()> {
2708        // Scan every candidate patch once the set is complete.
2709        if self.state.leaks.is_empty() {
2710            let cfg = self.state.config.blind.clone();
2711            let mut leaks = Vec::new();
2712            for c in &self.state.candidates {
2713                let Some(patch) =
2714                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2715                else {
2716                    continue;
2717                };
2718                leaks.extend(blind::scan(
2719                    &format!("candidate {} patch", c.label),
2720                    &patch,
2721                    &cfg.vendor_tokens,
2722                ));
2723            }
2724            if !leaks.is_empty() {
2725                let summary = leaks
2726                    .iter()
2727                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2728                    .collect::<Vec<_>>()
2729                    .join(", ");
2730                match cfg.on_leak {
2731                    LeakPolicy::Fail => {
2732                        self.state.status = RunStatus::Failed;
2733                        self.state
2734                            .event("blind", format!("vendor text in a patch: {summary}"));
2735                        self.state.leaks = leaks;
2736                        self.state.save()?;
2737                        self.settle_questions();
2738                        bail!(
2739                            "blind.on_leak = \"fail\" and vendor text reached a \
2740                             judged patch: {summary}"
2741                        );
2742                    }
2743                    LeakPolicy::Redact => self.state.event(
2744                        "blind",
2745                        format!("redacting vendor text for judging: {summary}"),
2746                    ),
2747                    LeakPolicy::Warn => self.state.event(
2748                        "blind",
2749                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2750                    ),
2751                }
2752                self.state.leaks = leaks;
2753            }
2754        }
2755
2756        if self.state.viable().is_empty() {
2757            if self.state.all_candidates_verified_noop() {
2758                // Every candidate agreed, with evidence the adoption guard
2759                // accepted, that nothing belongs in this worktree. That is
2760                // not the same fact as a candidate that simply failed to
2761                // write anything, and settling it as an ordinary `Failed`
2762                // (see `SCHEMA`'s doc for schema 10) is what let two of
2763                // task 391f's attempts burn a retry each re-discovering the
2764                // same already-landed fix. Terminal either way, so `judge`
2765                // must never run over an empty candidate set — unlike the
2766                // `Failed` branch below this returns `Ok`, not an error:
2767                // nothing here failed.
2768                self.state.status = RunStatus::VerifiedNoop;
2769                self.state.save()?;
2770                self.settle_questions();
2771                return Ok(());
2772            }
2773            self.state.status = RunStatus::Failed;
2774            self.state.save()?;
2775            self.settle_questions();
2776            bail!("no candidate produced a change; nothing to judge");
2777        }
2778        self.state.status = RunStatus::Judging;
2779        self.state.save()?;
2780        Ok(())
2781    }
2782
2783    // --------------------------------------------------------------- judge
2784
2785    async fn judge(&mut self) -> Result<()> {
2786        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2787        // agent files with `magi task add` name the run that paid for it. The
2788        // prompt overlay is cloned alongside it because the waves borrow it
2789        // while `self` is mutably borrowed by the node's own bookkeeping.
2790        let run_id = self.state.id.clone();
2791        let prompts = self.state.config.prompts.clone();
2792        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2793            return Ok(());
2794        }
2795        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2796        if viable.len() == 1 {
2797            // Recorded so this is a one-time event: `judgements` stays empty
2798            // either way, which without this flag is indistinguishable from
2799            // "not yet judged" on the next reentry — and status is left
2800            // untouched, so a later node's conclusion (e.g. `Blocked` after
2801            // the review budget ran out) survives a resume instead of being
2802            // clobbered back to `Judging` by this node running again.
2803            self.state.judge_skipped = true;
2804            self.state.event(
2805                "judge",
2806                format!(
2807                    "only candidate {} produced a change; judging skipped",
2808                    viable[0].label
2809                ),
2810            );
2811            self.state.save()?;
2812            return Ok(());
2813        }
2814        self.state.status = RunStatus::Judging;
2815
2816        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2817        let language = self.state.config.graph.language.clone();
2818        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2819        let sessions = self.state.config.graph.sessions;
2820        let artifacts = agent::artifacts_dir(&self.state.dir());
2821        let root = self.state.worktree_root();
2822        let base_short = short(&self.state.base_commit);
2823
2824        let mut jobs = Vec::new();
2825        let mut orders = Vec::new();
2826        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2827            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2828            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2829            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2830            let seat_key = format!("judge-{}", j + 1);
2831            let seat = self.seat(&seat_key, &spec.id);
2832            jobs.push(SeatJob {
2833                prompt: prompt::judge(
2834                    &self.state.instruction,
2835                    &views,
2836                    self.roles.judges.len(),
2837                    &base_short,
2838                    &language,
2839                ),
2840                spec,
2841                seat,
2842                cwd: root.join(format!("judge-{}", j + 1)),
2843                timeout,
2844                allow_write: false,
2845                sessions,
2846                artifacts: artifacts.clone(),
2847                stem: format!("judge-{}", j + 1),
2848                handover: None,
2849            });
2850        }
2851
2852        self.state.event(
2853            "judge",
2854            format!(
2855                "{} judges ranking {} candidates blind",
2856                jobs.len(),
2857                viable.len()
2858            ),
2859        );
2860        let labels_for_check = labels.clone();
2861        let mut quota_losses = Vec::new();
2862        let cache = self.state.config.cache_dir();
2863        let ctx = WaveCtx {
2864            carry_seats: false,
2865            pauses: Some((&self.pause, &self.interrupt)),
2866            run: &run_id,
2867            node: "judge",
2868            prompts: &prompts,
2869            cache: cache.as_deref(),
2870            round: None,
2871        };
2872        let results = ask_json_wave::<Ranking>(
2873            jobs,
2874            Arc::clone(&self.sem),
2875            self.state.config.graph.retries,
2876            &self.roles.judge_roster,
2877            &ctx,
2878            &mut quota_losses,
2879            &mut self.state,
2880            &move |r: &Ranking| r.validate(&labels_for_check),
2881        )
2882        .await;
2883        if self.state.resource_block.is_some() {
2884            return self.stop_for_resource();
2885        }
2886        self.state.quota.extend(quota_losses);
2887
2888        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2889            let agent_id = seat.agent.clone();
2890            self.state.seats.insert(seat.key.clone(), seat);
2891            let mut record = Judgement {
2892                judge: j + 1,
2893                seat: format!("judge-{}", j + 1),
2894                agent: agent_id,
2895                ranking: Vec::new(),
2896                reasons: BTreeMap::new(),
2897                confidence: None,
2898                strengths: BTreeMap::new(),
2899                order: orders[j].clone(),
2900                failed: None,
2901                duration_ms: 0,
2902            };
2903            match res {
2904                Ok((ranking, out)) => {
2905                    record.ranking = ranking.normalized();
2906                    record.reasons = ranking.reasons;
2907                    record.strengths = ranking.strengths;
2908                    record.confidence = ranking.confidence;
2909                    record.duration_ms = out.duration_ms;
2910                    self.state.event(
2911                        "judge",
2912                        format!(
2913                            "judge {} ranked {}",
2914                            j + 1,
2915                            record.ranking.iter().collect::<String>()
2916                        ),
2917                    );
2918                }
2919                Err(e) => {
2920                    record.failed = Some(e.to_string());
2921                    self.state
2922                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2923                }
2924            }
2925            self.state.judgements.push(record);
2926            self.state.save()?;
2927        }
2928        Ok(())
2929    }
2930
2931    // ---------------------------------------------------------- deliberate
2932
2933    async fn deliberate(&mut self) -> Result<()> {
2934        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2935        // agent files with `magi task add` name the run that paid for it. The
2936        // prompt overlay is cloned alongside it because the waves borrow it
2937        // while `self` is mutably borrowed by the node's own bookkeeping.
2938        let run_id = self.state.id.clone();
2939        let prompts = self.state.config.prompts.clone();
2940        if !self.state.deliberation.is_empty() {
2941            return Ok(());
2942        }
2943        let tops: Vec<char> = self
2944            .state
2945            .judgements
2946            .iter()
2947            .filter_map(|j| j.ranking.first().copied())
2948            .collect();
2949        let rounds = self.state.config.graph.deliberate_rounds;
2950        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2951            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2952                self.state.event(
2953                    "deliberate",
2954                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2955                );
2956            }
2957            self.state.status = RunStatus::Voting;
2958            self.state.save()?;
2959            return Ok(());
2960        }
2961
2962        self.state.status = RunStatus::Deliberating;
2963        self.state.event(
2964            "deliberate",
2965            format!(
2966                "split: first choices were {} — opening {rounds} round(s)",
2967                tops.iter().collect::<String>()
2968            ),
2969        );
2970
2971        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2972        let language = self.state.config.graph.language.clone();
2973        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2974        let sessions = self.state.config.graph.sessions;
2975        let artifacts = agent::artifacts_dir(&self.state.dir());
2976        let root = self.state.worktree_root();
2977        let base_short = short(&self.state.base_commit);
2978
2979        // Judges argue in sequence so that a turn can answer the one before it;
2980        // that is the difference between deliberation and three parallel
2981        // monologues.
2982        for round in 1..=rounds {
2983            let mut turns: Vec<DeliberationTurn> = Vec::new();
2984            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2985                if self.state.judgements[j].failed.is_some() {
2986                    continue;
2987                }
2988                let seat_key = format!("judge-{}", j + 1);
2989                let spec = self.occupant(&seat_key, spec);
2990                let mut seat = self.seat(&seat_key, &spec.id);
2991                let transcript = self.transcript(&turns, j);
2992                let build = |context: Option<&str>| {
2993                    prompt::deliberate(
2994                        &self.state.instruction,
2995                        context,
2996                        &transcript,
2997                        round,
2998                        rounds,
2999                        &language,
3000                    )
3001                };
3002                let block = self.candidate_block(&viable, &base_short);
3003                let full = build(Some(&block));
3004                let text = if has_context(&spec, &seat, sessions) {
3005                    build(None)
3006                } else {
3007                    full.clone()
3008                };
3009                let job = SeatJob {
3010                    spec,
3011                    seat: seat.clone(),
3012                    prompt: text,
3013                    cwd: root.join(format!("judge-{}", j + 1)),
3014                    timeout,
3015                    allow_write: false,
3016                    sessions,
3017                    artifacts: artifacts.clone(),
3018                    stem: format!("delib-{round}-judge-{}", j + 1),
3019                    handover: Some(full),
3020                };
3021                let cache = self.state.config.cache_dir();
3022                let ctx = WaveCtx {
3023                    carry_seats: false,
3024                    pauses: Some((&self.pause, &self.interrupt)),
3025                    run: &run_id,
3026                    node: "deliberate",
3027                    prompts: &prompts,
3028                    cache: cache.as_deref(),
3029                    round: None,
3030                };
3031                // A turn is never nudged (`retries` 0): a failed seat is
3032                // handed to the next roster agent, which gets the full
3033                // context. An empty answer is a turn, not a failure.
3034                let mut losses = Vec::new();
3035                let mut results = ask_wave_with::<String>(
3036                    vec![job],
3037                    Arc::clone(&self.sem),
3038                    0,
3039                    &self.roles.judge_roster,
3040                    &ctx,
3041                    &mut losses,
3042                    &mut self.state,
3043                    &|text: &str| {
3044                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3045                    },
3046                )
3047                .await;
3048                self.state.quota.extend(losses);
3049                let (updated, res, _) = results.pop().expect("one job in, one result out");
3050                seat = updated;
3051                let agent_id = seat.agent.clone();
3052                self.state.seats.insert(seat.key.clone(), seat);
3053                let body = match res {
3054                    Ok((body, _)) => body,
3055                    // Skip the seat; a CLI's raw error JSON is never read as
3056                    // this judge's position.
3057                    Err(e) => {
3058                        self.state
3059                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3060                        continue;
3061                    }
3062                };
3063                let tentative = verdict::extract_json::<Position>(&body)
3064                    .ok()
3065                    .and_then(|p| p.tentative)
3066                    .and_then(|s| s.trim().chars().next())
3067                    .map(|c| c.to_ascii_uppercase());
3068                self.state.event(
3069                    "deliberate",
3070                    format!(
3071                        "round {round}: judge {} now favours {}",
3072                        j + 1,
3073                        tentative.map_or("—".to_owned(), |c| c.to_string())
3074                    ),
3075                );
3076                turns.push(DeliberationTurn {
3077                    judge: j + 1,
3078                    agent: agent_id,
3079                    body: blind::sanitize_prose(&body, &self.state.config.blind),
3080                    tentative,
3081                });
3082            }
3083            self.state
3084                .deliberation
3085                .push(DeliberationRound { round, turns });
3086            self.state.save()?;
3087        }
3088
3089        self.state.status = RunStatus::Voting;
3090        self.state.save()?;
3091        Ok(())
3092    }
3093
3094    // ---------------------------------------------------------------- vote
3095
3096    async fn vote(&mut self) -> Result<()> {
3097        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3098        // agent files with `magi task add` name the run that paid for it. The
3099        // prompt overlay is cloned alongside it because the waves borrow it
3100        // while `self` is mutably borrowed by the node's own bookkeeping.
3101        let run_id = self.state.id.clone();
3102        let prompts = self.state.config.prompts.clone();
3103        if !self.state.votes.is_empty() {
3104            return Ok(());
3105        }
3106        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3107        if viable.len() == 1 {
3108            return Ok(());
3109        }
3110        self.state.status = RunStatus::Voting;
3111
3112        let language = self.state.config.graph.language.clone();
3113        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3114        let sessions = self.state.config.graph.sessions;
3115        let artifacts = agent::artifacts_dir(&self.state.dir());
3116        let root = self.state.worktree_root();
3117        let base_short = short(&self.state.base_commit);
3118        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3119
3120        let mut jobs = Vec::new();
3121        let mut seats_at = Vec::new();
3122        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3123            if self
3124                .state
3125                .judgements
3126                .get(j)
3127                .is_some_and(|r| r.failed.is_some())
3128            {
3129                continue;
3130            }
3131            let seat_key = format!("judge-{}", j + 1);
3132            let spec = self.occupant(&seat_key, spec);
3133            let seat = self.seat(&seat_key, &spec.id);
3134            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3135            let text = if has_context(&spec, &seat, sessions) {
3136                prompt::final_vote(&viable, &language)
3137            } else {
3138                full.clone()
3139            };
3140            jobs.push(SeatJob {
3141                spec,
3142                seat,
3143                prompt: text,
3144                cwd: root.join(format!("judge-{}", j + 1)),
3145                timeout,
3146                allow_write: false,
3147                sessions,
3148                artifacts: artifacts.clone(),
3149                stem: format!("vote-judge-{}", j + 1),
3150                handover: Some(full),
3151            });
3152            seats_at.push(j);
3153        }
3154
3155        self.state.event(
3156            "vote",
3157            format!(
3158                "collecting {} final votes one by one, privately",
3159                jobs.len()
3160            ),
3161        );
3162        let allowed = viable.clone();
3163        let mut quota_losses = Vec::new();
3164        let cache = self.state.config.cache_dir();
3165        let ctx = WaveCtx {
3166            carry_seats: false,
3167            pauses: Some((&self.pause, &self.interrupt)),
3168            run: &run_id,
3169            node: "vote",
3170            prompts: &prompts,
3171            cache: cache.as_deref(),
3172            round: None,
3173        };
3174        let results = ask_json_wave::<FinalVote>(
3175            jobs,
3176            Arc::clone(&self.sem),
3177            self.state.config.graph.retries,
3178            &self.roles.judge_roster,
3179            &ctx,
3180            &mut quota_losses,
3181            &mut self.state,
3182            &move |v: &FinalVote| match v.label() {
3183                Some(c) if allowed.contains(&c) => Ok(()),
3184                other => bail!("vote {other:?} is not one of {allowed:?}"),
3185            },
3186        )
3187        .await;
3188        if self.state.resource_block.is_some() {
3189            return self.stop_for_resource();
3190        }
3191        self.state.quota.extend(quota_losses);
3192
3193        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3194            let agent_id = seat.agent.clone();
3195            self.state.seats.insert(seat.key.clone(), seat);
3196            let initial = self
3197                .state
3198                .judgements
3199                .get(j)
3200                .and_then(|r| r.ranking.first().copied());
3201            let mut record = VoteRecord {
3202                judge: j + 1,
3203                agent: agent_id,
3204                vote: None,
3205                reason: String::new(),
3206                changed: false,
3207            };
3208            match res {
3209                Ok((v, _)) => {
3210                    record.vote = v.label();
3211                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3212                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3213                    self.state.event(
3214                        "vote",
3215                        format!(
3216                            "judge {} voted {}{}",
3217                            j + 1,
3218                            record.vote.unwrap_or('?'),
3219                            if record.changed { " (changed)" } else { "" }
3220                        ),
3221                    );
3222                }
3223                Err(e) => {
3224                    self.state
3225                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3226                }
3227            }
3228            self.state.votes.push(record);
3229            self.state.save()?;
3230        }
3231        Ok(())
3232    }
3233
3234    // --------------------------------------------------------------- tally
3235
3236    fn tally(&mut self) -> Result<()> {
3237        if self.state.tally.is_some() {
3238            return Ok(());
3239        }
3240        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3241        let tops: Vec<char> = self
3242            .state
3243            .judgements
3244            .iter()
3245            .filter_map(|j| j.ranking.first().copied())
3246            .collect();
3247        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3248
3249        // A judge whose private vote failed still counted once, in the initial
3250        // ranking; using it beats discarding a whole seat.
3251        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3252        let mut cast: Vec<char> = Vec::new();
3253        for (i, j) in self.state.judgements.iter().enumerate() {
3254            let vote = self
3255                .state
3256                .votes
3257                .iter()
3258                .find(|v| v.judge == i + 1)
3259                .and_then(|v| v.vote)
3260                .or_else(|| j.ranking.first().copied());
3261            if let Some(v) = vote {
3262                *first_choice.entry(v).or_insert(0) += 1;
3263                cast.push(v);
3264            }
3265        }
3266
3267        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3268        for j in &self.state.judgements {
3269            let n = j.ranking.len();
3270            for (pos, label) in j.ranking.iter().enumerate() {
3271                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3272            }
3273        }
3274
3275        let best = first_choice.values().copied().max().unwrap_or(0);
3276        let mut leaders: Vec<char> = first_choice
3277            .iter()
3278            .filter(|(_, v)| **v == best)
3279            .map(|(k, _)| *k)
3280            .collect();
3281        let mut tie_break = None;
3282        if leaders.len() > 1 {
3283            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3284            let borda_leaders: Vec<char> = leaders
3285                .iter()
3286                .copied()
3287                .filter(|l| borda[l] == top_borda)
3288                .collect();
3289            tie_break = Some(if borda_leaders.len() == 1 {
3290                format!(
3291                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3292                    leaders.len()
3293                )
3294            } else {
3295                format!(
3296                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3297                    leaders.len()
3298                )
3299            });
3300            leaders = borda_leaders;
3301            leaders.sort_unstable();
3302        }
3303        let winner = *leaders
3304            .first()
3305            .or(viable.first())
3306            .context("no candidate to declare a winner from")?;
3307
3308        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3309        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3310        let deliberated = !self.state.deliberation.is_empty();
3311
3312        // Whose verdict is this? A rate-limited seat is absent even if it
3313        // ranked before the limit hit, so presence is measured against the
3314        // recorded losses, not just "did a ranking ever appear".
3315        let quota_seats: std::collections::BTreeSet<&str> =
3316            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3317        let mut present = 0usize;
3318        for (i, j) in self.state.judgements.iter().enumerate() {
3319            if quota_seats.contains(j.seat.as_str()) {
3320                continue;
3321            }
3322            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3323            let voted = self
3324                .state
3325                .votes
3326                .iter()
3327                .any(|v| v.judge == i + 1 && v.vote.is_some());
3328            if ranked || voted {
3329                present += 1;
3330            }
3331        }
3332        // Strict majority of the configured panel. A bare majority is real
3333        // signal we can act on, while a minority verdict must never stand in
3334        // for a healthy one. A one-candidate run needs no panel at all, and
3335        // `judges` stays `0` rather than the roster size a panel that never
3336        // sat would otherwise be credited with.
3337        let needs_quorum = viable.len() > 1;
3338        let judges_total = if needs_quorum {
3339            self.roles.judges.len()
3340        } else {
3341            0
3342        };
3343        let quorum = if needs_quorum {
3344            judges_total / 2 + 1
3345        } else {
3346            0
3347        };
3348        let met_quorum = !needs_quorum || present >= quorum;
3349        let uncontested = (!needs_quorum).then(|| {
3350            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3351        });
3352
3353        self.state.event(
3354            "tally",
3355            match &uncontested {
3356                Some(reason) => format!("winner {winner} — {reason}"),
3357                None => format!(
3358                    "winner {winner} — votes {} | initial {} | {} changed | \
3359                     {present}/{judges_total} judges{}",
3360                    first_choice
3361                        .iter()
3362                        .map(|(k, v)| format!("{k}:{v}"))
3363                        .collect::<Vec<_>>()
3364                        .join(" "),
3365                    if unanimous_initial {
3366                        "unanimous"
3367                    } else {
3368                        "split"
3369                    },
3370                    changed_votes,
3371                    if met_quorum {
3372                        String::new()
3373                    } else {
3374                        format!(" — below quorum ({quorum} required)")
3375                    },
3376                ),
3377            },
3378        );
3379        if !met_quorum {
3380            self.state.event(
3381                "stall",
3382                format!(
3383                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3384                     the run stops here, resumable"
3385                ),
3386            );
3387        }
3388        self.state.tally = Some(Tally {
3389            first_choice,
3390            borda,
3391            winner,
3392            rankings: tops.len(),
3393            unanimous_initial,
3394            deliberated,
3395            changed_votes,
3396            unanimous_final,
3397            tie_break,
3398            judges: judges_total,
3399            present,
3400            quorum,
3401            met_quorum,
3402            uncontested,
3403        });
3404        self.state.status = if met_quorum {
3405            RunStatus::Reviewing
3406        } else {
3407            RunStatus::Stalled
3408        };
3409        self.state.save()?;
3410        Ok(())
3411    }
3412
3413    // ------------------------------------------------------------- recover
3414
3415    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3416    /// resumed toward completion once the transient cause clears.
3417    ///
3418    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3419    /// it toward the quorum, which is exactly the set of seats whose absence
3420    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3421    /// not depend on which node happened to hit the limit), or an ordinary
3422    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3423    /// seat is never disturbed.
3424    ///
3425    /// A seat that now answers with a usable ranking is "recovered": its
3426    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3427    /// `tally` counts it present again), and its vote re-collected. A seat that
3428    /// still fails keeps its loss and stays absent.
3429    ///
3430    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3431    /// to review/gate/merge), `false` when it is still below quorum (the run
3432    /// stays `Stalled`, still resumable for a later retry).
3433    #[allow(clippy::too_many_lines)]
3434    async fn recover_stall(&mut self) -> Result<bool> {
3435        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3436        // agent files with `magi task add` name the run that paid for it. The
3437        // prompt overlay is cloned alongside it because the waves borrow it
3438        // while `self` is mutably borrowed by the node's own bookkeeping.
3439        let run_id = self.state.id.clone();
3440        let prompts = self.state.config.prompts.clone();
3441        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3442        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3443        // is what keeps a non-quota collapse — or a quota loss recorded at the
3444        // deliberate node — from being a permanent dead-end on `--resume`.
3445        let quota_seats: BTreeSet<&str> =
3446            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3447        let absent: Vec<String> = self
3448            .state
3449            .judgements
3450            .iter()
3451            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3452            .map(|j| j.seat.clone())
3453            .collect();
3454        if absent.is_empty() {
3455            return Ok(false);
3456        }
3457        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3458        if viable.len() <= 1 {
3459            return Ok(false);
3460        }
3461        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3462        let language = self.state.config.graph.language.clone();
3463        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3464        let sessions = self.state.config.graph.sessions;
3465        let artifacts = agent::artifacts_dir(&self.state.dir());
3466        let root = self.state.worktree_root();
3467        let base_short = short(&self.state.base_commit);
3468        let candidates: Vec<Candidate> = viable.clone();
3469
3470        // Map each absent seat key to its 0-based position in `roles.judges`.
3471        let mut positions: Vec<usize> = absent
3472            .iter()
3473            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3474            .collect();
3475        if positions.is_empty() {
3476            return Ok(false);
3477        }
3478        positions.sort_unstable();
3479        positions.dedup();
3480
3481        // Re-rank the lost seats, one blind prompt each.
3482        let mut judge_jobs = Vec::new();
3483        for &j in &positions {
3484            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3485            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3486            let seat_key = format!("judge-{}", j + 1);
3487            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3488            let seat = self.seat(&seat_key, &spec.id);
3489            judge_jobs.push(SeatJob {
3490                spec,
3491                seat,
3492                prompt: prompt::judge(
3493                    &self.state.instruction,
3494                    &views,
3495                    self.roles.judges.len(),
3496                    &base_short,
3497                    &language,
3498                ),
3499                cwd: root.join(seat_key),
3500                timeout,
3501                allow_write: false,
3502                sessions,
3503                artifacts: artifacts.clone(),
3504                stem: format!("judge-{}-recover", j + 1),
3505                handover: None,
3506            });
3507        }
3508
3509        let labels_for_check = labels.clone();
3510        let mut judge_losses = Vec::new();
3511        let retries = self.state.config.graph.retries;
3512        let cache = self.state.config.cache_dir();
3513        let ctx = WaveCtx {
3514            carry_seats: false,
3515            pauses: Some((&self.pause, &self.interrupt)),
3516            run: &run_id,
3517            node: "judge",
3518            prompts: &prompts,
3519            cache: cache.as_deref(),
3520            round: None,
3521        };
3522        let results = ask_json_wave::<Ranking>(
3523            judge_jobs,
3524            Arc::clone(&self.sem),
3525            retries,
3526            &self.roles.judge_roster,
3527            &ctx,
3528            &mut judge_losses,
3529            &mut self.state,
3530            &move |r: &Ranking| r.validate(&labels_for_check),
3531        )
3532        .await;
3533        if self.state.resource_block.is_some() {
3534            self.stop_for_resource()?;
3535            return Ok(false);
3536        }
3537
3538        // Refresh the judgement of every seat that ranked again.
3539        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3540        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3541            let agent_id = seat.agent.clone();
3542            self.state.seats.insert(seat.key.clone(), seat);
3543            let record = &mut self.state.judgements[j];
3544            match res {
3545                Ok((ranking, out)) => {
3546                    record.agent = agent_id;
3547                    record.ranking = ranking.normalized();
3548                    record.reasons = ranking.reasons;
3549                    record.strengths = ranking.strengths;
3550                    record.confidence = ranking.confidence;
3551                    record.failed = None;
3552                    record.duration_ms = out.duration_ms;
3553                    recovered.insert(j);
3554                    self.state.event(
3555                        "recover",
3556                        format!("judge {} ranked again after the limit", j + 1),
3557                    );
3558                }
3559                Err(e) => {
3560                    self.state
3561                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3562                }
3563            }
3564        }
3565
3566        // Re-ask the votes of the seats that recovered a ranking.
3567        let mut vote_jobs = Vec::new();
3568        let mut vote_pos: Vec<usize> = Vec::new();
3569        for &j in &recovered {
3570            let seat_key = format!("judge-{}", j + 1);
3571            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3572            let seat = self.seat(&seat_key, &spec.id);
3573            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3574            let text = if has_context(&spec, &seat, sessions) {
3575                prompt::final_vote(&labels, &language)
3576            } else {
3577                full.clone()
3578            };
3579            vote_jobs.push(SeatJob {
3580                spec,
3581                seat,
3582                prompt: text,
3583                cwd: root.join(seat_key),
3584                timeout,
3585                allow_write: false,
3586                sessions,
3587                artifacts: artifacts.clone(),
3588                stem: format!("vote-judge-{}-recover", j + 1),
3589                handover: Some(full),
3590            });
3591            vote_pos.push(j);
3592        }
3593        let allowed = labels.clone();
3594        let mut vote_losses = Vec::new();
3595        let vote_retries = self.state.config.graph.retries;
3596        let vote_cache = self.state.config.cache_dir();
3597        let ctx = WaveCtx {
3598            carry_seats: false,
3599            pauses: Some((&self.pause, &self.interrupt)),
3600            run: &run_id,
3601            node: "vote",
3602            prompts: &prompts,
3603            cache: vote_cache.as_deref(),
3604            round: None,
3605        };
3606        let votes = ask_json_wave::<FinalVote>(
3607            vote_jobs,
3608            Arc::clone(&self.sem),
3609            vote_retries,
3610            &self.roles.judge_roster,
3611            &ctx,
3612            &mut vote_losses,
3613            &mut self.state,
3614            &move |v: &FinalVote| match v.label() {
3615                Some(c) if allowed.contains(&c) => Ok(()),
3616                other => bail!("vote {other:?} is not one of {allowed:?}"),
3617            },
3618        )
3619        .await;
3620        if self.state.resource_block.is_some() {
3621            self.stop_for_resource()?;
3622            return Ok(false);
3623        }
3624        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3625            let agent_id = seat.agent.clone();
3626            self.state.seats.insert(seat.key.clone(), seat);
3627            match res {
3628                Ok((v, _)) => {
3629                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3630                        rec.vote = v.label();
3631                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3632                    } else {
3633                        self.state.votes.push(VoteRecord {
3634                            judge: j + 1,
3635                            agent: agent_id,
3636                            vote: v.label(),
3637                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3638                            changed: false,
3639                        });
3640                    }
3641                    self.state.event(
3642                        "recover",
3643                        format!("judge {} voted again after the limit", j + 1),
3644                    );
3645                }
3646                Err(e) => {
3647                    self.state
3648                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3649                }
3650            }
3651        }
3652
3653        // A seat that ranked again is present even if its re-vote failed —
3654        // `tally` falls back to the initial ranking's first choice — so clear
3655        // its quota loss. Seats that still fail keep theirs and stay absent.
3656        let recovered_keys: BTreeSet<String> = recovered
3657            .iter()
3658            .map(|&j| format!("judge-{}", j + 1))
3659            .collect();
3660        self.state
3661            .quota
3662            .retain(|q| !recovered_keys.contains(&q.seat));
3663        // A seat that hit the limit again is a fresh loss, not the old one:
3664        // replace the stale entry so the history stays one-per-seat and the
3665        // daemon can tell this attempt's loss from a previous session's.
3666        for loss in judge_losses.into_iter().chain(vote_losses) {
3667            if recovered_keys.contains(&loss.seat) {
3668                continue;
3669            }
3670            self.state.quota.retain(|q| q.seat != loss.seat);
3671            self.state.quota.push(loss);
3672        }
3673
3674        // Recompute the verdict from the refreshed panel.
3675        self.state.tally = None;
3676        self.tally()?;
3677        Ok(self
3678            .state
3679            .tally
3680            .as_ref()
3681            .map(|t| t.met_quorum)
3682            .unwrap_or(false))
3683    }
3684
3685    // ---------------------------------------------------------------- graft
3686
3687    /// Port the strengths the judges attributed to the losing candidates onto
3688    /// the winner, once, between the tally and the review loop.
3689    ///
3690    /// Best-effort by construction: it returns nothing and fails nothing. A
3691    /// skip, a fixer that failed or hit quota, a round that changed nothing
3692    /// and a result over the size limit ([`graft::limit`]) all leave the
3693    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
3694    /// run carries on as if the phase were off. See [`graft`] for the
3695    /// decisions and `AGENTS.md` for the invariants.
3696    ///
3697    /// The one exception is a rollback that could not be confirmed: the
3698    /// rejected commit may still be the winner's tip, so the run is blocked
3699    /// (resumable; the record stays `Running`, which makes a resume retry the
3700    /// restore) instead of reviewing, gating and merging it.
3701    async fn graft(&mut self) -> Result<()> {
3702        let Err(e) = self.graft_inner().await else {
3703            return Ok(());
3704        };
3705        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3706            self.state.status = RunStatus::Blocked;
3707            self.state.event("graft", format!("blocked: {e:#}"));
3708            self.state.save()?;
3709            return Err(e);
3710        }
3711        // An error after the `Running` record was saved may have left the
3712        // fixer's work in the tree: the record must not stay unsettled
3713        // (a later resume would reset past the review fixes), so confirm the
3714        // restore now, or block.
3715        if let Some(rec) = self.state.graft.clone()
3716            && !rec.outcome.settled()
3717        {
3718            if let Some(winner) = self.state.winner().cloned() {
3719                if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3720                    self.state.status = RunStatus::Blocked;
3721                    self.state.event("graft", format!("blocked: {re:#}"));
3722                    self.state.save()?;
3723                    return Err(re);
3724                }
3725            }
3726            if let Some(g) = self.state.graft.as_mut() {
3727                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3728            }
3729        }
3730        self.state.event("graft", format!("skipped: {e:#}"));
3731        if let Err(e) = self.state.save() {
3732            tracing::warn!("save after graft: {e:#}");
3733        }
3734        Ok(())
3735    }
3736
3737    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
3738    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
3739    async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3740        let restored = git::restore_to(wt, pre).await;
3741        let tip = git::rev_parse(wt, "HEAD").await;
3742        match (restored, tip) {
3743            (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3744            (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3745            (Ok(()), Ok(tip)) => {
3746                Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3747            }
3748            (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3749        }
3750    }
3751
3752    /// A skip is a run event, recorded once however often the node is re-entered.
3753    fn graft_skipped(&mut self, why: &str) {
3754        let message = format!("skipped: {why}");
3755        if !self
3756            .state
3757            .events
3758            .iter()
3759            .any(|e| e.node == "graft" && e.message == message)
3760        {
3761            self.state.event("graft", message);
3762        }
3763    }
3764
3765    async fn graft_inner(&mut self) -> Result<()> {
3766        // A round that was started and never finished (crash, kill): the
3767        // fixer may have left anything in the tree, so put the winner back.
3768        if let Some(rec) = self.state.graft.clone()
3769            && !rec.outcome.settled()
3770        {
3771            // Reviews exist only if the graft ended without its record being
3772            // settled (a swallowed error): the tree has moved on since, and
3773            // a reset to `pre` would discard the review fixes.
3774            if self.state.reviews.is_empty()
3775                && let Some(winner) = self.state.winner().cloned()
3776            {
3777                Self::graft_restore(&winner.worktree, &rec.pre).await?;
3778            }
3779            if let Some(g) = self.state.graft.as_mut() {
3780                g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3781            }
3782            self.state.event(
3783                "graft",
3784                "rolled back: the previous round was interrupted; the winner is untouched",
3785            );
3786            self.state.save()?;
3787            return Ok(());
3788        }
3789        let plan = match graft::plan(&self.state) {
3790            Ok(plan) => plan,
3791            Err(skip) => {
3792                if skip != graft::Skip::AlreadyDone {
3793                    self.graft_skipped(skip.reason());
3794                    self.state.save()?;
3795                }
3796                return Ok(());
3797            }
3798        };
3799        let Some(winner) = self.state.winner().cloned() else {
3800            return Ok(());
3801        };
3802        let wt = winner.worktree.clone();
3803        if !git::is_clean(&wt).await? {
3804            self.graft_skipped("the winner's worktree has uncommitted changes");
3805            self.state.save()?;
3806            return Ok(());
3807        }
3808        let pre = git::rev_parse(&wt, "HEAD").await?;
3809        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3810        else {
3811            self.graft_skipped("the winner's diff could not be measured");
3812            self.state.save()?;
3813            return Ok(());
3814        };
3815        let limit = graft::limit(winner_lines);
3816        // Saved before the fixer is called: a stop from here on is repaired
3817        // by the branch above on the next entry.
3818        self.state.graft = Some(GraftRecord {
3819            pre: pre.clone(),
3820            post: None,
3821            lines: None,
3822            limit: Some(limit),
3823            agent: None,
3824            outcome: GraftOutcome::Running,
3825            requested: graft::requested(&plan),
3826            adoption: None,
3827            adoption_error: None,
3828        });
3829        self.state.event(
3830            "graft",
3831            format!(
3832                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3833                plan.strengths
3834                    .iter()
3835                    .map(|(l, _, _)| l.to_string())
3836                    .collect::<Vec<_>>()
3837                    .join(", "),
3838                plan.winner
3839            ),
3840        );
3841        self.state.save()?;
3842
3843        let fix_prompt = prompt::graft(
3844            &self.state.instruction,
3845            plan.winner,
3846            &plan.strengths,
3847            limit,
3848            &self.state.config.graph.language,
3849        );
3850        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3851        let sessions = self.state.config.graph.sessions;
3852        let artifacts = agent::artifacts_dir(&self.state.dir());
3853        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3854        let (_, seat, out) = self
3855            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3856                SeatJob {
3857                    prompt: fix_prompt.clone(),
3858                    spec,
3859                    seat,
3860                    cwd: wt.clone(),
3861                    timeout,
3862                    allow_write: true,
3863                    sessions,
3864                    artifacts: artifacts.clone(),
3865                    stem: "graft".to_owned(),
3866                    handover: None,
3867                }
3868            })
3869            .await;
3870        // Never spawned: nothing changed (`HEAD == pre` is confirmed), so the
3871        // record goes back to "not started" - not `RolledBack`, which would
3872        // settle it - and the run stops resumably. The seat, the chain and
3873        // the quota are untouched.
3874        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
3875            Self::graft_restore(&wt, &pre).await?;
3876            self.state.graft = None;
3877            self.state.save()?;
3878            return self.stop_for_resource();
3879        }
3880        let mut why: Option<String> = None;
3881        match &out {
3882            AgentOutcome::Ok(o) if agent::output_advances(o) => {
3883                why = Some("the fixer produced nothing usable".to_owned());
3884            }
3885            AgentOutcome::Ok(_) => {}
3886            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3887            AgentOutcome::Quota(o) => {
3888                self.state.quota.push(QuotaLoss {
3889                    seat: seat.key.clone(),
3890                    node: "graft".to_owned(),
3891                    at: Timestamp::now(),
3892                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3893                });
3894                why = Some("rate limited (quota)".to_owned());
3895            }
3896            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
3897                why = Some(format!(
3898                    "the fixer failed: {}",
3899                    e.lines()
3900                        .next()
3901                        .unwrap_or("")
3902                        .chars()
3903                        .take(160)
3904                        .collect::<String>()
3905                ));
3906            }
3907        }
3908        let agent_id = seat.agent.clone();
3909        self.state.seats.insert(seat.key.clone(), seat);
3910
3911        let mut post = None;
3912        let mut lines = None;
3913        if why.is_none() {
3914            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3915            if let Ok(r) = &rescued {
3916                self.state.note_withheld("graft", &r.withheld);
3917            }
3918            let head = git::rev_parse(&wt, "HEAD").await?;
3919            // What is measured must be what gets reviewed and gated: a failed
3920            // rescue, or anything still uncommitted (withheld lockfiles
3921            // aside, which are never part of the tree), rejects the graft.
3922            let dirty = match &rescued {
3923                Ok(r) => git::status_porcelain(&wt)
3924                    .await
3925                    .map(|st| {
3926                        st.lines().any(|l| {
3927                            !r.withheld
3928                                .iter()
3929                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
3930                        })
3931                    })
3932                    .unwrap_or(true),
3933                Err(_) => true,
3934            };
3935            if dirty {
3936                why = Some("the fixer left changes that could not be committed".to_owned());
3937            } else if head == pre {
3938                why = Some("the fixer changed nothing".to_owned());
3939            } else {
3940                lines = git::changed_lines(&wt, &pre, &head).await;
3941                if graft::within_budget(lines, limit) {
3942                    post = Some(head);
3943                } else {
3944                    why = Some(format!(
3945                        "{} changed lines against a limit of {limit}",
3946                        lines.map_or_else(
3947                            || "an unmeasurable number of".to_owned(),
3948                            |l| l.to_string()
3949                        )
3950                    ));
3951                }
3952            }
3953        }
3954        let (adoption, adoption_error) = match &out {
3955            AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
3956            _ => (None, None),
3957        };
3958        let requested = graft::requested(&plan);
3959        let outcome = match why {
3960            None => {
3961                self.state.event(
3962                    "graft",
3963                    format!(
3964                        "applied: {} changed lines (limit {limit})",
3965                        lines.unwrap_or(0)
3966                    ),
3967                );
3968                GraftOutcome::Applied
3969            }
3970            Some(why) => {
3971                // Not settled until the restore is confirmed: the record
3972                // stays `Running`, so a resume retries it.
3973                Self::graft_restore(&wt, &pre).await?;
3974                self.state.event(
3975                    "graft",
3976                    format!("rolled back: {why}; the winner is untouched"),
3977                );
3978                GraftOutcome::RolledBack(why)
3979            }
3980        };
3981        self.state.graft = Some(GraftRecord {
3982            pre,
3983            post,
3984            lines,
3985            limit: Some(limit),
3986            agent: Some(agent_id),
3987            outcome,
3988            requested,
3989            adoption,
3990            adoption_error,
3991        });
3992        self.state.save()?;
3993        Ok(())
3994    }
3995
3996    // ----------------------------------------------------------------- fold
3997
3998    async fn fold_losers(&mut self) -> Result<()> {
3999        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4000            return Ok(());
4001        };
4002        let repo = self.state.repo.clone();
4003        let mut folded = Vec::new();
4004        for i in 0..self.state.candidates.len() {
4005            let c = &self.state.candidates[i];
4006            if c.label == winner || c.folded {
4007                continue;
4008            }
4009            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4010            git::worktree_remove(&repo, &wt).await.ok();
4011            git::branch_delete(&repo, &branch).await.ok();
4012            self.state.candidates[i].folded = true;
4013            folded.push(label.to_string());
4014        }
4015        // The judges are finished; their checkouts are pure cost from here.
4016        let root = self.state.worktree_root();
4017        for j in 1..=self.roles.judges.len() {
4018            let wt = root.join(format!("judge-{j}"));
4019            if wt.exists() {
4020                git::worktree_remove(&repo, &wt).await.ok();
4021            }
4022        }
4023        // The design-deliberation stage is finished by the time a tally
4024        // exists — same reasoning as the judges above.
4025        if self.state.config.graph.advise {
4026            for k in 1..=self.state.config.graph.advisors {
4027                let wt = root.join(format!("advisor-{k}"));
4028                if wt.exists() {
4029                    git::worktree_remove(&repo, &wt).await.ok();
4030                }
4031            }
4032        }
4033        if !folded.is_empty() {
4034            self.state
4035                .event("fold", format!("folded candidates {}", folded.join(", ")));
4036            self.state.save()?;
4037        }
4038        Ok(())
4039    }
4040
4041    // ------------------------------------------------------------ base sync
4042
4043    /// Land the winner's tree on the current tip of `<remote>/<base>` before
4044    /// anything verifies it.
4045    ///
4046    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
4047    /// read whatever is checked out in the winner's worktree. Left alone that
4048    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
4049    /// it when the run *branched* - and a run takes long enough that the base
4050    /// has usually moved by the time it gets here. A gate that ran there
4051    /// answers "green on the commit this run started from", not "green on
4052    /// what is about to land", and the difference showed up three times in
4053    /// one day as a green run whose merge would have reverted a file another
4054    /// pull request had already landed.
4055    ///
4056    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
4057    /// `land::Step::Rebase` calls, rather than a second implementation of the
4058    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
4059    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
4060    /// doc warns about ("two answers to a question nobody notices until a
4061    /// diff is wrong").
4062    ///
4063    /// A conflict is not the end of the road: the standing rebase is handed
4064    /// to the fixer seat, at most `graph.review_rounds` times, counted in
4065    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
4066    /// land). Once it finishes, review and the gate run as usual on the
4067    /// rebased tree, which is where a breakage the new base caused is caught
4068    /// by the ordinary gate-fix round. magi resolves nothing itself.
4069    ///
4070    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
4071    /// `state.base_sync.attempts`, is how many times the base is *rebased
4072    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
4073    /// *conflict* was given to a fixer. When the fixer cannot finish the
4074    /// rebase the branch is restored, `state.base_sync.conflict` is set with
4075    /// what was tried (rounds spent, paths still conflicted) and the branch
4076    /// and worktree stay exactly as they were - untouched, for a person to
4077    /// look at - which is also what makes re-entering this function
4078    /// afterwards a no-op instead of a second attempt at the same wall. A
4079    /// push failure ends the same way.
4080    async fn sync_to_base(&mut self) -> Result<()> {
4081        if self.state.status == RunStatus::AlreadyInBase {
4082            return Ok(());
4083        }
4084        let conflicted = self
4085            .state
4086            .base_sync
4087            .as_ref()
4088            .is_some_and(|s| s.conflict.is_some());
4089        let Some(winner) = self.state.winner().cloned() else {
4090            return Ok(());
4091        };
4092
4093        let repo = self.state.repo.clone();
4094        let remote = self.state.config.merge.remote.clone();
4095        let base_branch = self.state.base_branch.clone();
4096        let tracking = format!("{remote}/{base_branch}");
4097
4098        git::fetch(&repo, &remote, &base_branch).await.ok();
4099        // No network, or the remote never had this branch: the run already
4100        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
4101        // otherwise), and one that got this far is not blocked by a fetch
4102        // that fails now. It just has no newer tip to compare against.
4103        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4104            return Ok(());
4105        };
4106
4107        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4108        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4109        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4110
4111        // Before any rebase, and before a recorded conflict is honoured: a
4112        // branch whose change reached the base under other commit ids has
4113        // nothing to rebase and nothing to conflict with, and a run that
4114        // already stopped on that phantom conflict recovers here on resume.
4115        // `behind == 0` with head == tip is a branch the base has since taken
4116        // in whole, whether or not a conflict was ever recorded: the ancestry
4117        // proof must still run (`classify` ignores a head still on the start
4118        // commit).
4119        if (behind > 0 || conflicted || head == tip)
4120            && self
4121                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4122                .await?
4123        {
4124            return Ok(());
4125        }
4126        if conflicted {
4127            return Ok(());
4128        }
4129
4130        if behind == 0 {
4131            // A fixer-finished rebase moves the branch ref before the
4132            // winner's worktree is told (`sync_to_head` below). A run that
4133            // died in between resumes here with `behind == 0` and a tree still
4134            // holding the pre-rebase files, which review and the gate would
4135            // then read. That state is exactly: HEAD moved off the tip the
4136            // rebase started from, yet the tree is still identical to that
4137            // tip. A tree with edits of its own differs from it, so nothing
4138            // is thrown away.
4139            if let Some(from) = self
4140                .state
4141                .rebase_fixes
4142                .iter()
4143                .rev()
4144                .find_map(|r| r.from.clone())
4145                && from != head
4146                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4147                    .await
4148                    .is_ok_and(|o| o.ok())
4149            {
4150                git::sync_to_head(&winner.worktree).await?;
4151            }
4152            // An earlier attempt may have rebased the branch locally and died
4153            // before pushing it (only the fresh-rebase arm below pushes).
4154            // Publish it now, so the plain push at PR time is not refused as
4155            // a non-fast-forward. A run already holding a recorded conflict
4156            // never reaches here; that case is out of scope.
4157            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4158            if let Some(why) = &conflict {
4159                self.state.status = RunStatus::Blocked;
4160                self.state.event("land", why.clone());
4161            }
4162            self.state.base_sync = Some(BaseSync {
4163                tip,
4164                behind: 0,
4165                attempts,
4166                conflict,
4167                already_in: None,
4168            });
4169            self.state.save()?;
4170            return Ok(());
4171        }
4172
4173        if attempts >= BASE_SYNC_ROUNDS {
4174            let why = format!(
4175                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4176                 rebase(s); rebasing again would only race it",
4177                winner.branch
4178            );
4179            self.state.status = RunStatus::Blocked;
4180            self.state.base_sync = Some(BaseSync {
4181                tip,
4182                behind,
4183                attempts,
4184                conflict: Some(why.clone()),
4185                already_in: None,
4186            });
4187            self.state.event("land", why);
4188            self.state.save()?;
4189            return Ok(());
4190        }
4191
4192        self.state.event(
4193            "land",
4194            format!(
4195                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4196                winner.branch
4197            ),
4198        );
4199        self.state.save()?;
4200
4201        // The remote's copy of the branch, read now and only if the fetch
4202        // really succeeded (a stale tracking ref must never pin a lease). It is
4203        // pushed over after a rebase only when it is a commit this branch
4204        // already contains, by ancestry or by patch (an earlier rebase of ours
4205        // that never reached the remote): anything else is somebody else's work.
4206        let branch_tracking = format!("{remote}/{}", winner.branch);
4207        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4208        let remote_tip = if self.state.task_pr_branch().is_none()
4209            && matches!(&fetched_branch, Ok(o) if o.ok())
4210        {
4211            git::rev_parse(&repo, &branch_tracking).await.ok()
4212        } else {
4213            None
4214        };
4215        // A remote tip this branch does not contain is somebody else's work:
4216        // rebasing would leave a local tip that can never be pushed. Stop
4217        // before touching anything and say so.
4218        if let Some(theirs) = &remote_tip
4219            && !git::is_ancestor(&repo, theirs, &head).await
4220            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4221                .await
4222                .is_ok_and(|missing| missing.is_empty())
4223        {
4224            let why = format!(
4225                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4226                 them",
4227                short(theirs),
4228                winner.branch
4229            );
4230            self.state.status = RunStatus::Blocked;
4231            self.state.base_sync = Some(BaseSync {
4232                tip,
4233                behind,
4234                attempts,
4235                conflict: Some(why.clone()),
4236                already_in: None,
4237            });
4238            self.state.event("land", why);
4239            self.state.save()?;
4240            return Ok(());
4241        }
4242
4243        let scratch = self.state.dir().join("base-sync");
4244        let rebased = match crate::rebase::rebase_with_fixer(
4245            &mut self.state,
4246            &scratch,
4247            &winner.branch,
4248            &tracking,
4249        )
4250        .await
4251        {
4252            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4253            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4254            Err(e) => Err(e),
4255        };
4256        let attempts = attempts + 1;
4257        match rebased {
4258            Ok(None) => {
4259                // The branch ref moved, but a worktree that already had it
4260                // checked out (the winner's) was not told; sync its index and
4261                // files before anything reads them.
4262                git::sync_to_head(&winner.worktree).await?;
4263                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4264                let mut conflict = None;
4265                if let Some(pinned) = &remote_tip {
4266                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4267                    match pushed {
4268                        Ok(o) if o.ok() => self.state.event(
4269                            "land",
4270                            format!("pushed rebased {} to {remote}", winner.branch),
4271                        ),
4272                        Ok(o) => {
4273                            conflict = Some(format!(
4274                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4275                                winner.branch,
4276                                short(pinned),
4277                                o.stderr.chars().take(600).collect::<String>()
4278                            ));
4279                        }
4280                        Err(e) => {
4281                            conflict = Some(format!(
4282                                "rebased {} locally but could not push it: {e:#}",
4283                                winner.branch
4284                            ));
4285                        }
4286                    }
4287                }
4288                if let Some(why) = &conflict {
4289                    self.state.status = RunStatus::Blocked;
4290                    self.state.event("land", why.clone());
4291                }
4292                self.state.base_sync = Some(BaseSync {
4293                    tip: tip.clone(),
4294                    behind: 0,
4295                    attempts,
4296                    conflict,
4297                    already_in: None,
4298                });
4299                self.state
4300                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4301            }
4302            Ok(Some(conflict)) => {
4303                let why = format!(
4304                    "{} conflicts with {tracking} and did not rebase: {}",
4305                    winner.branch,
4306                    conflict.chars().take(600).collect::<String>()
4307                );
4308                self.state.status = RunStatus::Blocked;
4309                self.state.base_sync = Some(BaseSync {
4310                    tip,
4311                    behind,
4312                    attempts,
4313                    conflict: Some(why.clone()),
4314                    already_in: None,
4315                });
4316                self.state.event("land", why);
4317            }
4318            Err(e) => {
4319                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4320                self.state.status = RunStatus::Blocked;
4321                self.state.base_sync = Some(BaseSync {
4322                    tip,
4323                    behind,
4324                    attempts,
4325                    conflict: Some(why.clone()),
4326                    already_in: None,
4327                });
4328                self.state.event("land", why);
4329            }
4330        }
4331        self.state.save()?;
4332        Ok(())
4333    }
4334
4335    /// Push a branch an earlier attempt rebased locally but never published,
4336    /// pinned to the remote tip read right after a successful fetch. Returns
4337    /// the reason when the run must stop; `None` when there was nothing to do
4338    /// (no remote copy, the same tip, or a remote copy this branch already
4339    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4340    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4341        // Task competitions publish only after the PR text gate, to their
4342        // public head; never resurrect a candidate branch on the remote.
4343        if self.state.task_pr_branch().is_some() {
4344            return None;
4345        }
4346        let repo = self.state.repo.clone();
4347        let remote = self.state.config.merge.remote.clone();
4348        let fetched = git::fetch(&repo, &remote, branch).await;
4349        if !matches!(&fetched, Ok(o) if o.ok()) {
4350            return None;
4351        }
4352        let branch_tracking = format!("{remote}/{branch}");
4353        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4354        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4355            return None;
4356        }
4357        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4358            .await
4359            .is_ok_and(|missing| missing.is_empty())
4360        {
4361            return Some(format!(
4362                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4363                 them",
4364                short(&theirs)
4365            ));
4366        }
4367        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4368            Ok(o) if o.ok() => {
4369                self.state
4370                    .event("land", format!("pushed rebased {branch} to {remote}"));
4371                None
4372            }
4373            Ok(o) => Some(format!(
4374                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4375                 someone may have pushed): {}",
4376                short(&theirs),
4377                o.stderr.chars().take(600).collect::<String>()
4378            )),
4379            Err(e) => Some(format!(
4380                "{branch} is rebased locally but could not be pushed: {e:#}"
4381            )),
4382        }
4383    }
4384
4385    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4386    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4387    /// whether it did.
4388    ///
4389    /// Checked only when the base is ahead of the branch. A failing check is
4390    /// "not proven" - the ordinary rebase path then decides - never a reason to
4391    /// stop the run.
4392    ///
4393    /// The remote copy of the branch is held to the same standard as the local
4394    /// one: if it carries a tip this worktree does not, that tip must itself be
4395    /// proven in the base, or nothing is settled (a pull request would
4396    /// otherwise be closed over commits nobody checked). The pull request is
4397    /// closed *before* the terminal status is saved; if that fails for a
4398    /// reason other than a refusal (no network, a `gh` error) the run is left
4399    /// `Blocked` with the reason as its conflict, which a resume retries -
4400    /// the same recovery a phantom conflict gets.
4401    async fn settle_already_in(
4402        &mut self,
4403        branch: &str,
4404        tip: &str,
4405        head: &str,
4406        attempts: usize,
4407        behind: usize,
4408    ) -> Result<bool> {
4409        let repo = self.state.repo.clone();
4410        let remote = self.state.config.merge.remote.clone();
4411        let start = self.state.base_commit.clone();
4412        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4413            Ok(Some(e)) => e,
4414            Ok(None) => return Ok(false),
4415            Err(e) => {
4416                tracing::warn!("already-in-base check for {branch}: {e:#}");
4417                return Ok(false);
4418            }
4419        };
4420        let mut verified = vec![head.to_owned()];
4421        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4422        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4423        if matches!(&fetched, Ok(o) if o.ok())
4424            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4425            && theirs != head
4426        {
4427            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4428                Ok(Some(_)) => verified.push(theirs),
4429                _ => return Ok(false),
4430            }
4431        }
4432        let base_branch = self.state.base_branch.clone();
4433        let message = format!(
4434            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4435             land",
4436            evidence.names(),
4437            evidence.proof.as_str()
4438        );
4439        let closed =
4440            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4441                .await;
4442        match closed {
4443            Ok(Ok(url)) => self
4444                .state
4445                .event("land", format!("closed {url}: superseded on {base_branch}")),
4446            Ok(Err(why)) => self
4447                .state
4448                .event("land", format!("did not close a pull request: {why}")),
4449            Err(e) => {
4450                let why = format!(
4451                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4452                     not be closed ({e:#}); resume to retry"
4453                );
4454                self.state.status = RunStatus::Blocked;
4455                self.state.base_sync = Some(BaseSync {
4456                    tip: tip.to_owned(),
4457                    behind,
4458                    attempts,
4459                    conflict: Some(why.clone()),
4460                    already_in: None,
4461                });
4462                self.state.event("land", why);
4463                self.state.save()?;
4464                return Ok(true);
4465            }
4466        }
4467        self.state.status = RunStatus::AlreadyInBase;
4468        self.state.base_sync = Some(BaseSync {
4469            tip: tip.to_owned(),
4470            behind,
4471            attempts,
4472            conflict: None,
4473            already_in: Some(evidence),
4474        });
4475        self.state.event("land", message);
4476        self.state.save()?;
4477        self.settle_questions();
4478        Ok(true)
4479    }
4480
4481    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4482    /// last landed the winner on, once it has run, else the commit the run
4483    /// branched from.
4484    ///
4485    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4486    /// and `vote` all happen before there is a winner to rebase, so they
4487    /// compare every candidate against the branch point on purpose, and a
4488    /// base that moves after they are already done cannot change an answer
4489    /// they already gave.
4490    fn landing_base(&self) -> String {
4491        self.state
4492            .base_sync
4493            .as_ref()
4494            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4495    }
4496
4497    // ------------------------------------------------------- operator fix
4498
4499    /// Route specific, already-recorded review findings to a fixer for a
4500    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4501    /// entry point.
4502    ///
4503    /// Distinct from `review_loop`'s own fix step in three ways: it never
4504    /// runs a reviewer wave, it never spends review-round budget, and what
4505    /// happened is recorded as an [`OperatorFixRequest`] appended to
4506    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4507    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4508    /// and vote must never be rewritten to look like a manufactured blocking
4509    /// verdict.
4510    ///
4511    /// Only meaningful once review has actually concluded: `Ready` (handed
4512    /// off with findings still open, or simply concluded clean while minor
4513    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4514    /// gate failed). Everything else is refused: a run still in progress
4515    /// should simply be resumed, and a `Merged` run's branch has already
4516    /// landed — reopening *this* run's own record cannot change that, so the
4517    /// answer there is a fresh `magi review <branch>`.
4518    ///
4519    /// A real commit here re-verifies through a fresh, ordinary review-only
4520    /// run on the same branch ([`Self::review`]) rather than reopening this
4521    /// run's own `review_loop`: once any round in this run's history went
4522    /// clean, `review_conclusion` treats that as permanent by design (the
4523    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4524    /// way to force one more genuine reviewer wave out of *this* run without
4525    /// either rewriting history or weakening that guarantee for every other
4526    /// caller. A review-only run costs nothing extra — no implementation, no
4527    /// judging, no vote — and exercises the exact same review → verify →
4528    /// gate → (human) merge path, unmodified.
4529    pub async fn fix_selected(
4530        &mut self,
4531        ids: &[String],
4532        reason: &str,
4533        allow_stale: bool,
4534    ) -> Result<()> {
4535        let reason = reason.trim();
4536        if reason.is_empty() {
4537            bail!("a fix request needs a reason — that is the operator's own record of why");
4538        }
4539        if ids.is_empty() {
4540            bail!("no finding id given");
4541        }
4542        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4543            bail!(
4544                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4545                 has already concluded — can be given a targeted fix. A run still \
4546                 in progress should simply be resumed; a `merged` run's branch has \
4547                 already landed, so its answer is a fresh `magi review <branch>`, \
4548                 not reopening this run's own record",
4549                self.state.id,
4550                self.state.status.as_str()
4551            );
4552        }
4553        let Some(winner) = self.state.winner().cloned() else {
4554            bail!("run {} has no winning candidate to fix", self.state.id);
4555        };
4556        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4557            bail!(
4558                "branch `{}` no longer exists; this run cannot be extended",
4559                winner.branch
4560            );
4561        }
4562        let home = crate::run::home();
4563        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4564            bail!(
4565                "run {} is currently being worked on by another magi process",
4566                self.state.id
4567            );
4568        }
4569        // Held for the rest of this call, including the follow-up review
4570        // below: two `magi fix` invocations against the same run must not
4571        // both reach the worktree manipulation further down, which would
4572        // otherwise race to remove and recreate the same directory — see
4573        // [`FixClaim`]'s own doc.
4574        let _claim = FixClaim::acquire(&self.state.dir())?;
4575
4576        // Resolve every id before spending anything — an unknown id refuses
4577        // the whole request rather than silently dropping it — and dedup
4578        // while keeping the operator's own order.
4579        let mut seen = BTreeSet::new();
4580        let mut findings = Vec::new();
4581        let mut missing = Vec::new();
4582        for id in ids {
4583            if !seen.insert(id.clone()) {
4584                continue;
4585            }
4586            match self.state.finding(id) {
4587                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4588                    id: f.id.clone(),
4589                    severity: f.severity,
4590                    reviewer_vote: rec.vote,
4591                    round: round.round,
4592                    round_head: round.head.clone(),
4593                    reviewer: rec.reviewer,
4594                    agent: rec.agent.clone(),
4595                    file: f.file.clone(),
4596                    line: f.line,
4597                    title: f.title.clone(),
4598                    detail: f.detail.clone(),
4599                    outcome: OperatorFixOutcome::Pending,
4600                }),
4601                None => missing.push(id.clone()),
4602            }
4603        }
4604        if !missing.is_empty() {
4605            bail!(
4606                "unknown finding id(s): {}; nothing was changed",
4607                missing.join(", ")
4608            );
4609        }
4610
4611        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4612        let stale_details: Vec<(String, String)> = findings
4613            .iter()
4614            .filter(|f| f.round_head != head_at_request)
4615            .map(|f| (f.id.clone(), f.round_head.clone()))
4616            .collect();
4617        let stale = !stale_details.is_empty();
4618        if stale && !allow_stale {
4619            bail!(
4620                "the branch has moved since some finding(s) were raised — {} — now \
4621                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4622                stale_details
4623                    .iter()
4624                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4625                    .collect::<Vec<_>>()
4626                    .join(", "),
4627                short(&head_at_request)
4628            );
4629        }
4630
4631        let request = OperatorFixRequest {
4632            requested_at: Timestamp::now(),
4633            reason: reason.to_owned(),
4634            findings,
4635            head_at_request: head_at_request.clone(),
4636            allow_stale,
4637            stale,
4638            fix: None,
4639            result_head: None,
4640            follow_up_review_run: None,
4641        };
4642        self.state.event(
4643            "fix",
4644            format!(
4645                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4646                request.findings.len(),
4647                request
4648                    .findings
4649                    .iter()
4650                    .map(|f| f.id.as_str())
4651                    .collect::<Vec<_>>()
4652                    .join(", "),
4653            ),
4654        );
4655        // Recorded now, before any worktree work or the fixer call itself —
4656        // and re-saved at each checkpoint below: a crash at any point after
4657        // this (mid fixer call, mid follow-up review) must not lose the fact
4658        // that this was requested, for which findings, and why. Everything
4659        // past this point reads and writes through `request_index` rather
4660        // than a local variable, since `request` itself is moved here.
4661        self.state.operator_fixes.push(request);
4662        self.state.save()?;
4663        let request_index = self.state.operator_fixes.len() - 1;
4664
4665        // A fresh, dedicated worktree for this one call, never the winner's
4666        // own worktree in place: that one may already be gone (folded away),
4667        // and reusing it in place would leave the branch checked out there
4668        // when the follow-up review below tries to check it out again. Freed
4669        // immediately after, either way — but only once confirmed clean:
4670        // `worktree_remove` is a `git worktree remove --force`, which would
4671        // otherwise discard uncommitted work left there by the operator or
4672        // another process before this had a chance to even look at it.
4673        if winner.worktree.exists() {
4674            // Lockfiles a rescue commit withheld stay untracked on purpose and
4675            // are already recorded; they are not the operator's work to protect.
4676            let dirty = git::git(
4677                &winner.worktree,
4678                &["status", "--porcelain", "--untracked-files=all"],
4679            )
4680            .await?;
4681            let only_withheld = dirty.lines().all(|l| {
4682                l.strip_prefix("?? ")
4683                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4684            });
4685            if !only_withheld {
4686                bail!(
4687                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4688                     discard them first",
4689                    winner.worktree.display()
4690                );
4691            }
4692            git::worktree_remove(&self.state.repo, &winner.worktree)
4693                .await
4694                .ok();
4695        }
4696        let fix_worktree = self.state.worktree_root().join("operator-fix");
4697        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4698        git::git(
4699            &self.state.repo,
4700            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4701        )
4702        .await
4703        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4704        if !git::is_clean(&fix_worktree).await? {
4705            git::worktree_remove(&self.state.repo, &fix_worktree)
4706                .await
4707                .ok();
4708            bail!(
4709                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4710                winner.branch
4711            );
4712        }
4713        let repo_for_setup = self.state.repo.clone();
4714        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4715            Ok(()) => {
4716                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4717            }
4718            Err(e) => Err(e),
4719        };
4720        if let Err(e) = setup {
4721            git::worktree_remove(&self.state.repo, &fix_worktree)
4722                .await
4723                .ok();
4724            return Err(e);
4725        }
4726
4727        let run_id = self.state.id.clone();
4728        let prompts = self.state.config.prompts.clone();
4729        let language = self.state.config.graph.language.clone();
4730        let sessions = self.state.config.graph.sessions;
4731        let artifacts = agent::artifacts_dir(&self.state.dir());
4732        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4733            .findings
4734            .iter()
4735            .map(|f| Finding {
4736                id: f.id.clone(),
4737                severity: f.severity,
4738                file: f.file.clone(),
4739                line: f.line,
4740                title: f.title.clone(),
4741                detail: f.detail.clone(),
4742            })
4743            .collect();
4744        let fix_prompt = prompt::operator_fix_in(
4745            &self.state.instruction,
4746            &finding_list,
4747            reason,
4748            &stale_details,
4749            &head_at_request,
4750            &language,
4751            &self.state.config.graph.github_language,
4752        );
4753        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4754        let (job, seat, out) = self
4755            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4756                prompt: fix_prompt.clone(),
4757                spec,
4758                seat,
4759                cwd: fix_worktree.clone(),
4760                timeout,
4761                allow_write: true,
4762                sessions,
4763                artifacts: artifacts.clone(),
4764                stem: "operator-fix".to_owned(),
4765                handover: None,
4766            })
4767            .await;
4768        let agent_id = seat.agent.clone();
4769
4770        let mut fix = FixRecord {
4771            agent: agent_id,
4772            addressed: Vec::new(),
4773            rejected: Vec::new(),
4774            notes: String::new(),
4775            committed: false,
4776            failed: None,
4777            duration_ms: 0,
4778            continuation: None,
4779        };
4780        let mut final_seat = seat.clone();
4781        match out {
4782            AgentOutcome::Ok(o) => {
4783                fix.duration_ms = o.duration_ms;
4784                let parsed = verdict::extract_json::<FixReport>(&o.text);
4785                let incomplete_reason = match &parsed {
4786                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4787                        "the reply parsed, but it reported a command whose own CLI \
4788                         never confirmed an exit status"
4789                            .to_owned(),
4790                    ),
4791                    Ok(_) => None,
4792                    Err(e) => Some(e.to_string()),
4793                };
4794                match incomplete_reason {
4795                    None => {
4796                        let report = parsed.expect("checked Ok above");
4797                        fix.addressed = report.addressed;
4798                        fix.rejected = report.rejected;
4799                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4800                    }
4801                    Some(reason) => {
4802                        let (resumed_seat, resolved, failure, cont) = self
4803                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4804                            .await;
4805                        fix.duration_ms += cont.cumulative_wait_ms;
4806                        fix.continuation = Some(cont);
4807                        final_seat = resumed_seat;
4808                        match resolved {
4809                            Some(report) => {
4810                                fix.addressed = report.addressed;
4811                                fix.rejected = report.rejected;
4812                                fix.notes =
4813                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4814                            }
4815                            None => fix.failed = failure,
4816                        }
4817                    }
4818                }
4819            }
4820            AgentOutcome::Dropped(o) => {
4821                fix.duration_ms = o.duration_ms;
4822                let why = o
4823                    .dropped
4824                    .as_ref()
4825                    .map(|d| d.why.as_str())
4826                    .unwrap_or("the CLI ended the stream without delivering its answer");
4827                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4828            }
4829            AgentOutcome::Quota(o) => {
4830                self.state.quota.push(QuotaLoss {
4831                    seat: final_seat.key.clone(),
4832                    node: "fix".to_owned(),
4833                    at: Timestamp::now(),
4834                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4835                });
4836                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4837            }
4838            AgentOutcome::Failed(e) => fix.failed = Some(e),
4839            AgentOutcome::ResourceBlocked(e) => {
4840                fix.failed = Some(format!("the fixer was not started: {e}"));
4841            }
4842        }
4843        if fix.continuation.is_none() {
4844            fix.continuation = Some(ContinuationRecord::not_needed());
4845        }
4846        self.state.seats.insert(final_seat.key.clone(), final_seat);
4847
4848        let rescue_message = format!(
4849            "magi: operator-selected fix ({}) (uncommitted work)",
4850            self.state.operator_fixes[request_index]
4851                .findings
4852                .iter()
4853                .map(|f| f.id.as_str())
4854                .collect::<Vec<_>>()
4855                .join(", ")
4856        );
4857        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4858            self.state.note_withheld("fix", &r.withheld);
4859        }
4860        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4861        fix.committed = after != head_at_request;
4862        git::worktree_remove(&self.state.repo, &fix_worktree)
4863            .await
4864            .ok();
4865
4866        self.state.event(
4867            "fix",
4868            match &fix.failed {
4869                Some(reason) => format!(
4870                    "operator fix: adoption report was lost ({reason}); {}",
4871                    if fix.committed {
4872                        "committed"
4873                    } else {
4874                        "NO new commit"
4875                    }
4876                ),
4877                None => format!(
4878                    "operator fix: {} addressed, {} rejected, {}",
4879                    fix.addressed.len(),
4880                    fix.rejected.len(),
4881                    if fix.committed {
4882                        "committed"
4883                    } else {
4884                        "NO new commit"
4885                    }
4886                ),
4887            },
4888        );
4889
4890        // Every selected finding gets an outcome — never left `Pending` once
4891        // the fixer's own turn is over. A report that never came back at all
4892        // marks every one of them `Unreported`, not silently "not addressed":
4893        // quota, a dropped stream, or an exhausted continuation are gaps in
4894        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4895        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4896        for f in &mut self.state.operator_fixes[request_index].findings {
4897            f.outcome = if fix.failed.is_some() {
4898                OperatorFixOutcome::Unreported
4899            } else if fix.addressed.contains(&f.id) {
4900                OperatorFixOutcome::Addressed
4901            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4902                OperatorFixOutcome::Rejected { why: r.why.clone() }
4903            } else {
4904                OperatorFixOutcome::Unreported
4905            };
4906        }
4907
4908        let committed = fix.committed;
4909        if committed {
4910            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4911        }
4912        self.state.operator_fixes[request_index].fix = Some(fix);
4913        // Saved again now that the fixer's own outcome is final, on top of
4914        // the save right after the request was first pushed above.
4915        self.state.save()?;
4916
4917        if committed {
4918            self.state.event(
4919                "fix",
4920                format!(
4921                    "operator fix committed {}; opening a follow-up review-only run",
4922                    short(&after)
4923                ),
4924            );
4925            // The operator asked for the fix, and the follow-up serves whatever
4926            // task the run it follows served.
4927            let origin =
4928                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4929            match Self::review(
4930                &self.state.repo,
4931                &winner.branch,
4932                self.state.config.clone(),
4933                origin,
4934            )
4935            .await
4936            {
4937                Ok(mut follow_up) => {
4938                    follow_up.state.event(
4939                        "start",
4940                        format!(
4941                            "requested by an operator fix on run {} for finding(s) {}",
4942                            self.state.id,
4943                            self.state.operator_fixes[request_index]
4944                                .findings
4945                                .iter()
4946                                .map(|f| f.id.as_str())
4947                                .collect::<Vec<_>>()
4948                                .join(", "),
4949                        ),
4950                    );
4951                    follow_up.state.save()?;
4952                    let follow_up_id = follow_up.state.id.clone();
4953                    // The follow-up is a run like any other: it belongs to the
4954                    // task of the run it follows, or to one filed for it.
4955                    let adopted = match crate::direct::adopt(
4956                        &follow_up.state,
4957                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4958                    ) {
4959                        Ok(a) => a,
4960                        Err(e) => {
4961                            // An ownerless run must not spend agent calls; it
4962                            // stays saved, and `magi run --resume` adopts it.
4963                            self.state.event(
4964                                "fix",
4965                                format!(
4966                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4967                                ),
4968                            );
4969                            self.state.save()?;
4970                            return Ok(());
4971                        }
4972                    };
4973                    let executed = follow_up.execute().await;
4974                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4975                    if let Some(a) = adopted {
4976                        a.finish(&follow_up.state, executed);
4977                    }
4978                    if let Some(e) = failure {
4979                        self.state.event(
4980                            "fix",
4981                            format!(
4982                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4983                            ),
4984                        );
4985                    }
4986                    self.state.operator_fixes[request_index].follow_up_review_run =
4987                        Some(follow_up_id);
4988                }
4989                Err(e) => {
4990                    self.state.event(
4991                        "fix",
4992                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4993                    );
4994                }
4995            }
4996            self.state.save()?;
4997        }
4998
4999        Ok(())
5000    }
5001
5002    // --------------------------------------------------------------- review
5003
5004    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
5005    /// names, in order, each at most once, moving on only when the call
5006    /// advances (an error, a quota hit or nothing usable - the same decision
5007    /// point `agent::chain_advances` is for the other chained roles).
5008    ///
5009    /// Each agent gets its own seat from `Runner::seat`: the same agent
5010    /// continues its conversation (the winner's own implementer seat when it
5011    /// is the winner's author, now that the competition is over), another
5012    /// takes a fresh one so the full prompt is sent again. A handover is
5013    /// recorded under `node`, which is what makes the fallback stick for the
5014    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
5015    /// is returned, so an exhausted chain reads exactly like a single failed
5016    /// fixer: one quota loss, the same wording, the same refund. An earlier
5017    /// attempt's edits are left in the tree and judged, with the final
5018    /// attempt's, by what git says afterwards.
5019    ///
5020    /// Returns the job that produced the outcome, for `continue_fix_report`.
5021    async fn ask_fixer(
5022        &mut self,
5023        winner: &Candidate,
5024        node: &'static str,
5025        round: Option<usize>,
5026        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5027    ) -> (SeatJob, SeatState, AgentOutcome) {
5028        let attempts = fixer::attempts(&self.state, &self.roles, winner);
5029        self.ask_fixer_among(attempts, node, round, None, build)
5030            .await
5031    }
5032
5033    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
5034    /// worktree and a commit: before the next agent is asked after one that
5035    /// did not satisfy the call, the worktree is put back on that commit, so
5036    /// a failed agent's partial edits are not carried into the next one.
5037    async fn ask_fixer_among(
5038        &mut self,
5039        attempts: Vec<(AgentSpec, String)>,
5040        node: &'static str,
5041        round: Option<usize>,
5042        restore: Option<(&Path, &str)>,
5043        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5044    ) -> (SeatJob, SeatState, AgentOutcome) {
5045        let run_id = self.state.id.clone();
5046        let prompts = self.state.config.prompts.clone();
5047        let cache = self.state.config.cache_dir();
5048        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5049        let mut last = None;
5050        for (i, (spec, key)) in attempts.into_iter().enumerate() {
5051            let seat = self.seat(&key, &spec.id);
5052            let mut job = build(spec.clone(), seat);
5053            if i > 0 {
5054                job.stem = format!("{}-{}", job.stem, spec.id);
5055            }
5056            let ctx = WaveCtx {
5057                carry_seats: false,
5058                pauses: Some((&self.pause, &self.interrupt)),
5059                run: &run_id,
5060                node,
5061                prompts: &prompts,
5062                cache: cache.as_deref(),
5063                round,
5064            };
5065            let (seat, out) =
5066                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5067            // A resource block never advances the chain: the chosen agent
5068            // was not asked, so it must be the one asked again on resume.
5069            let advances = match &out {
5070                AgentOutcome::Ok(o) => agent::output_advances(o),
5071                AgentOutcome::ResourceBlocked(_) => false,
5072                _ => true,
5073            };
5074            if let Some(next) = ids.get(i + 1)
5075                && advances
5076            {
5077                self.state.seats.insert(seat.key.clone(), seat.clone());
5078                let class =
5079                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5080                let reason = match &out {
5081                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5082                    other => fail_reason(other),
5083                };
5084                record_handover(
5085                    &mut self.state,
5086                    node,
5087                    &seat.key,
5088                    &spec.id,
5089                    next,
5090                    &class,
5091                    &reason,
5092                );
5093                // A failed agent's edits must not reach the next one, and a
5094                // restore that did not work ends the chain: the call fails
5095                // and the caller's own (confirmed) restore decides.
5096                if let Some((wt, rev)) = restore
5097                    && let Err(e) = git::restore_to(wt, rev).await
5098                {
5099                    let why =
5100                        format!("could not restore the worktree before the next fixer: {e:#}");
5101                    tracing::warn!("{why}");
5102                    last = Some((job, seat, AgentOutcome::Failed(why)));
5103                    break;
5104                }
5105                continue;
5106            }
5107            last = Some((job, seat, out));
5108            break;
5109        }
5110        last.expect("the fixer chain always has an entry")
5111    }
5112
5113    /// The one repair a verification-only failure earns (see
5114    /// [`crate::recovery`]), before the first review round of a run that took
5115    /// a branch over.
5116    ///
5117    /// Saved as started *before* the fixer is called, so a resume never
5118    /// repairs twice; a start found on re-entry is rolled back to the commit
5119    /// it began from. A failed or unusable fixer leaves the tree as it was and
5120    /// the review goes on over it, so the first round's own verification
5121    /// reports the failure again. The failure is never carried into a round as
5122    /// a result: verification is re-run on whatever commit this leaves.
5123    async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5124        use crate::recovery::{Mode, Repair};
5125        if !self.state.reviews.is_empty() {
5126            return Ok(());
5127        }
5128        let Some(mut rec) = self.state.recovery.clone() else {
5129            return Ok(());
5130        };
5131        if rec.mode != Mode::VerifyRepair {
5132            return Ok(());
5133        }
5134        let wt = winner.worktree.clone();
5135        let head = git::rev_parse(&wt, "HEAD").await?;
5136        match rec.repair.clone() {
5137            Repair::Done { .. } => return Ok(()),
5138            Repair::Started { before } => {
5139                // Interrupted: the unfinished edits are not trusted.
5140                // An unconfirmed rollback blocks the run with `Started` kept,
5141                // so a resume retries it; the tree is never reviewed as is.
5142                if let Err(e) = Self::graft_restore(&wt, &before).await {
5143                    return self
5144                        .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5145                        .await;
5146                }
5147                rec.repair = Repair::Done {
5148                    before: before.clone(),
5149                    after: before,
5150                    committed: false,
5151                    failed: Some("interrupted; rolled back".to_owned()),
5152                };
5153                self.state
5154                    .event("recovery", "the repair was interrupted and rolled back");
5155                self.state.recovery = Some(rec);
5156                self.state.save()?;
5157                return Ok(());
5158            }
5159            Repair::NotStarted => {}
5160        }
5161        if crate::recovery::recheck(&mut rec, &head) {
5162            self.state.event("recovery", rec.summary());
5163            self.state.recovery = Some(rec);
5164            self.state.save()?;
5165            return Ok(());
5166        }
5167        rec.repair = Repair::Started {
5168            before: head.clone(),
5169        };
5170        self.state.recovery = Some(rec.clone());
5171        self.state.save()?;
5172
5173        let language = self.state.config.graph.language.clone();
5174        let sessions = self.state.config.graph.sessions;
5175        let artifacts = agent::artifacts_dir(&self.state.dir());
5176        let fix_prompt = prompt::recovery_fix(
5177            &self.state.instruction,
5178            &rec,
5179            self.state.config.graph.review_rounds,
5180            &language,
5181        );
5182        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5183        self.state.event(
5184            "recovery",
5185            format!(
5186                "repair: the fixer starts from the failed verification on {}",
5187                short(&head)
5188            ),
5189        );
5190        let (_job, seat, out) = self
5191            .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5192                prompt: fix_prompt.clone(),
5193                spec,
5194                seat,
5195                cwd: wt.clone(),
5196                timeout,
5197                allow_write: true,
5198                sessions,
5199                artifacts: artifacts.clone(),
5200                stem: "fix-recovery".to_owned(),
5201                handover: None,
5202            })
5203            .await;
5204        // Never spawned: nothing changed, so the repair goes back to "not
5205        // started" and the run stops resumably, as for a graft.
5206        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5207            if let Err(e) = Self::graft_restore(&wt, &head).await {
5208                return self
5209                    .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5210                    .await;
5211            }
5212            rec.repair = Repair::NotStarted;
5213            self.state.recovery = Some(rec);
5214            self.state.save()?;
5215            return self.stop_for_resource();
5216        }
5217        let failed = match &out {
5218            AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5219            AgentOutcome::Quota(o) => {
5220                self.state.quota.push(QuotaLoss {
5221                    seat: seat.key.clone(),
5222                    node: "fix".to_owned(),
5223                    at: Timestamp::now(),
5224                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5225                });
5226                Some("rate limited (quota); the fixer could not run".to_owned())
5227            }
5228            AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5229            AgentOutcome::Failed(e) => Some(e.clone()),
5230        };
5231        self.state.seats.insert(seat.key.clone(), seat);
5232        let failed = match failed {
5233            // The agent's partial edits are not kept.
5234            Some(why) => {
5235                if let Err(e) = git::restore_to(&wt, &head).await {
5236                    return self
5237                        .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5238                        .await;
5239                }
5240                Some(why)
5241            }
5242            None => {
5243                if let Ok(r) = git::rescue_commit(
5244                    &wt,
5245                    "magi: recovery repair of the failed verification (uncommitted work)",
5246                )
5247                .await
5248                {
5249                    self.state.note_withheld("fix", &r.withheld);
5250                }
5251                None
5252            }
5253        };
5254        let after = git::rev_parse(&wt, "HEAD").await?;
5255        self.state.event(
5256            "recovery",
5257            match &failed {
5258                Some(why) => format!("repair: nothing kept ({why})"),
5259                None if after != head => {
5260                    format!("repair: committed {}", short(&after))
5261                }
5262                None => "repair: the fixer made no commit".to_owned(),
5263            },
5264        );
5265        rec.repair = Repair::Done {
5266            before: head.clone(),
5267            committed: after != head,
5268            after,
5269            failed,
5270        };
5271        self.state.recovery = Some(rec);
5272        self.state.save()?;
5273        Ok(())
5274    }
5275
5276    /// A repair whose rollback could not be confirmed: the run is blocked and
5277    /// resumable, and the unconfirmed state is never reviewed.
5278    async fn stop_recovery(
5279        &mut self,
5280        mut rec: crate::recovery::RecoveryRecord,
5281        head: &str,
5282        why: String,
5283    ) -> Result<()> {
5284        // `Started` stays recorded: a resume retries the restore.
5285        rec.repair = crate::recovery::Repair::Started {
5286            before: head.to_owned(),
5287        };
5288        self.state.recovery = Some(rec);
5289        self.state
5290            .event("recovery", format!("repair could not be undone: {why}"));
5291        self.state.status = RunStatus::Blocked;
5292        self.state.save()?;
5293        bail!("recovery repair could not be rolled back: {why}")
5294    }
5295
5296    async fn review_loop(&mut self) -> Result<()> {
5297        // A base that would not rebase is a person's decision, not a review
5298        // round: nothing here would change the answer, and reviewers and a
5299        // fixer would be spending real budget on a tree that cannot land
5300        // regardless of what they find.
5301        if self
5302            .state
5303            .base_sync
5304            .as_ref()
5305            .is_some_and(|s| s.conflict.is_some())
5306        {
5307            return Ok(());
5308        }
5309        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
5310        // agent files with `magi task add` name the run that paid for it. The
5311        // prompt overlay is cloned alongside it because the waves borrow it
5312        // while `self` is mutably borrowed by the node's own bookkeeping.
5313        let run_id = self.state.id.clone();
5314        let prompts = self.state.config.prompts.clone();
5315        let Some(winner) = self.state.winner().cloned() else {
5316            return Ok(());
5317        };
5318        let max_rounds = self.state.config.graph.review_rounds;
5319        // A clean round, an exhausted round budget, or a stalled tree (see
5320        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
5321        // they are recorded — recomputed here, not read off `status`, so a
5322        // reentry into a run that already stopped restates the identical
5323        // verdict instead of silently handing back whatever an earlier node
5324        // in this same walk clobbered `status` to (a solo-candidate
5325        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
5326        // below runs an empty range once the budget is spent, and would
5327        // otherwise fall through without touching `status` at all.
5328        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5329            // A reentry after a crash between the last round's save and
5330            // `stop_reviewing` reaches the hand-off here, not there.
5331            if status == RunStatus::Gating {
5332                self.record_contested_handoff();
5333            }
5334            self.state.status = status;
5335            self.state.save()?;
5336            return Ok(());
5337        }
5338        self.state.status = RunStatus::Reviewing;
5339        // A last recorded round whose own verification never resolved
5340        // (`ResourceBlocked` — the shared build cache, not the patch) is
5341        // never a concluded round, whatever the round budget says: starting
5342        // a fresh round on top of it would spend a whole new reviewer wave
5343        // re-reading an unchanged patch instead of just retrying the one
5344        // check that actually needs it, and once the budget is spent the
5345        // loop below has nothing left to do at all (its range is empty).
5346        // Retry that check directly instead, exactly the same retry
5347        // `stop_reviewing` already does for its own catch-up case.
5348        if self
5349            .state
5350            .reviews
5351            .last()
5352            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5353        {
5354            let shell = self.state.config.shell();
5355            return self
5356                .stop_reviewing(
5357                    "the last round's own verification never resolved",
5358                    &shell,
5359                    &winner.worktree,
5360                )
5361                .await;
5362        }
5363
5364        self.recovery_repair(&winner).await?;
5365        if self.resource_halt()? {
5366            return Ok(());
5367        }
5368
5369        let repo = self.state.repo.clone();
5370        let root = self.state.worktree_root();
5371        let language = self.state.config.graph.language.clone();
5372        let sessions = self.state.config.graph.sessions;
5373        let artifacts = agent::artifacts_dir(&self.state.dir());
5374        let base = self.landing_base();
5375        let base_short = short(&base);
5376        let reviewers = self.roles.reviewers.clone();
5377        let shell = self.state.config.shell();
5378
5379        for round in (self.state.reviews.len() + 1)..=max_rounds {
5380            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5381            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5382            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5383            // The prior round's own record, already persisted — never a
5384            // hand-carried variable of just its failing output: that is
5385            // exactly what let a round's e2e result drift out of sync with
5386            // which commit it was actually about (see `SCHEMA`'s doc for
5387            // schema 8). Judged against `head`, the commit reviewers are
5388            // about to look at now, so the summary always reads as "an
5389            // earlier head" here — this round's own patch has not been
5390            // checked yet.
5391            let prev_verification = self
5392                .state
5393                .reviews
5394                .last()
5395                .and_then(|r| r.verification_summary(&head));
5396            // Only the first round of a run that took a branch over: later
5397            // rounds have this run's own history.
5398            let prior_section = match (&self.state.recovery, round) {
5399                (Some(rec), 1) if self.state.reviews.is_empty() => {
5400                    let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5401                        (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5402                            Some(crate::recovery::head_of(
5403                                &git::diff(&winner.worktree, reviewed, "HEAD")
5404                                    .await
5405                                    .unwrap_or_default(),
5406                                crate::recovery::SINCE_DIFF_MAX,
5407                            ))
5408                        }
5409                        _ => None,
5410                    };
5411                    Some(prompt::recovery_review(rec, since.as_deref()))
5412                }
5413                _ => None,
5414            };
5415
5416            // Each reviewer gets its own detached checkout of exactly this
5417            // commit: nobody can perturb the winner's tree, and the fixer can
5418            // keep working without racing a reviewer.
5419            let mut jobs = Vec::new();
5420            for (r, spec) in reviewers.iter().cloned().enumerate() {
5421                let wt = root.join(format!("review-{}", r + 1));
5422                // `clean -fdx` in a reset wipes setup's products, so every
5423                // round sets the seat up again.
5424                if wt.exists() {
5425                    git::reset_detached(&wt, &head).await?;
5426                } else {
5427                    git::worktree_add_detached(&repo, &wt, &head).await?;
5428                }
5429                self.setup_seat_worktree(&repo, &wt, false).await?;
5430                let seat_key = format!("review-{}", r + 1);
5431                // The seat starts the round on whoever answered it last, not
5432                // on the agent the spec names, so a failure is not re-paid.
5433                let spec = pick_start_spec(
5434                    &self.roles.reviewer_roster,
5435                    spec,
5436                    self.state.seat_history.get(&seat_key),
5437                );
5438                let seat = self.seat(&seat_key, &spec.id);
5439                jobs.push(SeatJob {
5440                    prompt: prompt::review(&prompt::ReviewCtx {
5441                        instruction: &self.state.instruction,
5442                        branch: &winner.branch,
5443                        base_short: &base_short,
5444                        stat: &stat,
5445                        patch: &patch,
5446                        verification: prev_verification.as_ref(),
5447                        prior: prior_section.as_deref(),
5448                        reviewers: reviewers.len(),
5449                        round,
5450                        rounds: max_rounds,
5451                        // A review-only run has no rankings, so nothing
5452                        // competed for this patch and the reviewer is told so.
5453                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5454                        lens: Lens::for_seat(r),
5455                        language: &language,
5456                        github_language: &self.state.config.graph.github_language,
5457                    }),
5458                    spec,
5459                    seat,
5460                    cwd: wt,
5461                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5462                    allow_write: false,
5463                    sessions,
5464                    artifacts: artifacts.clone(),
5465                    stem: format!("review-{round}-{}", r + 1),
5466                    handover: None,
5467                });
5468            }
5469
5470            self.state.event(
5471                "review",
5472                format!(
5473                    "round {round}: {} reviewers on {}",
5474                    jobs.len(),
5475                    short(&head)
5476                ),
5477            );
5478            let mut quota_losses = Vec::new();
5479            let review_retries = self.state.config.graph.retries;
5480            let review_cache = self.state.config.cache_dir();
5481            let ctx = WaveCtx {
5482                carry_seats: true,
5483                pauses: Some((&self.pause, &self.interrupt)),
5484                run: &run_id,
5485                node: "review",
5486                prompts: &prompts,
5487                cache: review_cache.as_deref(),
5488                round: Some(round),
5489            };
5490            let results = ask_json_wave::<Review>(
5491                jobs,
5492                Arc::clone(&self.sem),
5493                review_retries,
5494                &self.roles.reviewer_roster,
5495                &ctx,
5496                &mut quota_losses,
5497                &mut self.state,
5498                &|_: &Review| Ok(()),
5499            )
5500            .await;
5501            if self.state.resource_block.is_some() {
5502                return self.stop_for_resource();
5503            }
5504            // Counted before the move below: how many of *this* round's
5505            // reviewer seats were lost to their own rate limit, as opposed to
5506            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5507            let round_quota_missing = quota_losses.len();
5508            self.state.quota.extend(quota_losses);
5509
5510            let mut records = Vec::new();
5511            let mut all_findings = Vec::new();
5512            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5513                let agent_id = seat.agent.clone();
5514                self.state.seats.insert(seat.key.clone(), seat);
5515                let mut record = ReviewRecord {
5516                    reviewer: r + 1,
5517                    agent: agent_id,
5518                    summary: String::new(),
5519                    findings: Vec::new(),
5520                    vote: None,
5521                    failed: None,
5522                    duration_ms: 0,
5523                    // Set for both outcomes: `failed: Some(_)` with
5524                    // `attempts > 0` is a seat every retry still lost, not a
5525                    // recovered one — only `failed: None` with `attempts > 0`
5526                    // reads as "answered after a nudge" (see this field's own
5527                    // doc).
5528                    attempts,
5529                };
5530                match res {
5531                    Ok((review, out)) => {
5532                        // Sanitized here, at the point every other piece of
5533                        // agent prose in this file is (candidate summaries,
5534                        // deliberation turns, vote reasons): a reviewer's own
5535                        // words are the one thing about it that could name
5536                        // it, and reconsideration below broadcasts this same
5537                        // summary and these same findings to every other
5538                        // seat on the panel.
5539                        record.summary =
5540                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5541                        record.vote = Some(review.vote);
5542                        record.duration_ms = out.duration_ms;
5543                        for (n, mut f) in review.findings.into_iter().enumerate() {
5544                            // ids are magi's, never the agent's: the fixer's
5545                            // adoption report is keyed by them.
5546                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5547                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5548                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5549                            // `file` is agent-supplied prose too, never
5550                            // checked against the real tree — the same
5551                            // exposure `title`/`detail` above have, just in
5552                            // a field easy to forget because it looks like a
5553                            // path rather than free text.
5554                            f.file = f
5555                                .file
5556                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5557                            all_findings.push(f.clone());
5558                            record.findings.push(f);
5559                        }
5560                        self.state.event(
5561                            "review",
5562                            format!(
5563                                "round {round}: reviewer {} voted {} with {} finding(s)",
5564                                r + 1,
5565                                review.vote.label(),
5566                                record.findings.len()
5567                            ),
5568                        );
5569                    }
5570                    Err(e) => {
5571                        record.failed = Some(e.to_string());
5572                        self.state.event(
5573                            "review",
5574                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5575                        );
5576                    }
5577                }
5578                records.push(record);
5579            }
5580
5581            // Tally the round's votes and, if they split, spend the one
5582            // round of reconsideration the split -> deliberate -> revote
5583            // shape `judge`/`vote` use for the panel, sized down to what a
5584            // read-only review round can afford: one round, and a revote
5585            // rather than an argument, because the panel already wrote its
5586            // reasoning down as findings the first time around.
5587            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5588            let vote_split =
5589                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5590            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5591            if vote_split {
5592                self.state.event(
5593                    "review",
5594                    format!(
5595                        "round {round}: votes split ({}) — one round of reconsideration",
5596                        initial_votes
5597                            .iter()
5598                            .map(|v| v.label())
5599                            .collect::<Vec<_>>()
5600                            .join(", ")
5601                    ),
5602                );
5603                // Seats read every seat's findings and votes, still numbered
5604                // and never named — the same anonymity `review` itself keeps.
5605                let panel: Vec<ReviewSeatReport<'_>> = records
5606                    .iter()
5607                    .filter_map(|r| {
5608                        r.vote.map(|vote| ReviewSeatReport {
5609                            reviewer: r.reviewer,
5610                            vote,
5611                            summary: &r.summary,
5612                            findings: &r.findings,
5613                        })
5614                    })
5615                    .collect();
5616
5617                let mut jobs = Vec::new();
5618                let mut seats_at = Vec::new();
5619                for (r, spec) in reviewers.iter().cloned().enumerate() {
5620                    // A seat with no initial vote has nothing to reconsider
5621                    // from and stays absent, the same as it stayed absent
5622                    // from `panel` above.
5623                    if records[r].vote.is_none() {
5624                        continue;
5625                    }
5626                    let wt = root.join(format!("review-{}", r + 1));
5627                    let seat_key = format!("review-{}", r + 1);
5628                    let spec = self.occupant(&seat_key, spec);
5629                    let seat = self.seat(&seat_key, &spec.id);
5630                    // A seat with no live session has already forgotten the
5631                    // initial review's prompt — restate the patch it is
5632                    // voting on, the same as `deliberate`/`vote` do for a
5633                    // judge in the same position.
5634                    // The panel already carries this seat's own review and
5635                    // vote, so restating the patch makes the prompt whole for
5636                    // a seat handed to another agent.
5637                    let build = |with_patch: bool| {
5638                        prompt::review_reconsider(&ReviewReconsiderCtx {
5639                            instruction: &self.state.instruction,
5640                            reviewer: r + 1,
5641                            lens: Lens::for_seat(r),
5642                            panel: &panel,
5643                            patch: with_patch.then_some(ReviewPatch {
5644                                branch: &winner.branch,
5645                                base_short: &base_short,
5646                                stat: &stat,
5647                                patch: &patch,
5648                            }),
5649                            round,
5650                            rounds: max_rounds,
5651                            language: &language,
5652                        })
5653                    };
5654                    let full = build(true);
5655                    let prompt = if has_context(&spec, &seat, sessions) {
5656                        build(false)
5657                    } else {
5658                        full.clone()
5659                    };
5660                    jobs.push(SeatJob {
5661                        prompt,
5662                        spec,
5663                        seat,
5664                        cwd: wt,
5665                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5666                        allow_write: false,
5667                        sessions,
5668                        artifacts: artifacts.clone(),
5669                        stem: format!("review-{round}-reconsider-{}", r + 1),
5670                        handover: Some(full),
5671                    });
5672                    seats_at.push(r);
5673                }
5674
5675                let mut recon_quota_losses = Vec::new();
5676                let recon_cache = self.state.config.cache_dir();
5677                let recon_ctx = WaveCtx {
5678                    carry_seats: true,
5679                    pauses: Some((&self.pause, &self.interrupt)),
5680                    run: &run_id,
5681                    node: "review",
5682                    prompts: &prompts,
5683                    cache: recon_cache.as_deref(),
5684                    round: Some(round),
5685                };
5686                let recon_results = ask_json_wave::<ReviewRevote>(
5687                    jobs,
5688                    Arc::clone(&self.sem),
5689                    review_retries,
5690                    &self.roles.reviewer_roster,
5691                    &recon_ctx,
5692                    &mut recon_quota_losses,
5693                    &mut self.state,
5694                    &|_: &ReviewRevote| Ok(()),
5695                )
5696                .await;
5697                if self.state.resource_block.is_some() {
5698                    return self.stop_for_resource();
5699                }
5700                self.state.quota.extend(recon_quota_losses);
5701
5702                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5703                    let agent_id = seat.agent.clone();
5704                    self.state.seats.insert(seat.key.clone(), seat);
5705                    let mut rec = ReviewRevoteRecord {
5706                        reviewer: r + 1,
5707                        agent: agent_id,
5708                        vote: None,
5709                        reason: String::new(),
5710                        failed: None,
5711                    };
5712                    match res {
5713                        Ok((rv, _)) => {
5714                            rec.vote = Some(rv.vote);
5715                            rec.reason =
5716                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5717                            self.state.event(
5718                                "review",
5719                                format!(
5720                                    "round {round}: reviewer {} revoted {}",
5721                                    r + 1,
5722                                    rv.vote.label()
5723                                ),
5724                            );
5725                        }
5726                        Err(e) => {
5727                            rec.failed = Some(e.to_string());
5728                            self.state.event(
5729                                "review",
5730                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5731                            );
5732                        }
5733                    }
5734                    reconsideration.push(rec);
5735                }
5736            } else if initial_votes.len() > 1 {
5737                self.state.event(
5738                    "review",
5739                    format!(
5740                        "round {round}: votes agreed ({}) — no reconsideration",
5741                        initial_votes[0].label()
5742                    ),
5743                );
5744            }
5745
5746            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5747            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5748            // A round that already has a blocking finding and a round left to
5749            // try is going back to the fixer no matter what `verify.e2e`
5750            // says, so running it first only spends the loop's slowest step
5751            // (minutes, for a Rust repo's full test suite) on a head about
5752            // to be rewritten. Deferred, never skipped: `verify.e2e` still
5753            // runs once a round has no blocking findings left (see
5754            // `round_is_clean`, which a deferred — empty — `e2e` can never
5755            // satisfy since `blocking` is nonzero whenever this branch is
5756            // taken), and `stop_reviewing` forces a real run before it will
5757            // ever read a deferred round as green.
5758            let defer_e2e =
5759                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5760            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5761                let reason =
5762                    format!("{blocking} blocking finding(s) already required a fix this round");
5763                self.state.event(
5764                    "verify",
5765                    format!(
5766                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5767                         {}); it will run once a round has none left",
5768                        short(&head)
5769                    ),
5770                );
5771                (Vec::new(), false, true, Some(reason))
5772            } else {
5773                let e2e_commands = self.state.config.verify.e2e.clone();
5774                let cache_dir = self.state.config.cache_dir();
5775                let context = format!("round {round}");
5776                let (e2e, verify_retried) = with_cache_lease(
5777                    &mut self.state,
5778                    cache_dir.as_deref(),
5779                    "e2e",
5780                    "e2e",
5781                    &winner.worktree,
5782                    &head,
5783                    verify_timeout,
5784                    &context,
5785                    |state, budget| {
5786                        let shell = shell.clone();
5787                        let e2e_commands = e2e_commands.clone();
5788                        let worktree = winner.worktree.clone();
5789                        let context = context.clone();
5790                        async move {
5791                            run_e2e_with_retry(
5792                                state,
5793                                &shell,
5794                                &e2e_commands,
5795                                &worktree,
5796                                budget,
5797                                &context,
5798                            )
5799                            .await
5800                        }
5801                    },
5802                )
5803                .await;
5804                (e2e, verify_retried, false, None)
5805            };
5806
5807            let expected = records.len();
5808            let answered = records.iter().filter(|r| r.failed.is_none()).count();
5809            let incomplete = answered < expected;
5810            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5811            let policy = self.state.config.graph.incomplete_review;
5812            let clean = round_is_clean(
5813                blocking,
5814                e2e_ok,
5815                answered,
5816                expected,
5817                round_quota_missing,
5818                policy,
5819            );
5820
5821            let mut round_record = ReviewRound {
5822                round,
5823                head: head.clone(),
5824                verified_head: None,
5825                verified_at: None,
5826                reviews: records,
5827                e2e,
5828                verify_retried,
5829                e2e_deferred,
5830                e2e_defer_reason,
5831                fix: None,
5832                blocking,
5833                answered,
5834                expected,
5835                clean,
5836                progressed: false,
5837                vote_split,
5838                reconsideration,
5839                verdict: None,
5840            };
5841            // The final vote per seat is its revote where reconsideration
5842            // ran and answered, its initial vote otherwise — the same
5843            // fallback `tally` uses for a judge whose private vote failed.
5844            round_record.verdict = ReviewVote::worst(
5845                round_record
5846                    .final_votes()
5847                    .into_iter()
5848                    .map(|(_, _, vote)| vote),
5849            );
5850            // Which commit and when magi actually attempted to check —
5851            // known the moment a command was dispatched against `head`,
5852            // whether or not it finished: a resource-blocked attempt still
5853            // targeted a specific commit at a specific time, and leaving
5854            // that unrecorded is exactly what made `verification_summary`
5855            // report a fresh attempt as "commit unknown ... recorded before
5856            // this was tracked", indistinguishable from a genuinely old,
5857            // untracked record. Only a deferred or unconfigured round never
5858            // ran at all and has nothing to record — see
5859            // `ReviewRound::verified_head`'s own doc.
5860            if !matches!(
5861                round_record.e2e_status(),
5862                E2eStatus::Deferred | E2eStatus::NotConfigured
5863            ) {
5864                round_record.verified_head = Some(head.clone());
5865                round_record.verified_at = Some(Timestamp::now());
5866            }
5867            let this_round_verification = round_record.verification_summary(&head);
5868
5869            if incomplete {
5870                let missing: Vec<String> = round_record
5871                    .reviews
5872                    .iter()
5873                    .filter(|r| r.failed.is_some())
5874                    .map(|r| format!("review-{}", r.reviewer))
5875                    .collect();
5876                self.state.event(
5877                    "review",
5878                    format!(
5879                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5880                        missing.join(", ")
5881                    ),
5882                );
5883            }
5884
5885            if clean {
5886                self.state.event(
5887                    "review",
5888                    if incomplete && policy == IncompleteReviewPolicy::Warn {
5889                        format!(
5890                            "round {round}: clean (warn policy, incomplete panel) — no \
5891                             blocking findings from the seats that answered, verification green"
5892                        )
5893                    } else if incomplete {
5894                        format!(
5895                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5896                             quorum) — no blocking findings from the seats that answered, \
5897                             verification green",
5898                            expected - answered
5899                        )
5900                    } else {
5901                        format!("round {round}: clean — no blocking findings, verification green")
5902                    },
5903                );
5904                self.state.reviews.push(round_record);
5905                self.state.status = RunStatus::Gating;
5906                self.state.save()?;
5907                return Ok(());
5908            }
5909
5910            // Nothing was raised and verification passed, but not every seat
5911            // answered and `round_is_clean` still refused to call it clean —
5912            // either a seat is missing for a reason other than its own quota
5913            // (a crash, a timeout, unparsable output — worth another try), or
5914            // every seat that could have answered lost its quota and nobody
5915            // is left to decide on: re-review rather than send the fixer
5916            // after a round with nothing to fix.
5917            if incomplete && blocking == 0 && e2e_ok {
5918                self.state.reviews.push(round_record);
5919                self.state.save()?;
5920                if round == max_rounds {
5921                    self.state.status = RunStatus::Blocked;
5922                    self.state.event(
5923                        "review",
5924                        format!(
5925                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5926                             refusing to call it clean",
5927                            expected - answered
5928                        ),
5929                    );
5930                    return Ok(());
5931                }
5932                continue;
5933            }
5934
5935            // Nothing for the fixer to act on (`blocking == 0`) and the only
5936            // reason this round is not clean is that magi itself never got
5937            // a command to run — the shared build cache, not the patch (see
5938            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5939            // the fixer would invite a change to appease contention that has
5940            // nothing to do with the diff, and would leave this attempt
5941            // sitting in the next round's prompt as if it were about an
5942            // earlier, superseded commit rather than what it actually is:
5943            // the same head, still waiting to be checked. Wait for it the
5944            // same way the final round's own contention is already handled,
5945            // whatever round this happens to be.
5946            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5947                self.state.reviews.push(round_record);
5948                return self
5949                    .stop_reviewing(
5950                        "the round's own verification could not run",
5951                        &shell,
5952                        &winner.worktree,
5953                    )
5954                    .await;
5955            }
5956
5957            if round == max_rounds {
5958                self.state.reviews.push(round_record);
5959                return self
5960                    .stop_reviewing(
5961                        &format!(
5962                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5963                        ),
5964                        &shell,
5965                        &winner.worktree,
5966                    )
5967                    .await;
5968            }
5969
5970            // Fix. The winner's own implementer seat continues its conversation:
5971            // the competition is over, so context is pure benefit now.
5972            let blocking_findings: Vec<_> = all_findings
5973                .iter()
5974                .filter(|f| f.severity.blocks())
5975                .cloned()
5976                .collect();
5977            let fix_prompt = prompt::fix_in(
5978                &self.state.instruction,
5979                &blocking_findings,
5980                this_round_verification.as_ref(),
5981                round,
5982                max_rounds,
5983                &language,
5984                &self.state.config.graph.github_language,
5985            );
5986            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5987            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5988            let (job, seat, out) = self
5989                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5990                    prompt: fix_prompt.clone(),
5991                    spec,
5992                    seat,
5993                    cwd: winner.worktree.clone(),
5994                    timeout,
5995                    allow_write: true,
5996                    sessions,
5997                    artifacts: artifacts.clone(),
5998                    stem: format!("fix-{round}"),
5999                    handover: None,
6000                })
6001                .await;
6002            // Not asked: the round is not recorded (so a resume repeats it),
6003            // no fixer was handed over to, and the seat is left as it was.
6004            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6005                return self.stop_for_resource();
6006            }
6007            let agent_id = seat.agent.clone();
6008
6009            let mut fix = FixRecord {
6010                agent: agent_id,
6011                addressed: Vec::new(),
6012                rejected: Vec::new(),
6013                notes: String::new(),
6014                committed: false,
6015                failed: None,
6016                duration_ms: 0,
6017                continuation: None,
6018            };
6019            let mut continuation = ContinuationRecord::not_needed();
6020            let mut final_seat = seat.clone();
6021            match out {
6022                AgentOutcome::Ok(o) => {
6023                    fix.duration_ms = o.duration_ms;
6024                    let parsed = verdict::extract_json::<FixReport>(&o.text);
6025                    // A parsed report standing next to a command this same
6026                    // reply's own CLI never confirmed the exit status of is
6027                    // not a resolved answer — the identical `CommandEvidence`
6028                    // `state.jobs` renders, read here instead of only on
6029                    // display, per the completion judgment and the shown
6030                    // record needing to agree.
6031                    let incomplete_reason = match &parsed {
6032                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6033                            "the reply parsed, but it reported a command whose own CLI never \
6034                             confirmed an exit status"
6035                                .to_owned(),
6036                        ),
6037                        Ok(_) => None,
6038                        Err(e) => Some(e.to_string()),
6039                    };
6040                    match incomplete_reason {
6041                        None => {
6042                            let report = parsed.expect("checked Ok above");
6043                            fix.addressed = report.addressed;
6044                            fix.rejected = report.rejected;
6045                            fix.notes =
6046                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
6047                        }
6048                        Some(reason) => {
6049                            let (resumed_seat, resolved, failure, cont) = self
6050                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6051                                .await;
6052                            fix.duration_ms += cont.cumulative_wait_ms;
6053                            continuation = cont;
6054                            final_seat = resumed_seat;
6055                            match resolved {
6056                                Some(report) => {
6057                                    fix.addressed = report.addressed;
6058                                    fix.rejected = report.rejected;
6059                                    fix.notes = blind::sanitize_prose(
6060                                        &report.notes,
6061                                        &self.state.config.blind,
6062                                    );
6063                                }
6064                                None => fix.failed = failure,
6065                            }
6066                        }
6067                    }
6068                }
6069                // The CLI's raw error JSON is not a fix report to parse.
6070                AgentOutcome::Dropped(o) => {
6071                    fix.duration_ms = o.duration_ms;
6072                    let why = o
6073                        .dropped
6074                        .as_ref()
6075                        .map(|d| d.why.as_str())
6076                        .unwrap_or("the CLI ended the stream without delivering its answer");
6077                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6078                }
6079                AgentOutcome::Quota(o) => {
6080                    self.state.quota.push(QuotaLoss {
6081                        seat: final_seat.key.clone(),
6082                        node: "fix".to_owned(),
6083                        at: Timestamp::now(),
6084                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6085                    });
6086                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6087                }
6088                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6089            }
6090            fix.continuation = Some(continuation);
6091            self.state.seats.insert(final_seat.key.clone(), final_seat);
6092            if let Ok(r) = git::rescue_commit(
6093                &winner.worktree,
6094                &format!("magi: review round {round} fixes (uncommitted work)"),
6095            )
6096            .await
6097            {
6098                self.state.note_withheld("fix", &r.withheld);
6099            }
6100            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6101            fix.committed = after != before;
6102            // Judged by what `git` says moved against base, never by the
6103            // fixer's own `addressed`/`rejected` count — see
6104            // `ReviewRound::progressed`. Propagated with `?`, the same as the
6105            // `patch` snapshot above: swallowing this error would default
6106            // `diff_after` to empty, which almost always differs from a
6107            // non-empty `patch` and reads as "progressed" — exactly backwards
6108            // for a `git` failure the stagnation check cannot see through.
6109            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6110            let progressed = diff_after != patch;
6111            let commit_note = if fix.committed {
6112                "committed"
6113            } else {
6114                "NO new commit"
6115            };
6116            let tree_note = if progressed {
6117                "changed vs base"
6118            } else {
6119                "unchanged vs base"
6120            };
6121            self.state.event(
6122                "fix",
6123                match &fix.failed {
6124                    // Distinct on purpose from "0 addressed, 0 rejected": the
6125                    // fixer's own diff still landed (blocking counts do keep
6126                    // falling round over round), only its adoption report did
6127                    // not come back, so this must never read like every
6128                    // finding was reviewed and declined.
6129                    Some(reason) => {
6130                        format!(
6131                            "round {round}: fixer's adoption report was lost ({reason}); \
6132                             {commit_note}, tree {tree_note}"
6133                        )
6134                    }
6135                    None => format!(
6136                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6137                         {tree_note}{}",
6138                        fix.addressed.len(),
6139                        fix.rejected.len(),
6140                        if continuation.outcome == ContinuationOutcome::Resumed {
6141                            format!(
6142                                " (adoption report recovered after {} continuation(s))",
6143                                continuation.attempts
6144                            )
6145                        } else {
6146                            String::new()
6147                        },
6148                    ),
6149                },
6150            );
6151            round_record.fix = Some(fix);
6152            round_record.progressed = progressed;
6153            self.state.reviews.push(round_record);
6154            self.state.save()?;
6155
6156            // The fixer's own report never came back this round, even after
6157            // `continue_fix_report`'s own budget was spent on it — not an
6158            // ordinary "no report" (dropped stream, quota, plain failure),
6159            // which already reads that way and is left to the existing round
6160            // budget. Stopping here, rather than opening another round, is
6161            // what keeps a next reviewer/fixer wave from ever being
6162            // dispatched onto `winner.worktree` while whatever the seat's
6163            // last call may still have running there is unaccounted for: no
6164            // process liveness check exists (and none is being added — see
6165            // AGENTS.md/this task's own scope), so the only way to honour
6166            // "nothing starts before a valid report returns" is to not start
6167            // anything further on this worktree from this run at all.
6168            if matches!(
6169                continuation.outcome,
6170                ContinuationOutcome::Exhausted
6171                    | ContinuationOutcome::QuotaLost
6172                    | ContinuationOutcome::NoSession
6173            ) {
6174                return self
6175                    .stop_reviewing(
6176                        "the fixer's adoption report never came back, even after resuming its \
6177                         own seat; refusing to start another round against the same worktree \
6178                         while that is unresolved",
6179                        &shell,
6180                        &winner.worktree,
6181                    )
6182                    .await;
6183            }
6184
6185            let streak = self
6186                .state
6187                .reviews
6188                .iter()
6189                .rev()
6190                .take_while(|r| !r.progressed)
6191                .count();
6192            if streak >= STAGNANT_LIMIT {
6193                return self
6194                    .stop_reviewing(
6195                        &format!(
6196                            "the tree has not moved against base for {streak} round(s) in a row"
6197                        ),
6198                        &shell,
6199                        &winner.worktree,
6200                    )
6201                    .await;
6202            }
6203        }
6204        Ok(())
6205    }
6206
6207    /// Decide, from the last recorded round's own verification, whether
6208    /// stopping the review loop is a hand-off or a genuine block.
6209    ///
6210    /// Called once the loop has given up trying — the round budget is spent,
6211    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
6212    /// findings still open, never while a round is still clean or the
6213    /// incomplete-panel case handled inline above. Gate and e2e are facts
6214    /// about the tree; a lingering review finding is an opinion, and this
6215    /// workload's own `magi stats` puts reviewer precision low enough
6216    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
6217    /// must not by itself stand between a green, verified change and the
6218    /// human who decides what to do with it. A red e2e is not an opinion, so
6219    /// that case still blocks, with the failing command and a tail of its
6220    /// output recorded here rather than left in `run.json` for someone to go
6221    /// find.
6222    ///
6223    /// A round that deferred its own e2e (see [`Config::graph`]'s
6224    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
6225    /// only because nothing ran, and treating an empty list as a passing one
6226    /// here is exactly the "deferred painted green" bug this function exists
6227    /// to not have. When the last round's own verification never resolved —
6228    /// deferred on purpose, or a real attempt the shared build cache blocked
6229    /// — this makes (or retries) the real run, on the actual worktree this
6230    /// loop is about to stop touching, before deciding anything. A
6231    /// resource-blocked attempt is likewise never read as either green or
6232    /// red: it is evidence about the machine, not the patch (see
6233    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
6234    /// blocked cache leaves this call without deciding rather than guessing
6235    /// — the caller retries on a later reentry.
6236    /// Record, once, that the review loop handed off over a blocking finding
6237    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
6238    /// `land` asks the owner even with `land_approval` off. Called from every
6239    /// path that concludes `Gating`; a reentry keeps the first record.
6240    fn record_contested_handoff(&mut self) {
6241        if self.state.contested_handoff.is_some() {
6242            return;
6243        }
6244        let Some(contested) = self
6245            .state
6246            .reviews
6247            .last()
6248            .and_then(ReviewRound::contested_handoff)
6249        else {
6250            return;
6251        };
6252        self.state.event(
6253            "review",
6254            format!(
6255                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6256                 wait for the owner's approval",
6257                contested.findings.len(),
6258                contested.rejecters.len()
6259            ),
6260        );
6261        self.state.contested_handoff = Some(contested);
6262    }
6263
6264    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6265        let round_idx = self.state.reviews.len() - 1;
6266        // A deferred round and a resource-blocked one are the same shape
6267        // here: neither has a real result yet, and both get one more
6268        // attempt. Read off `e2e_status` — the single source for this —
6269        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
6270        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
6271        // still retries instead of being read as a settled result the
6272        // instant it stops being empty.
6273        let needs_catchup_run = matches!(
6274            self.state.reviews[round_idx].e2e_status(),
6275            E2eStatus::Deferred | E2eStatus::ResourceBlocked
6276        );
6277        if needs_catchup_run {
6278            let round = self.state.reviews[round_idx].round;
6279            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6280            let commands = self.state.config.verify.e2e.clone();
6281            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6282            let cache_dir = self.state.config.cache_dir();
6283            let context = format!(
6284                "round {round}: verification unresolved, catching up before the final decision"
6285            );
6286            let (outcomes, verify_retried) = with_cache_lease(
6287                &mut self.state,
6288                cache_dir.as_deref(),
6289                "e2e",
6290                "e2e",
6291                worktree,
6292                &attempted_head,
6293                timeout,
6294                &context,
6295                |state, budget| {
6296                    let shell = shell.to_vec();
6297                    let commands = commands.clone();
6298                    let context = context.clone();
6299                    async move {
6300                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6301                            .await
6302                    }
6303                },
6304            )
6305            .await;
6306            let last = &mut self.state.reviews[round_idx];
6307            last.e2e = outcomes;
6308            last.verify_retried = verify_retried;
6309            // Always the commit and time this attempt actually targeted,
6310            // whether or not it happens to equal the reviewed `head` and
6311            // whether or not a command finished — see
6312            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
6313            // attempt is recorded too, so a later reader sees "attempted
6314            // again at T2" rather than silence.
6315            last.verified_head = Some(attempted_head);
6316            last.verified_at = Some(Timestamp::now());
6317            if verify_inconclusive(&last.e2e) {
6318                // Still not a real result: `e2e_deferred` is left exactly
6319                // as it was, so `needs_catchup_run` above reads
6320                // `ResourceBlocked` (via `e2e_status`, which checks
6321                // `resource_blocked` before `e2e_deferred`) and retries
6322                // again on the next reentry, rather than recording
6323                // contention as a red e2e and blocking the run on it.
6324                self.state.save()?;
6325                return Ok(());
6326            }
6327            last.e2e_deferred = false;
6328        }
6329        let last = &self.state.reviews[round_idx];
6330        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6331
6332        match last.e2e_status() {
6333            E2eStatus::Failed => {
6334                let red: Vec<String> = last
6335                    .e2e
6336                    .iter()
6337                    .filter(|o| !o.ok())
6338                    .map(|o| {
6339                        format!(
6340                            "`{}` -> {:?}\n{}",
6341                            o.command,
6342                            o.code,
6343                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6344                        )
6345                    })
6346                    .collect();
6347                self.state
6348                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6349                self.state.status = RunStatus::Blocked;
6350            }
6351            // `needs_catchup_run` above already retried once this call; if
6352            // it is still blocked, this is magi's own admission it could
6353            // not get a command to run, never a verdict on the patch — the
6354            // run is left exactly where a later reentry can retry again.
6355            E2eStatus::ResourceBlocked => {
6356                self.state.event(
6357                    "review",
6358                    format!(
6359                        "{why}; e2e could not run (shared build cache unavailable); not \
6360                         deciding yet"
6361                    ),
6362                );
6363            }
6364            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6365                self.state.event(
6366                    "review",
6367                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6368                );
6369                self.record_contested_handoff();
6370                self.state.status = RunStatus::Gating;
6371            }
6372        }
6373        self.state.save()?;
6374        Ok(())
6375    }
6376
6377    // ----------------------------------------------------------------- gate
6378
6379    async fn gate(&mut self) -> Result<()> {
6380        // Judged by the review record itself, not by `status`: a solo
6381        // candidate's `judge`/`deliberate` skip rewrites `status` on every
6382        // reentry (see `judge`), and trusting it here is exactly how a run
6383        // that exhausted its review budget got gated and merged a second
6384        // time around. `review_conclusion` recomputes the review loop's own
6385        // verdict from the round records themselves — `Gating` for a clean
6386        // round or a hand-off (see `stop_reviewing`), anything else means the
6387        // loop is still going or genuinely blocked.
6388        // A base the winner could not be replayed onto is a decision, not a
6389        // round: there is no landing tree to gate. Read as its own record for
6390        // the same reason the review verdict is.
6391        if self.state.status == RunStatus::Failed
6392            || self
6393                .state
6394                .base_sync
6395                .as_ref()
6396                .is_some_and(|s| s.conflict.is_some())
6397            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6398                != Some(RunStatus::Gating)
6399        {
6400            return Ok(());
6401        }
6402        if self.state.gate_ran {
6403            // `review_loop` derives its conclusion from the clean review
6404            // record on every reentry and therefore puts a completed run back
6405            // in `Gating`. A recorded gate is a stronger, terminal fact:
6406            // retain its original command output (or lack of any, for a repo
6407            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
6408            // doc) and restore `Blocked` on a real failure rather than
6409            // pretending the command is still running or running it a second
6410            // time. `gate_ran == false` remains the only shape — unattempted,
6411            // or a resource-blocked retry — that may still need to execute a
6412            // command.
6413            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6414                self.state.status = RunStatus::Blocked;
6415                self.state.save()?;
6416            }
6417            return Ok(());
6418        }
6419        let Some(winner) = self.state.winner().cloned() else {
6420            return Ok(());
6421        };
6422        self.state.status = RunStatus::Gating;
6423        let mut outcomes = self.run_gate(&winner).await?;
6424        loop {
6425            // A resource-blocked outcome means the gate command never actually
6426            // ran - the shared build cache could not be acquired or confirmed
6427            // fresh in time - which is evidence about the machine, not about
6428            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
6429            // Recording it as a red gate would mark a run `Blocked` on nothing
6430            // but contention magi has already logged; leaving `self.state.gate`
6431            // empty and `self.state.gate_ran` false instead keeps the shape
6432            // this function already treats as "still needs to run" (see the
6433            // early-return above), so the next call retries the command
6434            // rather than concluding anything.
6435            if verify_inconclusive(&outcomes) {
6436                self.state.save()?;
6437                return Ok(());
6438            }
6439            if outcomes.iter().all(CommandOutcome::ok) {
6440                break;
6441            }
6442            match self.gate_fix_round(&winner, &outcomes).await? {
6443                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6444                GateFix::Stop => break,
6445                GateFix::Defer => {
6446                    self.state.save()?;
6447                    return Ok(());
6448                }
6449            }
6450        }
6451        let passed = outcomes.iter().all(CommandOutcome::ok);
6452        self.state.gate = outcomes;
6453        self.state.gate_ran = true;
6454        self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6455        if !passed {
6456            self.state.status = RunStatus::Blocked;
6457            let spent = self.state.gate_fixes.len();
6458            self.state.event(
6459                "gate",
6460                if spent == 0 {
6461                    "gate failed; not merging".to_owned()
6462                } else {
6463                    format!("gate failed after {spent} gate-fix round(s); not merging")
6464                },
6465            );
6466        }
6467        self.state.save()?;
6468        Ok(())
6469    }
6470
6471    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6472    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6473    /// after review is clean and never on a candidate awaiting judging.
6474    ///
6475    /// Never fails the run: a non-zero exit or timeout is a warning and a
6476    /// recorded outcome, and the gate remains the single arbiter. Nothing
6477    /// configured means nothing happens - no event, no commit. `commit_all`
6478    /// commits any leftover change under the neutral identity and returns
6479    /// `false` when the tree is clean, so no empty commit is ever made.
6480    async fn run_pre_gate(&mut self, winner: &Candidate) {
6481        let commands = self.state.config.verify.pre_gate.clone();
6482        if commands.is_empty() {
6483            return;
6484        }
6485        let shell = self.state.config.shell();
6486        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6487        let (outcomes, _) = run_commands(
6488            &mut self.state,
6489            "pre_gate",
6490            "pre_gate",
6491            0,
6492            &shell,
6493            &commands,
6494            &winner.worktree,
6495            timeout,
6496        )
6497        .await;
6498        for o in &outcomes {
6499            if !o.ok() {
6500                tracing::warn!(
6501                    "pre_gate `{}` failed ({:?}); the gate decides",
6502                    o.command,
6503                    o.code
6504                );
6505            }
6506            self.state.event(
6507                "pre_gate",
6508                format!(
6509                    "`{}` -> {}",
6510                    o.command,
6511                    if o.ok() {
6512                        "pass".to_owned()
6513                    } else {
6514                        format!(
6515                            "FAIL ({:?})\n{}",
6516                            o.code,
6517                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6518                        )
6519                    }
6520                ),
6521            );
6522        }
6523        self.state.pre_gate = outcomes;
6524        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6525            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6526                Ok(head) => {
6527                    self.state
6528                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
6529                    self.state.pre_gate_commit = Some(head);
6530                }
6531                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6532            },
6533            Ok(false) => {}
6534            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6535        }
6536        if let Err(e) = self.state.save() {
6537            tracing::warn!("could not persist the pre_gate record: {e:#}");
6538        }
6539    }
6540
6541    /// Run `verify.gate` once against the winner's current tree, logging one
6542    /// event per command. Empty when nothing is configured.
6543    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6544        self.run_pre_gate(winner).await;
6545        let shell = self.state.config.shell();
6546        let gate_commands = self.state.config.verify.gate.clone();
6547        // Zero commands has nothing to run and nothing that could touch the
6548        // shared build cache, so it never needs a lease: `Config::cache_dir`
6549        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
6550        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
6551        // otherwise queue behind an unrelated run's lease and come back
6552        // resource-blocked - `gate_ran` would stay false on nothing but
6553        // cache contention, for a step that had nothing to check in the
6554        // first place.
6555        let outcomes = if gate_commands.is_empty() {
6556            Vec::new()
6557        } else {
6558            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6559            let cache_dir = self.state.config.cache_dir();
6560            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6561            let (outcomes, _) = with_cache_lease(
6562                &mut self.state,
6563                cache_dir.as_deref(),
6564                "gate",
6565                "gate",
6566                &winner.worktree,
6567                &head,
6568                timeout,
6569                "final gate",
6570                |state, budget| {
6571                    let shell = shell.clone();
6572                    let gate_commands = gate_commands.clone();
6573                    let worktree = winner.worktree.clone();
6574                    async move {
6575                        let (outcomes, timed_out_pids) = run_commands(
6576                            state,
6577                            "gate",
6578                            "gate",
6579                            0,
6580                            &shell,
6581                            &gate_commands,
6582                            &worktree,
6583                            budget,
6584                        )
6585                        .await;
6586                        (outcomes, false, timed_out_pids)
6587                    }
6588                },
6589            )
6590            .await;
6591            outcomes
6592        };
6593        if outcomes.is_empty() {
6594            // Nothing configured to check — distinct from every other
6595            // silence in this run's event log, since an empty `gate` alone
6596            // no longer says whether the gate ran at all (see
6597            // `RunState::gate_ran`'s own doc).
6598            self.state.event(
6599                "gate",
6600                "no gate commands configured; nothing to check, passing",
6601            );
6602        }
6603        for o in &outcomes {
6604            self.state.event(
6605                "gate",
6606                format!(
6607                    "`{}` -> {}",
6608                    o.command,
6609                    if o.ok() {
6610                        "pass".to_owned()
6611                    } else {
6612                        format!(
6613                            "FAIL ({:?})\n{}",
6614                            o.code,
6615                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6616                        )
6617                    }
6618                ),
6619            );
6620        }
6621        Ok(outcomes)
6622    }
6623
6624    /// One bounded fix round for a failing gate.
6625    ///
6626    /// The fixer is told the failure came from the gate itself, not from a
6627    /// reviewer, and is shown the failed commands, their exit codes and a tail
6628    /// of their output - whatever `[verify].gate` holds, nothing here knows
6629    /// what those commands run. Only a normal non-zero exit that printed
6630    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6631    /// command or a full disk says nothing about the code, and a fixer sent
6632    /// after it can only appease the machine. The round is judged by what git
6633    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6634    /// again before the gate does, so a fix cannot trade a green gate for a
6635    /// red e2e unnoticed.
6636    async fn gate_fix_round(
6637        &mut self,
6638        winner: &Candidate,
6639        outcomes: &[CommandOutcome],
6640    ) -> Result<GateFix> {
6641        let cap = self.state.config.graph.gate_fix_rounds;
6642        let spent = self.state.gate_fixes.len();
6643        if spent >= cap {
6644            if cap > 0 {
6645                self.state.event(
6646                    "gate",
6647                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6648                );
6649            }
6650            return Ok(GateFix::Stop);
6651        }
6652        if !gate_fixable(outcomes) {
6653            self.state.event(
6654                "gate",
6655                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6656                 command or similar); not spending a fix round on it",
6657            );
6658            return Ok(GateFix::Stop);
6659        }
6660        let min_free = self.state.config.disk.min_free_bytes;
6661        if min_free > 0 {
6662            match crate::disk::free_bytes(&winner.worktree) {
6663                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6664                Ok(free) => {
6665                    self.state.event(
6666                        "gate",
6667                        format!(
6668                            "only {free} bytes free ({min_free} required by `[disk] \
6669                             min_free_bytes`); not spending a fix round on a failure the disk \
6670                             may explain"
6671                        ),
6672                    );
6673                    return Ok(GateFix::Stop);
6674                }
6675                Err(e) => {
6676                    self.state.event(
6677                        "gate",
6678                        format!("free disk space could not be measured ({e:#}); no fix round"),
6679                    );
6680                    return Ok(GateFix::Stop);
6681                }
6682            }
6683        }
6684
6685        let attempt = spent + 1;
6686        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6687        let base = self.landing_base();
6688        let fix_prompt = prompt::gate_fix_in(
6689            &self.state.instruction,
6690            &failed,
6691            attempt,
6692            cap,
6693            &self.state.config.graph.language,
6694            &self.state.config.graph.github_language,
6695        );
6696        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6697        let sessions = self.state.config.graph.sessions;
6698        let artifacts = agent::artifacts_dir(&self.state.dir());
6699        self.state.event(
6700            "gate",
6701            format!("gate failed; gate-fix round {attempt} of {cap}"),
6702        );
6703        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6704        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6705        let (_, seat, out) = self
6706            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6707                prompt: fix_prompt.clone(),
6708                spec,
6709                seat,
6710                cwd: winner.worktree.clone(),
6711                timeout,
6712                allow_write: true,
6713                sessions,
6714                artifacts: artifacts.clone(),
6715                stem: format!("gate-fix-{attempt}"),
6716                handover: None,
6717            })
6718            .await;
6719        // The fixer was never asked: no round is spent (nothing is recorded)
6720        // and a later reentry asks the same agent again.
6721        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6722            return Ok(GateFix::Defer);
6723        }
6724        let mut record = GateFixRecord {
6725            agent: seat.agent.clone(),
6726            failed,
6727            notes: String::new(),
6728            committed: false,
6729            error: None,
6730        };
6731        match out {
6732            AgentOutcome::Ok(o) => {
6733                // A missing report is not a failed fix: the round is judged
6734                // by the tree below, and the report only carries prose.
6735                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6736                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6737                }
6738            }
6739            AgentOutcome::Dropped(_) => {
6740                record.error = Some("the CLI dropped the stream".to_owned());
6741            }
6742            AgentOutcome::Quota(o) => {
6743                self.state.quota.push(QuotaLoss {
6744                    seat: seat.key.clone(),
6745                    node: "gate-fix".to_owned(),
6746                    at: Timestamp::now(),
6747                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6748                });
6749                record.error = Some("rate limited (quota); fixer could not run".to_owned());
6750            }
6751            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6752        }
6753        self.state.seats.insert(seat.key.clone(), seat);
6754        if let Ok(r) = git::rescue_commit(
6755            &winner.worktree,
6756            &format!("magi: gate fix {attempt} (uncommitted work)"),
6757        )
6758        .await
6759        {
6760            self.state.note_withheld("gate-fix", &r.withheld);
6761        }
6762        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6763        record.committed = after != before;
6764        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6765        let note = record.error.clone();
6766        self.state.gate_fixes.push(record);
6767        self.state.save()?;
6768        if !changed {
6769            self.state.event(
6770                "gate",
6771                match note {
6772                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6773                    None => format!("gate-fix round {attempt}: the tree did not change"),
6774                },
6775            );
6776            return Ok(GateFix::Stop);
6777        }
6778        self.state.event(
6779            "gate",
6780            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6781        );
6782
6783        let commands = self.state.config.verify.e2e.clone();
6784        if !commands.is_empty() {
6785            let shell = self.state.config.shell();
6786            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6787            let cache_dir = self.state.config.cache_dir();
6788            let context = format!("gate-fix round {attempt}");
6789            let (e2e, _) = with_cache_lease(
6790                &mut self.state,
6791                cache_dir.as_deref(),
6792                "e2e",
6793                "e2e",
6794                &winner.worktree,
6795                &after,
6796                timeout,
6797                &context,
6798                |state, budget| {
6799                    let shell = shell.clone();
6800                    let commands = commands.clone();
6801                    let context = context.clone();
6802                    let worktree = winner.worktree.clone();
6803                    async move {
6804                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6805                            .await
6806                    }
6807                },
6808            )
6809            .await;
6810            if verify_inconclusive(&e2e) {
6811                return Ok(GateFix::Defer);
6812            }
6813            if e2e.iter().any(|o| !o.ok()) {
6814                self.state.event(
6815                    "gate",
6816                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6817                );
6818                return Ok(GateFix::Stop);
6819            }
6820        }
6821        Ok(GateFix::Retry)
6822    }
6823
6824    // ---------------------------------------------------------------- merge
6825
6826    /// One read-only rewrite by the summary's author; a text the gate still
6827    /// withholds goes to the owner (see `github_text`) and the fixed fallback
6828    /// applies on their word or on silence. `None` means the run is parked on
6829    /// that question and nothing may be pushed or opened yet.
6830    async fn guarded_pr_message(
6831        &mut self,
6832        winner: &Candidate,
6833        facts: Option<&BranchFacts>,
6834        posting: bool,
6835    ) -> Result<Option<PrMessage>> {
6836        let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6837        let head = match final_head.as_deref() {
6838            Some(h) => crate::pr_outcome::FinalHead::At(h),
6839            None => crate::pr_outcome::FinalHead::Unknown,
6840        };
6841        // Only a pull request that is about to be posted pays for a summary.
6842        let summarized = if posting {
6843            Some(self.summarize_change(winner, final_head.as_deref()).await)
6844        } else {
6845            None
6846        };
6847        // A busy shared resource is not a permanent cause: publishing an
6848        // "unavailable" description would stick (a later head move never
6849        // edits the body). Stop before anything is pushed or opened; the
6850        // run is `Blocked` and a resume asks the summary again.
6851        if self.state.resource_block.is_some() {
6852            self.state.event(
6853                "pr-summary",
6854                "a shared resource was busy; nothing was published, resume to summarize again",
6855            );
6856            return Ok(None);
6857        }
6858        if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
6859            anyhow::bail!(
6860                "the final commit moved during public description generation; retry before publishing"
6861            );
6862        }
6863        let note = match (&summarized, &self.state.change_summary) {
6864            (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
6865            (Some(Err(why)), _) => ChangeNote::Unavailable(why),
6866            (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
6867            (None, _) => ChangeNote::NotAsked,
6868        };
6869        let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
6870        if !posting {
6871            let identity = crate::scrub::Identity::current();
6872            return Ok(Some(PrMessage {
6873                title: crate::scrub::scrub(&pr.title, &identity),
6874                body: crate::scrub::scrub(&pr.body, &identity),
6875            }));
6876        }
6877        // This very text was already rejected and asked about: no second
6878        // judge call, rewrite or question, just carry the record on.
6879        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6880        if let Some(known) = self
6881            .state
6882            .github_text
6883            .clone()
6884            .filter(|g| g.fingerprint == raw_fp)
6885        {
6886            return self.settle_withheld(winner, &pr, known).await;
6887        }
6888        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6889        let mut violations =
6890            crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
6891        let identity = crate::scrub::Identity::current();
6892        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6893            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6894            && !violations.contains(&crate::github_text::Violation::SensitiveData)
6895        {
6896            violations.push(crate::github_text::Violation::SensitiveData);
6897        }
6898        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
6899        // only a language violation starts one; sensitive data alone goes
6900        // straight to the owner, with the redacted text as the fallback.
6901        let has_language = violations
6902            .iter()
6903            .any(|v| *v != crate::github_text::Violation::SensitiveData);
6904        if self.state.config.graph.github_text_guard && !violations.is_empty() {
6905            self.state.event(
6906                "github-text",
6907                if has_language {
6908                    format!("description rejected: {violations:?}; requesting one rewrite")
6909                } else {
6910                    format!("description rejected: {violations:?}; asking the owner")
6911                },
6912            );
6913            let mut rewritten = false;
6914            let mut rejected_rewrite = None;
6915            if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
6916            {
6917                let output = text;
6918                if let Some(title) = summary_title(&output) {
6919                    // Judge the redacted candidate that will actually be posted.
6920                    // Sensitive data alone must not discard a useful rewrite.
6921                    let raw_body = summary_without_title(&output);
6922                    let clean_title = crate::scrub::scrub(&title, &identity);
6923                    let mut body = crate::scrub::scrub(&raw_body, &identity);
6924                    let redacted = clean_title != title || body != raw_body;
6925                    let title = clean_title;
6926                    let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
6927                    if !complete {
6928                        self.state.event(
6929                            "github-text",
6930                            "description rewrite omitted required findings",
6931                        );
6932                    }
6933                    if !body.trim().is_empty() {
6934                        body.push_str("\n\n");
6935                        body.push_str(&public_pr_evidence(&self.state, head));
6936                    }
6937                    let marker = format!("magi:run/{}", self.state.id);
6938                    if !body.trim().is_empty() && !body.contains(&marker) {
6939                        body.push_str(&format!("\n\n{marker}"));
6940                    }
6941                    let rewrite = PrMessage {
6942                        title: title.clone(),
6943                        body: body.clone(),
6944                    };
6945                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6946                    let mut failed = crate::github_text::check_for(
6947                        &self.state.config.graph,
6948                        &title,
6949                        &body,
6950                        redecision,
6951                    );
6952                    if (!crate::github_text::shareable(&title)
6953                        || !crate::github_text::shareable(&body))
6954                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
6955                    {
6956                        failed.push(crate::github_text::Violation::SensitiveData);
6957                    }
6958                    if complete
6959                        && !title.trim().is_empty()
6960                        && !body.trim().is_empty()
6961                        && !failed.is_empty()
6962                    {
6963                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
6964                            title: title.clone(),
6965                            body: body.clone(),
6966                            failed: failed
6967                                .iter()
6968                                .map(|v| crate::github_text::category(*v).to_owned())
6969                                .collect(),
6970                        });
6971                    }
6972                    if complete
6973                        && !title.trim().is_empty()
6974                        && !raw_body.trim().is_empty()
6975                        && failed.is_empty()
6976                    {
6977                        decision = redecision;
6978                        pr = PrMessage { title, body };
6979                        rewritten = true;
6980                        self.state
6981                            .event("github-text", "description rewrite passed");
6982                        if redacted {
6983                            self.state.event(
6984                                "github-text",
6985                                "sensitive data removed from description rewrite",
6986                            );
6987                        }
6988                    }
6989                }
6990            }
6991            if !rewritten {
6992                self.state.event(
6993                    "github-text",
6994                    "description rewrite unavailable or rejected; asking the owner",
6995                );
6996                if let Some(w) =
6997                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6998                {
6999                    // A different text than the one asked about before: that
7000                    // question no longer describes anything.
7001                    self.retire_github_text_question("the withheld text changed");
7002                    let record = crate::run::GithubTextAsk {
7003                        rewrite: rejected_rewrite,
7004                        fingerprint: raw_fp,
7005                        title: w.title,
7006                        body: w.body,
7007                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7008                        question: None,
7009                        asks: 0,
7010                        resolved: false,
7011                        chosen_title: None,
7012                        choice: None,
7013                    };
7014                    return self.settle_withheld(winner, &pr, record).await;
7015                }
7016            }
7017        }
7018        let (title, body) =
7019            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7020        Ok(Some(PrMessage { title, body }))
7021    }
7022
7023    /// Describe the whole change from its final diff in either run mode.
7024    ///
7025    /// The summary is tied to the final commit and to the merge base of the
7026    /// resolved remote base; a record for any other pair is dropped and the
7027    /// agent asked again, so a description never outlives the diff it was
7028    /// written from. The seat is read-only and the prompt tells it to read the
7029    /// diff and the code around it. `[roles] synthesizer` selects the agents
7030    /// (unset: the default pick), each asked at most once, advancing on an
7031    /// error, quota, unusable answer or a reply with no `TITLE:` line. `Err`
7032    /// carries the reason it is unavailable; nothing is invented then.
7033    async fn summarize_change(
7034        &mut self,
7035        winner: &Candidate,
7036        head: Option<&str>,
7037    ) -> std::result::Result<(), String> {
7038        let Some(head) = head else {
7039            return Err("the final commit could not be read".to_owned());
7040        };
7041        let remote = self.state.config.merge.remote.clone();
7042        let tracking = format!("{remote}/{}", self.state.base_branch);
7043        if !git::rev_exists(&winner.worktree, &tracking).await {
7044            return Err(format!("the remote base `{tracking}` could not be read"));
7045        }
7046        let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7047            Ok(b) if !b.is_empty() => b,
7048            _ => return Err("the merge base with the remote base could not be read".to_owned()),
7049        };
7050        let evidence = crate::pr_outcome::summary_evidence(
7051            &self.state,
7052            crate::pr_outcome::FinalHead::At(head),
7053        );
7054        let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7055        if self
7056            .state
7057            .change_summary
7058            .as_ref()
7059            .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7060        {
7061            return Ok(());
7062        }
7063        if self.state.change_summary.take().is_some() {
7064            self.state.event(
7065                "pr-summary",
7066                "the final diff or evidence changed; the earlier change summary was discarded",
7067            );
7068        }
7069        // Save the source snapshot even if no generation agent is available.
7070        if let Err(e) = write_artifact(
7071            &self.state,
7072            &format!("public-description-{fingerprint}.md"),
7073            &format!("Final diff: {base}..{head}\n\n{evidence}"),
7074        ) {
7075            tracing::warn!("could not save public description evidence: {e:#}");
7076        }
7077        let chain = match agent::pick_chain(
7078            &self.state.config.agents,
7079            self.state.config.roles.synthesizer.as_ref(),
7080            &agent::installed,
7081            "synthesizer",
7082        ) {
7083            Ok(chain) => chain,
7084            Err(e) => {
7085                tracing::warn!("no agent for the change summary: {e:#}");
7086                self.state.event("pr-summary", "no summary agent available");
7087                return Err("no agent was available to summarize it".to_owned());
7088            }
7089        };
7090        let subjects = git::subjects(&winner.worktree, &base, head)
7091            .await
7092            .unwrap_or_default();
7093        let stat = git::diff_stat(&winner.worktree, &base, head)
7094            .await
7095            .unwrap_or_default();
7096        let stat: String = stat.chars().take(6_000).collect();
7097        let mut prompt = crate::prompt::change_summary_in(
7098            &base,
7099            head,
7100            &subjects,
7101            stat.trim(),
7102            &self.state.config.graph.github_language,
7103        );
7104        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."));
7105        let sessions = self.state.config.graph.sessions;
7106        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7107        let prompts = self.state.config.prompts.clone();
7108        let cache = self.state.config.cache_dir();
7109        let run = self.state.id.clone();
7110        for (i, spec) in chain.into_iter().enumerate() {
7111            let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7112            let job = SeatJob {
7113                spec: spec.clone(),
7114                seat,
7115                cwd: winner.worktree.clone(),
7116                prompt: prompt.clone(),
7117                timeout,
7118                allow_write: false,
7119                sessions,
7120                artifacts: agent::artifacts_dir(&self.state.dir()),
7121                stem: if i == 0 {
7122                    "change-summary".to_owned()
7123                } else {
7124                    format!("change-summary-{}", spec.id)
7125                },
7126                handover: None,
7127            };
7128            let ctx = WaveCtx {
7129                run: &run,
7130                node: "pr-summary",
7131                prompts: &prompts,
7132                cache: cache.as_deref(),
7133                round: None,
7134                carry_seats: false,
7135                pauses: Some((&self.pause, &self.interrupt)),
7136            };
7137            let (seat, outcome) =
7138                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7139            self.state.seats.insert(seat.key.clone(), seat);
7140            let text = match outcome {
7141                AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7142                AgentOutcome::ResourceBlocked(why) => {
7143                    // No agent was asked: the run stops resumably before
7144                    // anything is published (see `guarded_pr_message`).
7145                    if self.state.resource_block.is_none() {
7146                        self.state.resource_block = Some(crate::run::ResourceBlock {
7147                            node: "pr-summary".to_owned(),
7148                            kind: "shared resource".to_owned(),
7149                            detail: why,
7150                            at: Timestamp::now(),
7151                        });
7152                    }
7153                    return Err("a shared resource was busy".to_owned());
7154                }
7155                _ => continue,
7156            };
7157            let blind = &self.state.config.blind;
7158            let Some(title) = summary_title(&text) else {
7159                continue;
7160            };
7161            let body: String = summary_without_title(&text)
7162                .lines()
7163                .filter(|l| !l.contains("magi:run/"))
7164                .collect::<Vec<_>>()
7165                .join("\n");
7166            let body = blind::sanitize_prose(body.trim(), blind);
7167            let title = blind::sanitize_prose(&title, blind);
7168            if body.trim().is_empty() || title.trim().is_empty() {
7169                continue;
7170            }
7171            if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7172                continue;
7173            }
7174            // The branch must still be the one the agent was shown.
7175            if git::rev_parse(&winner.worktree, "HEAD")
7176                .await
7177                .ok()
7178                .as_deref()
7179                != Some(head)
7180            {
7181                return Err("the branch moved while the summary was written".to_owned());
7182            }
7183            if git::merge_base(&winner.worktree, &tracking, head)
7184                .await
7185                .ok()
7186                .as_deref()
7187                != Some(base.as_str())
7188                || crate::pr_outcome::summary_evidence(
7189                    &self.state,
7190                    crate::pr_outcome::FinalHead::At(head),
7191                ) != evidence
7192            {
7193                return Err("the summary evidence changed while it was written".to_owned());
7194            }
7195            self.state.change_summary = Some(crate::run::ChangeSummary {
7196                head: head.to_owned(),
7197                base,
7198                evidence: fingerprint,
7199                title,
7200                body,
7201            });
7202            self.state.event(
7203                "pr-summary",
7204                format!("change summary written by {} for the final diff", spec.id),
7205            );
7206            if let Err(e) = self.state.save() {
7207                tracing::warn!("could not save the change summary: {e:#}");
7208            }
7209            return Ok(());
7210        }
7211        self.state
7212            .event("pr-summary", "no agent produced a change summary");
7213        Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7214    }
7215
7216    /// One read-only rewrite request for a withheld description. The
7217    /// implementer's own conversation is reused only when it exists and can be
7218    /// resumed; otherwise the `[roles] language_judge` chain (or the default
7219    /// pick) writes it in a separate conversation. The prompt carries the whole
7220    /// text, so no agent needs any memory. Each agent is asked at most once and
7221    /// the chain advances on an error, quota, unusable answer or a reply with
7222    /// no `TITLE:` line. Nothing is recorded as a handover.
7223    async fn rewrite_pr_text(
7224        &mut self,
7225        winner: &Candidate,
7226        pr: &PrMessage,
7227        violations: &[crate::github_text::Violation],
7228    ) -> Option<String> {
7229        let prompt = format!(
7230            "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{}",
7231            pr.title, pr.body
7232        );
7233        let sessions = self.state.config.graph.sessions;
7234        let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7235        // (spec, seat, resumed)
7236        let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7237        let key = format!("impl-{}", winner.label);
7238        if let Some(spec) = self
7239            .state
7240            .config
7241            .agents
7242            .iter()
7243            .find(|a| a.id == winner.agent)
7244            .cloned()
7245            && let Some(existing) = self.state.seats.get(&key)
7246            && existing.agent == spec.id
7247            && agent::has_session(spec.kind, existing, sessions)
7248        {
7249            attempts.push((spec, existing.clone(), true));
7250        } else {
7251            let chain = agent::pick_chain(
7252                &self.state.config.agents,
7253                self.state.config.roles.language_judge.as_ref(),
7254                &agent::installed,
7255                "language_judge",
7256            );
7257            match chain {
7258                Ok(chain) => {
7259                    for spec in chain {
7260                        let seat = crate::fixer::seat_for(
7261                            &mut self.state,
7262                            "github-text-rewrite",
7263                            &spec.id,
7264                        );
7265                        attempts.push((spec, seat, false));
7266                    }
7267                }
7268                Err(e) => {
7269                    tracing::warn!("no agent for the description rewrite: {e:#}");
7270                    self.state
7271                        .event("github-text", "no rewrite agent available");
7272                    return None;
7273                }
7274            }
7275        }
7276        let prompts = self.state.config.prompts.clone();
7277        let cache = self.state.config.cache_dir();
7278        let run = self.state.id.clone();
7279        for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7280            let job = SeatJob {
7281                spec: spec.clone(),
7282                seat,
7283                cwd: winner.worktree.clone(),
7284                prompt: prompt.clone(),
7285                timeout: retry_budget(timeout_full, resumed),
7286                allow_write: false,
7287                sessions,
7288                artifacts: agent::artifacts_dir(&self.state.dir()),
7289                stem: if i == 0 {
7290                    "github-text-rewrite".to_owned()
7291                } else {
7292                    format!("github-text-rewrite-{}", spec.id)
7293                },
7294                handover: None,
7295            };
7296            let ctx = WaveCtx {
7297                run: &run,
7298                node: "github-text",
7299                prompts: &prompts,
7300                cache: cache.as_deref(),
7301                round: None,
7302                carry_seats: false,
7303                pauses: Some((&self.pause, &self.interrupt)),
7304            };
7305            let (seat, outcome) =
7306                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7307            self.state.seats.insert(seat.key.clone(), seat);
7308            match outcome {
7309                AgentOutcome::Ok(o)
7310                    if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7311                {
7312                    return Some(o.text);
7313                }
7314                AgentOutcome::ResourceBlocked(_) => return None,
7315                _ => {}
7316            }
7317        }
7318        None
7319    }
7320
7321    /// Abandon the standing posting-gate question, if any.
7322    fn retire_github_text_question(&mut self, why: &str) {
7323        let Some(id) = self
7324            .state
7325            .github_text
7326            .as_ref()
7327            .and_then(|g| g.question.clone())
7328        else {
7329            return;
7330        };
7331        let _ = ask::Questions::open().update(&id, |q| {
7332            q.abandon(why);
7333            Ok(())
7334        });
7335    }
7336
7337    /// Carry a withheld text to its end: file the question, park while it is
7338    /// open, and on an answer, silence or timeout pick the text to post. The
7339    /// decision is saved before it is used, so it applies once.
7340    async fn settle_withheld(
7341        &mut self,
7342        winner: &Candidate,
7343        pr: &PrMessage,
7344        mut g: crate::run::GithubTextAsk,
7345    ) -> Result<Option<PrMessage>> {
7346        use crate::github_text as gt;
7347        let store = ask::Questions::open();
7348        let timeout = self.state.config.graph.answer_timeout;
7349        if !g.resolved {
7350            let mut standing = g
7351                .question
7352                .as_ref()
7353                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7354            if let Some(q) = standing.as_ref()
7355                && q.status.open()
7356                && gt::expired(q, timeout)
7357            {
7358                let why = format!("no answer within {}s of asking", timeout.max(1));
7359                standing = store
7360                    .update(&q.id, |q| {
7361                        q.abandon(&why);
7362                        Ok(())
7363                    })
7364                    .ok()
7365                    .map(|(q, ())| q);
7366            }
7367            let mut ask_again: Option<Option<Vec<String>>> = None;
7368            match standing {
7369                // Recorded but never written (a stop between the two saves),
7370                // or removed: ask again without spending the retry.
7371                None if g.asks <= 1 => {
7372                    g.asks = 0;
7373                    ask_again = Some(None);
7374                }
7375                None => {
7376                    g.resolved = true;
7377                    g.chosen_title = None;
7378                    g.choice = Some(GithubTextChoice::Fallback);
7379                }
7380                Some(q) if q.status.open() => {
7381                    self.park_on_github_text(&g)?;
7382                    return Ok(None);
7383                }
7384                Some(q) => match gt::read_reply(&q) {
7385                    gt::Reply::Fallback => {
7386                        g.resolved = true;
7387                        g.chosen_title = None;
7388                        g.choice = Some(GithubTextChoice::Fallback);
7389                    }
7390                    gt::Reply::Original => {
7391                        g.resolved = true;
7392                        g.chosen_title = None;
7393                        g.choice = Some(GithubTextChoice::Original);
7394                    }
7395                    gt::Reply::Rewrite => {
7396                        g.resolved = true;
7397                        g.chosen_title = None;
7398                        g.choice = Some(if g.rewrite.is_some() {
7399                            GithubTextChoice::Rewrite
7400                        } else {
7401                            GithubTextChoice::Fallback
7402                        });
7403                    }
7404                    gt::Reply::Title(text) => {
7405                        let first = text
7406                            .lines()
7407                            .map(str::trim)
7408                            .find(|l| !l.is_empty())
7409                            .unwrap_or("");
7410                        let decision = if first.is_empty() {
7411                            None
7412                        } else {
7413                            let candidate = PrMessage {
7414                                title: first.to_owned(),
7415                                body: String::new(),
7416                            };
7417                            self.judge_pr_language(&winner.worktree, &candidate).await
7418                        };
7419                        match gt::vet_title(
7420                            &text,
7421                            decision,
7422                            self.state.config.graph.github_english(),
7423                        ) {
7424                            Ok(title) => {
7425                                g.resolved = true;
7426                                g.chosen_title = Some(title);
7427                                g.choice = Some(GithubTextChoice::MyTitle);
7428                            }
7429                            Err(cats) if g.asks < 2 => {
7430                                ask_again = Some(Some(
7431                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7432                                ))
7433                            }
7434                            Err(_) => {
7435                                g.resolved = true;
7436                                g.chosen_title = None;
7437                                g.choice = Some(GithubTextChoice::Fallback);
7438                            }
7439                        }
7440                    }
7441                },
7442            }
7443            if let Some(rejected) = ask_again {
7444                // A retry (after a rejected replacement title) is still about
7445                // the original text: the same ORIGINAL / FALLBACK comparison.
7446                let w = gt::Withheld {
7447                    title: g.title,
7448                    body: g.body,
7449                    categories: g.categories.clone(),
7450                };
7451                // Named by the original text's fingerprint alone, so a resume
7452                // rewrites the same file and the detail stays identical.
7453                let artifact = match crate::run::write_artifact(
7454                    &self.state,
7455                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
7456                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7457                ) {
7458                    Ok(p) => Some(p),
7459                    Err(e) => {
7460                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
7461                        None
7462                    }
7463                };
7464                let (detail, comparison) = gt::question_content(
7465                    &w,
7466                    &pr.title,
7467                    &pr.body,
7468                    &self.state.id,
7469                    rejected.as_deref(),
7470                    artifact.as_deref(),
7471                    g.rewrite.as_ref(),
7472                );
7473                let mut q = ask::Question::new(
7474                    self.state.id.clone(),
7475                    gt::ASK_NODE.to_owned(),
7476                    gt::ASK_SEAT.to_owned(),
7477                    gt::question_summary(&self.state.config.graph.language, &w),
7478                    detail,
7479                    gt::question_choices(&w, g.rewrite.as_ref()),
7480                );
7481                q.posting_comparison = Some(comparison);
7482                q.answer_timeout = timeout;
7483                // Recorded before it is written: a stop in between leaves a
7484                // record naming a question the resume then files again.
7485                g.question = Some(q.id.clone());
7486                g.asks += 1;
7487                self.state.github_text = Some(g.clone());
7488                self.state.event(
7489                    "github-text",
7490                    format!("asking the owner about the withheld text ({})", q.short()),
7491                );
7492                self.state.save()?;
7493                store
7494                    .put(&mut q)
7495                    .context("file the posting-gate question")?;
7496                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7497                    tracing::warn!(
7498                        "could not notify about posting-gate question {}: {e:#}",
7499                        q.short()
7500                    );
7501                }
7502                self.park_on_github_text(&g)?;
7503                return Ok(None);
7504            }
7505        }
7506        let resolved = g.chosen_title.clone();
7507        let original = g.choice == Some(GithubTextChoice::Original);
7508        // Field names only: the event log never records the text.
7509        self.state.event(
7510            "github-text",
7511            if original {
7512                format!(
7513                    "posting the original text for: {}",
7514                    match (g.title, g.body) {
7515                        (true, true) => "title, description",
7516                        (true, false) => "title",
7517                        _ => "description",
7518                    }
7519                )
7520            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7521                "posting the saved rewrite title and description".to_owned()
7522            } else if resolved.is_some() {
7523                "posting the owner's replacement title".to_owned()
7524            } else {
7525                "posting the neutral text for the withheld fields".to_owned()
7526            },
7527        );
7528        self.state.github_text = Some(g.clone());
7529        self.state.save()?;
7530        if g.choice == Some(GithubTextChoice::Rewrite)
7531            && let Some(r) = g.rewrite.as_ref()
7532        {
7533            return Ok(Some(PrMessage {
7534                title: r.title.clone(),
7535                body: r.body.clone(),
7536            }));
7537        }
7538        let w = gt::Withheld {
7539            title: g.title,
7540            body: g.body,
7541            categories: g.categories.clone(),
7542        };
7543        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7544        if let Some(t) = resolved {
7545            title = t;
7546        }
7547        // Every withheld field is now neutral or vetted English.
7548        let english = gt::LanguageDecision {
7549            title_english: true,
7550            body_english: true,
7551            body_complete: true,
7552        };
7553        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7554        if original {
7555            // The owner's explicit choice overrides the gate for the fields it
7556            // was made for; the others keep what the gate produced.
7557            if g.title {
7558                title = pr.title.clone();
7559            }
7560            if g.body {
7561                body = pr.body.clone();
7562            }
7563        }
7564        Ok(Some(PrMessage { title, body }))
7565    }
7566
7567    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7568        self.state.github_text = Some(g.clone());
7569        self.state.end_pass_parked(ParkCause::Question);
7570        self.state.parked = true;
7571        self.state.event(
7572            "github-text",
7573            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7574        );
7575        self.state.save()
7576    }
7577
7578    /// Ask `[roles] language_judge` about this text and record which source
7579    /// decided; the text itself is never recorded.
7580    async fn judge_pr_language(
7581        &mut self,
7582        cwd: &Path,
7583        pr: &PrMessage,
7584    ) -> Option<crate::github_text::LanguageDecision> {
7585        self.state.config.roles.language_judge.as_ref()?;
7586        if !self.state.config.graph.language_gate() {
7587            return None;
7588        }
7589        let decision =
7590            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7591        self.state.event(
7592            "github-text",
7593            if decision.is_some() {
7594                "language decided by the language judge"
7595            } else {
7596                "language judge unavailable; using built-in heuristics"
7597            },
7598        );
7599        decision
7600    }
7601
7602    async fn merge(&mut self) -> Result<()> {
7603        // Same reasoning as `gate`: ask the review and gate records directly
7604        // rather than `status`, which a solo-candidate `judge`/`deliberate`
7605        // skip can rewrite on reentry to something that no longer says
7606        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
7607        // so a hand-off (open findings, green verification) reaches merge
7608        // exactly like a genuinely clean round does.
7609        //
7610        // A run resumed mid-`land` never reaches here at all: `execute`
7611        // recognises `RunStatus::Landing` before it even calls `prep`, and
7612        // routes straight to `run_land` instead. That has to happen a level
7613        // up from this function, not with a check in here, because
7614        // `review_loop`'s own status recomputation (see its doc) runs
7615        // *before* `merge` on every reentry and would otherwise overwrite
7616        // the `Landing` marker with `Gating` before this node ever saw it.
7617        if self
7618            .state
7619            .base_sync
7620            .as_ref()
7621            .is_some_and(|s| s.conflict.is_some())
7622            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7623                != Some(RunStatus::Gating)
7624            // `gate_ran == false` is not "passed" - `gate` leaves it false
7625            // both before it has ever run and when its last attempt was
7626            // resource-blocked (see `Runner::gate`'s own doc), and neither is
7627            // permission to merge on nothing but the review record. Only a
7628            // gate that actually ran - zero commands configured and
7629            // vacuously passed, or one or more that all exited 0 - may
7630            // proceed; `RunState::gate_status` is the single place that
7631            // reading is computed.
7632            || !self.state.gate_status().ok()
7633        {
7634            return Ok(());
7635        }
7636        // This node's own record, not `status`: `status == Ready` is not
7637        // unique to the harmless `MergeMode::None` path this line was
7638        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
7639        // run's PR was closed without merging — and on that run `mode` is
7640        // still `Pr`, so a reentry that fell through here would push and
7641        // open a second pull request. `self.state.merge` is set exactly once
7642        // this node (or `land`) has already produced a verdict, under every
7643        // mode, which is what "already done" actually means here.
7644        if self.state.merge.is_some() && !self.state.pr_publication_pending {
7645            return Ok(());
7646        }
7647        let Some(winner) = self.state.winner().cloned() else {
7648            return Ok(());
7649        };
7650        let repo = self.state.repo.clone();
7651        let base = self.state.base_branch.clone();
7652        let mode = self.state.config.merge.mode;
7653        let style = self.state.config.merge.style;
7654        let facts = if is_review_run(&self.state) {
7655            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7656            let start = review_base(
7657                &repo,
7658                &self.state.config.merge.remote,
7659                &base,
7660                &self.state.base_commit,
7661                &winner.branch,
7662            )
7663            .await;
7664            branch_facts(&repo, &start, &winner.branch).await
7665        } else {
7666            None
7667        };
7668        // `None` when the base could not be freshly read: then an adopted
7669        // pull request's title is left alone.
7670        let leaked = if is_review_run(&self.state) {
7671            leaked_subjects(&self.state, &winner.branch).await
7672        } else {
7673            Some(Vec::new())
7674        };
7675        // `None`: parked on the owner's word about a withheld title or body.
7676        // Nothing has been pushed or opened, and `merge` stays unrecorded so
7677        // the resume arrives here again.
7678        let Some(pr) = self
7679            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7680            .await?
7681        else {
7682            return Ok(());
7683        };
7684        let message = pr.commit_message();
7685
7686        let outcome = match mode {
7687            MergeMode::None => MergeOutcome {
7688                mode,
7689                ok: true,
7690                detail: manual_merge_command(style, &repo, &winner.branch, &message),
7691                empty: false,
7692            },
7693            MergeMode::Pr | MergeMode::Local
7694                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7695            {
7696                MergeOutcome {
7697                    mode,
7698                    ok: false,
7699                    detail: empty_candidate_detail(&self.state, &base),
7700                    empty: true,
7701                }
7702            }
7703            MergeMode::Local => {
7704                let on = git::current_branch(&repo).await?;
7705                if on.as_deref() != Some(base.as_str()) {
7706                    MergeOutcome {
7707                        mode,
7708                        ok: false,
7709                        detail: format!(
7710                            "{} has {} checked out, not the base branch {base}",
7711                            repo.display(),
7712                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
7713                        ),
7714                        empty: false,
7715                    }
7716                } else if !git::is_clean(&repo).await? {
7717                    MergeOutcome {
7718                        mode,
7719                        ok: false,
7720                        detail: format!("{} is dirty; refusing to merge", repo.display()),
7721                        empty: false,
7722                    }
7723                } else {
7724                    let out = match style {
7725                        MergeStyle::Merge => {
7726                            git::merge_no_ff(&repo, &winner.branch, &message).await?
7727                        }
7728                        MergeStyle::Squash => {
7729                            git::merge_squash(&repo, &winner.branch, &message).await?
7730                        }
7731                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7732                    };
7733                    MergeOutcome {
7734                        mode,
7735                        ok: out.ok(),
7736                        detail: if out.ok() { out.stdout } else { out.stderr },
7737                        empty: false,
7738                    }
7739                }
7740            }
7741            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7742                self.state.pr_publication_pending = true;
7743                self.state.save()?;
7744                let out =
7745                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7746                self.state.pr_publication_pending = out.is_err();
7747                MergeOutcome {
7748                    mode,
7749                    ok: out.is_ok(),
7750                    detail: match out {
7751                        Ok(url) => url,
7752                        Err(e) => format!("{e:#}"),
7753                    },
7754                    empty: false,
7755                }
7756            }
7757            MergeMode::Pr => {
7758                let remote = self.state.config.merge.remote.clone();
7759                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7760                if !pushed.ok() {
7761                    MergeOutcome {
7762                        mode,
7763                        ok: false,
7764                        detail: pushed.stderr,
7765                        empty: false,
7766                    }
7767                } else {
7768                    // A retry or resume of a run whose branch already has an
7769                    // open pull request adopts it rather than failing on a
7770                    // duplicate. Only this winner branch into this base:
7771                    // `branch_for` derives the name from the run id, so a
7772                    // different run's pull request never matches.
7773                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7774                    let out = match pr_merge_plan(found) {
7775                        PrPlan::Create => {
7776                            gh_pr_create(
7777                                &winner.worktree,
7778                                &base,
7779                                &winner.branch,
7780                                &pr.title,
7781                                &pr.body,
7782                            )
7783                            .await
7784                        }
7785                        PrPlan::Adopt { url, title } => {
7786                            self.state
7787                                .event("merge", format!("Pr: adopted open pull request {url}"));
7788                            if self.state.github_text.as_ref().is_some_and(|g| {
7789                                g.resolved
7790                                    && g.choice == Some(GithubTextChoice::Rewrite)
7791                                    && g.rewrite.is_some()
7792                            }) {
7793                                // The owner chose the entire pair, even if the
7794                                // existing title already matches or was edited.
7795                                land::set_pr_text_verbatim(
7796                                    &winner.worktree,
7797                                    &url,
7798                                    &pr.title,
7799                                    &pr.body,
7800                                )
7801                                .await
7802                                .map(|()| url)
7803                            } else {
7804                                if title != pr.title
7805                                    && (!is_review_run(&self.state)
7806                                        || leaked
7807                                            .as_deref()
7808                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7809                                    && let Err(e) =
7810                                        if self.state.github_text.as_ref().is_some_and(|g| {
7811                                            g.resolved
7812                                                && g.title
7813                                                && g.choice == Some(GithubTextChoice::Original)
7814                                        }) {
7815                                            // The owner chose to post this title unmodified.
7816                                            land::set_pr_title_verbatim(
7817                                                &winner.worktree,
7818                                                &url,
7819                                                &pr.title,
7820                                            )
7821                                            .await
7822                                        } else {
7823                                            land::set_pr_title(
7824                                                &mut self.state,
7825                                                &winner.worktree,
7826                                                &url,
7827                                                &pr.title,
7828                                            )
7829                                            .await
7830                                        }
7831                                {
7832                                    tracing::warn!("could not refresh title of {url}: {e:#}");
7833                                    self.state
7834                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
7835                                }
7836                                Ok(url)
7837                            }
7838                        }
7839                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7840                    };
7841                    match out {
7842                        Ok(url) => MergeOutcome {
7843                            mode,
7844                            ok: true,
7845                            detail: url,
7846                            empty: false,
7847                        },
7848                        Err(e) => MergeOutcome {
7849                            mode,
7850                            ok: false,
7851                            detail: e.to_string(),
7852                            empty: false,
7853                        },
7854                    }
7855                }
7856            }
7857        };
7858
7859        self.state.status = match (mode, outcome.ok) {
7860            (MergeMode::None, _) => RunStatus::Ready,
7861            (_, true) => RunStatus::Merged,
7862            (_, false) => RunStatus::Blocked,
7863        };
7864        self.state.event(
7865            "merge",
7866            format!(
7867                "{:?}: {}",
7868                mode,
7869                outcome.detail.lines().next().unwrap_or("")
7870            ),
7871        );
7872        self.state.merge = Some(outcome);
7873        self.state.save()?;
7874
7875        // The PR is open and the run would historically stop here, leaving the
7876        // operator to watch checks, feed review comments back to a fixer, and
7877        // merge. That was done by hand six times in one session before this
7878        // existed. Opt-in, because merging is the one irreversible thing magi
7879        // can do to a repository.
7880        if self.state.config.graph.land
7881            && mode == MergeMode::Pr
7882            && self.state.status == RunStatus::Merged
7883        {
7884            self.run_land().await?;
7885        }
7886        // `run_land` may have left `status` at `Landing` - still waiting on
7887        // CI or the owner's approval, not actually settled - so this has to
7888        // read whatever `status` ended up as here, not the `Merged` this
7889        // function set a few lines up.
7890        self.settle_questions();
7891        Ok(())
7892    }
7893
7894    /// Enter `land`.
7895    ///
7896    /// Shared between a fresh run's first pass through [`Runner::merge`] and
7897    /// a resumed run's re-entry. `land::land` itself is what serialises the
7898    /// two git-mutating moments inside the loop — the rebase push and
7899    /// `gh pr merge` — per repository (see its own doc); nothing here needs
7900    /// to hold a lock across the whole call, and doing so would serialise
7901    /// this run's CI wait against a *different* run's land-approval resume
7902    /// in the same repository, which is exactly the "must not wait on
7903    /// another task" property the daemon's slot-freeing exists to give.
7904    async fn run_land(&mut self) -> Result<()> {
7905        crate::pr_head::ensure_owner(&self.state)?;
7906        let url = self
7907            .state
7908            .merge
7909            .as_ref()
7910            .map(|m| m.detail.clone())
7911            .unwrap_or_default();
7912        let url = url.lines().next().unwrap_or("").trim().to_owned();
7913        if !url.starts_with("http") {
7914            return Ok(());
7915        }
7916        // A land failure is not a lost run: the work is on a branch and the
7917        // pull request is open, which is exactly where a human takes over.
7918        match land::land(&mut self.state, &url).await {
7919            Ok(pr) if self.state.parked => {
7920                // `land` already saved the parked marker; nothing here
7921                // overrides `status` back to a terminal value while an
7922                // approval is still outstanding.
7923                let _ = pr;
7924            }
7925            Ok(pr) => {
7926                self.state.status = match pr.state {
7927                    land::PrLifecycle::Merged => RunStatus::Merged,
7928                    _ => RunStatus::Blocked,
7929                };
7930                // Downstream of a confirmed merge only - see
7931                // `bump::should_release_bump`'s own doc for why this one
7932                // check covers all three of `land`'s success paths.
7933                // Best-effort: the run already landed, so a failure here
7934                // (the decision call, `gh`, `cargo`) is recorded and never
7935                // turns a landed run into a failed one.
7936                if bump::should_release_bump(self.state.status)
7937                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7938                {
7939                    // The event is the run's own record. Not-eligible cases
7940                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
7941                    // `Err` is a bump that was tried and failed:
7942                    // `after_merge` itself raises the operator notice for
7943                    // that, whether or not a release PR exists yet.
7944                    self.state
7945                        .event("bump", format!("release bump skipped: {e:#}"));
7946                }
7947                // Independent of the bump, and best-effort in the same way:
7948                // findings the merge left open become follow-up tasks.
7949                if self.state.status == RunStatus::Merged {
7950                    crate::followup::after_merge(&mut self.state, &pr.url).await;
7951                }
7952                self.state.save()?;
7953            }
7954            Err(e) => {
7955                self.state.status = RunStatus::Blocked;
7956                self.state.event("land", format!("gave up: {e}"));
7957                self.state.save()?;
7958            }
7959        }
7960        Ok(())
7961    }
7962
7963    // -------------------------------------------------------------- helpers
7964
7965    /// Fetch or create a seat, keeping its conversation across nodes.
7966    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7967        if let Some(existing) = self.state.seats.get(key)
7968            && existing.agent == agent
7969        {
7970            return existing.clone();
7971        }
7972        // A seat that changes agent mints its session id from the agent too,
7973        // like a handover: the old agent's uuid is already taken by the CLI.
7974        let fresh = if self.state.seats.contains_key(key) {
7975            handover_seat(key, agent, self.state.next_seat_seed())
7976        } else {
7977            SeatState::new(key, agent, self.state.seed)
7978        };
7979        self.state.seats.insert(key.to_owned(), fresh.clone());
7980        fresh
7981    }
7982
7983    /// The agent now holding seat `key`: `spec`, unless a handover moved the
7984    /// seat to another roster agent, in which case that agent. Nodes that
7985    /// continue a seat's conversation (deliberation, the votes, a reviewer's
7986    /// reconsideration) must keep talking to whoever answered it, not slip
7987    /// back to the agent that failed it.
7988    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7989        match self.state.seats.get(key) {
7990            Some(s) if s.agent != spec.id => {
7991                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7992            }
7993            _ => spec,
7994        }
7995    }
7996
7997    /// A candidate rendered for judging, with the leak policy applied.
7998    fn view(&self, c: &Candidate) -> CandidateView {
7999        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8000            .unwrap_or_default();
8001        let (patch, _) = blind::sanitize_patch(
8002            &format!("candidate {} patch", c.label),
8003            &raw,
8004            &self.state.config.blind,
8005        );
8006        CandidateView {
8007            label: c.label,
8008            branch: c.branch.clone(),
8009            summary: c.summary.clone(),
8010            stat: c.stat.clone(),
8011            patch,
8012        }
8013    }
8014
8015    /// The full candidate set as prompt text, for seats with no live session.
8016    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8017        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8018        prompt::judge(
8019            "(see above)",
8020            &views,
8021            self.roles.judges.len(),
8022            base_short,
8023            "en",
8024        )
8025    }
8026
8027    /// The final-vote prompt with everything a seat that has no session of its
8028    /// own needs: the candidates, the seat's own ranking and reasons, and the
8029    /// anonymised deliberation it took part in (only when there was one, so a
8030    /// handed-over seat never sees more than the seat it replaces did). The
8031    /// `Final vote` heading stays first.
8032    fn vote_prompt_full(
8033        &self,
8034        j: usize,
8035        labels: &[char],
8036        language: &str,
8037        candidates: &[Candidate],
8038        base_short: &str,
8039    ) -> String {
8040        let mut text = format!(
8041            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8042            prompt::final_vote(labels, language),
8043            self.state.instruction,
8044            self.candidate_block(candidates, base_short)
8045        );
8046        if let Some(own) = self
8047            .state
8048            .judgements
8049            .get(j)
8050            .filter(|r| !r.ranking.is_empty())
8051        {
8052            let reasons = own
8053                .reasons
8054                .iter()
8055                .map(|(k, v)| format!("- {k}: {v}"))
8056                .collect::<Vec<_>>()
8057                .join("\n");
8058            text.push_str(&format!(
8059                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8060                own.ranking.iter().collect::<String>(),
8061                if reasons.is_empty() {
8062                    ""
8063                } else {
8064                    ", because:\n"
8065                }
8066            ));
8067        }
8068        if !self.state.deliberation.is_empty() {
8069            text.push_str("\n# What was argued before this vote\n");
8070            for t in self.transcript(&[], j) {
8071                text.push_str(&format!(
8072                    "\n## {}{}\n\n{}\n",
8073                    t.who,
8074                    if t.is_self { " (you)" } else { "" },
8075                    t.body.trim()
8076                ));
8077            }
8078        }
8079        text
8080    }
8081
8082    /// Anonymised transcript for judge `self_idx`.
8083    ///
8084    /// The initial rankings are always the opening statements. Seeding them
8085    /// only when no turn had been taken yet meant every judge after the first
8086    /// argued against a single voice instead of against the actual split — the
8087    /// disagreement is the information, so it is always on the table.
8088    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8089        let mut turns = Vec::new();
8090        for j in &self.state.judgements {
8091            if j.ranking.is_empty() {
8092                continue;
8093            }
8094            let reasons = j
8095                .reasons
8096                .iter()
8097                .map(|(k, v)| format!("- {k}: {v}"))
8098                .collect::<Vec<_>>()
8099                .join("\n");
8100            turns.push(Turn {
8101                who: format!("Judge {} (opening ranking)", j.judge),
8102                is_self: j.judge == self_idx + 1,
8103                body: format!(
8104                    "Ranked {}{}{reasons}",
8105                    j.ranking.iter().collect::<String>(),
8106                    if reasons.is_empty() {
8107                        ""
8108                    } else {
8109                        ", because:\n"
8110                    }
8111                ),
8112            });
8113        }
8114        for t in self
8115            .state
8116            .deliberation
8117            .iter()
8118            .flat_map(|r| r.turns.iter())
8119            .chain(current)
8120        {
8121            turns.push(Turn {
8122                who: format!("Judge {}", t.judge),
8123                is_self: t.judge == self_idx + 1,
8124                body: t.body.clone(),
8125            });
8126        }
8127        turns
8128    }
8129}
8130
8131/// Does this seat still hold the context a follow-up prompt would rely on?
8132fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8133    agent::has_session(spec.kind, seat, sessions)
8134}
8135
8136/// The next entry in `roster` after `start`, never wrapping back to the
8137/// front, whose id is not in `tried` yet.
8138///
8139/// Starts one past `start` rather than at the front of `roster`: `start` is
8140/// the seat's own original position, and a seat whose candidate slot already
8141/// sits on the roster's second entry must fall through to the third next, not
8142/// restart at the first — which is very likely a different candidate's own
8143/// agent already. Never wraps back past `start`, for the same reason: an
8144/// entry earlier in the roster than the seat's own position is almost
8145/// certainly some *other* candidate slot's own agent, and once the tail of
8146/// the roster is exhausted there are no more untried agents for *this* seat
8147/// to fall through to — the caller's fallback chain ends there, exactly as
8148/// "no further untried agents remain in the list for that seat" asks for.
8149///
8150/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
8151/// the same id twice (an operator's `roles.implementers` typo, or a
8152/// `[[agents]]` list reused across roles) must not let
8153/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
8154/// over `roster` either finds an untried id or runs out, so this always
8155/// terminates regardless of duplicates.
8156fn next_untried_in_roster<'a>(
8157    roster: &'a [AgentSpec],
8158    start: usize,
8159    tried: &BTreeSet<String>,
8160) -> Option<&'a AgentSpec> {
8161    roster
8162        .get(start + 1..)?
8163        .iter()
8164        .find(|s| !tried.contains(&s.id))
8165}
8166
8167/// The successor for a seat, shared by all four seat kinds (implement, judge,
8168/// review, advise). `others` holds the ids that *currently* occupy the other
8169/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
8170/// sees them), so roster entries beyond the seat count act as spares: a failed
8171/// seat goes to an agent no other seat holds whenever the roster permits.
8172///
8173/// `carried` is `Some` only for the review loop's carried failure history.
8174/// Order: (1) forward from `start`, never wrapping, untried, not a carried
8175/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
8176/// whole roster: untried and not another seat's occupant; (3) the plain walk
8177/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
8178/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
8179/// a better outcome than an empty seat, and it keeps the answer to "is there
8180/// a successor at all" exactly what it was before occupants were considered,
8181/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
8182/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
8183/// carried failure is a cheaper bet than doubling an agent on the panel, and
8184/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
8185/// the same round are handled one at a time, so a seat later in the batch
8186/// sees an earlier one's new occupant but not yet its own freed agent.
8187fn pick_successor<'a>(
8188    roster: &'a [AgentSpec],
8189    start: usize,
8190    tried: &BTreeSet<String>,
8191    carried: Option<&BTreeSet<String>>,
8192    others: &BTreeSet<String>,
8193) -> Option<&'a AgentSpec> {
8194    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8195    roster
8196        .get(start + 1..)
8197        .and_then(|tail| {
8198            tail.iter()
8199                .filter(free)
8200                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8201        })
8202        .or_else(|| {
8203            carried
8204                .is_some()
8205                .then(|| roster.iter().find(free))
8206                .flatten()
8207        })
8208        .or_else(|| match carried {
8209            Some(c) => next_for_seat(roster, start, tried, c),
8210            None => next_untried_in_roster(roster, start, tried),
8211        })
8212}
8213
8214/// The next agent for a seat that carries its failure history across rounds
8215/// (the review loop). `round_tried` is this round's own bound and starts
8216/// empty every round; `carried_failed` only decides priority.
8217///
8218/// First: an id walking forward from `start`, never wrapping, that is neither
8219/// tried this round nor failed in an earlier one. Only when that is exhausted
8220/// does it rescue: the first roster id (in roster order, so this one *does*
8221/// look before `start`) not yet tried this round, which is by then a carried
8222/// failure. Each id is rescued at most once per round, so it cannot loop.
8223fn next_for_seat<'a>(
8224    roster: &'a [AgentSpec],
8225    start: usize,
8226    round_tried: &BTreeSet<String>,
8227    carried_failed: &BTreeSet<String>,
8228) -> Option<&'a AgentSpec> {
8229    roster
8230        .get(start + 1..)?
8231        .iter()
8232        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8233        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8234}
8235
8236/// Where a reviewer seat starts a round: the agent that last answered it when
8237/// it is still on the roster and not marked failed, else the spec's own agent
8238/// unless it failed, else the next roster agent that has not failed, else the
8239/// spec's own agent again (the whole roster failed). Ids no longer on the
8240/// roster are ignored. An empty roster has no handover, so the spec stands.
8241fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8242    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8243        return spec;
8244    };
8245    let ok = |id: &str| !h.failed.contains(id);
8246    if let Some(last) = h.last_ok.as_deref()
8247        && ok(last)
8248        && let Some(s) = roster.iter().find(|s| s.id == last)
8249    {
8250        return s.clone();
8251    }
8252    if ok(&spec.id) {
8253        return spec;
8254    }
8255    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8256    roster
8257        .get(from + 1..)
8258        .into_iter()
8259        .flatten()
8260        .chain(roster.iter())
8261        .find(|s| ok(&s.id))
8262        .cloned()
8263        .unwrap_or(spec)
8264}
8265
8266/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
8267/// seed so a CLI that mints its session id up front (`--session-id`) never
8268/// reuses the uuid the previous agent already opened under the same seat key.
8269pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8270    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8271}
8272
8273/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
8274/// so the classifier below and the code that builds the message cannot drift.
8275const TIMED_OUT: &str = "timed out";
8276
8277impl FailClass {
8278    /// `None` for an answer; otherwise how the turn failed.
8279    fn of(out: &AgentOutcome) -> Option<Self> {
8280        match out {
8281            AgentOutcome::Ok(_) => None,
8282            AgentOutcome::Quota(_) => Some(Self::Quota),
8283            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8284            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8285            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8286            // Not a failure of the agent. Callers check for it first; `None`
8287            // here means "no class to hand over on".
8288            AgentOutcome::ResourceBlocked(_) => None,
8289        }
8290    }
8291
8292    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
8293    fn stem_word(&self) -> &'static str {
8294        match self {
8295            Self::Quota => "quota",
8296            _ => "handover",
8297        }
8298    }
8299}
8300
8301/// The message's first line with its variable parts removed — digit runs and
8302/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
8303/// as one kind of failure.
8304fn failure_signature(msg: &str) -> String {
8305    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8306    let mut out = Vec::new();
8307    for word in line.split_whitespace() {
8308        if word.contains('/') || word.contains('\\') {
8309            out.push("<path>".to_owned());
8310            continue;
8311        }
8312        let mut w = String::new();
8313        let mut in_digits = false;
8314        for c in word.chars() {
8315            if c.is_ascii_digit() {
8316                if !in_digits {
8317                    w.push('#');
8318                }
8319                in_digits = true;
8320            } else {
8321                in_digits = false;
8322                w.push(c);
8323            }
8324        }
8325        out.push(w);
8326    }
8327    out.join(" ").chars().take(120).collect()
8328}
8329
8330/// Whether a seat that just failed with `cur` may go to the next roster agent.
8331/// A quota or a timeout always may. Any other failure may not when the agent
8332/// before it failed the same way: an error the prompt causes would otherwise
8333/// walk the whole roster. `prev` is the class of the immediately preceding
8334/// agent's failure, so a quota or timeout in between breaks the run of
8335/// identical failures by itself.
8336fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8337    match cur {
8338        FailClass::Quota | FailClass::Timeout => true,
8339        FailClass::Other(_) => prev != Some(cur),
8340    }
8341}
8342
8343/// A short human reason for a failed outcome, for the handover record.
8344fn fail_reason(out: &AgentOutcome) -> String {
8345    match out {
8346        AgentOutcome::Ok(_) => String::new(),
8347        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8348        AgentOutcome::Dropped(o) => format!(
8349            "the CLI dropped the stream ({})",
8350            o.dropped
8351                .as_ref()
8352                .map(|d| d.why.as_str())
8353                .unwrap_or("it ended without delivering its answer")
8354        ),
8355        AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8356            e.lines().next().unwrap_or("").chars().take(160).collect()
8357        }
8358    }
8359}
8360
8361/// Note one handover in the run: the structured record and, in the timeline,
8362/// the sentence a person reads. A quota keeps the wording it always had.
8363pub(crate) fn record_handover(
8364    state: &mut RunState,
8365    node: &str,
8366    seat: &str,
8367    from: &str,
8368    to: &str,
8369    class: &FailClass,
8370    reason: &str,
8371) {
8372    let message = if *class == FailClass::Quota {
8373        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8374    } else {
8375        format!("{seat}: handed over {from} -> {to} ({reason})")
8376    };
8377    state.event(node, message);
8378    state.handovers.push(Handover {
8379        at: Timestamp::now(),
8380        node: node.to_owned(),
8381        seat: seat.to_owned(),
8382        from: from.to_owned(),
8383        to: to.to_owned(),
8384        reason: reason.to_owned(),
8385    });
8386}
8387
8388/// Did this reply report running a command whose own CLI never confirmed an
8389/// exit status?
8390///
8391/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
8392/// command *finished* (see that type's own doc), so this can only be `true`
8393/// for a command whose completion event carried no readable exit code — not
8394/// for one that simply is not mentioned at all. That is the one signal this
8395/// crate can read, from the same record `state.jobs` renders, about a reply
8396/// standing next to work its own CLI cannot vouch for finishing; it is
8397/// deliberately not a check on the exit code's *value* (a fixer legitimately
8398/// runs a command that fails mid-iteration before it succeeds) and not a
8399/// guess at a command still running in the background (which emits no event
8400/// at all, and so leaves no evidence here to find).
8401fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8402    commands.iter().any(|c| c.exit_code.is_none())
8403}
8404
8405/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
8406/// trusted as a verified no-op — the adoption guard's own text-level half.
8407///
8408/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
8409/// timed out): a marker only earns the benefit of the doubt from a turn the
8410/// CLI itself vouches for finishing properly, the same house style
8411/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
8412/// *fix* report to for `commands`. A candidate that timed out, exited
8413/// non-zero, or left a command unconfirmed is read as the ordinary loss it
8414/// is, whatever prose it wrote — this returns `None` before it ever looks at
8415/// `text`. The remaining guards (the tree really is empty, the evidence is
8416/// non-empty) are the caller's: this only reads what the reply *claimed*.
8417fn verified_noop_claim(
8418    usable: bool,
8419    commands: &[agent::CommandEvidence],
8420    text: &str,
8421) -> Option<String> {
8422    (usable && !has_unconfirmed_command(commands))
8423        .then(|| verdict::verified_noop(text))
8424        .flatten()
8425}
8426
8427fn short(commit: &str) -> String {
8428    commit.chars().take(7).collect()
8429}
8430
8431fn make_executable(path: &Path) -> Result<()> {
8432    #[cfg(unix)]
8433    {
8434        use std::os::unix::fs::PermissionsExt as _;
8435        let mut perms = std::fs::metadata(path)?.permissions();
8436        perms.set_mode(0o755);
8437        std::fs::set_permissions(path, perms)?;
8438    }
8439    #[cfg(not(unix))]
8440    {
8441        let _ = path;
8442    }
8443    Ok(())
8444}
8445
8446/// What every seat in one batch shares: where the answers are attributed, the
8447/// prompt overlay they inherit, and the build cache they are told to use.
8448///
8449/// A struct rather than four more parameters: `wave` also needs the run's
8450/// state (to record who is answering right now) and the attempt number, and
8451/// eight positional arguments is both unreadable and a clippy error.
8452struct WaveCtx<'a> {
8453    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
8454    /// paid for it.
8455    run: &'a str,
8456    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
8457    node: &'a str,
8458    prompts: &'a Prompts,
8459    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
8460    cache: Option<&'a Path>,
8461    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
8462    /// `JobRecord::round`. `None` for every other node.
8463    round: Option<usize>,
8464    /// Carry each seat's failed-agent history across waves (the review loop
8465    /// only): start-of-round priority and handover choice read
8466    /// [`RunState::seat_history`], and every answer or failure writes it.
8467    carry_seats: bool,
8468    /// The run's park / interrupt requests, so a wait for a shared resource
8469    /// can be abandoned the moment the operator asks to park.
8470    pauses: Option<(&'a Pause, &'a Pause)>,
8471}
8472
8473/// Run one job, honouring the parallelism budget.
8474async fn run_one(
8475    job: SeatJob,
8476    sem: Arc<Semaphore>,
8477    ctx: &WaveCtx<'_>,
8478    state: &mut RunState,
8479    attempt: usize,
8480) -> (SeatState, AgentOutcome) {
8481    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8482        .await
8483        .pop()
8484        .expect("one job in, one result out");
8485    (seat, out)
8486}
8487
8488/// Run every job concurrently, capped by the semaphore, preserving order.
8489///
8490/// Every seat in the batch is recorded into [`RunState::active`] before the
8491/// wave starts and cleared as each answer lands, so the run's own record says
8492/// who is still being waited on rather than only who finished.
8493async fn wave(
8494    jobs: Vec<SeatJob>,
8495    sem: Arc<Semaphore>,
8496    ctx: &WaveCtx<'_>,
8497    state: &mut RunState,
8498    attempt: usize,
8499) -> Vec<(usize, SeatState, AgentOutcome)> {
8500    let WaveCtx {
8501        run,
8502        node,
8503        prompts,
8504        cache,
8505        round,
8506        carry_seats: _,
8507        pauses,
8508    } = *ctx;
8509    for job in &jobs {
8510        state.seat_started(node, &job.seat.key, job.timeout, attempt);
8511    }
8512    if let Err(e) = state.save() {
8513        // A failed persist of "who is answering right now" must not abort the
8514        // wave: the seats are already being asked, and the alternative is
8515        // losing the answers to save a status line nobody may even be
8516        // watching.
8517        tracing::warn!("could not persist in-progress seats: {e:#}");
8518    }
8519    // Hold the shared build cache's lease for the whole batch, not per job:
8520    // several candidates (an implement wave) or a fixer legitimately share
8521    // one cache concurrently within this run, and that stays untouched — a
8522    // single lease taken once for the whole wave and released once it is
8523    // done is what stops a *different* borrower (another run's own wave, its
8524    // e2e/gate, a human's `magi review`) from interleaving a build into the
8525    // same directory while this one is in flight. Best-effort, not
8526    // all-or-nothing: a wave that cannot get the lease within its own
8527    // longest job's budget still runs — an hour of paid implementer calls is
8528    // not thrown away over cache contention — but every write-allowed seat
8529    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
8530    // below), the same fallback a read-only seat always gets, rather than
8531    // building into a directory this run was never granted. The identity
8532    // record is still invalidated below either way, so the next tracked
8533    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
8534    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8535    // The wait for the lease has its own bound, `timeout_resource_wait`; it is
8536    // never borrowed from a job's agent budget (and never charged to it either,
8537    // see below). The agent's clock starts in `agent::invoke`, at spawn.
8538    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8539    let cache_guard = if let Some(cache_dir) = cache {
8540        if jobs_had_a_writer {
8541            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8542            let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8543            match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8544            {
8545                Ok(Some(g)) => Some(g),
8546                // Parking: leave without spawning anything. The run parks at
8547                // the next boundary and the seats are asked afresh on resume.
8548                Ok(None) => {
8549                    state.resource_block = Some(crate::run::ResourceBlock {
8550                        node: node.to_owned(),
8551                        kind: "shared build cache".to_owned(),
8552                        detail: "the run is parking".to_owned(),
8553                        at: Timestamp::now(),
8554                    });
8555                    return block_wave(
8556                        jobs,
8557                        state,
8558                        node,
8559                        attempt,
8560                        "parked while waiting for the shared build cache",
8561                    );
8562                }
8563                // An unreadable lease is not contention: keep the old
8564                // fallback (no shared cache for this wave's seats).
8565                Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8566                Err(e) => {
8567                    // Writing seats may not build into a cache this run does
8568                    // not hold, and an agent that cannot be given one is not
8569                    // asked at all: the run stops resumably instead.
8570                    let why = format!("{e:#}");
8571                    state.resource_block = Some(crate::run::ResourceBlock {
8572                        node: node.to_owned(),
8573                        kind: "shared build cache".to_owned(),
8574                        detail: why.clone(),
8575                        at: Timestamp::now(),
8576                    });
8577                    state.event(
8578                        "resource",
8579                        format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8580                    );
8581                    return block_wave(jobs, state, node, attempt, &why);
8582                }
8583            }
8584        } else {
8585            None
8586        }
8587    } else {
8588        None
8589    };
8590    // Every job keeps its whole budget: the wait above was not the agent's.
8591    // Restart the seats' clocks so "started" means "being spawned now".
8592    state.seats_spawning(node, attempt);
8593    if let Err(e) = state.save() {
8594        tracing::warn!("could not persist the start of a wave: {e:#}");
8595    }
8596    // Queueing behind this wave's own seats for a parallelism slot is normal
8597    // (each of them is bounded by its own agent timeout); only a wait beyond
8598    // all of them plus the resource limit means the slots never came back.
8599    let slot_limit = resource_limit
8600        + jobs
8601            .iter()
8602            .map(|j| j.timeout)
8603            .max()
8604            .unwrap_or_default()
8605            .saturating_mul(jobs.len() as u32);
8606    let mut set = tokio::task::JoinSet::new();
8607    // Seats announce themselves here the moment they hold a parallelism
8608    // permit, i.e. just before their CLI is spawned.
8609    let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8610    let overlay = prompts.overlay(node);
8611    for (i, mut job) in jobs.into_iter().enumerate() {
8612        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8613        if cache.is_some() {
8614            job.prompt.push('\n');
8615            job.prompt
8616                .push_str(&prompt::build_cache_note(node, job.allow_write));
8617        }
8618        let sem = Arc::clone(&sem);
8619        let run = run.to_owned();
8620        let node = node.to_owned();
8621        // Only implementers were told about the task's attachments, so only
8622        // their seats get the directory widened for reading.
8623        let attachments = if node == "implement" {
8624            state.attachments.clone()
8625        } else {
8626            Vec::new()
8627        };
8628        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
8629        // `prompt::build_cache_note`'s doc for why setting it anyway is
8630        // exactly how a sandboxed reviewer's write refusal got reported as a
8631        // defect in the patch, not a property of its own seat. And a
8632        // write-allowed one is handed it only when the lease above was
8633        // actually acquired: a wave that could not get it (`cache_guard` is
8634        // `None`, see its own comment) must not send seats to build into a
8635        // directory this run does not hold - that is the exact concurrent,
8636        // unmanaged-write race this module exists to prevent, not something
8637        // "proceeding anyway" is allowed to reintroduce.
8638        let cache = cache
8639            .filter(|_| job.allow_write && cache_guard.is_some())
8640            .map(Path::to_path_buf);
8641        let spawned_tx = spawned_tx.clone();
8642        set.spawn(async move {
8643            // A wait for a parallelism slot is scheduling, not the agent: it
8644            // is bounded by its own limit and ends as a resource block, never
8645            // as a timeout of the agent that was not started.
8646            let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
8647                Ok(p) => p,
8648                Err(_) => {
8649                    return (
8650                        i,
8651                        job.seat,
8652                        AgentOutcome::ResourceBlocked(format!(
8653                            "no parallel slot became free within {}s",
8654                            slot_limit.as_secs()
8655                        )),
8656                    );
8657                }
8658            };
8659            spawned_tx.send(job.seat.key.clone()).ok();
8660            let mut seat = job.seat;
8661            let out = agent::invoke(
8662                &job.spec,
8663                &mut seat,
8664                &Invocation {
8665                    cwd: &job.cwd,
8666                    prompt: &job.prompt,
8667                    timeout: job.timeout,
8668                    allow_write: job.allow_write,
8669                    unsandboxed: false,
8670                    sessions: job.sessions,
8671                    artifacts: &job.artifacts,
8672                    stem: &job.stem,
8673                    run: &run,
8674                    node: &node,
8675                    cache_dir: cache.as_deref(),
8676                    attachments: &attachments,
8677                    writable: &[],
8678                },
8679            )
8680            .await;
8681            let out = match out {
8682                Ok(o) if o.usable() => AgentOutcome::Ok(o),
8683                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
8684                // Billed work the CLI failed to hand over is not an ordinary
8685                // failure, but its text is the CLI's raw error JSON, not an
8686                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
8687                // read it as one by forgetting to check. `usable()` is always
8688                // false here (dropped implies an empty response), so this has
8689                // to be checked before the catch-all `Failed` below or the
8690                // one shape this exists for is lost with the rest.
8691                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
8692                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
8693                Ok(o) => AgentOutcome::Failed(format!(
8694                    "exited with {:?} and no usable output",
8695                    o.exit_code
8696                )),
8697                Err(e) => AgentOutcome::Failed(e.to_string()),
8698            };
8699            (i, seat, out)
8700        });
8701    }
8702    drop(spawned_tx);
8703    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
8704    loop {
8705        let joined = tokio::select! {
8706            Some(key) = spawned_rx.recv() => {
8707                // The clock of this seat starts now, not when it queued.
8708                state.seat_spawned(&key);
8709                if let Err(e) = state.save() {
8710                    tracing::warn!("could not persist a seat's start: {e:#}");
8711                }
8712                continue;
8713            }
8714            joined = set.join_next() => joined,
8715        };
8716        let Some(joined) = joined else {
8717            break;
8718        };
8719        let (i, seat, out) = match joined {
8720            Ok(v) => v,
8721            // No seat to clear: a panicked task never reported which one it
8722            // was. The defensive sweep below this loop is what stops that
8723            // seat's `active` entry from surviving forever.
8724            Err(e) => {
8725                tracing::error!("agent task panicked: {e}");
8726                continue;
8727            }
8728        };
8729        state.seat_finished(&seat.key);
8730        if let AgentOutcome::ResourceBlocked(why) = &out
8731            && state.resource_block.is_none()
8732        {
8733            state.resource_block = Some(crate::run::ResourceBlock {
8734                node: node.to_owned(),
8735                kind: "parallel slot".to_owned(),
8736                detail: why.clone(),
8737                at: Timestamp::now(),
8738            });
8739            state.event("resource", format!("{node}: no agent was started - {why}"));
8740        }
8741        record_jobs(state, node, round, &seat.key, &out);
8742        if let Err(e) = state.save() {
8743            tracing::warn!("could not persist a seat's completion: {e:#}");
8744        }
8745        if collected.len() <= i {
8746            collected.resize_with(i + 1, || None);
8747        }
8748        collected[i] = Some((i, seat, out));
8749    }
8750    // Belt-and-braces for the panic branch above: every seat this exact batch
8751    // started shares this `(node, attempt)` pair, and every seat that finished
8752    // normally already cleared itself, so anything left tagged with it here
8753    // can only be a panicked task's leftover. Cleared unconditionally rather
8754    // than left to read as still answering forever.
8755    if state
8756        .active
8757        .values()
8758        .any(|a| a.node == node && a.attempt == attempt)
8759    {
8760        state
8761            .active
8762            .retain(|_, a| !(a.node == node && a.attempt == attempt));
8763        if let Err(e) = state.save() {
8764            tracing::warn!("could not persist the end of a wave: {e:#}");
8765        }
8766    }
8767    // Whether or not the lease above was actually held, several worktrees
8768    // may just have built into the cache with nothing here able to name one
8769    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
8770    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
8771    // clean it might not have strictly needed; trusting a stale match would
8772    // cost it a wrong answer.
8773    if let Some(cache_dir) = cache
8774        && jobs_had_a_writer
8775    {
8776        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
8777    }
8778    if let Some(guard) = cache_guard {
8779        guard.release();
8780    }
8781    collected.into_iter().flatten().collect()
8782}
8783
8784/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
8785/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
8786/// this is data collection, not the fix-specific completion contract in
8787/// [`Runner::continue_fix_report`], and applies regardless of which node
8788/// asked.
8789///
8790/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
8791/// evidence from; `Failed` does not, and correctly contributes nothing — a
8792/// timeout or crash is not itself evidence about a command the seat may have
8793/// started.
8794fn record_jobs(
8795    state: &mut RunState,
8796    node: &str,
8797    round: Option<usize>,
8798    seat: &str,
8799    out: &AgentOutcome,
8800) {
8801    let commands: &[agent::CommandEvidence] = match out {
8802        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
8803        AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
8804    };
8805    let checked_at = Timestamp::now();
8806    for c in commands {
8807        state.jobs.push(JobRecord {
8808            node: node.to_owned(),
8809            round,
8810            seat: seat.to_owned(),
8811            id: c.id.clone(),
8812            description: c.description.clone(),
8813            checked_at,
8814            status: match c.exit_code {
8815                Some(0) => JobStatus::Completed,
8816                Some(_) => JobStatus::Failed,
8817                None => JobStatus::Unknown,
8818            },
8819            exit_code: c.exit_code,
8820            result_summary: c.result_summary.clone(),
8821            source: c.source.clone(),
8822        });
8823    }
8824}
8825
8826/// Is a review round clean, given how many reviewer seats answered against
8827/// how many the round expected?
8828///
8829/// A seat that never answered (timeout, crash, unparsable output) is not a
8830/// seat that read the patch and found nothing — treating it as such is
8831/// exactly the bug this function exists to close. Under the default `block`
8832/// policy a missing seat can never be clean; `warn` still requires the seats
8833/// that *did* answer to have found nothing blocking and verification to be
8834/// green.
8835///
8836/// `quota_missing` narrows that `block` default for exactly one cause of
8837/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
8838/// a session limit lifts by the very next round buys nothing — the seat is
8839/// asked again with the same quota — so once every missing seat is accounted
8840/// for by a quota loss (and at least one seat *did* answer, so a decision has
8841/// something to rest on) the round is decided on the panel that could answer,
8842/// same as `warn` would. A panel that lost every seat to quota is not
8843/// decided here: `answered == 0` falls through to the existing `block`
8844/// fallback so a fully collapsed panel still waits rather than landing on no
8845/// review at all.
8846fn round_is_clean(
8847    blocking: usize,
8848    e2e_ok: bool,
8849    answered: usize,
8850    expected: usize,
8851    quota_missing: usize,
8852    policy: IncompleteReviewPolicy,
8853) -> bool {
8854    if blocking != 0 || !e2e_ok {
8855        return false;
8856    }
8857    if answered == expected || policy == IncompleteReviewPolicy::Warn {
8858        return true;
8859    }
8860    answered > 0 && expected - answered <= quota_missing
8861}
8862
8863/// The review loop's own conclusion, derived entirely from its persisted
8864/// round records and the round budget that produced them — never from
8865/// `status`, so a reentry (or `gate`/`merge` reading it independently)
8866/// recomputes the identical answer regardless of what an earlier node in the
8867/// same walk, or a previous walk, did to `status`.
8868///
8869/// `None` while more rounds remain to try, including when review never ran
8870/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
8871/// gone clean, or the budget is spent, or the tree has stopped moving (see
8872/// [`STAGNANT_LIMIT`]), the answer is one of two things:
8873///
8874/// - An incomplete panel that raised nothing is missing input, not a
8875///   verified tree — never a hand-off candidate, whatever verification said
8876///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
8877/// - Otherwise, green e2e on the last round hands off (see
8878///   [`Runner::stop_reviewing`]); red e2e blocks.
8879///
8880/// A last round whose own verification is still `ResourceBlocked` — magi
8881/// itself never got a command to run, not evidence the patch is broken —
8882/// is neither: this returns `None` for it too, the same as "more rounds
8883/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
8884/// handling of that shape) instead of this cheap recomputation guessing a
8885/// verdict a real attempt never produced.
8886fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8887    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8888        return Some(RunStatus::Gating);
8889    }
8890    let last = reviews.last()?;
8891    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8892    if reviews.len() < max_rounds && !stagnant {
8893        return None;
8894    }
8895    if last.incomplete() && last.blocking == 0 {
8896        return Some(RunStatus::Blocked);
8897    }
8898    if last.e2e_status() == E2eStatus::ResourceBlocked {
8899        return None;
8900    }
8901    Some(if last.e2e.iter().all(CommandOutcome::ok) {
8902        RunStatus::Gating
8903    } else {
8904        RunStatus::Blocked
8905    })
8906}
8907
8908/// How long a re-ask may take, given the budget the first attempt had.
8909///
8910/// A `nudged` retry is a request to restate an answer the seat has already
8911/// worked out: it carries no new work, so it does not deserve the original
8912/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
8913/// 133 seconds while a third sat for over ten minutes on a resumed session
8914/// holding 410 KB of prior output - and because the retry had inherited the
8915/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
8916/// judging round whose other seats were long finished.
8917///
8918/// A quarter of the budget, with a floor so that a deliberately short timeout
8919/// does not collapse to nothing. A retry that re-sends the whole prompt
8920/// (because the seat kept no context) is the original job again, and keeps the
8921/// original budget.
8922pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8923    if nudged {
8924        (full / 4).max(Duration::from_secs(120)).min(full)
8925    } else {
8926        full
8927    }
8928}
8929
8930/// Run a wave and parse each reply, re-asking the seats whose reply was
8931/// unusable.
8932///
8933/// The re-ask is a nudge rather than the whole prompt again when the seat still
8934/// holds its conversation, which is the difference between a cheap retry and
8935/// paying for the entire candidate set twice.
8936///
8937/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
8938/// successor in `roster` is handed to it instead of being re-asked: the
8939/// handover is the retry. A seat with no successor left (a single-agent
8940/// roster, the roster's tail) is nudged as before, up to `retries` times. So
8941/// the asks to one seat in one node number at most
8942/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
8943/// is asked once (a dropped stream is nudged first), and only the last agent
8944/// of the chain gets the `retries` same-agent nudges. Each roster agent is
8945/// tried at most once per seat, walking forward from the seat's own position and never wrapping
8946/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
8947/// other failure stops the chain when the previous agent failed the same way
8948/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
8949/// [`has_context`] is false and the job's own full prompt and full budget are
8950/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
8951/// intermediate ones are not losses) and is returned as a failure like any
8952/// other absent seat — the caller decides whether the panel still has a
8953/// quorum. An empty `roster` disables handover: failures are nudged as they
8954/// always were, and a quota is simply lost. A reply that fails to parse or
8955/// validate is the prompt's doing and is only ever nudged, never handed over.
8956///
8957/// The returned [`SeatState`] names the agent that answered (or tried last).
8958#[allow(clippy::too_many_arguments)]
8959async fn ask_json_wave<T>(
8960    jobs: Vec<SeatJob>,
8961    sem: Arc<Semaphore>,
8962    retries: usize,
8963    roster: &[AgentSpec],
8964    ctx: &WaveCtx<'_>,
8965    losses: &mut Vec<QuotaLoss>,
8966    state: &mut RunState,
8967    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8968) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8969where
8970    T: serde::de::DeserializeOwned + Send + 'static,
8971{
8972    ask_wave_with(
8973        jobs,
8974        sem,
8975        retries,
8976        roster,
8977        ctx,
8978        losses,
8979        state,
8980        &|text: &str| {
8981            let v = verdict::extract_json::<T>(text)?;
8982            validate(&v)?;
8983            Ok(v)
8984        },
8985    )
8986    .await
8987}
8988
8989/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
8990/// a node whose answer is prose (deliberation) shares the same handover,
8991/// failure classification, quota bookkeeping and bounds instead of a copy.
8992///
8993/// A seat handed to another roster agent is sent the job's `handover` prompt
8994/// (when it has one) rather than `prompt`: the new agent has no session, so a
8995/// resume-style prompt would be incomplete. That holds for the handover ask
8996/// and for every nudge to that agent whose `has_context` is false.
8997#[allow(clippy::too_many_arguments)]
8998async fn ask_wave_with<T>(
8999    jobs: Vec<SeatJob>,
9000    sem: Arc<Semaphore>,
9001    retries: usize,
9002    roster: &[AgentSpec],
9003    ctx: &WaveCtx<'_>,
9004    losses: &mut Vec<QuotaLoss>,
9005    state: &mut RunState,
9006    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9007) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9008where
9009    T: Send + 'static,
9010{
9011    let n = jobs.len();
9012    let originals: Vec<SeatJob> = jobs;
9013    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9014    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9015    // Nudges each seat's *current* agent has taken — 0 for a first-ask
9016    // answer, N once it has gone through N nudges. Read back once this
9017    // returns, so a caller building a history record (`ReviewRecord`) can
9018    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
9019    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
9020    // field's own doc.
9021    let mut nudges: Vec<usize> = vec![0; n];
9022    // The agent now occupying each seat, the ids it has already been through,
9023    // where in the roster the walk began, the class of the last failure, and
9024    // the stem word of a handover not yet asked (full prompt, full budget).
9025    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9026    let mut tried: Vec<BTreeSet<String>> = specs
9027        .iter()
9028        .map(|s| BTreeSet::from([s.id.clone()]))
9029        .collect();
9030    let starts: Vec<usize> = specs
9031        .iter()
9032        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9033        .collect();
9034    let carry = ctx.carry_seats && !roster.is_empty();
9035    // Carried across rounds: ids that failed the seat earlier, and how the
9036    // last failure went (so a repeat of it is not handed over again).
9037    let carried: Vec<BTreeSet<String>> = originals
9038        .iter()
9039        .map(|j| {
9040            state
9041                .seat_history
9042                .get(&j.seat.key)
9043                .filter(|_| carry)
9044                .map(|h| h.failed.clone())
9045                .unwrap_or_default()
9046        })
9047        .collect();
9048    let mut prev: Vec<Option<FailClass>> = originals
9049        .iter()
9050        .map(|j| {
9051            state
9052                .seat_history
9053                .get(&j.seat.key)
9054                .filter(|_| carry)
9055                .and_then(|h| h.last_fail.clone())
9056        })
9057        .collect();
9058    let next_agent =
9059        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9060            let others: BTreeSet<String> = specs
9061                .iter()
9062                .enumerate()
9063                .filter(|(j, _)| *j != i)
9064                .map(|(_, s)| s.id.clone())
9065                .collect();
9066            pick_successor(
9067                roster,
9068                starts[i],
9069                tried,
9070                carry.then(|| &carried[i]),
9071                &others,
9072            )
9073            .cloned()
9074        };
9075    let mut fresh: Vec<Option<String>> = vec![None; n];
9076    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9077    let mut pending: Vec<usize> = (0..n).collect();
9078
9079    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
9080    // asks: an agent with a successor is asked once and handed over, and only
9081    // a seat with no successor spends `retries` nudges on the same agent. This
9082    // only guarantees the loop's own termination whatever those say.
9083    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9084    for round in 0..max_rounds {
9085        if pending.is_empty() {
9086            break;
9087        }
9088        let mut batch = Vec::with_capacity(pending.len());
9089        let mut renudged: Vec<&str> = Vec::new();
9090        for &i in &pending {
9091            let src = &originals[i];
9092            // A seat now held by another agent than the job named has no
9093            // session of its own: it gets the full-context prompt whenever
9094            // it is asked in full (the handover ask, a nudge it cannot
9095            // resume).
9096            let full: &str = match &src.handover {
9097                Some(h) if specs[i].id != src.spec.id => h,
9098                _ => &src.prompt,
9099            };
9100            // The prompt and the budget are one decision: a nudge restates
9101            // finished work, a re-sent prompt redoes it.
9102            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9103                (
9104                    full.to_owned(),
9105                    src.timeout,
9106                    format!("{}-{word}-{}", src.stem, specs[i].id),
9107                )
9108            } else if nudges[i] == 0 {
9109                (full.to_owned(), src.timeout, src.stem.clone())
9110            } else {
9111                renudged.push(src.seat.key.as_str());
9112                let why = done[i]
9113                    .as_ref()
9114                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
9115                    .unwrap_or_else(|| "no parsable answer".to_owned());
9116                let nudge = prompt::nudge(&why);
9117                let nudged = has_context(&specs[i], &seats[i], src.sessions);
9118                let prompt = if nudged {
9119                    nudge
9120                } else {
9121                    format!("{full}\n\n---\n\n{nudge}")
9122                };
9123                (
9124                    prompt,
9125                    retry_budget(src.timeout, nudged),
9126                    format!("{}-retry{}", src.stem, nudges[i]),
9127                )
9128            };
9129            batch.push(SeatJob {
9130                spec: specs[i].clone(),
9131                seat: seats[i].clone(),
9132                cwd: src.cwd.clone(),
9133                prompt,
9134                timeout,
9135                allow_write: src.allow_write,
9136                sessions: src.sessions,
9137                artifacts: src.artifacts.clone(),
9138                stem,
9139                handover: None,
9140            });
9141        }
9142
9143        if !renudged.is_empty() {
9144            state.event(
9145                ctx.node,
9146                format!("retry {round}: re-asking {}", renudged.join(", ")),
9147            );
9148        }
9149        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9150        let mut still = Vec::new();
9151        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9152            seats[i] = seat;
9153            // No agent was asked: nothing to hand over, nudge, remember or
9154            // charge. The caller sees `resource_block` and stops the node.
9155            if let AgentOutcome::ResourceBlocked(why) = &out {
9156                done[i] = Some(Err(anyhow::anyhow!("{why}")));
9157                continue;
9158            }
9159            let class = FailClass::of(&out);
9160            // A dropped stream is nudged first (the conversation is still
9161            // there to pick up); only a seat whose nudges are spent hands over.
9162            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9163                && nudges[i] < retries
9164                && !roster.is_empty();
9165            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9166                let next = should_hand_over(prev[i].as_ref(), &cur)
9167                    .then(|| next_agent(i, &tried[i], &specs))
9168                    .flatten();
9169                if carry {
9170                    let h = state
9171                        .seat_history
9172                        .entry(originals[i].seat.key.clone())
9173                        .or_default();
9174                    h.failed.insert(specs[i].id.clone());
9175                    h.last_fail = Some(cur.clone());
9176                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9177                        h.last_ok = None;
9178                    }
9179                    // Saved before the next agent is asked, so a restart in
9180                    // between does not forget who failed.
9181                    if let Err(e) = state.save() {
9182                        tracing::warn!("could not persist a seat's failure history: {e:#}");
9183                    }
9184                }
9185                if let Some(next) = next {
9186                    record_handover(
9187                        state,
9188                        ctx.node,
9189                        &originals[i].seat.key,
9190                        &specs[i].id,
9191                        &next.id,
9192                        &cur,
9193                        &fail_reason(&out),
9194                    );
9195                    tried[i].insert(next.id.clone());
9196                    prev[i] = Some(cur.clone());
9197                    seats[i] =
9198                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9199                    specs[i] = next;
9200                    fresh[i] = Some(cur.stem_word().to_owned());
9201                    nudges[i] = 0;
9202                    done[i] = Some(Err(anyhow::anyhow!(
9203                        "handed over after: {}",
9204                        fail_reason(&out)
9205                    )));
9206                    still.push(i);
9207                    continue;
9208                }
9209            }
9210            if carry
9211                && !nudge_first
9212                && let Some(cur) = class.clone()
9213            {
9214                // The chain ended here (no successor, or a repeated failure).
9215                let h = state
9216                    .seat_history
9217                    .entry(originals[i].seat.key.clone())
9218                    .or_default();
9219                h.failed.insert(specs[i].id.clone());
9220                h.last_fail = Some(cur);
9221            }
9222            let parsed = match out {
9223                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9224                AgentOutcome::Quota(o) => {
9225                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9226                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9227                }
9228                // Not a parseable answer: the nudge loop re-asks it, which is
9229                // exactly what a dropped stream needs. Just don't hand its raw
9230                // error JSON to `extract_json`.
9231                AgentOutcome::Dropped(o) => {
9232                    let why = o
9233                        .dropped
9234                        .as_ref()
9235                        .map(|d| d.why.as_str())
9236                        .unwrap_or("the CLI ended the stream without delivering its answer");
9237                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9238                }
9239                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9240                    Err(anyhow::anyhow!(e))
9241                }
9242            };
9243            let quota = class == Some(FailClass::Quota);
9244            let failed = parsed.is_err();
9245            done[i] = Some(parsed);
9246            if carry && !failed {
9247                let h = state
9248                    .seat_history
9249                    .entry(originals[i].seat.key.clone())
9250                    .or_default();
9251                h.failed.remove(&specs[i].id);
9252                h.last_ok = Some(specs[i].id.clone());
9253                h.last_fail = None;
9254            }
9255            // Do not re-ask a rate-limited seat (quota) — a retry is known to
9256            // fail the same way; and never re-ask a seat that already parsed.
9257            // A failed agent that still has a successor is not re-asked
9258            // either: the handover was its remedy and has just been refused
9259            // (the chain stops on a repeated failure class). A seat with no
9260            // successor left (a single-agent roster, the roster's tail, or an
9261            // empty roster) keeps the same-agent nudge, bounded by `retries`.
9262            let agent_failure = class.is_some()
9263                && !nudge_first
9264                && !roster.is_empty()
9265                && next_agent(i, &tried[i], &specs).is_some();
9266            if failed && !quota && !agent_failure && nudges[i] < retries {
9267                nudges[i] += 1;
9268                still.push(i);
9269            }
9270        }
9271        pending = still;
9272    }
9273
9274    // One loss per seat whose chain ended on a quota: the intermediate ones
9275    // were absorbed by a handover and are not losses.
9276    for (i, q) in last_quota.into_iter().enumerate() {
9277        if let Some(reset) = q {
9278            losses.push(QuotaLoss {
9279                seat: originals[i].seat.key.clone(),
9280                node: ctx.node.to_owned(),
9281                at: Timestamp::now(),
9282                reset,
9283            });
9284        }
9285    }
9286
9287    seats
9288        .into_iter()
9289        .zip(done)
9290        .zip(nudges)
9291        .map(|((seat, res), attempts)| {
9292            (
9293                seat,
9294                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9295                attempts,
9296            )
9297        })
9298        .collect()
9299}
9300
9301/// Did the wave end without asking any agent (see [`block_wave`])?
9302fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9303    results
9304        .iter()
9305        .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9306}
9307
9308/// Settle a wave that never spawned anything: every job answers
9309/// [`AgentOutcome::ResourceBlocked`], its seat leaves `active`, and nothing
9310/// else about the seat (session, history, quota) is touched.
9311fn block_wave(
9312    jobs: Vec<SeatJob>,
9313    state: &mut RunState,
9314    node: &str,
9315    attempt: usize,
9316    why: &str,
9317) -> Vec<(usize, SeatState, AgentOutcome)> {
9318    state.resource_wait = None;
9319    state
9320        .active
9321        .retain(|_, a| !(a.node == node && a.attempt == attempt));
9322    if let Err(e) = state.save() {
9323        tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9324    }
9325    jobs.into_iter()
9326        .enumerate()
9327        .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9328        .collect()
9329}
9330
9331/// Acquire the shared build cache's lease, waiting out contention within
9332/// `budget` (never past it — see AGENTS.md's build-cache section on why an
9333/// unbounded wait is never acceptable).
9334///
9335/// A first, non-blocking check happens before ever waiting; if it finds the
9336/// lease busy, that fact is logged as a `verify` event *and* flushed with
9337/// [`RunState::save`] immediately — not only once the wait finally succeeds
9338/// or gives up — so a `magi show` run by a different process while this one
9339/// is still waiting reads a `run.json` that says so, rather than whatever it
9340/// looked like before the wait started. The same applies to the terminal
9341/// failure: logged and saved before this returns `Err`, so a caller that
9342/// could not get the lease at all still leaves a legible record of why.
9343async fn acquire_cache_lease(
9344    state: &mut RunState,
9345    cache_dir: &Path,
9346    owner: &crate::cache::Owner,
9347    budget: Duration,
9348    context: &str,
9349    cancel: &(dyn Fn() -> bool + Sync),
9350) -> Result<Option<crate::cache::Guard>> {
9351    let home = crate::run::home();
9352    let started = Instant::now();
9353    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9354        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9355        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9356        Err(e) => {
9357            state.event(
9358                "verify",
9359                format!("{context}: could not check the shared build cache: {e:#}"),
9360            );
9361            if let Err(e2) = state.save() {
9362                tracing::warn!("could not persist a cache-check failure: {e2:#}");
9363            }
9364            return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9365        }
9366    };
9367    state.event(
9368        "verify",
9369        format!(
9370            "{context}: waiting for the shared build cache at {} ({})",
9371            cache_dir.display(),
9372            busy.describe()
9373        ),
9374    );
9375    // Seats of this node are waiting, not running: say so, so the countdown a
9376    // reader sees only ever measures an agent that has been spawned.
9377    state.resource_wait = Some(crate::run::ResourceWait {
9378        node: context.to_owned(),
9379        kind: "shared build cache".to_owned(),
9380        holder: busy.describe(),
9381        since: Timestamp::now(),
9382        limit_secs: budget.as_secs(),
9383    });
9384    if let Err(e) = state.save() {
9385        tracing::warn!("could not persist a cache wait: {e:#}");
9386    }
9387    let remaining = budget.saturating_sub(started.elapsed());
9388    let waited = crate::cache::wait_for_unless(
9389        &home,
9390        cache_dir,
9391        owner,
9392        remaining,
9393        Duration::from_secs(5),
9394        cancel,
9395    )
9396    .await;
9397    state.resource_wait = None;
9398    match waited {
9399        Ok(g) => Ok(g),
9400        Err(e) => {
9401            state.event("verify", format!("{context}: {e:#}"));
9402            if let Err(e2) = state.save() {
9403                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9404            }
9405            Err(e)
9406        }
9407    }
9408}
9409
9410/// The lease state could not be read at all (unreadable lease file, a
9411/// permission problem). Unlike a busy lease that outlasts its bound, nothing is
9412/// known to be contended, so a wave degrades to running without a shared cache
9413/// instead of stopping on a hold that [`crate::triage`] could never release.
9414#[derive(Debug)]
9415struct CacheCheckError(String);
9416
9417impl std::fmt::Display for CacheCheckError {
9418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9419        f.write_str(&self.0)
9420    }
9421}
9422
9423impl std::error::Error for CacheCheckError {}
9424
9425/// Run `body` — a verify command batch — while holding the shared build
9426/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
9427/// never interleave with another borrower's build against the same
9428/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
9429/// timeout, or a human's own `magi review`. See the `cache` module doc for
9430/// why this matters more than Cargo's own per-target locking covers — two
9431/// *different* worktrees building the same package name/version into one
9432/// cache directory is a staleness bug, not a lock contention one.
9433///
9434/// The wait for the lease is carved out of `budget`, never on top of it —
9435/// `body` is handed whatever is left, so a caller's own node timeout is the
9436/// only clock involved, exactly what AGENTS.md's build-cache section asks
9437/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
9438/// cache configured at all — this is a pass-through: `body` runs with the
9439/// full budget and nothing is leased.
9440///
9441/// A lease that cannot be acquired within `budget` is reported as a single
9442/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
9443/// verification — the same shape a spawn failure already takes in
9444/// [`run_commands`], so a caller need not special-case it.
9445#[allow(clippy::too_many_arguments)]
9446async fn with_cache_lease<'s, F, Fut>(
9447    state: &'s mut RunState,
9448    cache_dir: Option<&Path>,
9449    node: &str,
9450    seat: &str,
9451    worktree: &Path,
9452    head: &str,
9453    budget: Duration,
9454    context: &str,
9455    body: F,
9456) -> (Vec<CommandOutcome>, bool)
9457where
9458    F: FnOnce(&'s mut RunState, Duration) -> Fut,
9459    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9460{
9461    let Some(cache_dir) = cache_dir else {
9462        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9463        return (outcomes, retried);
9464    };
9465    let home = crate::run::home();
9466    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9467    let started = Instant::now();
9468    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9469        .await
9470        .and_then(|g| {
9471            g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9472        }) {
9473        Ok(g) => g,
9474        Err(e) => {
9475            return (
9476                vec![CommandOutcome {
9477                    command: "(waiting for the shared build cache)".to_owned(),
9478                    code: None,
9479                    output_tail: e.to_string(),
9480                    duration_ms: started.elapsed().as_millis() as u64,
9481                    resource_blocked: true,
9482                }],
9483                false,
9484            );
9485        }
9486    };
9487    let identity = crate::cache::Identity::new(worktree, head);
9488    if let Err(e) = crate::cache::ensure_fresh(
9489        &home,
9490        cache_dir,
9491        &identity,
9492        &state.config.verify.cargo_manifests(),
9493    ) {
9494        // A failed freshness check means this process cannot vouch for what
9495        // is sitting in the cache right now - on Windows this is exactly the
9496        // "a stale test executable is still locked, `cargo clean -p` cannot
9497        // remove it" case the evidence log records. Running verify anyway
9498        // and reporting whatever it says would let a result nobody can trust
9499        // stand for the tree it claims to have checked; fail the step
9500        // instead of the patch.
9501        state.event(
9502            "verify",
9503            format!(
9504                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9505                worktree.display(),
9506                short(head)
9507            ),
9508        );
9509        guard.release();
9510        return (
9511            vec![CommandOutcome {
9512                command: "(confirming the shared build cache is fresh)".to_owned(),
9513                code: None,
9514                output_tail: e.to_string(),
9515                duration_ms: started.elapsed().as_millis() as u64,
9516                resource_blocked: true,
9517            }],
9518            false,
9519        );
9520    }
9521    let remaining = budget.saturating_sub(started.elapsed());
9522    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9523    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
9524    // `start_kill`); confirm it actually has before handing the directory to
9525    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
9526    // for what this can and cannot see.
9527    if !timed_out_pids.is_empty() {
9528        wait_for_timed_out_children_to_die(&timed_out_pids).await;
9529    }
9530    guard.release();
9531    (outcomes, retried)
9532}
9533
9534/// Poll `pids` — commands [`run_commands`] reports as still running when its
9535/// own timeout elapsed — until every one is confirmed gone, or
9536/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
9537///
9538/// Real confirmation where confirmation is possible, not a substitute for
9539/// full process-tree observation: a grandchild the timed-out process spawned
9540/// and that survives independently of it is invisible to a pid check the
9541/// same way it always was, and continuing to observe and collect *that*
9542/// stays a different piece of work with its own owner. This only narrows a
9543/// fixed blind wait into an actual check of the pids this process does know
9544/// about.
9545async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9546    wait_for_pids_with(
9547        pids,
9548        crate::proc::pid_alive,
9549        LEASE_RELEASE_POLL,
9550        LEASE_RELEASE_MAX_WAIT,
9551    )
9552    .await;
9553}
9554
9555/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
9556/// interval and ceiling supplied by the caller, so the polling *logic* -
9557/// returns as soon as every pid reports dead, gives up at the ceiling
9558/// otherwise - is testable on millisecond durations without asking the real
9559/// OS about a pid at all.
9560async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9561    pids: &[u32],
9562    alive: F,
9563    poll: Duration,
9564    max_wait: Duration,
9565) {
9566    let deadline = Instant::now() + max_wait;
9567    loop {
9568        if pids.iter().all(|&pid| !alive(pid)) {
9569            return;
9570        }
9571        if Instant::now() >= deadline {
9572            return;
9573        }
9574        tokio::time::sleep(poll).await;
9575    }
9576}
9577
9578/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
9579/// admission that it could not even get a verify command to run, as opposed
9580/// to evidence the command actually produced? A caller that would otherwise
9581/// read a resource-blocked outcome as a red command must check this first:
9582/// see [`Runner::gate`], which retries rather than records `Blocked` when
9583/// this is true.
9584fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9585    outcomes.iter().any(|o| o.resource_blocked)
9586}
9587
9588/// What [`Runner::gate_fix_round`] decided.
9589enum GateFix {
9590    /// The tree changed and `verify.e2e` is still green: run the gate again.
9591    Retry,
9592    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
9593    /// last failure stands and the run ends blocked.
9594    Stop,
9595    /// `verify.e2e` could not run after the fix (magi's own contention):
9596    /// decide nothing now, a later reentry retries.
9597    Defer,
9598}
9599
9600/// Is every red command in `outcomes` an ordinary failure the code could
9601/// explain: it ran, exited non-zero, and said something?
9602///
9603/// A timeout, a spawn failure and a killed process all leave `code` `None`;
9604/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
9605/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
9606/// what the command is stays the gate's business.
9607fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9608    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9609    red.peek().is_some()
9610        && red.all(|o| {
9611            !o.resource_blocked
9612                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9613                && !o.output_tail.trim().is_empty()
9614        })
9615}
9616
9617/// Describe one verify command's outcome for the event log, distinguishing a
9618/// build/link failure — the toolchain never produced a binary to run — from
9619/// an actual test failure, since only the latter is a verdict on the patch.
9620fn e2e_outcome_label(o: &CommandOutcome) -> String {
9621    if o.ok() {
9622        return "pass".to_owned();
9623    }
9624    let reason = if o.build_failed() {
9625        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
9626    } else {
9627        format!("FAIL ({:?})", o.code)
9628    };
9629    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
9630}
9631
9632/// Run `verify.e2e`, retrying once if the first attempt could not build or
9633/// link — a build/link failure is frequently a race against a shared
9634/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
9635/// `verify` event per command, tagged with `context` (normally `"round N"`)
9636/// so the two call sites that need this — the ordinary per-round leg in
9637/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
9638/// it will ever call a round green — read identically in the event log.
9639async fn run_e2e_with_retry(
9640    state: &mut RunState,
9641    shell: &[String],
9642    commands: &[String],
9643    worktree: &Path,
9644    timeout: Duration,
9645    context: &str,
9646) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
9647    let (mut e2e, mut timed_out_pids) = run_commands(
9648        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
9649    )
9650    .await;
9651    for o in &e2e {
9652        state.event(
9653            "verify",
9654            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
9655        );
9656    }
9657    // A build/link failure is not a verdict on the patch — it is frequently a
9658    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
9659    // one retry before letting a red like that decide the round.
9660    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
9661    if verify_retried {
9662        state.event(
9663            "verify",
9664            format!(
9665                "{context}: verify could not build/link, not a test result — retrying once \
9666                 before concluding"
9667            ),
9668        );
9669        let retried = run_commands(
9670            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
9671        )
9672        .await;
9673        e2e = retried.0;
9674        // Both attempts' timeouts matter, not just the last one: the first
9675        // attempt's descendants may still be alive alongside the retry's.
9676        timed_out_pids.extend(retried.1);
9677        for o in &e2e {
9678            state.event(
9679                "verify",
9680                format!(
9681                    "{context}: retry `{}` -> {}",
9682                    o.command,
9683                    e2e_outcome_label(o)
9684                ),
9685            );
9686        }
9687    }
9688    (e2e, verify_retried, timed_out_pids)
9689}
9690
9691/// Run configured shell commands in `cwd`, in order. The second element is
9692/// the pid of every command that hit `timeout` and was still running when
9693/// this stopped waiting on it (best-effort: `None` when the platform did not
9694/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
9695/// that releases a shared resource afterward needs to know.
9696///
9697/// Records `task` into [`RunState::active`] at every command boundary
9698/// (`RunState::task_command`) and clears it once the whole list has run
9699/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
9700/// for minutes with no seat and no output of its own to show for it (see
9701/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
9702/// apart from "not yet run" without this), and this is the only place that
9703/// knows which command is running right now and how many are left. Three
9704/// saves per command — start, not per second — matching the same "only at a
9705/// boundary" rule [`wave`] already follows for seats.
9706#[allow(clippy::too_many_arguments)]
9707async fn run_commands(
9708    state: &mut RunState,
9709    node: &str,
9710    task: &str,
9711    attempt: usize,
9712    shell: &[String],
9713    commands: &[String],
9714    cwd: &Path,
9715    timeout: Duration,
9716) -> (Vec<CommandOutcome>, Vec<u32>) {
9717    if commands.is_empty() {
9718        // Nothing to mark as running and nothing to clear — an empty list
9719        // means "not configured", and touching `active` (or the disk) over
9720        // that would be a write for every round of a repo with no
9721        // `verify.e2e` / `verify.gate` commands at all.
9722        return (Vec::new(), Vec::new());
9723    }
9724    let mut out = Vec::new();
9725    let mut timed_out_pids = Vec::new();
9726    let total = commands.len();
9727    for (idx, command) in commands.iter().enumerate() {
9728        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
9729        if let Err(e) = state.save() {
9730            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
9731        }
9732        let started = Instant::now();
9733        let mut cmd = tokio::process::Command::new(&shell[0]);
9734        cmd.quiet();
9735        cmd.args(&shell[1..])
9736            .arg(command)
9737            .current_dir(cwd)
9738            .stdin(std::process::Stdio::null())
9739            .stdout(std::process::Stdio::piped())
9740            .stderr(std::process::Stdio::piped())
9741            .kill_on_drop(true);
9742        let spawned = cmd.spawn();
9743        let (code, body) = match spawned {
9744            Ok(child) => {
9745                // Captured before the child is consumed below: `kill_on_drop`
9746                // only *asks* the process to die when the timeout branch
9747                // drops it, and the pid is the only way anyone downstream can
9748                // later check whether that request actually took.
9749                let pid = child.id();
9750                match tokio::time::timeout(timeout, child.wait_with_output()).await {
9751                    Ok(Ok(o)) => {
9752                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
9753                        body.push_str(&String::from_utf8_lossy(&o.stderr));
9754                        (o.status.code(), body)
9755                    }
9756                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
9757                    Err(_) => {
9758                        if let Some(pid) = pid {
9759                            timed_out_pids.push(pid);
9760                        }
9761                        (None, format!("timed out after {}s", timeout.as_secs()))
9762                    }
9763                }
9764            }
9765            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
9766        };
9767        out.push(CommandOutcome {
9768            command: command.clone(),
9769            code,
9770            output_tail: tail(&body, OUTPUT_TAIL),
9771            duration_ms: started.elapsed().as_millis() as u64,
9772            resource_blocked: false,
9773        });
9774    }
9775    state.task_finished(task);
9776    if let Err(e) = state.save() {
9777        tracing::warn!("could not persist the end of {task}: {e:#}");
9778    }
9779    (out, timed_out_pids)
9780}
9781
9782/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
9783/// section (`report::run`) and in the `merge` event this node records — for
9784/// the operator to run by hand.
9785///
9786/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
9787/// branch whose ruleset forbids merge commits (GitHub's "must not contain
9788/// merge commits", or "require linear history") rejects the push a `--no-ff`
9789/// merge would produce, which is exactly the guidance this function replaces.
9790/// `message`'s first line becomes the squash commit's subject, matching the
9791/// note `report::run` prints alongside this command — see that function for
9792/// why an explicit subject is not optional there.
9793fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
9794    let repo = repo.display();
9795    match style {
9796        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
9797        MergeStyle::Squash => {
9798            // The subject sits inside double quotes, and a title an agent
9799            // wrote may carry the characters that break out of them.
9800            let subject = message
9801                .lines()
9802                .next()
9803                .unwrap_or(branch)
9804                .replace(['\\', '"', '$', '`'], "");
9805            format!(
9806                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
9807            )
9808        }
9809        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
9810    }
9811}
9812
9813/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
9814/// under the hood, rejects a `title` over 256 characters and the whole
9815/// command fails — no PR at all, for a run whose body was otherwise fine
9816/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
9817/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
9818/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
9819/// this bound. It is a margin, not a guarantee — a title packed
9820/// with multi-unit characters could still in principle land close to the
9821/// edge, but a real task title's occasional emoji or accented letter fits
9822/// comfortably inside it.
9823const PR_TITLE_MAX: usize = 240;
9824
9825/// A pull request title from the opening line of `text`, or `None` when that
9826/// line is not English (GitHub text is) or has no letters.
9827///
9828/// A line within `max` is kept as is. A longer one is cut at the end of its
9829/// first sentence when that falls inside `max`, else at a word boundary with a
9830/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
9831/// make every merely-truncated title look non-English). The language check
9832/// runs on the kept text before any mark is added, so only what GitHub will
9833/// show is judged: an English opening followed by non-ASCII far past the cut
9834/// still passes.
9835fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
9836    let line = queue::first_line(text)?;
9837    let chars: Vec<char> = line.chars().collect();
9838    let (kept, mark) = if chars.len() <= max {
9839        (line.to_owned(), "")
9840    } else if let Some(end) = sentence_end(&chars, max) {
9841        (chars[..end].iter().collect::<String>(), "")
9842    } else {
9843        let room = max.saturating_sub(3);
9844        // Cut at the last space inside the room; when the char just past the
9845        // room is a space the room already ends on a word.
9846        let cut = if chars[room].is_whitespace() {
9847            room
9848        } else {
9849            chars[..room]
9850                .iter()
9851                .rposition(|c| c.is_whitespace())
9852                .unwrap_or(room)
9853        };
9854        let head: String = chars[..cut].iter().collect();
9855        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
9856        (head.to_owned(), "...")
9857    };
9858    if kept.is_empty() {
9859        return None;
9860    }
9861    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
9862        return None;
9863    }
9864    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
9865        return None;
9866    }
9867    Some(format!("{kept}{mark}"))
9868}
9869
9870/// The char length of the first sentence of `chars` when it ends within `max`
9871/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
9872/// abbreviations so `e.g. foo` does not end a title early.
9873fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
9874    const MIN: usize = 20;
9875    for i in MIN..max.min(chars.len()) {
9876        if !matches!(chars[i], '.' | '!' | '?') {
9877            continue;
9878        }
9879        let Some(&next) = chars.get(i + 1) else {
9880            continue;
9881        };
9882        if !next.is_whitespace() {
9883            continue;
9884        }
9885        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
9886        if after.is_some_and(|c| c.is_ascii_lowercase()) {
9887            continue;
9888        }
9889        let word: String = chars[..i]
9890            .iter()
9891            .rev()
9892            .take_while(|c| !c.is_whitespace())
9893            .collect::<Vec<_>>()
9894            .into_iter()
9895            .rev()
9896            .collect();
9897        let word = word.to_ascii_lowercase();
9898        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
9899            continue;
9900        }
9901        let end = chars[..i]
9902            .iter()
9903            .rposition(|c| !c.is_whitespace())
9904            .map_or(i, |p| p + 1);
9905        return Some(end);
9906    }
9907    None
9908}
9909
9910/// What `merge = "pr"` (and the merge commit of the other modes) says about a
9911/// change: a title and a body describing what was *implemented*, not the task
9912/// that asked for it. A task reads as a request; a reader of the merged
9913/// history wants the change.
9914struct PrMessage {
9915    title: String,
9916    body: String,
9917}
9918
9919impl PrMessage {
9920    /// Title, blank line, body. The first line is the squash/merge commit
9921    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
9922    /// has to stay one sensible line.
9923    fn commit_message(&self) -> String {
9924        format!("{}\n\n{}", self.title, self.body)
9925    }
9926}
9927
9928/// The text after a leading `TITLE:` (any case) on `line`.
9929fn title_marker(line: &str) -> Option<&str> {
9930    let line = line.trim();
9931    let head = line.get(..6)?;
9932    head.eq_ignore_ascii_case("title:")
9933        .then(|| line[6..].trim())
9934}
9935
9936/// The implementer's own one-line title: the `TITLE:` line the implement
9937/// prompt asks for at the top of its SUMMARY. Candidate commits are all
9938/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
9939/// source, and a title that says as much is refused here too.
9940fn summary_title(summary: &str) -> Option<String> {
9941    let first = summary.lines().find(|l| !l.trim().is_empty())?;
9942    let raw = title_marker(first)?;
9943    if raw.is_empty() {
9944        return None;
9945    }
9946    let title = queue::title_from(raw, PR_TITLE_MAX);
9947    let lower = title.to_ascii_lowercase();
9948    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9949        return None;
9950    }
9951    Some(title)
9952}
9953
9954/// How `open_review`'s instruction begins; see [`landing_title`].
9955const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9956
9957/// Marker `open_review` gives a candidate that nothing in the roster wrote.
9958const EXISTING_BRANCH: &str = "(existing branch)";
9959
9960/// Does this run review work that already existed, rather than implement a
9961/// task? Runs recorded before `reviewed_commits` existed carry only the
9962/// candidate marker.
9963fn is_review_run(state: &RunState) -> bool {
9964    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9965}
9966
9967/// The title of a review-only run: the subject of the oldest commit under
9968/// review. Later commits are usually fixups, and `instruction` is the review
9969/// prompt, which says nothing about the change. GitHub text is English, so a
9970/// non-ASCII or blank subject yields `None` and the caller's neutral title.
9971fn review_title(state: &RunState) -> Option<String> {
9972    english_subject(
9973        state.reviewed_commits.as_ref()?.first()?,
9974        state.config.graph.github_english(),
9975    )
9976}
9977
9978/// `raw` as a pull request title, or `None` when it is blank, not English
9979/// (GitHub text is), or one of magi's own candidate commit subjects.
9980fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
9981    let raw = raw.trim();
9982    if raw.is_empty() {
9983        return None;
9984    }
9985    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
9986        return None;
9987    }
9988    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
9989        return None;
9990    }
9991    let title = queue::title_from(raw, PR_TITLE_MAX);
9992    let lower = title.to_ascii_lowercase();
9993    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9994        return None;
9995    }
9996    Some(title)
9997}
9998
9999/// What a review-only run's branch says about itself, read at the moment the
10000/// pull request is opened.
10001#[derive(Debug, Clone, PartialEq, Eq)]
10002struct BranchFacts {
10003    /// `(subject, body)` of each commit, oldest first.
10004    commits: Vec<(String, String)>,
10005    /// Trimmed `git diff --stat`.
10006    stat: String,
10007}
10008
10009/// Longest diff stat shown: this many file lines plus the summary line.
10010const STAT_FILE_LINES: usize = 25;
10011/// Cap on the commit list, well inside GitHub's 65536-character body limit.
10012const COMMITS_MAX_CHARS: usize = 20_000;
10013
10014/// Read the commits and diff stat of `base..branch`. `None` when git cannot
10015/// say or finds nothing, so the caller falls back to what the run recorded.
10016async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10017    let commits = git::commit_log(repo, base, branch).await.ok()?;
10018    if commits.is_empty() {
10019        return None;
10020    }
10021    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10022    let lines: Vec<&str> = stat.lines().collect();
10023    let stat = if lines.len() > STAT_FILE_LINES + 1 {
10024        let omitted = lines.len() - 1 - STAT_FILE_LINES;
10025        let more = format!(" ... {omitted} more file(s)");
10026        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10027        kept.push(&more);
10028        kept.push(lines[lines.len() - 1]);
10029        kept.join("\n")
10030    } else {
10031        lines.join("\n")
10032    };
10033    Some(BranchFacts { commits, stat })
10034}
10035
10036/// Defang what would break the surrounding markdown: a closing `</details>`
10037/// and a code fence.
10038fn markdown_safe(text: &str) -> String {
10039    text.replace("</details>", "&lt;/details&gt;")
10040        .replace("\x60\x60\x60", "~~~")
10041}
10042
10043fn neutral_title(state: &RunState, winner: char) -> String {
10044    format!(
10045        "chore: land candidate {} of run {}",
10046        winner.to_ascii_uppercase(),
10047        state.id
10048    )
10049}
10050
10051/// The pull request title to hand to `land::merge_subject`. A review-only run
10052/// opened by an earlier build titled its pull request with the review prompt;
10053/// that title is dropped (empty, so the fallback applies) rather than landed.
10054/// Any other title, including an operator's rename, passes through untouched,
10055/// and so does every title of a run that implements a task.
10056pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10057    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10058        ""
10059    } else {
10060        pr_title
10061    }
10062}
10063
10064/// What the squash subject falls back to when the pull request title is empty
10065/// or candidate-shaped: for a review-only run the derived title, never the
10066/// review prompt held in `instruction`.
10067pub fn landing_subject_source(state: &RunState) -> String {
10068    if is_review_run(state) {
10069        let winner = state.candidates.first().map_or('A', |c| c.label);
10070        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10071    }
10072    state.instruction.clone()
10073}
10074
10075/// `summary` without its `TITLE:` line, which the pull request title already
10076/// carries.
10077fn summary_without_title(summary: &str) -> String {
10078    let mut lines = summary.trim().lines().peekable();
10079    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10080        lines.next();
10081    }
10082    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10083}
10084
10085/// The pull request title and body for the winning candidate.
10086///
10087/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10088/// the task's own opening line via [`queue::title_from`] when there is none.
10089/// `state.instruction` can open with blank lines (`task_text` only rejects a
10090/// body that is blank *entirely*), which `title_from` skips.
10091///
10092/// Publishing uses an independent description generated from the final diff
10093/// and structured evidence. Internal summaries and reports remain local.
10094/// Non-publishing previews may display the original task and candidate summary.
10095/// The footer retains the run and candidate provenance.
10096#[cfg(test)]
10097fn pr_message(state: &RunState, winner: char) -> PrMessage {
10098    pr_message_with(state, winner, None)
10099}
10100
10101/// [`pr_message`] with what the branch of a review-only run says about itself.
10102/// `facts` is ignored for a run that implements a task.
10103#[cfg(test)]
10104fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10105    let raw = pr_message_raw(
10106        state,
10107        winner,
10108        facts,
10109        crate::pr_outcome::FinalHead::Unreported,
10110        ChangeNote::NotAsked,
10111    );
10112    let id = crate::scrub::Identity::current();
10113    PrMessage {
10114        title: crate::scrub::scrub(&raw.title, &id),
10115        body: crate::scrub::scrub(&raw.body, &id),
10116    }
10117}
10118
10119/// What the public description may say about the change itself.
10120#[derive(Debug, Clone, Copy)]
10121enum ChangeNote<'a> {
10122    /// A local preview or non-publishing merge mode: no public generation needed.
10123    NotAsked,
10124    /// A summary written from the final diff.
10125    Summary(&'a crate::run::ChangeSummary),
10126    /// A summary was asked for and could not be produced; the text says why.
10127    Unavailable(&'a str),
10128}
10129
10130fn pr_message_raw(
10131    state: &RunState,
10132    winner: char,
10133    facts: Option<&BranchFacts>,
10134    head: crate::pr_outcome::FinalHead<'_>,
10135    change: ChangeNote<'_>,
10136) -> PrMessage {
10137    if !matches!(change, ChangeNote::NotAsked) {
10138        let (title, mut body) = match change {
10139            ChangeNote::Summary(c) => (
10140                c.title.clone(),
10141                format!("## Summary\n\n{}\n\n", c.body.trim()),
10142            ),
10143            ChangeNote::Unavailable(why) => (
10144                neutral_title(state, winner),
10145                format!(
10146                    "## 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"
10147                ),
10148            ),
10149            ChangeNote::NotAsked => unreachable!(),
10150        };
10151        body.push_str(&public_pr_evidence(state, head));
10152        body.push_str(&format!(
10153            "\n---\nmagi:run/{} magi:candidate-{}\n",
10154            state.id,
10155            winner.to_ascii_lowercase()
10156        ));
10157        return PrMessage { title, body };
10158    }
10159    pr_preview(state, winner, facts, head)
10160}
10161
10162/// Append public facts from the run records after every prose generation,
10163/// including posting-gate rewrites. The agent cannot drop this provenance.
10164fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10165    let mut body = crate::pr_outcome::review_rounds(state);
10166    body.push_str(&crate::pr_outcome::final_verification(state, head));
10167    let open = state.last_round_findings();
10168    if !open.is_empty() {
10169        body.push_str("## Open review findings\n\n");
10170        for f in open {
10171            body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10172        }
10173        body.push('\n');
10174    }
10175    if let Some(c) = state.change_summary.as_ref()
10176        && head == crate::pr_outcome::FinalHead::At(&c.head)
10177    {
10178        body.push_str(&format!(
10179            "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10180            c.base, c.head
10181        ));
10182    }
10183    body
10184}
10185
10186/// Local-only preview of the operator's task and candidate summary.
10187fn pr_preview(
10188    state: &RunState,
10189    winner: char,
10190    facts: Option<&BranchFacts>,
10191    head: crate::pr_outcome::FinalHead<'_>,
10192) -> PrMessage {
10193    let summary = state
10194        .candidates
10195        .iter()
10196        .find(|c| c.label == winner)
10197        .map(|c| c.summary.as_str())
10198        .unwrap_or_default();
10199    // The fallback is the operator's own words and may not be English; GitHub
10200    // text always is, so a non-English task gets a neutral title instead.
10201    let review = is_review_run(state);
10202    let title = if review {
10203        facts
10204            .and_then(|f| {
10205                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10206            })
10207            .or_else(|| review_title(state))
10208            .or_else(|| {
10209                state
10210                    .candidates
10211                    .iter()
10212                    .find(|c| c.label == winner)
10213                    .filter(|c| !c.branch.starts_with("magi/"))
10214                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10215            })
10216            .unwrap_or_else(|| neutral_title(state, winner))
10217    } else {
10218        summary_title(summary).unwrap_or_else(|| {
10219            english_title(
10220                &state.instruction,
10221                PR_TITLE_MAX,
10222                state.config.graph.github_english(),
10223            )
10224            .unwrap_or_else(|| neutral_title(state, winner))
10225        })
10226    };
10227
10228    let mut body = String::new();
10229    let what = summary_without_title(summary);
10230    if !what.is_empty() {
10231        body.push_str("## Summary\n\n");
10232        body.push_str(&what);
10233        body.push_str("\n\n");
10234    }
10235
10236    // Review history uses structured facts across all rounds, never reports.
10237    body.push_str(&crate::pr_outcome::review_rounds(state));
10238    body.push_str(&crate::pr_outcome::final_verification(state, head));
10239    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10240
10241    let open = state.open_findings();
10242    if !open.is_empty() {
10243        body.push_str("## Open review findings\n\n");
10244        for f in &open {
10245            body.push_str(&format!(
10246                "- `{}` [{:?}] {}\n",
10247                f.id, f.severity, "Context retained in local review records."
10248            ));
10249        }
10250        body.push('\n');
10251    }
10252
10253    if let Some(fix) = fix
10254        && !fix.rejected.is_empty()
10255    {
10256        body.push_str("## Declined by the fixer\n\n");
10257        for r in &fix.rejected {
10258            body.push_str(&format!(
10259                "- `{}`: {}\n",
10260                r.id, "Reason retained in local review records."
10261            ));
10262        }
10263        body.push('\n');
10264    }
10265
10266    if review {
10267        // The review prompt is not the task; list what the branch carries.
10268        body.push_str("## Commits under review\n\n");
10269        if let Some(facts) = facts {
10270            let mut left = COMMITS_MAX_CHARS;
10271            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10272                let mut entry = format!("- {}\n", markdown_safe(subject));
10273                for l in markdown_safe(text).lines() {
10274                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10275                }
10276                if left == 0 {
10277                    body.push_str(&format!(
10278                        "- ... {} more commit(s)\n",
10279                        facts.commits.len() - i
10280                    ));
10281                    break;
10282                }
10283                if entry.len() > left {
10284                    // Even the first commit is cut: one huge body must not
10285                    // push the whole description past GitHub's limit.
10286                    let mut end = left;
10287                    while !entry.is_char_boundary(end) {
10288                        end -= 1;
10289                    }
10290                    entry.truncate(end);
10291                    entry.push_str("\n  ... (truncated)\n");
10292                    left = 0;
10293                } else {
10294                    left -= entry.len();
10295                }
10296                body.push_str(&entry);
10297            }
10298            if !facts.stat.trim().is_empty() {
10299                body.push_str(&format!(
10300                    "\n## Diff stat\n\n```\n{}\n```\n",
10301                    markdown_safe(facts.stat.trim())
10302                ));
10303            }
10304        } else {
10305            match &state.reviewed_commits {
10306                Some(subjects) => {
10307                    for s in subjects {
10308                        body.push_str(&format!("- {}\n", s.trim()));
10309                    }
10310                }
10311                None => {
10312                    // An older run kept only the prompt, with the commit list
10313                    // after its first paragraph.
10314                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10315                    body.push_str(rest.trim());
10316                    body.push('\n');
10317                }
10318            }
10319        }
10320    } else {
10321        let task = state.instruction.trim();
10322        let task = if task.is_empty() {
10323            "(empty task)"
10324        } else {
10325            task
10326        };
10327        body.push_str(&format!(
10328            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10329            task.replace("</details>", "&lt;/details&gt;")
10330        ));
10331    }
10332
10333    body.push_str(&format!(
10334        "\n---\nmagi:run/{} magi:candidate-{}\n",
10335        state.id,
10336        winner.to_ascii_lowercase()
10337    ));
10338
10339    PrMessage { title, body }
10340}
10341
10342/// The task with what the repository says about the existing work it names
10343/// appended, so an implementer knows what it started from and what it must
10344/// not redo. Unchanged when the task names nothing.
10345fn seeded_instruction(state: &RunState) -> String {
10346    match refs::describe(&state.seeds) {
10347        Some(facts) => format!(
10348            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10349             Candidates start from the unmerged branch named above, when there \
10350             is one, and carry any unmerged commit named by sha as a \
10351             cherry-pick. Check that this is what the task meant before \
10352             building on it.",
10353            state.instruction
10354        ),
10355        None => state.instruction.clone(),
10356    }
10357}
10358
10359/// Does the winner have no commits ahead of the base it would land on?
10360/// Any failure to find out reads as "not empty": the merge then behaves as it
10361/// always did rather than refusing on a guess.
10362async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10363    let base = &state.base_branch;
10364    let mut against = base.clone();
10365    if mode == MergeMode::Pr {
10366        // A pull request lands on the remote's base, never the local branch
10367        // of the same name: if that cannot be read, "not empty" is the safe
10368        // answer.
10369        let remote = &state.config.merge.remote;
10370        let tracking = format!("{remote}/{base}");
10371        let fetched = git::fetch(repo, remote, base).await;
10372        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10373            against = tracking;
10374        } else {
10375            return false;
10376        }
10377    }
10378    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10379}
10380
10381/// Why nothing was opened for an empty winner, with what the task's own
10382/// references resolved to.
10383fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10384    let mut detail = format!(
10385        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10386         nothing to open a pull request for"
10387    );
10388    match refs::describe(&state.seeds) {
10389        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10390        None => detail.push_str(
10391            "\nThe task names no existing branch or commit; if it means to land work \
10392             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10393        ),
10394    }
10395    detail
10396}
10397
10398/// What the `Pr` merge does once it knows whether the branch already has an
10399/// open pull request.
10400#[derive(Debug, PartialEq, Eq)]
10401enum PrPlan {
10402    Create,
10403    Adopt { url: String, title: String },
10404    Stop(String),
10405}
10406
10407/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
10408/// a failed lookup -> stop with the real reason. Never guesses.
10409fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10410    match found {
10411        Ok(land::OpenPr::None) => PrPlan::Create,
10412        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10413        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10414            "several open pull requests exist for this branch, not picking one: {}",
10415            urls.join(" ")
10416        )),
10417        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10418    }
10419}
10420
10421/// `gh pr create`, returning the PR url.
10422pub(crate) async fn gh_pr_create(
10423    cwd: &Path,
10424    base: &str,
10425    head: &str,
10426    title: &str,
10427    body: &str,
10428) -> Result<String> {
10429    let out = tokio::process::Command::new("gh")
10430        .args([
10431            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10432        ])
10433        .current_dir(cwd)
10434        .quiet()
10435        .stdin(std::process::Stdio::null())
10436        .output()
10437        .await
10438        .context("spawn gh")?;
10439    if out.status.success() {
10440        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10441    } else {
10442        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10443    }
10444}
10445
10446/// Tear a run's worktrees and branches down.
10447///
10448/// `home` is where the updated `run.json` is saved (via
10449/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
10450/// a housekeeping pass already has its own honest `home` handed to it, and
10451/// falling through to the global here would write back through whichever
10452/// directory some other process or test pinned into that `OnceLock` first,
10453/// not the one the caller actually resolved its `runs` and `state` from.
10454pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10455    let repo = state.repo.clone();
10456    let root = state.worktree_root();
10457    let winner = state.tally.as_ref().map(|t| t.winner);
10458    let mut removed = Vec::new();
10459
10460    for i in 0..state.candidates.len() {
10461        let c = state.candidates[i].clone();
10462        let is_winner = Some(c.label) == winner;
10463        if is_winner && !drop_winner {
10464            continue;
10465        }
10466        if c.worktree.exists() {
10467            git::worktree_remove(&repo, &c.worktree).await.ok();
10468            removed.push(c.worktree.to_string_lossy().into_owned());
10469        }
10470        // A branch handed to a later run (and its pull request) is not this
10471        // run's to delete.
10472        let handed_over = state.released_branches.contains(&c.branch);
10473        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10474            git::branch_delete(&repo, &c.branch).await.ok();
10475            removed.push(c.branch.clone());
10476        }
10477        state.candidates[i].folded = true;
10478    }
10479
10480    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10481        let path = name.path();
10482        let keep = !drop_winner
10483            && winner.is_some_and(|w| {
10484                path.file_name()
10485                    .is_some_and(|n| n == format!("cand-{w}").as_str())
10486            });
10487        if keep {
10488            continue;
10489        }
10490        git::worktree_remove(&repo, &path).await.ok();
10491        removed.push(path.to_string_lossy().into_owned());
10492    }
10493
10494    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
10495    // judge worktrees, so once the loop above has cleared all of them out,
10496    // the parent is a bare directory nobody else was ever going to remove -
10497    // git only ever managed what was inside it. Left alone, one of these
10498    // accumulates per fully-folded run; the operator's own machine had 74.
10499    // `remove_if_empty` re-checks rather than assuming: a run whose winner
10500    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
10501    // so does anything a run never claimed that happens to share the bay.
10502    remove_if_empty(&root);
10503
10504    if state.enabled_worktree_config && drop_winner {
10505        // A release, not a raw disable: some sibling run in this repository
10506        // may still hold its own reference (see `git::acquire_worktree_config`),
10507        // and only the last release actually turns the setting back off.
10508        git::release_worktree_config(&repo).await.ok();
10509        state.enabled_worktree_config = false;
10510    }
10511    state.save_under(home)?;
10512    Ok(removed)
10513}
10514
10515/// Remove `dir` if it exists and has nothing in it.
10516///
10517/// Best-effort and silent by design: a directory that is not empty (a run
10518/// whose winner is still parked there, a stray file some other process left)
10519/// is exactly the case this must refuse, and a directory that is already gone
10520/// is not a failure worth reporting either. `std::fs::remove_dir` itself
10521/// already refuses a non-empty directory, so the emptiness check below is
10522/// belt, not suspenders - it is what keeps this from ever attempting the
10523/// removal in the case that matters, rather than trusting `remove_dir`'s
10524/// error path to have no side effects if it ever changed.
10525fn remove_if_empty(dir: &Path) {
10526    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10527        std::fs::remove_dir(dir).ok();
10528    }
10529}
10530
10531/// Severity of the worst open finding in the last review round, for reporting.
10532pub fn worst_open(state: &RunState) -> Option<Severity> {
10533    state
10534        .reviews
10535        .last()?
10536        .reviews
10537        .iter()
10538        .flat_map(|r| r.findings.iter())
10539        .map(|f| f.severity)
10540        .max()
10541}
10542
10543/// `[worktree] setup` hides its products per worktree, which needs
10544/// `extensions.worktreeConfig`. Taken once per run and released with the run's
10545/// worktrees, like the hook's; every place that runs setup calls this first.
10546async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10547    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10548        git::acquire_worktree_config(repo).await?;
10549        state.enabled_worktree_config = true;
10550    }
10551    Ok(())
10552}
10553
10554#[cfg(test)]
10555mod tests {
10556    #[test]
10557    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10558        let leaked = vec!["chore(deps): update a crate".to_owned()];
10559        let own = "fix(daemon): apply a chosen action";
10560        assert!(should_retitle("", own, &leaked));
10561        assert!(should_retitle(
10562            &format!("{REVIEW_PROMPT_OPENING} `x`"),
10563            own,
10564            &leaked
10565        ));
10566        assert!(should_retitle(
10567            "chore: land candidate A of run 1",
10568            own,
10569            &leaked
10570        ));
10571        assert!(should_retitle(
10572            "magi: candidate A (uncommitted work)",
10573            own,
10574            &leaked
10575        ));
10576        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10577        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10578        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10579    }
10580
10581    #[test]
10582    fn pr_merge_plan_creates_adopts_or_stops() {
10583        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10584        assert_eq!(
10585            pr_merge_plan(Ok(land::OpenPr::One {
10586                url: "u".into(),
10587                title: "t".into()
10588            })),
10589            PrPlan::Adopt {
10590                url: "u".into(),
10591                title: "t".into()
10592            }
10593        );
10594        let PrPlan::Stop(many) =
10595            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10596        else {
10597            panic!("many must stop");
10598        };
10599        assert!(many.contains('a') && many.contains('b'));
10600        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10601            panic!("a failed lookup must stop");
10602        };
10603        assert!(err.contains("bad token"));
10604    }
10605
10606    use super::*;
10607    use crate::run::GateStatus;
10608    use std::collections::BTreeMap;
10609    use std::time::Duration;
10610
10611    fn conductor() -> AgentSpec {
10612        AgentSpec {
10613            id: "conductor".to_owned(),
10614            kind: crate::config::AgentKind::Command,
10615            model: None,
10616            command: vec!["true".to_owned()],
10617            extra_args: Vec::new(),
10618            env: BTreeMap::new(),
10619            prompt_delivery: None,
10620        }
10621    }
10622
10623    fn spec(id: &str) -> AgentSpec {
10624        AgentSpec {
10625            id: id.to_owned(),
10626            kind: crate::config::AgentKind::Command,
10627            model: None,
10628            command: vec!["true".to_owned()],
10629            extra_args: Vec::new(),
10630            env: BTreeMap::new(),
10631            prompt_delivery: None,
10632        }
10633    }
10634
10635    fn ids(xs: &[&str]) -> BTreeSet<String> {
10636        xs.iter().map(|s| (*s).to_owned()).collect()
10637    }
10638
10639    #[test]
10640    fn pick_successor_skips_an_agent_another_seat_holds() {
10641        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
10642        let roster = [spec("a"), spec("b"), spec("c")];
10643        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10644        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10645    }
10646
10647    #[test]
10648    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
10649        // The other seat started on c but was handed to d; c is free again.
10650        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
10651        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10652        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10653        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
10654        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
10655    }
10656
10657    #[test]
10658    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
10659        let roster = [spec("a"), spec("b")];
10660        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
10661        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10662        let carried = ids(&["b"]);
10663        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
10664        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10665    }
10666
10667    #[test]
10668    fn pick_successor_returns_none_without_any_untried_successor() {
10669        let roster = [spec("a"), spec("b")];
10670        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
10671        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
10672        let carried = ids(&["a"]);
10673        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
10674    }
10675
10676    #[test]
10677    fn pick_successor_rescue_avoids_another_seats_occupant() {
10678        // b is a carried failure and free; a is held by the other seat.
10679        let roster = [spec("a"), spec("b"), spec("c")];
10680        let carried = ids(&["b", "c"]);
10681        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
10682        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10683    }
10684
10685    #[test]
10686    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
10687        let roster = [spec("a"), spec("b"), spec("c")];
10688        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
10689        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
10690    }
10691
10692    #[test]
10693    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
10694        let roster = [spec("a"), spec("b"), spec("c")];
10695        let failed = ids(&["a", "b", "c"]);
10696        // Rescue looks at the whole roster, once per id, then runs out.
10697        let mut tried = ids(&["b"]);
10698        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10699        assert_eq!(first.id, "a");
10700        tried.insert(first.id.clone());
10701        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
10702        assert_eq!(second.id, "c");
10703        tried.insert(second.id.clone());
10704        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
10705    }
10706
10707    #[test]
10708    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
10709        let roster = [spec("a"), spec("b")];
10710        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
10711        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10712    }
10713
10714    #[test]
10715    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
10716        let roster = [spec("a"), spec("b"), spec("c")];
10717        let h = SeatHistory {
10718            failed: ids(&["a"]),
10719            last_ok: Some("b".to_owned()),
10720            last_fail: None,
10721        };
10722        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
10723        // A last answerer that left the roster, or later failed, is ignored.
10724        let gone = SeatHistory {
10725            last_ok: Some("zzz".to_owned()),
10726            ..h.clone()
10727        };
10728        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
10729        let failed = SeatHistory {
10730            failed: ids(&["a", "b"]),
10731            last_ok: Some("b".to_owned()),
10732            last_fail: None,
10733        };
10734        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
10735    }
10736
10737    #[test]
10738    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
10739        let first = SeatState::new("review-1", "alpha", 7);
10740        let next = handover_seat("review-1", "gamma", 7);
10741        assert_ne!(first.claude_session, next.claude_session);
10742    }
10743
10744    #[test]
10745    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
10746        let mut state = state_with_summary("x", "y");
10747        let a = handover_seat("review-1", "beta", state.next_seat_seed());
10748        let b = handover_seat("review-1", "beta", state.next_seat_seed());
10749        assert_ne!(a.claude_session, b.claude_session);
10750    }
10751
10752    #[test]
10753    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
10754        let roster = [spec("a"), spec("b")];
10755        let h = SeatHistory {
10756            failed: ids(&["a", "b"]),
10757            ..SeatHistory::default()
10758        };
10759        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
10760        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
10761        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
10762    }
10763
10764    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
10765    // fallback loop depends on to terminate: it must walk forward from the
10766    // seat's own position, never restart at the front of the roster, and it
10767    // must never hand back an id already tried, however many times that id
10768    // happens to appear.
10769
10770    #[test]
10771    fn failure_signature_ignores_numbers_and_paths() {
10772        assert_eq!(
10773            failure_signature("exited with Some(2) and no usable output"),
10774            failure_signature("exited with Some(137) and no usable output")
10775        );
10776        assert_eq!(
10777            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
10778            failure_signature("cannot open /var/x.txt: denied")
10779        );
10780        assert_ne!(failure_signature("boom"), failure_signature("bang"));
10781    }
10782
10783    #[test]
10784    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
10785        let other = FailClass::Other("x".into());
10786        assert!(should_hand_over(None, &FailClass::Quota));
10787        assert!(should_hand_over(Some(&other), &FailClass::Quota));
10788        assert!(should_hand_over(
10789            Some(&FailClass::Timeout),
10790            &FailClass::Timeout
10791        ));
10792        assert!(should_hand_over(None, &other));
10793        assert!(!should_hand_over(Some(&other), &other));
10794        assert!(should_hand_over(
10795            Some(&other),
10796            &FailClass::Other("y".into())
10797        ));
10798        // A quota or timeout in between ends the run of identical failures.
10799        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
10800        assert!(should_hand_over(Some(&FailClass::Quota), &other));
10801    }
10802
10803    #[test]
10804    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
10805        let a = SeatState::new("judge-1", "alpha", 7);
10806        let b = handover_seat("judge-1", "beta", 7);
10807        assert_ne!(a.claude_session, b.claude_session);
10808        assert_eq!(b.turns, 0);
10809    }
10810
10811    #[test]
10812    fn a_timeout_is_classified_apart_from_other_failures() {
10813        assert_eq!(
10814            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
10815            Some(FailClass::Timeout)
10816        );
10817        assert!(matches!(
10818            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
10819            Some(FailClass::Other(_))
10820        ));
10821    }
10822
10823    #[test]
10824    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
10825        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10826        let tried = BTreeSet::from(["beta".to_owned()]);
10827        // beta sits at index 1; the next candidate is gamma, never alpha —
10828        // which is very likely a different candidate slot's own agent.
10829        let next = next_untried_in_roster(&roster, 1, &tried);
10830        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
10831    }
10832
10833    #[test]
10834    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
10835        let roster = vec![spec("alpha"), spec("beta")];
10836        let tried = BTreeSet::from(["beta".to_owned()]);
10837        // beta is the roster's last entry: nothing follows it, and alpha —
10838        // earlier in the roster, almost certainly a different candidate
10839        // slot's own agent — must not be reached by wrapping back to it.
10840        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10841    }
10842
10843    #[test]
10844    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
10845        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
10846        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
10847        // beta (index 1) and gamma (index 2, the only entry after it) have
10848        // both been tried; alpha (index 0) never has, but it comes before
10849        // beta's own position, so there is nothing further for this seat.
10850        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
10851    }
10852
10853    #[test]
10854    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
10855        let roster = vec![spec("a"), spec("a"), spec("b")];
10856        let tried = BTreeSet::from(["a".to_owned()]);
10857        let next = next_untried_in_roster(&roster, 0, &tried);
10858        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
10859    }
10860
10861    #[test]
10862    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
10863        let roster = vec![spec("a"), spec("b")];
10864        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
10865        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
10866    }
10867
10868    #[test]
10869    fn remove_if_empty_only_ever_takes_a_bare_directory() {
10870        let dir = tempfile::tempdir().unwrap();
10871        let bay = dir.path().join("ffff");
10872
10873        // Not there yet: nothing to do, nothing to panic on.
10874        remove_if_empty(&bay);
10875        assert!(!bay.exists());
10876
10877        // Something still inside - the winner's worktree, or a stray file -
10878        // keeps the directory standing.
10879        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
10880        remove_if_empty(&bay);
10881        assert!(bay.exists(), "non-empty directory must survive");
10882
10883        // Once the last entry is gone, so is the directory itself.
10884        std::fs::remove_dir(bay.join("cand-A")).unwrap();
10885        remove_if_empty(&bay);
10886        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
10887    }
10888
10889    // `round_is_clean` is the exact decision this task fixed: a round with a
10890    // seat that never answered must not read the same as a round every seat
10891    // actually reviewed. These are deterministic and process-free by design —
10892    // the equivalent end-to-end check (a real reviewer timing out under a
10893    // live graph run) is a genuine race against wall-clock contention, and a
10894    // spawn slow enough to blow even a generous budget under a loaded test
10895    // run must not turn this specific regression check flaky.
10896
10897    #[test]
10898    fn a_full_panel_that_found_nothing_is_clean() {
10899        assert!(round_is_clean(
10900            0,
10901            true,
10902            2,
10903            2,
10904            0,
10905            IncompleteReviewPolicy::Block
10906        ));
10907    }
10908
10909    #[test]
10910    fn a_missing_seat_is_never_clean_under_the_default_policy() {
10911        assert!(!round_is_clean(
10912            0,
10913            true,
10914            1,
10915            2,
10916            0,
10917            IncompleteReviewPolicy::Block
10918        ));
10919    }
10920
10921    #[test]
10922    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
10923        assert!(!round_is_clean(
10924            1,
10925            true,
10926            1,
10927            2,
10928            0,
10929            IncompleteReviewPolicy::Warn
10930        ));
10931    }
10932
10933    #[test]
10934    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
10935        assert!(round_is_clean(
10936            0,
10937            true,
10938            1,
10939            2,
10940            0,
10941            IncompleteReviewPolicy::Warn
10942        ));
10943    }
10944
10945    #[test]
10946    fn a_full_panel_with_an_open_finding_is_not_clean() {
10947        assert!(!round_is_clean(
10948            1,
10949            true,
10950            2,
10951            2,
10952            0,
10953            IncompleteReviewPolicy::Block
10954        ));
10955    }
10956
10957    #[test]
10958    fn a_full_panel_with_a_red_e2e_is_not_clean() {
10959        assert!(!round_is_clean(
10960            0,
10961            false,
10962            2,
10963            2,
10964            0,
10965            IncompleteReviewPolicy::Block
10966        ));
10967    }
10968
10969    // The stall this task closes: under the default `block` policy, a seat
10970    // missing only because it was rate limited must not force a wait for a
10971    // session limit that will not lift by the next round. `round_is_clean`
10972    // is where that quorum carve-out lives; the review loop around it never
10973    // changes what a reviewer's vote or a finding's severity means.
10974
10975    #[test]
10976    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
10977        // 1 of 2 answered, and the one missing was quota'd — the exact
10978        // "review-2 rate limited (quota)" shape from the field report.
10979        assert!(round_is_clean(
10980            0,
10981            true,
10982            1,
10983            2,
10984            1,
10985            IncompleteReviewPolicy::Block
10986        ));
10987    }
10988
10989    #[test]
10990    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
10991        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
10992        // not a quota loss (`quota_missing` stays 0) — worth another try.
10993        assert!(!round_is_clean(
10994            0,
10995            true,
10996            1,
10997            2,
10998            0,
10999            IncompleteReviewPolicy::Block
11000        ));
11001    }
11002
11003    #[test]
11004    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11005        assert!(!round_is_clean(
11006            1,
11007            true,
11008            1,
11009            2,
11010            1,
11011            IncompleteReviewPolicy::Block
11012        ));
11013        assert!(!round_is_clean(
11014            0,
11015            false,
11016            1,
11017            2,
11018            1,
11019            IncompleteReviewPolicy::Block
11020        ));
11021    }
11022
11023    #[test]
11024    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11025        // Every seat quota'd, nobody answered: there is no panel to decide
11026        // on, so this must fall through to the existing block-and-retry
11027        // fallback rather than call an unreviewed patch clean.
11028        assert!(!round_is_clean(
11029            0,
11030            true,
11031            0,
11032            2,
11033            2,
11034            IncompleteReviewPolicy::Block
11035        ));
11036    }
11037
11038    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11039        CommandOutcome {
11040            command: "test".to_owned(),
11041            code,
11042            output_tail: String::new(),
11043            duration_ms: 0,
11044            resource_blocked,
11045        }
11046    }
11047
11048    #[test]
11049    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11050        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11051        assert!(
11052            !verify_inconclusive(&[outcome(Some(1), false)]),
11053            "an ordinary failure is still evidence about the patch"
11054        );
11055        assert!(verify_inconclusive(&[outcome(None, true)]));
11056        assert!(
11057            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11058            "one inconclusive outcome taints the whole batch"
11059        );
11060        assert!(!verify_inconclusive(&[]));
11061    }
11062
11063    #[tokio::test]
11064    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11065        // Alive for the first two checks, then dead - confirms the loop
11066        // actually re-polls rather than deciding once and sleeping out the
11067        // ceiling regardless.
11068        let calls = std::sync::atomic::AtomicUsize::new(0);
11069        let started = Instant::now();
11070        wait_for_pids_with(
11071            &[123],
11072            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11073            Duration::from_millis(5),
11074            Duration::from_secs(5),
11075        )
11076        .await;
11077        assert!(
11078            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11079            "must keep checking rather than deciding on the first answer"
11080        );
11081        assert!(
11082            started.elapsed() < Duration::from_secs(1),
11083            "must return the moment it is confirmed dead, not wait out the ceiling"
11084        );
11085    }
11086
11087    #[tokio::test]
11088    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11089        let started = Instant::now();
11090        wait_for_pids_with(
11091            &[123],
11092            |_| true, // never reports dead
11093            Duration::from_millis(5),
11094            Duration::from_millis(30),
11095        )
11096        .await;
11097        let elapsed = started.elapsed();
11098        assert!(
11099            elapsed >= Duration::from_millis(30),
11100            "must not give up before its own ceiling: {elapsed:?}"
11101        );
11102        assert!(
11103            elapsed < Duration::from_secs(1),
11104            "must not wait past its own ceiling either: {elapsed:?}"
11105        );
11106    }
11107
11108    #[tokio::test]
11109    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11110        let started = Instant::now();
11111        wait_for_pids_with(
11112            &[],
11113            |_| true,
11114            Duration::from_secs(5),
11115            Duration::from_secs(5),
11116        )
11117        .await;
11118        assert!(
11119            started.elapsed() < Duration::from_millis(200),
11120            "an empty pid list has nothing to confirm"
11121        );
11122    }
11123
11124    // `review_conclusion` is the exact decision the review hand-off task
11125    // fixed: a round budget spent (or a tree that stopped moving) must not
11126    // collapse into `Blocked` regardless of what verification actually
11127    // said. Deterministic and process-free for the same reason the
11128    // `round_is_clean` family above is.
11129    fn review_round(
11130        clean: bool,
11131        blocking: usize,
11132        answered: usize,
11133        expected: usize,
11134        progressed: bool,
11135        e2e_ok: bool,
11136    ) -> ReviewRound {
11137        ReviewRound {
11138            round: 1,
11139            head: "h".to_owned(),
11140            verified_head: None,
11141            verified_at: None,
11142            reviews: Vec::new(),
11143            e2e: vec![CommandOutcome {
11144                command: "test".to_owned(),
11145                code: Some(if e2e_ok { 0 } else { 1 }),
11146                output_tail: String::new(),
11147                duration_ms: 0,
11148                resource_blocked: false,
11149            }],
11150            verify_retried: false,
11151            e2e_deferred: false,
11152            e2e_defer_reason: None,
11153            fix: None,
11154            blocking,
11155            answered,
11156            expected,
11157            clean,
11158            progressed,
11159            vote_split: false,
11160            reconsideration: Vec::new(),
11161            verdict: None,
11162        }
11163    }
11164
11165    #[test]
11166    fn review_conclusion_is_none_when_nothing_has_run() {
11167        assert_eq!(review_conclusion(&[], 3), None);
11168    }
11169
11170    #[test]
11171    fn review_conclusion_is_none_while_rounds_remain() {
11172        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11173        assert_eq!(review_conclusion(&rounds, 3), None);
11174    }
11175
11176    #[test]
11177    fn review_conclusion_is_gating_once_a_round_is_clean() {
11178        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11179        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11180    }
11181
11182    #[test]
11183    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11184        let rounds = vec![
11185            review_round(false, 1, 2, 2, true, true),
11186            review_round(false, 1, 2, 2, true, true),
11187        ];
11188        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11189    }
11190
11191    #[test]
11192    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11193        let rounds = vec![
11194            review_round(false, 1, 2, 2, true, true),
11195            review_round(false, 1, 2, 2, true, false),
11196        ];
11197        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11198    }
11199
11200    #[test]
11201    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11202        // Magi never got a command to run against this round's own head — a
11203        // resource-blocked attempt, not a red one — so this must never
11204        // settle on `Blocked` the way a genuine e2e failure would. `None`
11205        // here is what tells `Runner::review_loop` to retry the check
11206        // itself rather than trust this cheap recomputation with a verdict
11207        // it cannot actually produce.
11208        let mut blocked = review_round(false, 1, 2, 2, true, false);
11209        blocked.e2e[0].resource_blocked = true;
11210        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11211        assert_eq!(review_conclusion(&rounds, 2), None);
11212    }
11213
11214    #[test]
11215    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11216        // Missing input, not a verified tree — never a hand-off candidate.
11217        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11218        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11219    }
11220
11221    #[test]
11222    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11223        let rounds = vec![
11224            review_round(false, 1, 2, 2, false, true),
11225            review_round(false, 1, 2, 2, false, true),
11226        ];
11227        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11228    }
11229
11230    fn secs(n: u64) -> Duration {
11231        Duration::from_secs(n)
11232    }
11233
11234    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11235    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11236    fn init_repo(dir: &Path) {
11237        let run = |args: &[&str]| {
11238            let out = std::process::Command::new("git")
11239                .args(args)
11240                .current_dir(dir)
11241                .quiet()
11242                .output()
11243                .expect("spawn git");
11244            assert!(
11245                out.status.success(),
11246                "git {args:?} failed: {}",
11247                String::from_utf8_lossy(&out.stderr)
11248            );
11249        };
11250        run(&["init", "-b", "main"]);
11251        run(&["config", "user.name", "magi test"]);
11252        run(&["config", "user.email", "magi@example.com"]);
11253        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11254        run(&["add", "-A"]);
11255        run(&["commit", "-m", "init"]);
11256    }
11257
11258    // `settle_questions` is what closes the ghost the phone showed: a run's
11259    // seat asked something, the run then ended, and nothing was left to
11260    // abandon the question it left `open`. `HOME` is a process-wide
11261    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
11262    // first time it runs in the binary — every test below still reaches the
11263    // same directory whichever call won, and each gets its own run id from
11264    // `RunState::new`, so they never collide there.
11265    fn ask_test_home() {
11266        crate::run::pin_test_home();
11267    }
11268
11269    /// A minimal, git-free `Runner` at a given status — `settle_questions`
11270    /// reads nothing else off it.
11271    fn runner_at(status: RunStatus) -> Runner {
11272        let mut state = RunState::new(
11273            PathBuf::from("/nonexistent/repo"),
11274            "main".to_owned(),
11275            "deadbeef".to_owned(),
11276            "task".to_owned(),
11277            Config::default(),
11278        );
11279        state.status = status;
11280        Runner {
11281            state,
11282            roles: ResolvedRoles {
11283                implementers: Vec::new(),
11284                judges: Vec::new(),
11285                reviewers: Vec::new(),
11286                fixer: None,
11287                conductor: conductor(),
11288                implementer_roster: Vec::new(),
11289                judge_roster: Vec::new(),
11290                reviewer_roster: Vec::new(),
11291            },
11292            sem: Arc::new(Semaphore::new(1)),
11293            pause: Pause::new(),
11294            interrupt: Pause::new(),
11295        }
11296    }
11297
11298    /// `park_here` folding in the reason `Pause::park_because` recorded -
11299    /// this is what lets an operator reading a run's events tell an
11300    /// interrupt-driven park from an ordinary shutdown park.
11301    #[test]
11302    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
11303        crate::run::pin_test_home();
11304        let mut runner = runner_at(RunStatus::Implementing);
11305        let interrupt = Pause::new();
11306        runner.watch_interrupt(interrupt.clone());
11307
11308        interrupt.park_because("task a1b2 asked to run first");
11309
11310        assert!(runner.park_here().expect("park_here"));
11311        assert!(runner.state.parked);
11312        let last = runner.state.events.last().expect("a park event");
11313        assert_eq!(last.node, "park");
11314        assert!(
11315            last.message.contains("task a1b2 asked to run first"),
11316            "expected the interrupt reason in {:?}",
11317            last.message
11318        );
11319    }
11320
11321    /// The cause is stored on the pass when it parks, and the first cause a
11322    /// shared pause was given is the one that stays.
11323    #[test]
11324    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
11325        crate::run::pin_test_home();
11326        let mut runner = runner_at(RunStatus::Implementing);
11327        runner.state.begin_pass();
11328        let shutdown = Pause::new();
11329        runner.on_pause(shutdown.clone());
11330        let upgrade = ParkCause::Upgrade {
11331            from: "0.121.0".to_owned(),
11332            to: Some("0.121.1".to_owned()),
11333        };
11334        shutdown.park_for(upgrade.clone());
11335        shutdown.park_for(ParkCause::Operator);
11336
11337        assert!(runner.park_here().expect("park_here"));
11338        assert_eq!(
11339            runner.state.passes[0].end,
11340            Some(crate::run::PassEnd::Parked { cause: upgrade })
11341        );
11342        let last = runner.state.events.last().expect("a park event");
11343        assert!(last.message.contains("0.121.0"), "{}", last.message);
11344    }
11345
11346    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
11347    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
11348    /// cleared) must not make a *different* run - one only watching its own,
11349    /// unshared interrupt `Pause` - see itself as parked. If a future change
11350    /// ever collapsed these back into one handle, the interrupt scheduler
11351    /// would park every run for the rest of the daemon's life, not just the
11352    /// one it meant to interrupt.
11353    #[test]
11354    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
11355        crate::run::pin_test_home();
11356        let mut runner = runner_at(RunStatus::Implementing);
11357        let shutdown = Pause::new();
11358        runner.on_pause(shutdown.clone());
11359        let interrupt = Pause::new();
11360        runner.watch_interrupt(interrupt.clone());
11361
11362        // Nobody has asked for anything yet.
11363        assert!(!runner.park_here().expect("park_here"));
11364        assert!(!runner.state.parked);
11365
11366        // Only the interrupt handle fires; the shutdown handle stays clear.
11367        interrupt.park_because("test");
11368        assert!(!shutdown.parked());
11369        assert!(runner.park_here().expect("park_here"));
11370    }
11371
11372    /// The property every prior attempt at this feature failed to pin down:
11373    /// asking a run to park while one of its nodes has a real, in-flight
11374    /// async operation running (an agent call, in production) must not cut
11375    /// that operation short. `park_here` is only ever consulted *between*
11376    /// `execute`'s node calls - see its own doc - so nothing inside a node
11377    /// can observe a park request until the node itself returns. This proves
11378    /// that structurally, with real `tokio` concurrency and a channel
11379    /// handshake (never a sleep, which would only prove "usually", not
11380    /// "cannot"): the "node" below reports that it has genuinely started,
11381    /// and only then is the park requested; the node still has to be told to
11382    /// finish before `park_here` is ever called, exactly mirroring every
11383    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
11384    /// in `execute`.
11385    #[tokio::test]
11386    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
11387        crate::run::pin_test_home();
11388        let mut runner = runner_at(RunStatus::Implementing);
11389        let interrupt = Pause::new();
11390        runner.watch_interrupt(interrupt.clone());
11391
11392        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
11393        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
11394
11395        // Stands in for one node's in-flight agent call: it proves it has
11396        // genuinely started, then blocks - exactly as a spawned CLI process
11397        // does - until told to finish.
11398        let node = async move {
11399            started_tx.send(()).expect("send started");
11400            finish_rx.await.expect("recv finish");
11401            "node finished"
11402        };
11403
11404        let interrupter = async move {
11405            started_rx.await.expect("recv started");
11406            // The call is now genuinely in flight. Ask it to park.
11407            interrupt.park_because("higher-priority task waiting");
11408            // Nothing the node does can observe this yet - there is no
11409            // check inside it, by construction - so let the executor run
11410            // anything pending and then let the node finish on its own.
11411            tokio::task::yield_now().await;
11412            finish_tx.send(()).expect("send finish");
11413        };
11414
11415        let (node_result, ()) = tokio::join!(node, interrupter);
11416        assert_eq!(
11417            node_result, "node finished",
11418            "the in-flight call ran to completion"
11419        );
11420
11421        // Only now, at the boundary the real `execute` would check right
11422        // after this node, does the park take effect.
11423        assert!(runner.park_here().expect("park_here"));
11424        assert!(runner.state.parked);
11425    }
11426
11427    /// A run parked mid-competition carries every field it had accumulated
11428    /// through the exact same disk round-trip an ordinary resume uses -
11429    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
11430    /// Nothing about parking for an interrupt is a special case of that path;
11431    /// this is what proves it rather than assuming it.
11432    #[test]
11433    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
11434        crate::run::pin_test_home();
11435        let mut runner = runner_at(RunStatus::Judging);
11436        // `Runner::resume` re-resolves roles from the saved config, which
11437        // refuses an empty roster - give it the same minimal one `conductor`
11438        // itself uses.
11439        runner.state.config.agents = vec![conductor()];
11440        runner.state.candidates = vec![Candidate {
11441            index: 0,
11442            label: 'A',
11443            agent: "alpha".to_owned(),
11444            branch: "magi/x/A".to_owned(),
11445            worktree: PathBuf::from("/nonexistent/worktree"),
11446            summary: "did the thing".to_owned(),
11447            stat: "1 file changed".to_owned(),
11448            files: 1,
11449            commits: 1,
11450            empty: false,
11451            failed: None,
11452            verified_noop: None,
11453            duration_ms: 1234,
11454            folded: false,
11455        }];
11456        let run_id = runner.state.id.clone();
11457
11458        let interrupt = Pause::new();
11459        runner.watch_interrupt(interrupt.clone());
11460        interrupt.park_because("task c3d4 asked to run first");
11461        assert!(runner.park_here().expect("park_here"));
11462
11463        let resumed = Runner::resume(&run_id).expect("resume");
11464        assert_eq!(resumed.state.candidates.len(), 1);
11465        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
11466        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
11467        assert_eq!(resumed.state.status, runner.state.status);
11468        assert!(
11469            resumed.state.parked,
11470            "still parked until `execute` actually walks the graph again"
11471        );
11472        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
11473    }
11474
11475    /// A fresh open question on `run`, stored and handed back for assertions.
11476    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
11477        let mut q = ask::Question::new(
11478            run.to_owned(),
11479            "implement".to_owned(),
11480            "impl-A".to_owned(),
11481            "Which storage backend should the cache use?".to_owned(),
11482            String::new(),
11483            vec!["SQLite".to_owned(), "Redis".to_owned()],
11484        );
11485        store.put(&mut q).unwrap();
11486        q
11487    }
11488
11489    #[test]
11490    fn a_failed_runs_open_question_is_abandoned() {
11491        ask_test_home();
11492        let store = ask::Questions::open();
11493        let mut runner = runner_at(RunStatus::Failed);
11494        let run = runner.state.id.clone();
11495        let q = ask_open_question(&store, &run);
11496
11497        runner.settle_questions();
11498
11499        let back = store.get(&q.id).unwrap();
11500        assert!(
11501            !back.status.open(),
11502            "the seat that asked died with the run; nobody is left to read an answer"
11503        );
11504        assert!(
11505            back.detail.contains(&run) && back.detail.contains("failed"),
11506            "the reason names what the run became, not just that it is gone: {}",
11507            back.detail
11508        );
11509    }
11510
11511    #[test]
11512    fn a_merged_runs_open_question_is_abandoned_too() {
11513        ask_test_home();
11514        let store = ask::Questions::open();
11515        // A run that finishes cleanly still leaves nobody to read an answer -
11516        // this is not only a failure-path cleanup.
11517        for status in [RunStatus::Merged, RunStatus::Ready] {
11518            let mut runner = runner_at(status);
11519            let run = runner.state.id.clone();
11520            let q = ask_open_question(&store, &run);
11521
11522            runner.settle_questions();
11523
11524            let back = store.get(&q.id).unwrap();
11525            assert!(
11526                !back.status.open(),
11527                "{status:?} run's question must not outlive the run"
11528            );
11529        }
11530    }
11531
11532    #[test]
11533    fn a_still_resumable_runs_open_question_is_left_alone() {
11534        ask_test_home();
11535        let store = ask::Questions::open();
11536        // `Blocked` and `Stalled` can still be resumed — the candidates, the
11537        // review round and the seat sessions are all still on disk — so a
11538        // question asked mid-round may yet get a real answer from a real
11539        // resume. Sweeping it here would be exactly the failure mode this
11540        // whole feature exists to avoid on the other side.
11541        for status in [RunStatus::Blocked, RunStatus::Stalled] {
11542            let mut runner = runner_at(status);
11543            let run = runner.state.id.clone();
11544            let q = ask_open_question(&store, &run);
11545
11546            runner.settle_questions();
11547
11548            let back = store.get(&q.id).unwrap();
11549            assert!(
11550                back.status.open(),
11551                "{status:?} is still alive; the question must still be waiting"
11552            );
11553        }
11554    }
11555
11556    #[test]
11557    fn settle_questions_never_touches_an_already_answered_question() {
11558        ask_test_home();
11559        let store = ask::Questions::open();
11560        let mut runner = runner_at(RunStatus::Failed);
11561        let run = runner.state.id.clone();
11562        let mut q = ask_open_question(&store, &run);
11563        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
11564            .unwrap();
11565        store.put(&mut q).unwrap();
11566
11567        // Called twice, the way a crash-recovered daemon reclaim and the
11568        // graph's own cleanup both can for the same run — `abandon_for_run`
11569        // only ever touches what is still open, so this must be inert both
11570        // times, not merely the second.
11571        runner.settle_questions();
11572        runner.settle_questions();
11573
11574        let back = store.get(&q.id).unwrap();
11575        assert_eq!(
11576            back.status,
11577            ask::QuestionStatus::Answered,
11578            "a real answer is a decision on record, never overwritten by a sweep"
11579        );
11580    }
11581
11582    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
11583    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
11584    /// without merging, whose winner is still the operator's answer to read.
11585    /// Nothing previously called `fold_run` itself with a real `tally`, so
11586    /// this is the first test to pin down the one distinction the whole
11587    /// automatic-fold feature depends on: the winner's worktree and branch
11588    /// must survive, everything else sharing the run's worktree bay - a
11589    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
11590    /// second sweep treats every non-winner directory under the bay alike -
11591    /// must not.
11592    #[tokio::test]
11593    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
11594        crate::run::pin_test_home();
11595        let tmp = tempfile::tempdir().expect("tempdir");
11596        let repo = tmp.path().join("repo");
11597        std::fs::create_dir_all(&repo).unwrap();
11598        init_repo(&repo);
11599
11600        let mut config = Config::default();
11601        config.graph.worktree_root = Some(tmp.path().join("wt"));
11602
11603        let mut state = RunState::new(
11604            repo.clone(),
11605            "main".to_owned(),
11606            "deadbeef".to_owned(),
11607            "task".to_owned(),
11608            config,
11609        );
11610        let root = state.worktree_root();
11611        let wt_a = root.join("cand-A");
11612        let wt_b = root.join("cand-B");
11613        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
11614            .await
11615            .expect("worktree A");
11616        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
11617            .await
11618            .expect("worktree B");
11619
11620        state.candidates = vec![
11621            Candidate {
11622                index: 0,
11623                label: 'A',
11624                agent: "alpha".to_owned(),
11625                branch: "magi/x/A".to_owned(),
11626                worktree: wt_a.clone(),
11627                summary: String::new(),
11628                stat: String::new(),
11629                files: 0,
11630                commits: 0,
11631                empty: false,
11632                failed: None,
11633                verified_noop: None,
11634                duration_ms: 0,
11635                folded: false,
11636            },
11637            Candidate {
11638                index: 1,
11639                label: 'B',
11640                agent: "beta".to_owned(),
11641                branch: "magi/x/B".to_owned(),
11642                worktree: wt_b.clone(),
11643                summary: String::new(),
11644                stat: String::new(),
11645                files: 0,
11646                commits: 0,
11647                empty: false,
11648                failed: None,
11649                verified_noop: None,
11650                duration_ms: 0,
11651                folded: false,
11652            },
11653        ];
11654        state.tally = Some(Tally {
11655            first_choice: BTreeMap::from([('A', 1)]),
11656            borda: BTreeMap::new(),
11657            winner: 'A',
11658            rankings: 1,
11659            unanimous_initial: true,
11660            deliberated: false,
11661            changed_votes: 0,
11662            unanimous_final: true,
11663            tie_break: None,
11664            judges: 1,
11665            present: 1,
11666            quorum: 1,
11667            met_quorum: true,
11668            uncontested: None,
11669        });
11670        state.status = RunStatus::Ready;
11671
11672        fold_run(&mut state, false, &crate::run::home())
11673            .await
11674            .expect("fold_run");
11675
11676        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
11677        assert!(
11678            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11679            "the unmerged winner's branch survives"
11680        );
11681        assert!(
11682            !state.candidates[0].folded,
11683            "the winner is not marked folded"
11684        );
11685
11686        assert!(!wt_b.exists(), "the loser's worktree is removed");
11687        assert!(
11688            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
11689            "the loser's branch is removed"
11690        );
11691        assert!(state.candidates[1].folded, "the loser is marked folded");
11692    }
11693
11694    /// A branch handed to a later run is that run's (and its pull request's):
11695    /// folding the run that released it must not delete it.
11696    #[tokio::test]
11697    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
11698        let tmp = tempfile::tempdir().expect("tempdir");
11699        let repo = tmp.path().join("repo");
11700        std::fs::create_dir_all(&repo).unwrap();
11701        init_repo(&repo);
11702        let home = tmp.path().join("home");
11703
11704        let mut config = Config::default();
11705        config.graph.worktree_root = Some(tmp.path().join("wt"));
11706        let mut state = RunState::new(
11707            repo.clone(),
11708            "main".to_owned(),
11709            "deadbeef".to_owned(),
11710            "task".to_owned(),
11711            config,
11712        );
11713        // The worktree is already gone (released); the branch survives.
11714        git::git(&repo, &["branch", "magi/x/A", "main"])
11715            .await
11716            .expect("branch");
11717        state.candidates = vec![Candidate {
11718            index: 0,
11719            label: 'A',
11720            agent: "alpha".to_owned(),
11721            branch: "magi/x/A".to_owned(),
11722            worktree: state.worktree_root().join("cand-A"),
11723            summary: String::new(),
11724            stat: String::new(),
11725            files: 0,
11726            commits: 0,
11727            empty: false,
11728            failed: None,
11729            verified_noop: None,
11730            duration_ms: 0,
11731            folded: true,
11732        }];
11733        state.released_to = Some("20260901-000000-new1".to_owned());
11734        state.released_branches = vec!["magi/x/A".to_owned()];
11735
11736        fold_run(&mut state, true, &home).await.expect("fold_run");
11737
11738        assert!(
11739            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
11740            "the handed-over branch survives a fold"
11741        );
11742    }
11743
11744    /// A winner with nothing ahead of the base is caught before `gh` is ever
11745    /// asked for a pull request, and the message carries what the task's
11746    /// references resolved to.
11747    #[tokio::test]
11748    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
11749        let tmp = tempfile::tempdir().expect("tempdir");
11750        let repo = tmp.path().join("repo");
11751        std::fs::create_dir_all(&repo).unwrap();
11752        init_repo(&repo);
11753        let run = |args: &[&str]| {
11754            let out = std::process::Command::new("git")
11755                .quiet()
11756                .args(args)
11757                .current_dir(&repo)
11758                .output()
11759                .expect("spawn git");
11760            assert!(out.status.success(), "git {args:?}");
11761        };
11762        run(&["branch", "magi/x/A"]);
11763        run(&["checkout", "-q", "-b", "magi/x/B"]);
11764        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
11765        run(&["add", "-A"]);
11766        run(&["commit", "-q", "-m", "work"]);
11767        run(&["checkout", "-q", "main"]);
11768
11769        // A pull request is compared against the remote's base, so the
11770        // fixture needs one. Before it exists nothing can be read, and the
11771        // answer must be "not empty".
11772        let probe = RunState::new(
11773            repo.clone(),
11774            "main".to_owned(),
11775            "deadbeef".to_owned(),
11776            "task".to_owned(),
11777            Config::default(),
11778        );
11779        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
11780        let bare = tmp.path().join("origin.git");
11781        let out = std::process::Command::new("git")
11782            .quiet()
11783            .args(["init", "-q", "--bare"])
11784            .arg(&bare)
11785            .output()
11786            .expect("spawn git");
11787        assert!(out.status.success(), "git init --bare");
11788        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
11789        run(&["push", "-q", "origin", "main"]);
11790
11791        let mut state = RunState::new(
11792            repo.clone(),
11793            "main".to_owned(),
11794            "deadbeef".to_owned(),
11795            "task".to_owned(),
11796            Config::default(),
11797        );
11798        state.seeds = vec![refs::Seed {
11799            token: "magi/27b2/A".to_owned(),
11800            kind: refs::SeedKind::Unresolved,
11801            sha: String::new(),
11802            branch: true,
11803            detail: "no branch or commit named magi/27b2/A".to_owned(),
11804        }];
11805
11806        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
11807        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
11808        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
11809        let detail = empty_candidate_detail(&state, "main");
11810        assert!(detail.starts_with("empty candidate"), "{detail}");
11811        assert!(detail.contains("magi/27b2/A"), "{detail}");
11812    }
11813
11814    /// `status == Ready` used to be read as "this is the harmless
11815    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
11816    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
11817    /// run's PR was closed without merging — and reentering `merge` with
11818    /// `mode` still `Pr` does not know the difference, so it pushed and
11819    /// opened a second pull request. `mode == Local` reproduces the same
11820    /// blind spot without a network call: reentry must not attempt another
11821    /// git merge once this node has already recorded an outcome.
11822    #[tokio::test]
11823    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
11824        let tmp = tempfile::tempdir().expect("tempdir");
11825        let repo = tmp.path().join("repo");
11826        std::fs::create_dir_all(&repo).unwrap();
11827        init_repo(&repo);
11828
11829        let mut config = Config::default();
11830        config.merge.mode = MergeMode::Local;
11831
11832        let mut state = RunState::new(
11833            repo.clone(),
11834            "main".to_owned(),
11835            "deadbeef".to_owned(),
11836            "task".to_owned(),
11837            config,
11838        );
11839        state.candidates = vec![Candidate {
11840            index: 0,
11841            label: 'A',
11842            agent: "alpha".to_owned(),
11843            branch: "does-not-exist".to_owned(),
11844            worktree: repo.clone(),
11845            summary: String::new(),
11846            stat: String::new(),
11847            files: 0,
11848            commits: 0,
11849            empty: false,
11850            failed: None,
11851            verified_noop: None,
11852            duration_ms: 0,
11853            folded: false,
11854        }];
11855        state.tally = Some(Tally {
11856            first_choice: BTreeMap::from([('A', 1)]),
11857            borda: BTreeMap::new(),
11858            winner: 'A',
11859            rankings: 1,
11860            unanimous_initial: true,
11861            deliberated: false,
11862            changed_votes: 0,
11863            unanimous_final: true,
11864            tie_break: None,
11865            judges: 0,
11866            present: 0,
11867            quorum: 0,
11868            met_quorum: true,
11869            uncontested: Some("only candidate A produced a change".to_owned()),
11870        });
11871        state.reviews = vec![ReviewRound {
11872            round: 1,
11873            head: "deadbeef".to_owned(),
11874            verified_head: None,
11875            verified_at: None,
11876            reviews: Vec::new(),
11877            e2e: Vec::new(),
11878            fix: None,
11879            blocking: 0,
11880            answered: 0,
11881            expected: 0,
11882            clean: true,
11883            verify_retried: false,
11884            e2e_deferred: false,
11885            e2e_defer_reason: None,
11886            progressed: false,
11887            vote_split: false,
11888            reconsideration: Vec::new(),
11889            verdict: None,
11890        }];
11891        state.gate = vec![CommandOutcome {
11892            command: "test".to_owned(),
11893            code: Some(0),
11894            output_tail: String::new(),
11895            duration_ms: 0,
11896            resource_blocked: false,
11897        }];
11898        state.gate_ran = true;
11899        // Reached its conclusion already — e.g. `land` closing the PR without
11900        // merging it, which (like the honest `MergeMode::None` path) leaves
11901        // `status` at `Ready`. The recorded outcome is what actually marks
11902        // this node done.
11903        state.status = RunStatus::Ready;
11904        state.merge = Some(MergeOutcome {
11905            mode: MergeMode::Local,
11906            ok: false,
11907            detail: "already concluded".to_owned(),
11908            empty: false,
11909        });
11910
11911        let mut runner = Runner {
11912            state,
11913            roles: ResolvedRoles {
11914                implementers: Vec::new(),
11915                judges: Vec::new(),
11916                reviewers: Vec::new(),
11917                fixer: None,
11918                conductor: conductor(),
11919                implementer_roster: Vec::new(),
11920                judge_roster: Vec::new(),
11921                reviewer_roster: Vec::new(),
11922            },
11923            sem: Arc::new(Semaphore::new(1)),
11924            pause: Pause::new(),
11925            interrupt: Pause::new(),
11926        };
11927
11928        runner.merge().await.expect("merge");
11929
11930        assert_eq!(
11931            runner.state.status,
11932            RunStatus::Ready,
11933            "a concluded run's status must not change on reentry"
11934        );
11935        assert_eq!(
11936            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11937            Some("already concluded"),
11938            "merge must not run again once the node already recorded an outcome"
11939        );
11940    }
11941
11942    /// `gate` leaves `state.gate_ran` false both before it has ever run and
11943    /// when its last attempt was resource-blocked (the shared build cache
11944    /// could not be acquired or confirmed fresh in time - see
11945    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
11946    /// `Vec` this also leaves behind used to read as "nothing failed" and let
11947    /// a run merge a tree the gate never actually checked - exactly the case
11948    /// a contended cache produces on every retry until it clears. `merge`
11949    /// must refuse until `gate` has actually recorded an attempt.
11950    #[tokio::test]
11951    async fn merge_refuses_a_gate_that_has_not_actually_run() {
11952        let tmp = tempfile::tempdir().expect("tempdir");
11953        let repo = tmp.path().join("repo");
11954        std::fs::create_dir_all(&repo).unwrap();
11955        init_repo(&repo);
11956
11957        let mut config = Config::default();
11958        config.merge.mode = MergeMode::Local;
11959
11960        let mut state = RunState::new(
11961            repo.clone(),
11962            "main".to_owned(),
11963            "deadbeef".to_owned(),
11964            "task".to_owned(),
11965            config,
11966        );
11967        state.candidates = vec![Candidate {
11968            index: 0,
11969            label: 'A',
11970            agent: "alpha".to_owned(),
11971            branch: "does-not-exist".to_owned(),
11972            worktree: repo.clone(),
11973            summary: String::new(),
11974            stat: String::new(),
11975            files: 0,
11976            commits: 0,
11977            empty: false,
11978            failed: None,
11979            verified_noop: None,
11980            duration_ms: 0,
11981            folded: false,
11982        }];
11983        state.tally = Some(Tally {
11984            first_choice: BTreeMap::from([('A', 1)]),
11985            borda: BTreeMap::new(),
11986            winner: 'A',
11987            rankings: 1,
11988            unanimous_initial: true,
11989            deliberated: false,
11990            changed_votes: 0,
11991            unanimous_final: true,
11992            tie_break: None,
11993            judges: 0,
11994            present: 0,
11995            quorum: 0,
11996            met_quorum: true,
11997            uncontested: Some("only candidate A produced a change".to_owned()),
11998        });
11999        state.reviews = vec![ReviewRound {
12000            round: 1,
12001            head: "deadbeef".to_owned(),
12002            verified_head: None,
12003            verified_at: None,
12004            reviews: Vec::new(),
12005            e2e: Vec::new(),
12006            fix: None,
12007            blocking: 0,
12008            answered: 0,
12009            expected: 0,
12010            clean: true,
12011            verify_retried: false,
12012            e2e_deferred: false,
12013            e2e_defer_reason: None,
12014            progressed: false,
12015            vote_split: false,
12016            reconsideration: Vec::new(),
12017            verdict: None,
12018        }];
12019        // The point: `gate` has not recorded anything yet.
12020        state.gate = Vec::new();
12021        state.gate_ran = false;
12022        state.status = RunStatus::Gating;
12023
12024        let mut runner = Runner {
12025            state,
12026            roles: ResolvedRoles {
12027                implementers: Vec::new(),
12028                judges: Vec::new(),
12029                reviewers: Vec::new(),
12030                fixer: None,
12031                conductor: conductor(),
12032                implementer_roster: Vec::new(),
12033                judge_roster: Vec::new(),
12034                reviewer_roster: Vec::new(),
12035            },
12036            sem: Arc::new(Semaphore::new(1)),
12037            pause: Pause::new(),
12038            interrupt: Pause::new(),
12039        };
12040
12041        runner.merge().await.expect("merge");
12042
12043        assert!(
12044            runner.state.merge.is_none(),
12045            "an empty gate must never be read as a passing one: {:?}",
12046            runner.state.merge
12047        );
12048    }
12049
12050    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
12051    /// commands configured and `merge.mode` is `none` (a review-only run).
12052    /// `gate` must still record a real attempt — zero commands, vacuously
12053    /// passed — rather than leaving `state.gate` empty in a way `merge`
12054    /// cannot tell apart from "never ran"; otherwise the run reaches
12055    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
12056    #[tokio::test]
12057    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12058        let tmp = tempfile::tempdir().expect("tempdir");
12059        let repo = tmp.path().join("repo");
12060        std::fs::create_dir_all(&repo).unwrap();
12061        init_repo(&repo);
12062
12063        // Default config: `verify.gate` empty, `merge.mode` is `none`.
12064        let config = Config::default();
12065
12066        let mut state = RunState::new(
12067            repo.clone(),
12068            "main".to_owned(),
12069            "deadbeef".to_owned(),
12070            "task".to_owned(),
12071            config,
12072        );
12073        state.candidates = vec![Candidate {
12074            index: 0,
12075            label: 'A',
12076            agent: "alpha".to_owned(),
12077            branch: "does-not-exist".to_owned(),
12078            worktree: repo.clone(),
12079            summary: String::new(),
12080            stat: String::new(),
12081            files: 0,
12082            commits: 0,
12083            empty: false,
12084            failed: None,
12085            verified_noop: None,
12086            duration_ms: 0,
12087            folded: false,
12088        }];
12089        state.tally = Some(Tally {
12090            first_choice: BTreeMap::from([('A', 1)]),
12091            borda: BTreeMap::new(),
12092            winner: 'A',
12093            rankings: 1,
12094            unanimous_initial: true,
12095            deliberated: false,
12096            changed_votes: 0,
12097            unanimous_final: true,
12098            tie_break: None,
12099            judges: 0,
12100            present: 0,
12101            quorum: 0,
12102            met_quorum: true,
12103            uncontested: Some("only candidate A produced a change".to_owned()),
12104        });
12105        state.reviews = vec![ReviewRound {
12106            round: 1,
12107            head: "deadbeef".to_owned(),
12108            verified_head: None,
12109            verified_at: None,
12110            reviews: Vec::new(),
12111            e2e: Vec::new(),
12112            fix: None,
12113            blocking: 0,
12114            answered: 0,
12115            expected: 0,
12116            clean: true,
12117            verify_retried: false,
12118            e2e_deferred: false,
12119            e2e_defer_reason: None,
12120            progressed: false,
12121            vote_split: false,
12122            reconsideration: Vec::new(),
12123            verdict: None,
12124        }];
12125
12126        let mut runner = Runner {
12127            state,
12128            roles: ResolvedRoles {
12129                implementers: Vec::new(),
12130                judges: Vec::new(),
12131                reviewers: Vec::new(),
12132                fixer: None,
12133                conductor: conductor(),
12134                implementer_roster: Vec::new(),
12135                judge_roster: Vec::new(),
12136                reviewer_roster: Vec::new(),
12137            },
12138            sem: Arc::new(Semaphore::new(1)),
12139            pause: Pause::new(),
12140            interrupt: Pause::new(),
12141        };
12142
12143        runner.gate().await.expect("gate");
12144        assert!(
12145            runner.state.gate_ran,
12146            "zero configured commands is still a real attempt, not an unrun gate"
12147        );
12148        assert!(runner.state.gate.is_empty());
12149        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12150        assert_ne!(
12151            runner.state.status,
12152            RunStatus::Blocked,
12153            "a gate with nothing to check must not read as failed"
12154        );
12155
12156        runner.merge().await.expect("merge");
12157        assert_eq!(
12158            runner.state.status,
12159            RunStatus::Ready,
12160            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12161        );
12162    }
12163
12164    /// `Config::cache_dir` is derived from `verify.e2e` as well as
12165    /// `verify.gate` (so the e2e leg and the final gate never build against
12166    /// different directories). With zero `verify.gate` commands but a
12167    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
12168    /// that lease before discovering it had nothing to run - so a repo with
12169    /// no gate commands could come back `resource_blocked` (and therefore
12170    /// still `gate_ran == false`) on nothing but an unrelated run holding the
12171    /// cache, exactly the contention this run's own zero commands could
12172    /// never have touched. `gate` must recognise there is nothing to check
12173    /// before it ever asks for the lease.
12174    #[tokio::test]
12175    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12176        crate::run::pin_test_home();
12177        let home = crate::run::home();
12178
12179        let tmp = tempfile::tempdir().expect("tempdir");
12180        let repo = tmp.path().join("repo");
12181        std::fs::create_dir_all(&repo).unwrap();
12182        init_repo(&repo);
12183        // Unique to this test, so holding its lease cannot collide with
12184        // another test sharing the same process-wide `home`.
12185        let cache_dir = tmp.path().join("target");
12186
12187        let mut config = Config::default();
12188        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12189        // `verify.gate` stays empty (the default). Bounded so a regression
12190        // that does start waiting fails the test in seconds, not hangs it.
12191        config.graph.timeout_verify = Some(2);
12192
12193        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12194        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12195            .expect("no io error acquiring directly")
12196        {
12197            crate::cache::AcquireOutcome::Acquired(g) => g,
12198            crate::cache::AcquireOutcome::Busy(b) => {
12199                panic!("expected the direct acquire to win the lease first: {b:?}")
12200            }
12201        };
12202
12203        let mut state = RunState::new(
12204            repo.clone(),
12205            "main".to_owned(),
12206            "deadbeef".to_owned(),
12207            "task".to_owned(),
12208            config,
12209        );
12210        state.candidates = vec![Candidate {
12211            index: 0,
12212            label: 'A',
12213            agent: "alpha".to_owned(),
12214            branch: "does-not-exist".to_owned(),
12215            worktree: repo.clone(),
12216            summary: String::new(),
12217            stat: String::new(),
12218            files: 0,
12219            commits: 0,
12220            empty: false,
12221            failed: None,
12222            verified_noop: None,
12223            duration_ms: 0,
12224            folded: false,
12225        }];
12226        state.tally = Some(Tally {
12227            first_choice: BTreeMap::from([('A', 1)]),
12228            borda: BTreeMap::new(),
12229            winner: 'A',
12230            rankings: 1,
12231            unanimous_initial: true,
12232            deliberated: false,
12233            changed_votes: 0,
12234            unanimous_final: true,
12235            tie_break: None,
12236            judges: 0,
12237            present: 0,
12238            quorum: 0,
12239            met_quorum: true,
12240            uncontested: Some("only candidate A produced a change".to_owned()),
12241        });
12242        state.reviews = vec![ReviewRound {
12243            round: 1,
12244            head: "deadbeef".to_owned(),
12245            verified_head: None,
12246            verified_at: None,
12247            reviews: Vec::new(),
12248            e2e: Vec::new(),
12249            fix: None,
12250            blocking: 0,
12251            answered: 0,
12252            expected: 0,
12253            clean: true,
12254            verify_retried: false,
12255            e2e_deferred: false,
12256            e2e_defer_reason: None,
12257            progressed: false,
12258            vote_split: false,
12259            reconsideration: Vec::new(),
12260            verdict: None,
12261        }];
12262
12263        let mut runner = Runner {
12264            state,
12265            roles: ResolvedRoles {
12266                implementers: Vec::new(),
12267                judges: Vec::new(),
12268                reviewers: Vec::new(),
12269                fixer: None,
12270                conductor: conductor(),
12271                implementer_roster: Vec::new(),
12272                judge_roster: Vec::new(),
12273                reviewer_roster: Vec::new(),
12274            },
12275            sem: Arc::new(Semaphore::new(1)),
12276            pause: Pause::new(),
12277            interrupt: Pause::new(),
12278        };
12279
12280        let started = std::time::Instant::now();
12281        runner.gate().await.expect("gate");
12282        assert!(
12283            started.elapsed() < Duration::from_secs(1),
12284            "a gate with nothing to run must never wait on a lease it never needed"
12285        );
12286        assert!(
12287            runner.state.gate_ran,
12288            "zero commands is still a real, immediate attempt"
12289        );
12290        assert!(runner.state.gate.is_empty());
12291        assert_ne!(
12292            runner.state.status,
12293            RunStatus::Blocked,
12294            "must not read as resource-blocked on a lease it never asked for"
12295        );
12296    }
12297
12298    /// The addendum's second gap: a `verify.gate` command running for real
12299    /// wall-clock time had nothing at all to show for it in `active` before
12300    /// `run_commands` learned to record it — a run could sit in `Gating` for
12301    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
12302    /// what was actually happening. Proven with a genuinely still-running
12303    /// command, not just a before/after check on the final state: a poller
12304    /// task reads the same `run.json` `gate()` is writing, the same way the
12305    /// phone or `magi show` would, while the shell command is still blocked
12306    /// on its own release marker.
12307    #[tokio::test]
12308    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
12309        crate::run::pin_test_home();
12310
12311        let tmp = tempfile::tempdir().expect("tempdir");
12312        let repo = tmp.path().join("repo");
12313        std::fs::create_dir_all(&repo).unwrap();
12314        init_repo(&repo);
12315
12316        let mut config = Config::default();
12317        config.verify.gate = vec![
12318            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
12319             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
12320                .to_owned(),
12321        ];
12322
12323        let mut state = RunState::new(
12324            repo.clone(),
12325            "main".to_owned(),
12326            "deadbeef".to_owned(),
12327            "task".to_owned(),
12328            config,
12329        );
12330        let run_id = state.id.clone();
12331        state.candidates = vec![Candidate {
12332            index: 0,
12333            label: 'A',
12334            agent: "alpha".to_owned(),
12335            branch: "does-not-exist".to_owned(),
12336            worktree: repo.clone(),
12337            summary: String::new(),
12338            stat: String::new(),
12339            files: 0,
12340            commits: 0,
12341            empty: false,
12342            failed: None,
12343            verified_noop: None,
12344            duration_ms: 0,
12345            folded: false,
12346        }];
12347        state.tally = Some(Tally {
12348            first_choice: BTreeMap::from([('A', 1)]),
12349            borda: BTreeMap::new(),
12350            winner: 'A',
12351            rankings: 1,
12352            unanimous_initial: true,
12353            deliberated: false,
12354            changed_votes: 0,
12355            unanimous_final: true,
12356            tie_break: None,
12357            judges: 0,
12358            present: 0,
12359            quorum: 0,
12360            met_quorum: true,
12361            uncontested: Some("only candidate A produced a change".to_owned()),
12362        });
12363        state.reviews = vec![ReviewRound {
12364            round: 1,
12365            head: "deadbeef".to_owned(),
12366            verified_head: None,
12367            verified_at: None,
12368            reviews: Vec::new(),
12369            e2e: Vec::new(),
12370            fix: None,
12371            blocking: 0,
12372            answered: 0,
12373            expected: 0,
12374            clean: true,
12375            verify_retried: false,
12376            e2e_deferred: false,
12377            e2e_defer_reason: None,
12378            progressed: false,
12379            vote_split: false,
12380            reconsideration: Vec::new(),
12381            verdict: None,
12382        }];
12383
12384        let mut runner = Runner {
12385            state,
12386            roles: ResolvedRoles {
12387                implementers: Vec::new(),
12388                judges: Vec::new(),
12389                reviewers: Vec::new(),
12390                fixer: None,
12391                conductor: conductor(),
12392                implementer_roster: Vec::new(),
12393                judge_roster: Vec::new(),
12394                reviewer_roster: Vec::new(),
12395            },
12396            sem: Arc::new(Semaphore::new(1)),
12397            pause: Pause::new(),
12398            interrupt: Pause::new(),
12399        };
12400
12401        let started_marker = repo.join("started.marker");
12402        let release_marker = repo.join("release.marker");
12403        let poller = tokio::spawn(async move {
12404            // Bounded so a regression that never records the task entry
12405            // fails this test in seconds instead of hanging the suite —
12406            // the same shape `a_park_requested_while_a_seat_is_mid_call_
12407            // does_not_cut_it_short` uses for the same reason.
12408            for _ in 0..100 {
12409                if started_marker.exists()
12410                    && let Ok(s) = crate::run::RunState::load(&run_id)
12411                    && let Some(a) = s.active.get("gate")
12412                {
12413                    std::fs::write(&release_marker, b"go").expect("release marker");
12414                    return Some(a.clone());
12415                }
12416                tokio::time::sleep(Duration::from_millis(50)).await;
12417            }
12418            None
12419        });
12420
12421        runner.gate().await.expect("gate");
12422        let captured = poller.await.expect("poller task");
12423        let captured = captured.expect(
12424            "the poller never saw a `gate` task entry in run.json while the command was \
12425             still blocked on its own release marker",
12426        );
12427
12428        assert_eq!(captured.task.as_deref(), Some("gate"));
12429        assert_eq!(captured.node, "gate");
12430        assert_eq!(captured.index, Some(1));
12431        assert_eq!(captured.total, Some(1));
12432        assert!(
12433            captured
12434                .command
12435                .as_deref()
12436                .is_some_and(|c| c.contains("started.marker")),
12437            "{captured:?}"
12438        );
12439
12440        assert!(
12441            runner.state.active.is_empty(),
12442            "the entry must be cleared once the command actually finished: {:?}",
12443            runner.state.active
12444        );
12445        assert!(runner.state.gate_ran);
12446        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
12447    }
12448
12449    /// The hand-off over a blocking finding a reviewer rejected on leaves a
12450    /// record for `land`; one with only a Minor, or no reject, leaves none.
12451    #[tokio::test]
12452    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
12453        use crate::verdict::{Finding, ReviewVote, Severity};
12454        crate::run::pin_test_home();
12455        let tmp = tempfile::tempdir().expect("tempdir");
12456        let repo = tmp.path().join("repo");
12457        std::fs::create_dir_all(&repo).unwrap();
12458        init_repo(&repo);
12459
12460        for (severity, vote, expect) in [
12461            (Severity::Major, ReviewVote::Reject, true),
12462            (Severity::Minor, ReviewVote::Reject, false),
12463            (Severity::Major, ReviewVote::Approve, false),
12464        ] {
12465            let mut round = review_round(false, 1, 1, 1, false, true);
12466            round.reviews = vec![ReviewRecord {
12467                reviewer: 1,
12468                agent: "alpha".to_owned(),
12469                summary: String::new(),
12470                findings: vec![Finding {
12471                    id: "R1-1-1".to_owned(),
12472                    severity,
12473                    file: None,
12474                    line: None,
12475                    title: "t".to_owned(),
12476                    detail: String::new(),
12477                }],
12478                vote: Some(vote),
12479                failed: None,
12480                duration_ms: 0,
12481                attempts: 0,
12482            }];
12483            let mut state = RunState::new(
12484                repo.clone(),
12485                "main".to_owned(),
12486                "deadbeef".to_owned(),
12487                "task".to_owned(),
12488                Config::default(),
12489            );
12490            state.reviews = vec![round];
12491            let mut runner = Runner {
12492                state,
12493                roles: ResolvedRoles {
12494                    implementers: Vec::new(),
12495                    judges: Vec::new(),
12496                    reviewers: Vec::new(),
12497                    fixer: None,
12498                    conductor: conductor(),
12499                    implementer_roster: Vec::new(),
12500                    judge_roster: Vec::new(),
12501                    reviewer_roster: Vec::new(),
12502                },
12503                sem: Arc::new(Semaphore::new(1)),
12504                pause: Pause::new(),
12505                interrupt: Pause::new(),
12506            };
12507            let shell = runner.state.config.shell();
12508            runner
12509                .stop_reviewing("round budget spent", &shell, &repo)
12510                .await
12511                .expect("stop_reviewing");
12512            assert_eq!(runner.state.status, RunStatus::Gating);
12513            assert_eq!(
12514                runner.state.contested_handoff.is_some(),
12515                expect,
12516                "{severity:?} + {vote:?}"
12517            );
12518        }
12519    }
12520
12521    /// The shape the incident this whole fix responds to actually had: the
12522    /// round budget spent, the last round's own e2e blocked on the shared
12523    /// build cache (held here by a live pid — this test process — exactly
12524    /// `cache`'s own unit tests' pattern for "another owner, still alive"
12525    /// without forking a process). `stop_reviewing` must retry it — not
12526    /// silently leave the round looking untouched (the catch-up-only half of
12527    /// the bug), and not read the contention as a red `e2e` and block the
12528    /// run on it (the other half). Called directly, the same way
12529    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
12530    /// above exercises `gate`, so this never needs a real cargo build to
12531    /// reach: the lease is never released, so `with_cache_lease` never gets
12532    /// past acquiring it into anything that would need a real workspace.
12533    #[tokio::test]
12534    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
12535        crate::run::pin_test_home();
12536        let home = crate::run::home();
12537
12538        let tmp = tempfile::tempdir().expect("tempdir");
12539        let repo = tmp.path().join("repo");
12540        std::fs::create_dir_all(&repo).unwrap();
12541        init_repo(&repo);
12542        let head = crate::git::rev_parse(&repo, "HEAD")
12543            .await
12544            .expect("rev-parse");
12545        // Unique to this test, so holding its lease cannot collide with
12546        // another test sharing the same process-wide `home`.
12547        let cache_dir = tmp.path().join("target");
12548
12549        let mut config = Config::default();
12550        config.verify.e2e = vec![format!(
12551            "CARGO_TARGET_DIR='{}' test -f README.md",
12552            cache_dir.display()
12553        )];
12554        config.graph.review_rounds = 1;
12555        // Bounded so a regression that does start waiting fails the test in
12556        // seconds, not hangs it.
12557        config.graph.timeout_verify = Some(2);
12558
12559        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12560        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12561            .expect("no io error acquiring directly")
12562        {
12563            crate::cache::AcquireOutcome::Acquired(g) => g,
12564            crate::cache::AcquireOutcome::Busy(b) => {
12565                panic!("expected the direct acquire to win the lease first: {b:?}")
12566            }
12567        };
12568
12569        let mut state = RunState::new(
12570            repo.clone(),
12571            "main".to_owned(),
12572            head.clone(),
12573            "task".to_owned(),
12574            config,
12575        );
12576        state.candidates = vec![Candidate {
12577            index: 0,
12578            label: 'A',
12579            agent: "alpha".to_owned(),
12580            branch: "does-not-exist".to_owned(),
12581            worktree: repo.clone(),
12582            summary: String::new(),
12583            stat: String::new(),
12584            files: 0,
12585            commits: 0,
12586            empty: false,
12587            failed: None,
12588            verified_noop: None,
12589            duration_ms: 0,
12590            folded: false,
12591        }];
12592        state.tally = Some(Tally {
12593            first_choice: BTreeMap::from([('A', 1)]),
12594            borda: BTreeMap::new(),
12595            winner: 'A',
12596            rankings: 1,
12597            unanimous_initial: true,
12598            deliberated: false,
12599            changed_votes: 0,
12600            unanimous_final: true,
12601            tie_break: None,
12602            judges: 0,
12603            present: 0,
12604            quorum: 0,
12605            met_quorum: true,
12606            uncontested: Some("only candidate A produced a change".to_owned()),
12607        });
12608        // The round budget's last round, deferred: `needs_catchup_run`'s
12609        // other trigger. `stop_reviewing`'s retry machinery must treat this
12610        // exactly like a resource-blocked attempt once it actually runs.
12611        state.reviews = vec![ReviewRound {
12612            round: 1,
12613            head: head.clone(),
12614            verified_head: None,
12615            verified_at: None,
12616            reviews: Vec::new(),
12617            e2e: Vec::new(),
12618            fix: None,
12619            blocking: 1,
12620            answered: 1,
12621            expected: 1,
12622            clean: false,
12623            verify_retried: false,
12624            e2e_deferred: true,
12625            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
12626            progressed: false,
12627            vote_split: false,
12628            reconsideration: Vec::new(),
12629            verdict: None,
12630        }];
12631
12632        let mut runner = Runner {
12633            state,
12634            roles: ResolvedRoles {
12635                implementers: Vec::new(),
12636                judges: Vec::new(),
12637                reviewers: Vec::new(),
12638                fixer: None,
12639                conductor: conductor(),
12640                implementer_roster: Vec::new(),
12641                judge_roster: Vec::new(),
12642                reviewer_roster: Vec::new(),
12643            },
12644            sem: Arc::new(Semaphore::new(1)),
12645            pause: Pause::new(),
12646            interrupt: Pause::new(),
12647        };
12648
12649        let shell = runner.state.config.shell();
12650        runner
12651            .stop_reviewing("round budget spent", &shell, &repo)
12652            .await
12653            .expect("stop_reviewing");
12654
12655        let last = runner.state.reviews.last().expect("round record");
12656        assert_eq!(
12657            last.e2e_status(),
12658            E2eStatus::ResourceBlocked,
12659            "the shared cache is still held; the attempt must read as blocked, not deferred or \
12660             failed: {last:?}"
12661        );
12662        assert_eq!(
12663            last.verified_head.as_deref(),
12664            Some(head.as_str()),
12665            "which commit this attempt targeted is known even though nothing finished checking \
12666             it"
12667        );
12668        let first_attempt_at = last
12669            .verified_at
12670            .expect("when this attempt ran is known too");
12671        assert_ne!(
12672            runner.state.status,
12673            RunStatus::Blocked,
12674            "contention is evidence about the machine, not the patch — it must not settle the \
12675             run as blocked: {:?}",
12676            runner.state.status
12677        );
12678        assert!(
12679            !runner
12680                .state
12681                .events
12682                .iter()
12683                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
12684            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
12685            runner.state.events
12686        );
12687
12688        // The cache is still held: a later reentry must retry the same
12689        // round's verification again — not leave it looking exactly as
12690        // untouched as the first blocked attempt, which is indistinguishable
12691        // from never having tried again at all.
12692        runner
12693            .stop_reviewing("round budget spent", &shell, &repo)
12694            .await
12695            .expect("stop_reviewing retry");
12696        assert_eq!(
12697            runner.state.reviews.len(),
12698            1,
12699            "no new round was started: {:?}",
12700            runner.state.reviews
12701        );
12702        let last = runner.state.reviews.last().expect("round record");
12703        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
12704        assert!(
12705            last.verified_at.expect("still known") > first_attempt_at,
12706            "a second reentry must be a fresh attempt, not a stale copy of the first"
12707        );
12708        assert_ne!(runner.state.status, RunStatus::Blocked);
12709
12710        held.release();
12711    }
12712
12713    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
12714    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
12715    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
12716    /// own top-of-function fast path (`review_conclusion`) correctly reads
12717    /// this shape as `None` rather than guessing `Blocked`, and the loop's
12718    /// own `for` range is empty once the round budget is spent, so
12719    /// `review_loop` must retry the check itself rather than silently doing
12720    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
12721    /// above exercises directly, but through `review_loop`'s own entry point
12722    /// this time, proving the wiring between the two rather than just the
12723    /// retry logic in isolation.
12724    #[tokio::test]
12725    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
12726        crate::run::pin_test_home();
12727        let home = crate::run::home();
12728
12729        let tmp = tempfile::tempdir().expect("tempdir");
12730        let repo = tmp.path().join("repo");
12731        std::fs::create_dir_all(&repo).unwrap();
12732        init_repo(&repo);
12733        let head = crate::git::rev_parse(&repo, "HEAD")
12734            .await
12735            .expect("rev-parse");
12736        let cache_dir = tmp.path().join("target");
12737
12738        let mut config = Config::default();
12739        config.verify.e2e = vec![format!(
12740            "CARGO_TARGET_DIR='{}' test -f README.md",
12741            cache_dir.display()
12742        )];
12743        config.graph.review_rounds = 1;
12744        config.graph.timeout_verify = Some(2);
12745
12746        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12747        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12748            .expect("no io error acquiring directly")
12749        {
12750            crate::cache::AcquireOutcome::Acquired(g) => g,
12751            crate::cache::AcquireOutcome::Busy(b) => {
12752                panic!("expected the direct acquire to win the lease first: {b:?}")
12753            }
12754        };
12755
12756        let mut state = RunState::new(
12757            repo.clone(),
12758            "main".to_owned(),
12759            head.clone(),
12760            "task".to_owned(),
12761            config,
12762        );
12763        state.candidates = vec![Candidate {
12764            index: 0,
12765            label: 'A',
12766            agent: "alpha".to_owned(),
12767            branch: "does-not-exist".to_owned(),
12768            worktree: repo.clone(),
12769            summary: String::new(),
12770            stat: String::new(),
12771            files: 0,
12772            commits: 0,
12773            empty: false,
12774            failed: None,
12775            verified_noop: None,
12776            duration_ms: 0,
12777            folded: false,
12778        }];
12779        state.tally = Some(Tally {
12780            first_choice: BTreeMap::from([('A', 1)]),
12781            borda: BTreeMap::new(),
12782            winner: 'A',
12783            rankings: 1,
12784            unanimous_initial: true,
12785            deliberated: false,
12786            changed_votes: 0,
12787            unanimous_final: true,
12788            tie_break: None,
12789            judges: 0,
12790            present: 0,
12791            quorum: 0,
12792            met_quorum: true,
12793            uncontested: Some("only candidate A produced a change".to_owned()),
12794        });
12795        // The exact shape a prior process's `stop_reviewing` would have left
12796        // on disk: the round budget's last round, a real attempt already
12797        // made and already resource-blocked.
12798        state.reviews = vec![ReviewRound {
12799            round: 1,
12800            head: head.clone(),
12801            verified_head: Some(head.clone()),
12802            verified_at: Some(jiff::Timestamp::now()),
12803            reviews: Vec::new(),
12804            e2e: vec![CommandOutcome {
12805                command: format!(
12806                    "CARGO_TARGET_DIR='{}' test -f README.md",
12807                    cache_dir.display()
12808                ),
12809                code: None,
12810                output_tail: "waiting for the shared build cache".to_owned(),
12811                duration_ms: 0,
12812                resource_blocked: true,
12813            }],
12814            fix: None,
12815            blocking: 1,
12816            answered: 1,
12817            expected: 1,
12818            clean: false,
12819            verify_retried: false,
12820            e2e_deferred: false,
12821            e2e_defer_reason: None,
12822            progressed: false,
12823            vote_split: false,
12824            reconsideration: Vec::new(),
12825            verdict: None,
12826        }];
12827
12828        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
12829        let mut runner = Runner {
12830            state,
12831            roles: ResolvedRoles {
12832                implementers: Vec::new(),
12833                judges: Vec::new(),
12834                reviewers: Vec::new(),
12835                fixer: None,
12836                conductor: conductor(),
12837                implementer_roster: Vec::new(),
12838                judge_roster: Vec::new(),
12839                reviewer_roster: Vec::new(),
12840            },
12841            sem: Arc::new(Semaphore::new(1)),
12842            pause: Pause::new(),
12843            interrupt: Pause::new(),
12844        };
12845
12846        // The lease is still held throughout, so this reentry's own retry is
12847        // also contended — proving `review_loop` actually tried again (not
12848        // that it happened to succeed) is what the timestamp comparison
12849        // below is for.
12850        runner.review_loop().await.expect("review_loop");
12851
12852        assert_eq!(
12853            runner.state.reviews.len(),
12854            1,
12855            "no new round was started on top of the unresolved one: {:?}",
12856            runner.state.reviews
12857        );
12858        let last = &runner.state.reviews[0];
12859        assert_eq!(
12860            last.e2e_status(),
12861            E2eStatus::ResourceBlocked,
12862            "still contended: {last:?}"
12863        );
12864        assert!(
12865            last.verified_at.expect("still known") > first_attempt_at,
12866            "review_loop must have actually retried the check, not left it exactly as found"
12867        );
12868        assert_ne!(
12869            runner.state.status,
12870            RunStatus::Blocked,
12871            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
12872            runner.state.status
12873        );
12874
12875        held.release();
12876    }
12877
12878    #[tokio::test]
12879    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
12880        crate::run::pin_test_home();
12881        let tmp = tempfile::tempdir().expect("tempdir");
12882        let repo = tmp.path().join("repo");
12883        std::fs::create_dir_all(&repo).unwrap();
12884        init_repo(&repo);
12885
12886        let mut config = Config::default();
12887        config.merge.mode = MergeMode::Pr;
12888        config.graph.land = true;
12889        config.graph.land_approval = false;
12890
12891        let mut state = RunState::new(
12892            repo.clone(),
12893            "main".to_owned(),
12894            "deadbeef".to_owned(),
12895            "task".to_owned(),
12896            config,
12897        );
12898        state.candidates = vec![Candidate {
12899            index: 0,
12900            label: 'A',
12901            agent: "alpha".to_owned(),
12902            branch: "does-not-exist".to_owned(),
12903            worktree: repo.clone(),
12904            summary: String::new(),
12905            stat: String::new(),
12906            files: 0,
12907            commits: 0,
12908            empty: false,
12909            failed: None,
12910            verified_noop: None,
12911            duration_ms: 0,
12912            folded: false,
12913        }];
12914        state.tally = Some(Tally {
12915            first_choice: BTreeMap::from([('A', 1)]),
12916            borda: BTreeMap::new(),
12917            winner: 'A',
12918            rankings: 1,
12919            unanimous_initial: true,
12920            deliberated: false,
12921            changed_votes: 0,
12922            unanimous_final: true,
12923            tie_break: None,
12924            judges: 0,
12925            present: 0,
12926            quorum: 0,
12927            met_quorum: true,
12928            uncontested: Some("only candidate A produced a change".to_owned()),
12929        });
12930        state.reviews = vec![ReviewRound {
12931            round: 1,
12932            head: "deadbeef".to_owned(),
12933            verified_head: None,
12934            verified_at: None,
12935            reviews: Vec::new(),
12936            e2e: Vec::new(),
12937            fix: None,
12938            blocking: 0,
12939            answered: 0,
12940            expected: 0,
12941            clean: true,
12942            verify_retried: false,
12943            e2e_deferred: false,
12944            e2e_defer_reason: None,
12945            progressed: false,
12946            vote_split: false,
12947            reconsideration: Vec::new(),
12948            verdict: None,
12949        }];
12950        state.gate = vec![CommandOutcome {
12951            command: "test".to_owned(),
12952            code: Some(0),
12953            output_tail: String::new(),
12954            duration_ms: 0,
12955            resource_blocked: false,
12956        }];
12957        state.gate_ran = true;
12958        // A first pass through `merge` already pushed and opened this pull
12959        // request; `status` is `Landing` because a previous call into `land`
12960        // parked or was interrupted before it reached a terminal outcome.
12961        state.status = RunStatus::Landing;
12962        state.merge = Some(MergeOutcome {
12963            mode: MergeMode::Pr,
12964            ok: true,
12965            detail: "https://example.invalid/x/y/pull/1".to_owned(),
12966            empty: false,
12967        });
12968
12969        // The Landing-resume shortcut calls `run_land` directly rather than
12970        // through `merge`, which is exactly the call site that used to skip
12971        // `settle_questions` - see the fixture below.
12972        ask_test_home();
12973        // A real resume loads a persisted run; driver ownership is checked
12974        // against that record before the Landing shortcut is entered.
12975        state.save().expect("save landing run");
12976        let store = ask::Questions::open();
12977        let q = ask_open_question(&store, &state.id);
12978
12979        let mut runner = Runner {
12980            state,
12981            roles: ResolvedRoles {
12982                implementers: Vec::new(),
12983                judges: Vec::new(),
12984                reviewers: Vec::new(),
12985                fixer: None,
12986                conductor: conductor(),
12987                implementer_roster: Vec::new(),
12988                judge_roster: Vec::new(),
12989                reviewer_roster: Vec::new(),
12990            },
12991            sem: Arc::new(Semaphore::new(1)),
12992            pause: Pause::new(),
12993            interrupt: Pause::new(),
12994        };
12995
12996        // `execute`, not `merge` directly: the Landing-resume shortcut lives
12997        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
12998        // exactly because `review_loop` would otherwise clobber the marker
12999        // first.
13000        runner.execute().await.expect("execute");
13001
13002        assert_eq!(
13003            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13004            Some("https://example.invalid/x/y/pull/1"),
13005            "reentry must not push again or open a second pull request over the \
13006             one `land` is already watching"
13007        );
13008        assert_ne!(
13009            runner.state.status,
13010            RunStatus::Landing,
13011            "land could not actually reach the fake pull request, so it must \
13012             have given up rather than left the run silently parked forever"
13013        );
13014        // `land` could not reach the fake pull request, so it gave up into
13015        // `Blocked` - still resumable, so the question must not have been
13016        // swept just because this branch now also calls `settle_questions`.
13017        assert_eq!(runner.state.status, RunStatus::Blocked);
13018        assert!(
13019            store.get(&q.id).unwrap().status.open(),
13020            "Blocked is still alive; settle_questions must have been a no-op here"
13021        );
13022    }
13023
13024    fn state_with_round(round: ReviewRound) -> RunState {
13025        let mut s = RunState::new(
13026            PathBuf::from("/repo"),
13027            "main".to_owned(),
13028            "abc1234".to_owned(),
13029            "add retries".to_owned(),
13030            Config::default(),
13031        );
13032        s.reviews = vec![round];
13033        s
13034    }
13035
13036    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13037        crate::verdict::Finding {
13038            id: id.to_owned(),
13039            severity,
13040            file: None,
13041            line: None,
13042            title: title.to_owned(),
13043            detail: String::new(),
13044        }
13045    }
13046
13047    #[test]
13048    fn pr_body_names_open_findings_and_declined_ones() {
13049        let round = ReviewRound {
13050            round: 2,
13051            head: "deadbee".to_owned(),
13052            verified_head: None,
13053            verified_at: None,
13054            reviews: vec![ReviewRecord {
13055                attempts: 0,
13056                reviewer: 1,
13057                agent: "alpha".to_owned(),
13058                summary: String::new(),
13059                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13060                vote: None,
13061                failed: None,
13062                duration_ms: 0,
13063            }],
13064            e2e: vec![CommandOutcome {
13065                command: "cargo test".to_owned(),
13066                code: Some(0),
13067                output_tail: String::new(),
13068                duration_ms: 0,
13069                resource_blocked: false,
13070            }],
13071            verify_retried: false,
13072            e2e_deferred: false,
13073            e2e_defer_reason: None,
13074            fix: Some(FixRecord {
13075                agent: "alpha".to_owned(),
13076                addressed: Vec::new(),
13077                rejected: vec![crate::verdict::Rejection {
13078                    id: "R1-1-1".to_owned(),
13079                    why: "not reachable from any caller".to_owned(),
13080                }],
13081                notes: String::new(),
13082                committed: true,
13083                failed: None,
13084                duration_ms: 0,
13085                continuation: None,
13086            }),
13087            blocking: 0,
13088            answered: 1,
13089            expected: 1,
13090            clean: false,
13091            progressed: true,
13092            vote_split: false,
13093            reconsideration: Vec::new(),
13094            verdict: None,
13095        };
13096        let state = state_with_round(round);
13097        let body = pr_message(&state, 'A').body;
13098
13099        assert!(body.contains("add retries"), "the task must still be there");
13100        assert!(body.contains("R2-1-1"), "{body}");
13101        assert!(!body.contains("unused import"), "{body}");
13102        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13103        assert!(
13104            !body.contains("not reachable from any caller"),
13105            "the reason it was declined: {body}"
13106        );
13107    }
13108
13109    #[test]
13110    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13111        let round = ReviewRound {
13112            round: 1,
13113            head: "deadbee".to_owned(),
13114            verified_head: None,
13115            verified_at: None,
13116            reviews: vec![ReviewRecord {
13117                attempts: 0,
13118                reviewer: 1,
13119                agent: "alpha".to_owned(),
13120                summary: String::new(),
13121                findings: Vec::new(),
13122                vote: None,
13123                failed: None,
13124                duration_ms: 0,
13125            }],
13126            e2e: Vec::new(),
13127            verify_retried: false,
13128            e2e_deferred: false,
13129            e2e_defer_reason: None,
13130            fix: None,
13131            blocking: 0,
13132            answered: 1,
13133            expected: 1,
13134            clean: true,
13135            progressed: false,
13136            vote_split: false,
13137            reconsideration: Vec::new(),
13138            verdict: None,
13139        };
13140        let state = state_with_round(round);
13141        let body = pr_message(&state, 'A').body;
13142        assert!(!body.contains("Open review findings"), "{body}");
13143        assert!(!body.contains("Declined"), "{body}");
13144    }
13145
13146    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13147        let mut state = RunState::new(
13148            PathBuf::from("/repo"),
13149            "main".to_owned(),
13150            "abc1234".to_owned(),
13151            instruction.to_owned(),
13152            Config::default(),
13153        );
13154        state.candidates.push(Candidate {
13155            index: 0,
13156            label: 'A',
13157            agent: "alpha".to_owned(),
13158            branch: "magi/x/A".to_owned(),
13159            worktree: PathBuf::from("/wt"),
13160            summary: summary.to_owned(),
13161            stat: String::new(),
13162            files: 1,
13163            commits: 1,
13164            empty: false,
13165            failed: None,
13166            verified_noop: None,
13167            folded: false,
13168            duration_ms: 0,
13169        });
13170        state
13171    }
13172
13173    fn review_state(subjects: &[&str]) -> RunState {
13174        let mut state = state_with_summary(
13175            "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",
13176            "",
13177        );
13178        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13179        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13180        state
13181    }
13182
13183    /// A branch rebased onto a main that moved past the recorded
13184    /// `base_commit` is titled from its own first commit, never main's.
13185    #[tokio::test]
13186    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13187        ask_test_home();
13188        let tmp = tempfile::tempdir().unwrap();
13189        let repo = tmp.path().join("repo");
13190        std::fs::create_dir_all(&repo).unwrap();
13191        init_repo(&repo);
13192        let origin = tmp.path().join("origin.git");
13193        let g = |dir: &Path, args: &[&str]| {
13194            let out = std::process::Command::new("git")
13195                .args(args)
13196                .current_dir(dir)
13197                .quiet()
13198                .output()
13199                .expect("spawn git");
13200            assert!(
13201                out.status.success(),
13202                "git {args:?}: {}",
13203                String::from_utf8_lossy(&out.stderr)
13204            );
13205        };
13206        g(
13207            tmp.path(),
13208            &[
13209                "clone",
13210                "--bare",
13211                "-q",
13212                repo.to_str().unwrap(),
13213                origin.to_str().unwrap(),
13214            ],
13215        );
13216        g(
13217            &repo,
13218            &["remote", "add", "origin", origin.to_str().unwrap()],
13219        );
13220        let c1 = git::rev_parse(&repo, "main").await.unwrap();
13221
13222        // Main moves on; the branch is built on top of the new main.
13223        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
13224        g(&repo, &["add", "-A"]);
13225        g(
13226            &repo,
13227            &["commit", "-q", "-m", "chore(deps): update a crate"],
13228        );
13229        g(&repo, &["push", "-q", "origin", "main"]);
13230        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
13231        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13232        g(&repo, &["add", "-A"]);
13233        g(
13234            &repo,
13235            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
13236        );
13237        g(&repo, &["checkout", "-q", "main"]);
13238
13239        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
13240        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
13241        // Without a readable tracking ref the recorded base's merge base is used.
13242        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
13243        assert_eq!(fallback, c1);
13244
13245        let mut state = review_state(&[
13246            "chore(deps): update a crate",
13247            "fix(daemon): apply a chosen action",
13248        ]);
13249        state.repo = repo.clone();
13250        state.base_branch = "main".to_owned();
13251        state.base_commit = c1;
13252        refresh_reviewed_commits(&mut state, "feat/own").await;
13253        assert_eq!(
13254            state.reviewed_commits,
13255            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
13256        );
13257        assert_eq!(
13258            review_title(&state).as_deref(),
13259            Some("fix(daemon): apply a chosen action")
13260        );
13261        assert_eq!(
13262            leaked_subjects(&state, "feat/own").await,
13263            Some(vec!["chore(deps): update a crate".to_owned()])
13264        );
13265        // A stale tracking ref is still used when the fetch fails, but the
13266        // leak list is withheld.
13267        state.config.merge.remote = "nowhere".to_owned();
13268        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
13269    }
13270
13271    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
13272    /// with `origin/main` one commit behind it, and a summarizing agent that
13273    /// prints whatever `reply` holds.
13274    struct SummaryFixture {
13275        _tmp: tempfile::TempDir,
13276        repo: PathBuf,
13277        reply: PathBuf,
13278        runner: Runner,
13279    }
13280
13281    fn summary_fixture() -> SummaryFixture {
13282        ask_test_home();
13283        let tmp = tempfile::tempdir().unwrap();
13284        let repo = tmp.path().join("repo");
13285        std::fs::create_dir_all(&repo).unwrap();
13286        init_repo(&repo);
13287        let origin = tmp.path().join("origin.git");
13288        let g = |args: &[&str]| {
13289            let out = std::process::Command::new("git")
13290                .args(args)
13291                .current_dir(&repo)
13292                .quiet()
13293                .output()
13294                .expect("spawn git");
13295            assert!(
13296                out.status.success(),
13297                "git {args:?}: {}",
13298                String::from_utf8_lossy(&out.stderr)
13299            );
13300        };
13301        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
13302        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
13303        g(&["push", "-q", "origin", "main"]);
13304        g(&["checkout", "-q", "-b", "feat/own"]);
13305        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
13306        g(&["add", "-A"]);
13307        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
13308        let reply = tmp.path().join("reply.txt");
13309        std::fs::write(
13310            &reply,
13311            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
13312        )
13313        .unwrap();
13314        let mut runner = runner_at(RunStatus::Gating);
13315        let mut state = review_state(&["feat(own): add the own file"]);
13316        state.repo = repo.clone();
13317        state.base_branch = "main".to_owned();
13318        state.candidates[0].branch = "feat/own".to_owned();
13319        state.candidates[0].worktree = repo.clone();
13320        let mut author = spec("alpha");
13321        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
13322        author
13323            .env
13324            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
13325        state.config.agents = vec![author];
13326        runner.state = state;
13327        SummaryFixture {
13328            _tmp: tmp,
13329            repo,
13330            reply,
13331            runner,
13332        }
13333    }
13334
13335    async fn posted(f: &mut SummaryFixture) -> PrMessage {
13336        let winner = f.runner.state.candidates[0].clone();
13337        f.runner
13338            .guarded_pr_message(&winner, None, true)
13339            .await
13340            .unwrap()
13341            .expect("an English description never parks")
13342    }
13343
13344    fn clean_round_with_minor_findings() -> ReviewRound {
13345        let mut round = passing_round(2, None);
13346        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
13347        round.reviews = vec![ReviewRecord {
13348            attempts: 0,
13349            reviewer: 1,
13350            agent: "alpha".into(),
13351            summary: "内部レビュー".into(),
13352            findings: vec![
13353                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
13354                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
13355            ],
13356            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
13357            failed: None,
13358            duration_ms: 0,
13359        }];
13360        round
13361    }
13362
13363    #[tokio::test]
13364    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
13365        for review_only in [false, true] {
13366            let mut f = summary_fixture();
13367            if !review_only {
13368                f.runner.state.candidates[0].agent = "alpha".into();
13369            }
13370            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
13371            assert!(f.runner.state.reviews[0].clean);
13372            assert!(f.runner.state.reviews[0].fix.is_none());
13373            assert!(f.runner.state.open_findings().is_empty());
13374            assert_eq!(
13375                crate::pr_outcome::required_finding_ids(&f.runner.state),
13376                ["R2-1-1", "R2-1-2"]
13377            );
13378            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13379            let evidence = crate::pr_outcome::summary_evidence(
13380                &f.runner.state,
13381                crate::pr_outcome::FinalHead::At(&head),
13382            );
13383            assert!(
13384                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
13385            );
13386            // The ordinary reply omits both IDs: no cached description is adopted.
13387            let omitted = posted(&mut f).await;
13388            assert!(omitted.body.contains("No description"));
13389            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
13390            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
13391            assert!(f.runner.state.change_summary.is_none());
13392            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();
13393            let described = posted(&mut f).await;
13394            assert!(described.body.contains("improve documentation for callers"));
13395            assert!(described.body.contains("remaining edge case"));
13396            assert!(described.body.contains("## Open review findings"));
13397            assert!(!described.body.contains("未修正"));
13398        }
13399    }
13400
13401    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
13402        let mut f = summary_fixture();
13403        if !review_only {
13404            f.runner.state.candidates[0].agent = "alpha".into();
13405        }
13406        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13407        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
13408        fixed
13409            .fix
13410            .as_mut()
13411            .unwrap()
13412            .rejected
13413            .push(crate::verdict::Rejection {
13414                id: "R1-1-1".into(),
13415                why: "呼び出されないコードのため辞退".into(),
13416            });
13417        let mut clean = clean_round_with_minor_findings();
13418        clean.verified_head = Some(head.clone());
13419        f.runner.state.reviews = vec![fixed, clean];
13420        ok_gate(&mut f.runner.state, Some(&head));
13421        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
13422        f.runner.state.config.agents.push(scripted("beta", reply));
13423        f.runner.state.config.roles.language_judge =
13424            Some(crate::config::AgentChoice::One("beta".into()));
13425        f
13426    }
13427
13428    #[tokio::test]
13429    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
13430        for review_only in [false, true] {
13431            let mut f = rewrite_fixture_with_findings(review_only,
13432                "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;
13433            let m = posted(&mut f).await;
13434            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13435            let expected =
13436                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
13437            assert!(m.body.contains(&expected));
13438            assert!(m.body.contains("## Review rounds"));
13439            assert!(
13440                m.body
13441                    .contains("Gate (`verify.gate`): passed on the final commit")
13442            );
13443            assert!(
13444                m.body
13445                    .contains("Tests (`verify.e2e`): passed on the final commit")
13446            );
13447            let c = f.runner.state.change_summary.as_ref().unwrap();
13448            assert!(
13449                m.body
13450                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
13451            );
13452            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13453            assert!(crate::pr_outcome::covers_required_findings(
13454                &f.runner.state,
13455                &m.body
13456            ));
13457            assert!(m.body.contains("documentation remains incomplete"));
13458            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
13459            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
13460            assert!(f.runner.state.github_text.is_none());
13461        }
13462    }
13463
13464    #[tokio::test]
13465    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
13466        for review_only in [false, true] {
13467            for reply in [
13468                "TITLE: fix: public description\nA short English description.",
13469                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
13470            ] {
13471                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
13472                let winner = f.runner.state.candidates[0].clone();
13473                let posted = f
13474                    .runner
13475                    .guarded_pr_message(&winner, None, true)
13476                    .await
13477                    .unwrap();
13478                assert!(posted.is_none());
13479                assert!(f.runner.state.parked);
13480                let withheld = f.runner.state.github_text.as_ref().unwrap();
13481                assert!(
13482                    withheld.rewrite.is_none(),
13483                    "an incomplete rewrite must not be selectable"
13484                );
13485                let q = ask::Questions::open()
13486                    .get(withheld.question.as_ref().unwrap())
13487                    .unwrap();
13488                assert!(
13489                    !q.choices
13490                        .contains(&crate::github_text::USE_REWRITE.to_owned())
13491                );
13492                assert!(
13493                    q.detail.contains("R1-1-1")
13494                        && q.detail.contains("R2-1-1")
13495                        && q.detail.contains("R2-1-2")
13496                );
13497            }
13498        }
13499    }
13500
13501    #[tokio::test]
13502    async fn japanese_reports_stay_local_in_both_public_run_modes() {
13503        for review_only in [false, true] {
13504            let mut f = summary_fixture();
13505            if !review_only {
13506                f.runner.state.candidates[0].agent = "alpha".into();
13507            }
13508            f.runner.state.instruction = "内部タスクの説明".into();
13509            f.runner.state.candidates[0].summary = "内部の候補報告".into();
13510            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13511            f.runner.state.reviews = vec![
13512                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
13513                round_with_notes(2, Some("二回目の内部修正報告です。")),
13514                passing_round(3, Some(&head)),
13515            ];
13516            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
13517            ok_gate(&mut f.runner.state, Some(&head));
13518            let captured = f.repo.join("public-prompt.txt");
13519            let author = &mut f.runner.state.config.agents[0];
13520            author.command = vec![
13521                "sh".into(),
13522                "-c".into(),
13523                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
13524            ];
13525            author.env.insert(
13526                "PROMPT_FILE".into(),
13527                captured.to_string_lossy().into_owned(),
13528            );
13529            let m = posted(&mut f).await;
13530            assert!(m.body.contains("Adds `own.txt`"));
13531            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
13532            assert!(m.body.contains("R1-1-1"));
13533            assert!(
13534                m.body
13535                    .contains("Gate (`verify.gate`): passed on the final commit")
13536            );
13537            assert!(
13538                m.body
13539                    .contains("Tests (`verify.e2e`): passed on the final commit")
13540            );
13541            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13542            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
13543            let prompt = std::fs::read_to_string(captured).unwrap();
13544            assert!(prompt.contains(&format!(
13545                "git diff {} {head}",
13546                f.runner.state.change_summary.as_ref().unwrap().base
13547            )));
13548            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
13549            assert_eq!(
13550                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
13551                "二回目の内部修正報告です。"
13552            );
13553        }
13554    }
13555
13556    #[tokio::test]
13557    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
13558        for review_only in [false, true] {
13559            let mut f = summary_fixture();
13560            if !review_only {
13561                f.runner.state.candidates[0].agent = "alpha".into();
13562            }
13563            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
13564            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
13565            std::fs::write(&f.reply, "no usable description").unwrap();
13566            let failed = posted(&mut f).await;
13567            assert!(
13568                failed
13569                    .body
13570                    .contains("No description of what this change does could be generated")
13571            );
13572            assert!(failed.body.is_ascii());
13573            assert!(f.runner.state.github_text.is_none());
13574            assert!(f.runner.state.change_summary.is_none());
13575            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13576            let evidence = crate::pr_outcome::summary_evidence(
13577                &f.runner.state,
13578                crate::pr_outcome::FinalHead::At(&head),
13579            );
13580            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
13581            assert!(
13582                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
13583                    .exists()
13584            );
13585            f.runner.state =
13586                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
13587            std::fs::write(
13588                &f.reply,
13589                "TITLE: feat(own): add the own file\nAdds the committed own file.",
13590            )
13591            .unwrap();
13592            let retried = posted(&mut f).await;
13593            assert!(retried.body.contains("Adds the committed own file."));
13594            assert_eq!(
13595                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
13596                "日本語の修正報告"
13597            );
13598        }
13599    }
13600
13601    #[tokio::test]
13602    async fn same_head_verification_and_findings_invalidate_public_prose() {
13603        let mut f = summary_fixture();
13604        posted(&mut f).await;
13605        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13606        let original = f
13607            .runner
13608            .state
13609            .change_summary
13610            .as_ref()
13611            .unwrap()
13612            .evidence
13613            .clone();
13614        ok_gate(&mut f.runner.state, Some(&head));
13615        std::fs::write(
13616            &f.reply,
13617            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
13618        )
13619        .unwrap();
13620        let verified = posted(&mut f).await;
13621        assert!(verified.body.contains("now verified"));
13622        assert_ne!(
13623            f.runner.state.change_summary.as_ref().unwrap().evidence,
13624            original
13625        );
13626        let mut round = round_with_notes(1, Some("内部報告"));
13627        round.reviews.push(ReviewRecord {
13628            attempts: 0,
13629            reviewer: 1,
13630            agent: "alpha".into(),
13631            summary: "内部レビュー".into(),
13632            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
13633            vote: None,
13634            failed: None,
13635            duration_ms: 0,
13636        });
13637        round
13638            .fix
13639            .as_mut()
13640            .unwrap()
13641            .rejected
13642            .push(crate::verdict::Rejection {
13643                id: "R1-1-1".into(),
13644                why: "呼び出し元から到達できない".into(),
13645            });
13646        f.runner.state.reviews = vec![round];
13647        // A description omitting the required ID is unusable, even with a title.
13648        let omitted = posted(&mut f).await;
13649        assert!(omitted.body.contains("No description"));
13650        assert!(omitted.body.contains("R1-1-1"));
13651        assert!(!omitted.body.contains("到達"));
13652        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();
13653        let described = posted(&mut f).await;
13654        assert!(described.body.contains("unreachable from callers"));
13655        assert!(described.body.contains("R1-1-1"));
13656        assert!(!described.body.contains("到達"));
13657        let evidence = f
13658            .runner
13659            .state
13660            .change_summary
13661            .as_ref()
13662            .unwrap()
13663            .evidence
13664            .clone();
13665        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
13666        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
13667        assert!(posted(&mut f).await.body.contains("rationale updated"));
13668        assert_ne!(
13669            f.runner.state.change_summary.as_ref().unwrap().evidence,
13670            evidence
13671        );
13672        // Old serialized summaries have no evidence fingerprint and must regenerate.
13673        f.runner
13674            .state
13675            .change_summary
13676            .as_mut()
13677            .unwrap()
13678            .evidence
13679            .clear();
13680        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
13681        assert!(posted(&mut f).await.body.contains("current specification"));
13682    }
13683
13684    #[tokio::test]
13685    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
13686        let mut f = summary_fixture();
13687        let m = posted(&mut f).await;
13688        assert_eq!(m.title, "feat(own): add the own file");
13689        assert!(
13690            m.body
13691                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
13692            "{}",
13693            m.body
13694        );
13695        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
13696        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
13697        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
13698        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
13699        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
13700        let kept = f.runner.state.change_summary.as_ref().unwrap();
13701        assert_eq!(
13702            (kept.head.as_str(), kept.base.as_str()),
13703            (head.as_str(), base.as_str())
13704        );
13705        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
13706    }
13707
13708    #[tokio::test]
13709    async fn review_only_summary_receives_the_configured_github_language() {
13710        let mut f = summary_fixture();
13711        f.runner.state.config.graph.github_language = "ja".into();
13712        let captured = f.repo.join("summary-prompt.txt");
13713        let author = &mut f.runner.state.config.agents[0];
13714        author.command = vec![
13715            "sh".into(),
13716            "-c".into(),
13717            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
13718        ];
13719        author.env.insert(
13720            "PROMPT_FILE".into(),
13721            captured.to_string_lossy().into_owned(),
13722        );
13723        std::fs::write(
13724            &f.reply,
13725            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
13726        )
13727        .unwrap();
13728        let m = posted(&mut f).await;
13729        let prompt = std::fs::read_to_string(captured).unwrap();
13730        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
13731        assert!(!prompt.contains("in English"));
13732        assert_eq!(m.title, "feat(own): ファイルを追加");
13733        assert!(m.body.contains("own.txt を追加します。"));
13734    }
13735
13736    #[tokio::test]
13737    async fn review_fixes_come_after_the_whole_change_summary() {
13738        let mut f = summary_fixture();
13739        f.runner.state.reviews = vec![
13740            round_with_notes(1, Some("handled the PATH case")),
13741            round_with_notes(2, None),
13742        ];
13743        let m = posted(&mut f).await;
13744        let summary = m.body.find("## Summary").expect("summary");
13745        let fixes = m.body.find("## Review rounds").expect("review rounds");
13746        assert!(summary < fixes, "{}", m.body);
13747        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
13748        assert!(m.body.contains("## Final verification"), "{}", m.body);
13749    }
13750
13751    #[tokio::test]
13752    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
13753        let mut f = summary_fixture();
13754        let first = posted(&mut f).await;
13755        let first_head = f.runner.state.change_summary.clone().unwrap().head;
13756        // The same head reuses the record: a different reply is not read.
13757        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
13758        let again = posted(&mut f).await;
13759        assert_eq!((again.title, again.body), (first.title, first.body));
13760
13761        let g = |args: &[&str]| {
13762            let out = std::process::Command::new("git")
13763                .args(args)
13764                .current_dir(&f.repo)
13765                .quiet()
13766                .output()
13767                .expect("spawn git");
13768            assert!(out.status.success(), "git {args:?}");
13769        };
13770        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
13771        g(&["add", "-A"]);
13772        g(&["commit", "-q", "-m", "fix(own): handle more"]);
13773        let second = posted(&mut f).await;
13774        let kept = f.runner.state.change_summary.clone().unwrap();
13775        assert_ne!(kept.head, first_head);
13776        assert!(second.body.contains("Not read yet."), "{}", second.body);
13777        assert!(!second.body.contains("so the first summary is read"));
13778        assert_eq!(second.title, "feat(own): other");
13779    }
13780
13781    #[tokio::test]
13782    async fn an_unavailable_summary_is_stated_and_never_invented() {
13783        let mut f = summary_fixture();
13784        std::fs::write(&f.reply, "no title line here").unwrap();
13785        let m = posted(&mut f).await;
13786        assert!(
13787            m.body
13788                .contains("No description of what this change does could be generated"),
13789            "{}",
13790            m.body
13791        );
13792        assert!(m.body.contains("internal reports remain in local records"));
13793        assert!(f.runner.state.change_summary.is_none());
13794        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
13795        // A preview asks nobody and claims nothing.
13796        let winner = f.runner.state.candidates[0].clone();
13797        let preview = f
13798            .runner
13799            .guarded_pr_message(&winner, None, false)
13800            .await
13801            .unwrap()
13802            .unwrap();
13803        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
13804    }
13805
13806    #[test]
13807    fn pr_message_review_single_commit_uses_its_subject() {
13808        let state = review_state(&["feat(nats): per-role user"]);
13809        let m = pr_message(&state, 'A');
13810        assert_eq!(m.title, "feat(nats): per-role user");
13811        assert!(!m.body.contains("Review the work already"), "{}", m.body);
13812        assert!(m.body.contains("## Commits under review"), "{}", m.body);
13813    }
13814
13815    #[test]
13816    fn pr_message_review_multi_commit_takes_the_oldest() {
13817        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
13818        let m = pr_message(&state, 'A');
13819        assert_eq!(m.title, "feat: the change");
13820        for s in ["feat: the change", "fix: typo", "fix: again"] {
13821            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
13822        }
13823    }
13824
13825    #[test]
13826    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
13827        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
13828            let mut state = review_state(&[first, "fix: later fixup"]);
13829            state.candidates[0].branch = "機能/ログイン".to_owned();
13830            let m = pr_message(&state, 'A');
13831            assert!(
13832                m.title.starts_with("chore: land candidate A of run"),
13833                "{}",
13834                m.title
13835            );
13836        }
13837    }
13838
13839    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
13840        BranchFacts {
13841            commits: commits
13842                .iter()
13843                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
13844                .collect(),
13845            stat: stat.to_owned(),
13846        }
13847    }
13848
13849    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
13850        let mut r = review_round(true, 0, 1, 1, true, true);
13851        r.round = round;
13852        r.fix = notes.map(|n| FixRecord {
13853            agent: "fixer".to_owned(),
13854            addressed: Vec::new(),
13855            rejected: Vec::new(),
13856            notes: n.to_owned(),
13857            committed: true,
13858            failed: None,
13859            duration_ms: 0,
13860            continuation: None,
13861        });
13862        r
13863    }
13864
13865    #[test]
13866    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
13867        let state = review_state(&["ignored"]);
13868        let f = facts(
13869            &[
13870                (
13871                    "fix(login): resolve PATH on macOS",
13872                    "Login shells skip rc files.",
13873                ),
13874                ("fix: address review", ""),
13875            ],
13876            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
13877        );
13878        let m = pr_message_with(&state, 'A', Some(&f));
13879        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
13880        assert!(
13881            m.body
13882                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
13883            "{}",
13884            m.body
13885        );
13886        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
13887        assert!(m.body.contains("## Diff stat"), "{}", m.body);
13888        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
13889        for banned in [
13890            "Review the work already",
13891            "no task statement",
13892            "Original task",
13893        ] {
13894            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
13895        }
13896        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
13897    }
13898
13899    #[test]
13900    fn pr_message_review_truncates_a_huge_first_commit_body() {
13901        let state = review_state(&["ignored"]);
13902        let f = facts(
13903            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
13904            "s",
13905        );
13906        let m = pr_message_with(&state, 'A', Some(&f));
13907        assert!(m.body.len() < 30_000, "{}", m.body.len());
13908        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
13909        assert!(
13910            m.body.contains("- ... 1 more commit(s)"),
13911            "{}",
13912            m.body.len()
13913        );
13914        assert!(m.body.ends_with("magi:candidate-a\n"));
13915    }
13916
13917    #[test]
13918    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
13919        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
13920        assert_eq!(m.title, "feat: x");
13921        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
13922        assert!(!m.body.contains("Diff stat"), "{}", m.body);
13923        assert!(!m.body.contains("no task statement"), "{}", m.body);
13924    }
13925
13926    #[test]
13927    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
13928        let mut state = review_state(&["日本語の件名"]);
13929        state.candidates[0].branch = "fix/macos-login-path".to_owned();
13930        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
13931        state.candidates[0].branch = "機能/ログイン".to_owned();
13932        assert!(
13933            pr_message(&state, 'A')
13934                .title
13935                .starts_with("chore: land candidate A")
13936        );
13937    }
13938
13939    #[test]
13940    fn pr_message_review_fixes_survive_a_clean_final_round() {
13941        let mut state = review_state(&["feat: x"]);
13942        state.reviews = vec![
13943            round_with_notes(1, Some("handled the PATH case")),
13944            round_with_notes(2, None),
13945        ];
13946        let body = pr_message(&state, 'A').body;
13947        assert!(
13948            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
13949            "{body}"
13950        );
13951        assert!(body.contains("### Round 1"), "{body}");
13952
13953        state.reviews = vec![
13954            round_with_notes(1, Some("first fix")),
13955            round_with_notes(2, Some("")),
13956            round_with_notes(3, Some("second fix")),
13957            round_with_notes(4, None),
13958        ];
13959        let body = pr_message(&state, 'A').body;
13960        assert!(
13961            body.contains("### Round 1") && !body.contains("first fix"),
13962            "{body}"
13963        );
13964        assert!(
13965            body.contains("### Round 3") && !body.contains("second fix"),
13966            "{body}"
13967        );
13968        assert!(body.contains("### Round 2"), "{body}");
13969    }
13970
13971    const FINAL: &str = "abcdef1234567890";
13972
13973    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
13974        let mut r = round_with_notes(round, None);
13975        r.verified_head = verified.map(str::to_owned);
13976        r
13977    }
13978
13979    fn ok_gate(state: &mut RunState, head: Option<&str>) {
13980        state.gate = vec![CommandOutcome {
13981            command: "gate".to_owned(),
13982            code: Some(0),
13983            output_tail: String::new(),
13984            duration_ms: 0,
13985            resource_blocked: false,
13986        }];
13987        state.gate_ran = true;
13988        state.gate_head = head.map(str::to_owned);
13989    }
13990
13991    fn body_at(state: &RunState) -> String {
13992        pr_message_raw(
13993            state,
13994            'A',
13995            None,
13996            crate::pr_outcome::FinalHead::At(FINAL),
13997            ChangeNote::NotAsked,
13998        )
13999        .body
14000    }
14001
14002    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14003
14004    #[test]
14005    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14006        let mut state = review_state(&["feat: x"]);
14007        state.reviews = vec![
14008            round_with_notes(1, Some(SANDBOXED)),
14009            passing_round(2, Some(FINAL)),
14010        ];
14011        ok_gate(&mut state, Some(FINAL));
14012        let body = body_at(&state);
14013        assert!(!body.contains("Handled the PATH case."), "{body}");
14014        assert!(!body.contains("could not commit"), "{body}");
14015        assert!(!body.contains("could not run"), "{body}");
14016        assert!(
14017            body.contains("The fix is committed on the branch"),
14018            "{body}"
14019        );
14020        assert!(!body.contains("sandbox"), "{body}");
14021        assert!(
14022            body.contains("Tests (`verify.e2e`): passed on the final commit"),
14023            "{body}"
14024        );
14025        assert!(
14026            body.contains("Gate (`verify.gate`): passed on the final commit"),
14027            "{body}"
14028        );
14029        // The raw report is untouched in the record.
14030        assert!(
14031            state.reviews[0]
14032                .fix
14033                .as_ref()
14034                .unwrap()
14035                .notes
14036                .contains("could not commit")
14037        );
14038    }
14039
14040    #[test]
14041    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14042        let mut state = review_state(&["feat: x"]);
14043        let mut r1 = round_with_notes(1, Some(SANDBOXED));
14044        r1.fix.as_mut().unwrap().committed = false;
14045        let mut r2 = passing_round(2, Some(FINAL));
14046        r2.e2e[0].code = Some(1);
14047        state.reviews = vec![r1, r2];
14048        let body = body_at(&state);
14049        assert!(!body.contains("could not commit"), "{body}");
14050        assert!(!body.contains("could not run"), "{body}");
14051        assert!(body.contains("This round produced no commit"), "{body}");
14052        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14053        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14054        assert!(!body.contains("passed"), "{body}");
14055    }
14056
14057    #[test]
14058    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14059        let mut state = review_state(&["feat: x"]);
14060        state.reviews = vec![
14061            round_with_notes(1, Some(SANDBOXED)),
14062            passing_round(2, Some("0123456789abcdef")),
14063        ];
14064        ok_gate(&mut state, Some("0123456789abcdef"));
14065        let body = body_at(&state);
14066        assert!(!body.contains("HTTP tests could not run"), "{body}");
14067        // The commit claim is still settled by the structured record.
14068        assert!(!body.contains("could not commit"), "{body}");
14069        assert!(
14070            body.contains("passed on an earlier commit (`0123456`)"),
14071            "{body}"
14072        );
14073        assert!(!body.contains("passed on the final commit"), "{body}");
14074
14075        // A gate recorded before `gate_head` existed is "not recorded".
14076        ok_gate(&mut state, None);
14077        let body = body_at(&state);
14078        assert!(
14079            body.contains("passed on a commit that was not recorded"),
14080            "{body}"
14081        );
14082    }
14083
14084    #[test]
14085    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14086        let state = state_with_summary(
14087            "add retries to the client",
14088            "TITLE: feat: retries\n\nDid it.",
14089        );
14090        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14091        assert_eq!(m.title, "feat: retries");
14092        assert!(
14093            m.body.contains("<summary>Original task</summary>"),
14094            "{}",
14095            m.body
14096        );
14097        assert!(!m.body.contains("Commits under review"), "{}", m.body);
14098        assert!(
14099            m.body
14100                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14101            "{}",
14102            m.body
14103        );
14104    }
14105
14106    #[test]
14107    fn pr_message_review_bounds_a_long_english_subject() {
14108        let long = format!("feat: {}", "word ".repeat(100));
14109        let m = pr_message(&review_state(&[&long]), 'A');
14110        assert!(m.title.starts_with("feat: word"), "{}", m.title);
14111        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14112    }
14113
14114    #[test]
14115    fn pr_message_implementation_run_is_unchanged_by_review_support() {
14116        let state = state_with_summary("add retries\n\ndetails", "- did some things");
14117        let m = pr_message(&state, 'A');
14118        assert_eq!(m.title, "add retries");
14119        assert!(m.body.contains("<summary>Original task</summary>"));
14120        assert!(!m.body.contains("Commits under review"));
14121        assert_eq!(landing_subject_source(&state), state.instruction);
14122    }
14123
14124    #[test]
14125    fn review_run_squash_subject_is_the_change_not_the_prompt() {
14126        let state = review_state(&["feat: the change", "fix: typo"]);
14127        let source = landing_subject_source(&state);
14128        assert_eq!(land::merge_subject("", &source), "feat: the change");
14129        assert_eq!(
14130            land::merge_subject("magi: candidate A (uncommitted work)", &source),
14131            "feat: the change"
14132        );
14133        // An operator's rename still wins.
14134        assert_eq!(
14135            land::merge_subject("feat: renamed by hand", &source),
14136            "feat: renamed by hand"
14137        );
14138        let blank = review_state(&["日本語"]);
14139        assert!(
14140            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14141        );
14142    }
14143
14144    #[test]
14145    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
14146        let state = review_state(&["feat: the change"]);
14147        let source = landing_subject_source(&state);
14148        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
14149        assert_eq!(
14150            land::merge_subject(landing_title(&state, old), &source),
14151            "feat: the change"
14152        );
14153        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
14154        let task = state_with_summary("add retries", "");
14155        assert_eq!(landing_title(&task, old), old);
14156    }
14157
14158    #[test]
14159    fn pr_message_describes_the_change_not_the_task() {
14160        let state = state_with_summary(
14161            "今回やってほしいこと: results projector を直す",
14162            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
14163        );
14164        let m = pr_message(&state, 'A');
14165        assert_eq!(m.title, "fix(web): batch the runs list reads");
14166        assert!(
14167            m.body.starts_with("## Summary\n\n- reads run.json once"),
14168            "{}",
14169            m.body
14170        );
14171        assert!(!m.body.contains("TITLE:"), "{}", m.body);
14172        let task_at = m.body.find("今回やってほしいこと").unwrap();
14173        let details_at = m.body.find("<details>").unwrap();
14174        assert!(
14175            details_at < task_at,
14176            "the task lives inside <details>: {}",
14177            m.body
14178        );
14179        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
14180        assert!(m.body.contains("magi:candidate-a"));
14181    }
14182
14183    #[test]
14184    fn pr_message_falls_back_to_the_task_without_a_title_line() {
14185        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
14186        let m = pr_message(&state, 'A');
14187        assert_eq!(m.title, "add retries");
14188        assert!(
14189            m.body.contains("## Summary\n\n- did some things"),
14190            "{}",
14191            m.body
14192        );
14193
14194        let none = RunState::new(
14195            PathBuf::from("/repo"),
14196            "main".to_owned(),
14197            "abc1234".to_owned(),
14198            "add retries".to_owned(),
14199            Config::default(),
14200        );
14201        let m = pr_message(&none, 'A');
14202        assert_eq!(m.title, "add retries");
14203        assert!(!m.body.contains("## Summary"), "{}", m.body);
14204    }
14205
14206    #[test]
14207    fn pr_message_refuses_the_candidate_commit_subject() {
14208        for bad in [
14209            "TITLE: magi: candidate A (uncommitted work)",
14210            "TITLE: chore: stuff (uncommitted work)",
14211            "TITLE:   ",
14212        ] {
14213            let state = state_with_summary("add retries", bad);
14214            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
14215        }
14216    }
14217
14218    #[test]
14219    fn pr_message_bounds_a_very_long_task_and_title() {
14220        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
14221        let state = state_with_summary(&long, "- nothing");
14222        let m = pr_message(&state, 'A');
14223        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14224        assert!(!m.title.contains('\n'));
14225
14226        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
14227        let m = pr_message(&state, 'A');
14228        assert!(m.title.starts_with("feat: "));
14229        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14230        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
14231    }
14232
14233    fn long_title_of(instruction: &str) -> String {
14234        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
14235    }
14236
14237    #[test]
14238    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
14239        let first = "Make the landing path keep a readable title for long tasks";
14240        let line = format!(
14241            "{first}. {}",
14242            "And then keep going with more words ".repeat(20)
14243        );
14244        let t = long_title_of(&line);
14245        assert_eq!(t, first);
14246        assert!(!t.starts_with("chore: land"));
14247    }
14248
14249    #[test]
14250    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
14251        let line = "word ".repeat(200);
14252        let t = long_title_of(&line);
14253        assert!(t.ends_with("word..."), "{t}");
14254        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
14255    }
14256
14257    #[test]
14258    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
14259        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
14260            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
14261        }
14262    }
14263
14264    #[test]
14265    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
14266        let mut state = state_with_summary("再試行を追加する", "- nothing");
14267        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
14268        state.config.graph.github_language = "ja".into();
14269        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
14270    }
14271
14272    #[test]
14273    fn pr_message_title_limit_is_exact() {
14274        let at = "a".repeat(PR_TITLE_MAX);
14275        assert_eq!(long_title_of(&at), at);
14276        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
14277        assert!(over.ends_with("..."), "{over}");
14278        assert_eq!(over.chars().count(), PR_TITLE_MAX);
14279    }
14280
14281    #[test]
14282    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
14283        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
14284        let t = long_title_of(&line);
14285        assert!(t.ends_with("..."), "{t}");
14286        assert!(t.is_ascii(), "{t}");
14287    }
14288
14289    #[test]
14290    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
14291        let line = format!(
14292            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
14293            "plus more filler words ".repeat(20)
14294        );
14295        let t = long_title_of(&line);
14296        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
14297    }
14298
14299    #[test]
14300    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
14301        let line = format!("\n\n# {}", "title words ".repeat(40));
14302        let state = state_with_summary(&line, "- nothing");
14303        let m = pr_message(&state, 'A');
14304        assert!(m.title.starts_with("title words"), "{}", m.title);
14305        assert_eq!(
14306            land::merge_subject(&m.title, &landing_subject_source(&state)),
14307            m.title
14308        );
14309        // An operator's rename wins untouched.
14310        assert_eq!(
14311            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
14312            "feat: renamed by hand"
14313        );
14314    }
14315
14316    #[tokio::test]
14317    async fn github_text_rewrite_is_bounded_and_falls_back() {
14318        crate::run::pin_test_home();
14319        for (reply, accepted) in [
14320            (
14321                "TITLE: fix: retries\nAdd retries for failed requests.",
14322                true,
14323            ),
14324            (
14325                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
14326                false,
14327            ),
14328            (
14329                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
14330                true,
14331            ),
14332            (
14333                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
14334                true,
14335            ),
14336            (
14337                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
14338                true,
14339            ),
14340            (
14341                "TITLE: fix: retries\n日本語の説明です。 token=secret",
14342                false,
14343            ),
14344            ("TITLE: fix: retries\n", false),
14345        ] {
14346            let dir = tempfile::tempdir().unwrap();
14347            let mut runner = runner_at(RunStatus::Gating);
14348            runner.state = state_with_summary(
14349                "add retries",
14350                "TITLE: fix: retries\n日本語の説明を書きます。",
14351            );
14352            runner.state.repo = dir.path().to_owned();
14353            runner.state.candidates[0].worktree = dir.path().to_owned();
14354            let mut author = spec("alpha");
14355            author.command = vec![
14356                "sh".into(),
14357                "-c".into(),
14358                "printf '%s' \"$REWRITE_REPLY\"".into(),
14359            ];
14360            author.env.insert("REWRITE_REPLY".into(), reply.into());
14361            runner.state.config.agents = vec![author];
14362            seed_public_summary(&mut runner);
14363            let winner = runner.state.candidates[0].clone();
14364            let first = runner
14365                .guarded_pr_message(&winner, None, true)
14366                .await
14367                .unwrap();
14368            let message = if accepted {
14369                first.expect("a passing rewrite never asks")
14370            } else {
14371                // Withheld: the run parks on a question and posts nothing yet.
14372                assert!(first.is_none());
14373                assert!(runner.state.parked);
14374                let store = ask::Questions::open();
14375                let asked: Vec<_> = store
14376                    .list()
14377                    .into_iter()
14378                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14379                    .collect();
14380                assert_eq!(asked.len(), 1);
14381                assert!(!asked[0].detail.contains("secret"));
14382                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
14383                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
14384                assert_eq!(
14385                    asked[0]
14386                        .choices
14387                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
14388                    rewrite.is_some()
14389                );
14390                // Silence: the question lapses and the neutral text is used,
14391                // without a second rewrite call or a second question.
14392                store
14393                    .update(&asked[0].id, |q| {
14394                        q.abandon("test");
14395                        Ok(())
14396                    })
14397                    .unwrap();
14398                let again = runner
14399                    .guarded_pr_message(&winner, None, true)
14400                    .await
14401                    .unwrap()
14402                    .expect("an abandoned question falls back");
14403                let n = store
14404                    .list()
14405                    .into_iter()
14406                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14407                    .count();
14408                assert_eq!(n, 1, "asked once per rejected text");
14409                again
14410            };
14411            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
14412            assert!(!message.body.contains("secret"));
14413            assert!(!message.body.contains("prefix"));
14414            assert!(!message.title.contains("secret"));
14415            assert_eq!(
14416                message.body.contains("Add retries for failed requests."),
14417                accepted
14418            );
14419            assert_eq!(
14420                runner.state.seats["github-text-rewrite"].turns, 1,
14421                "only one rewrite invocation"
14422            );
14423            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
14424            if accepted {
14425                assert!(runner.state.github_text.is_none());
14426                assert!(!runner.state.parked);
14427                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
14428                assert!(message.body.contains("## Final verification"));
14429                assert!(message.body.contains("Evidence: final diff"));
14430                assert!(
14431                    message
14432                        .body
14433                        .contains(&format!("magi:run/{}", runner.state.id))
14434                );
14435            }
14436            if !accepted {
14437                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14438                assert!(
14439                    message
14440                        .body
14441                        .contains(&format!("magi:run/{}", runner.state.id))
14442                );
14443            }
14444        }
14445    }
14446
14447    fn scripted(id: &str, reply: &str) -> AgentSpec {
14448        let mut a = spec(id);
14449        a.command = vec![
14450            "sh".into(),
14451            "-c".into(),
14452            "printf '%s' \"$REWRITE_REPLY\"".into(),
14453        ];
14454        a.env.insert("REWRITE_REPLY".into(), reply.into());
14455        a
14456    }
14457
14458    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
14459    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
14460
14461    /// Gate tests inject generated public output, not internal reports.
14462    fn seed_public_summary(runner: &mut Runner) {
14463        let repo = &runner.state.repo;
14464        let g = |args: &[&str]| {
14465            let out = std::process::Command::new("git")
14466                .args(args)
14467                .current_dir(repo)
14468                .quiet()
14469                .output()
14470                .unwrap();
14471            assert!(out.status.success(), "git {args:?}");
14472            String::from_utf8_lossy(&out.stdout).trim().to_owned()
14473        };
14474        g(&["init", "-q", "-b", "main"]);
14475        g(&[
14476            "-c",
14477            "user.name=Test",
14478            "-c",
14479            "user.email=test@example.invalid",
14480            "commit",
14481            "-q",
14482            "--allow-empty",
14483            "-m",
14484            "feat: fixture",
14485        ]);
14486        let head = g(&["rev-parse", "HEAD"]);
14487        g(&["update-ref", "refs/remotes/origin/main", &head]);
14488        let evidence = crate::pr_outcome::summary_evidence(
14489            &runner.state,
14490            crate::pr_outcome::FinalHead::At(&head),
14491        );
14492        let generated = &runner.state.candidates[0].summary;
14493        runner.state.change_summary = Some(crate::run::ChangeSummary {
14494            base: head.clone(),
14495            head,
14496            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
14497            title: summary_title(generated).unwrap(),
14498            body: summary_without_title(generated),
14499        });
14500    }
14501
14502    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
14503        let mut runner = runner_at(RunStatus::Gating);
14504        runner.state = state_with_summary("add retries", JA_SUMMARY);
14505        if review_only {
14506            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
14507        }
14508        runner.state.repo = dir.to_owned();
14509        runner.state.candidates[0].worktree = dir.to_owned();
14510        runner.state.config.agents = agents;
14511        seed_public_summary(&mut runner);
14512        runner
14513    }
14514
14515    fn gate_questions(runner: &Runner) -> usize {
14516        ask::Questions::open()
14517            .list()
14518            .into_iter()
14519            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
14520            .count()
14521    }
14522
14523    #[tokio::test]
14524    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
14525        crate::run::pin_test_home();
14526        let dir = tempfile::tempdir().unwrap();
14527        let mut runner = rewrite_runner(
14528            dir.path(),
14529            true,
14530            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14531        );
14532        runner.state.config.roles.language_judge =
14533            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
14534        let winner = runner.state.candidates[0].clone();
14535        let msg = runner
14536            .guarded_pr_message(&winner, None, true)
14537            .await
14538            .unwrap()
14539            .expect("an English rewrite posts without asking");
14540        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
14541        assert!(msg.body.contains("<details>kept</details>"));
14542        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
14543        assert_eq!(gate_questions(&runner), 0);
14544        assert!(runner.state.github_text.is_none());
14545        assert!(!runner.state.seats.contains_key("impl-A"));
14546        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14547    }
14548
14549    #[tokio::test]
14550    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
14551        crate::run::pin_test_home();
14552        let dir = tempfile::tempdir().unwrap();
14553        let mut runner = rewrite_runner(
14554            dir.path(),
14555            false,
14556            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
14557        );
14558        runner.state.config.roles.language_judge =
14559            Some(crate::config::AgentChoice::One("beta".into()));
14560        runner.state.seats.insert(
14561            "impl-A".into(),
14562            SeatState::new("impl-A", "alpha", runner.state.seed),
14563        );
14564        let winner = runner.state.candidates[0].clone();
14565        let msg = runner
14566            .guarded_pr_message(&winner, None, true)
14567            .await
14568            .unwrap()
14569            .expect("rewritten");
14570        assert!(msg.body.contains("<details>kept</details>"));
14571        assert_eq!(runner.state.seats["impl-A"].turns, 0);
14572        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14573        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
14574    }
14575
14576    #[tokio::test]
14577    async fn rewrite_reuses_a_resumable_implementer_session() {
14578        crate::run::pin_test_home();
14579        let dir = tempfile::tempdir().unwrap();
14580        let mut runner = rewrite_runner(
14581            dir.path(),
14582            false,
14583            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
14584        );
14585        runner.state.config.roles.language_judge =
14586            Some(crate::config::AgentChoice::One("beta".into()));
14587        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
14588        seat.turns = 1;
14589        runner.state.seats.insert("impl-A".into(), seat);
14590        let winner = runner.state.candidates[0].clone();
14591        runner
14592            .guarded_pr_message(&winner, None, true)
14593            .await
14594            .unwrap()
14595            .expect("rewritten");
14596        assert_eq!(runner.state.seats["impl-A"].turns, 2);
14597        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
14598    }
14599
14600    #[tokio::test]
14601    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
14602        crate::run::pin_test_home();
14603        let dir = tempfile::tempdir().unwrap();
14604        let mut broken = spec("bad");
14605        broken.command = vec!["false".into()];
14606        let mut runner = rewrite_runner(
14607            dir.path(),
14608            true,
14609            vec![
14610                broken,
14611                scripted("noformat", "Add retries."),
14612                scripted("good", EN_REWRITE),
14613            ],
14614        );
14615        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
14616            "bad".into(),
14617            "noformat".into(),
14618            "good".into(),
14619        ]));
14620        let winner = runner.state.candidates[0].clone();
14621        let msg = runner
14622            .guarded_pr_message(&winner, None, true)
14623            .await
14624            .unwrap()
14625            .expect("the third agent rewrites");
14626        assert!(msg.body.contains("`src/net.rs`"));
14627        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
14628        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
14629    }
14630
14631    #[tokio::test]
14632    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
14633        crate::run::pin_test_home();
14634        // (agents, role): still Japanese; no TITLE; nothing resolvable.
14635        for (agents, role, saved) in [
14636            (
14637                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
14638                Some("beta"),
14639                true,
14640            ),
14641            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
14642            (Vec::new(), None, false),
14643        ] {
14644            let dir = tempfile::tempdir().unwrap();
14645            let mut runner = rewrite_runner(dir.path(), true, agents);
14646            runner.state.config.roles.language_judge =
14647                role.map(|r| crate::config::AgentChoice::One(r.into()));
14648            let winner = runner.state.candidates[0].clone();
14649            let first = runner
14650                .guarded_pr_message(&winner, None, true)
14651                .await
14652                .unwrap();
14653            assert!(first.is_none(), "withheld text parks the run");
14654            assert!(runner.state.parked);
14655            assert_eq!(gate_questions(&runner), 1);
14656            let g = runner.state.github_text.as_ref().unwrap();
14657            assert_eq!(g.rewrite.is_some(), saved);
14658            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
14659        }
14660    }
14661
14662    #[tokio::test]
14663    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
14664        crate::run::pin_test_home();
14665        let dir = tempfile::tempdir().unwrap();
14666        let mut runner = runner_at(RunStatus::Gating);
14667        runner.state =
14668            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
14669        runner.state.repo = dir.path().to_owned();
14670        runner.state.candidates[0].worktree = dir.path().to_owned();
14671        let mut author = spec("alpha");
14672        author.command = vec![
14673            "sh".into(),
14674            "-c".into(),
14675            "printf '%s' \"$REWRITE_REPLY\"".into(),
14676        ];
14677        author.env.insert(
14678            "REWRITE_REPLY".into(),
14679            format!(
14680                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
14681                "English sentence. ".repeat(600)
14682            ),
14683        );
14684        runner.state.config.agents = vec![author];
14685        seed_public_summary(&mut runner);
14686        let winner = runner.state.candidates[0].clone();
14687        assert!(
14688            runner
14689                .guarded_pr_message(&winner, None, true)
14690                .await
14691                .unwrap()
14692                .is_none()
14693        );
14694        let record = runner.state.github_text.clone().unwrap();
14695        let rewrite = record.rewrite.clone().unwrap();
14696        assert_eq!(rewrite.failed, ["body-language"]);
14697        assert!(
14698            rewrite
14699                .body
14700                .ends_with(&format!("magi:run/{}", runner.state.id))
14701        );
14702        let store = ask::Questions::open();
14703        let q = store.get(record.question.as_ref().unwrap()).unwrap();
14704        assert!(
14705            q.choices
14706                .contains(&crate::github_text::USE_REWRITE.to_owned())
14707        );
14708        for label in [
14709            "ORIGINAL",
14710            "FALLBACK",
14711            "REWRITE",
14712            "Rules still failed: body-language",
14713            "truncated",
14714        ] {
14715            assert!(q.detail.contains(label), "{label}");
14716        }
14717        let artifact = std::fs::read_to_string(crate::run::artifact_path(
14718            &runner.state,
14719            &crate::github_text::artifact_name(&record.fingerprint),
14720        ))
14721        .unwrap();
14722        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
14723        // Restart from serialized state, then retry after a bad owner title.
14724        runner.state =
14725            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
14726        store
14727            .update(&q.id, |q| {
14728                q.say("まだ日本語")?;
14729                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
14730            })
14731            .unwrap();
14732        assert!(
14733            runner
14734                .guarded_pr_message(&winner, None, true)
14735                .await
14736                .unwrap()
14737                .is_none()
14738        );
14739        let retry = runner.state.github_text.as_ref().unwrap();
14740        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
14741        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
14742        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
14743        assert!(
14744            q.posting_comparison
14745                .as_ref()
14746                .unwrap()
14747                .matches_detail(&q.detail)
14748        );
14749        store
14750            .update(&q.id, |q| {
14751                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14752            })
14753            .unwrap();
14754        let message = runner
14755            .guarded_pr_message(&winner, None, true)
14756            .await
14757            .unwrap()
14758            .unwrap();
14759        assert_eq!(message.title, rewrite.title);
14760        assert_eq!(message.body, rewrite.body);
14761        assert_eq!(
14762            runner.state.github_text.as_ref().unwrap().choice,
14763            Some(GithubTextChoice::Rewrite)
14764        );
14765        let again = runner
14766            .guarded_pr_message(&winner, None, true)
14767            .await
14768            .unwrap()
14769            .unwrap();
14770        assert_eq!(again.title, message.title);
14771        assert_eq!(again.body, message.body);
14772        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
14773        assert!(
14774            !runner
14775                .state
14776                .events
14777                .iter()
14778                .any(|e| e.message.contains(&rewrite.title)
14779                    || e.message.contains("English sentence."))
14780        );
14781    }
14782
14783    #[tokio::test]
14784    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
14785        crate::run::pin_test_home();
14786        let dir = tempfile::tempdir().unwrap();
14787        let mut runner = runner_at(RunStatus::Gating);
14788        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
14789        runner.state.repo = dir.path().to_owned();
14790        runner.state.candidates[0].worktree = dir.path().to_owned();
14791        seed_public_summary(&mut runner);
14792        let winner = runner.state.candidates[0].clone();
14793        assert!(
14794            runner
14795                .guarded_pr_message(&winner, None, true)
14796                .await
14797                .unwrap()
14798                .is_none()
14799        );
14800        let record = runner.state.github_text.as_ref().unwrap();
14801        assert!(record.rewrite.is_none());
14802        // Records written before the additive field deserialize with no rewrite.
14803        let mut old = serde_json::to_value(record).unwrap();
14804        old.as_object_mut().unwrap().remove("rewrite");
14805        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
14806        assert!(old.rewrite.is_none());
14807        let id = record.question.clone().unwrap();
14808        ask::Questions::open()
14809            .update(&id, |q| {
14810                q.choices.push(crate::github_text::USE_REWRITE.into());
14811                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
14812            })
14813            .unwrap();
14814        let message = runner
14815            .guarded_pr_message(&winner, None, true)
14816            .await
14817            .unwrap()
14818            .unwrap();
14819        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
14820        assert_eq!(
14821            runner.state.github_text.as_ref().unwrap().choice,
14822            Some(GithubTextChoice::Fallback)
14823        );
14824    }
14825
14826    #[tokio::test]
14827    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
14828        crate::run::pin_test_home();
14829        let dir = tempfile::tempdir().unwrap();
14830        let mut runner = runner_at(RunStatus::Gating);
14831        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
14832        runner.state.repo = dir.path().to_owned();
14833        runner.state.candidates[0].worktree = dir.path().to_owned();
14834        seed_public_summary(&mut runner);
14835        let winner = runner.state.candidates[0].clone();
14836        assert!(
14837            runner
14838                .guarded_pr_message(&winner, None, true)
14839                .await
14840                .unwrap()
14841                .is_none()
14842        );
14843        let store = ask::Questions::open();
14844        let find = |run: &str| {
14845            store
14846                .list()
14847                .into_iter()
14848                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
14849                .unwrap()
14850        };
14851        let run = runner.state.id.clone();
14852        let q = find(&run);
14853        let fp = runner
14854            .state
14855            .github_text
14856            .as_ref()
14857            .unwrap()
14858            .fingerprint
14859            .clone();
14860        let name = crate::github_text::artifact_name(&fp);
14861        let path = crate::run::artifact_path(&runner.state, &name);
14862        assert!(q.detail.contains(&path.display().to_string()));
14863        assert!(q.detail.contains("title-language: title"));
14864        let saved = std::fs::read_to_string(&path).expect("artifact written");
14865        assert!(saved.contains("日本語のタイトル"));
14866        assert!(q.cwd.is_none());
14867        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
14868        // A bad replacement is refused and asked about once more.
14869        let say = |id: &str, text: &str, choice: &str| {
14870            store
14871                .update(id, |q| {
14872                    q.thread.push(ask::Turn {
14873                        who: ask::Who::Operator,
14874                        body: text.to_owned(),
14875                        at: jiff::Timestamp::now(),
14876                        note: None,
14877                    });
14878                    q.answer(ask::Answer::Choice(choice.to_owned()))
14879                })
14880                .unwrap();
14881        };
14882        say(&q.id, "まだ日本語", "use my text");
14883        assert!(
14884            runner
14885                .guarded_pr_message(&winner, None, true)
14886                .await
14887                .unwrap()
14888                .is_none()
14889        );
14890        let second = find(&run);
14891        assert_ne!(second.id, q.id);
14892        assert!(second.detail.contains("did not pass"));
14893        // The retry still compares the original text; the rejected
14894        // replacement is named by category only.
14895        assert!(second.detail.contains("日本語のタイトル"));
14896        assert!(!second.detail.contains("まだ日本語"));
14897        assert!(second.detail.contains(&path.display().to_string()));
14898        say(&second.id, "fix: retry failed requests", "use my text");
14899        let message = runner
14900            .guarded_pr_message(&winner, None, true)
14901            .await
14902            .unwrap()
14903            .expect("a vetted replacement is posted");
14904        assert_eq!(message.title, "fix: retry failed requests");
14905        assert_eq!(
14906            runner
14907                .state
14908                .github_text
14909                .as_ref()
14910                .unwrap()
14911                .chosen_title
14912                .as_deref(),
14913            Some("fix: retry failed requests")
14914        );
14915        assert!(runner.state.github_text.as_ref().unwrap().resolved);
14916        // Resumed again: the saved decision is applied, nothing is re-asked.
14917        let again = runner
14918            .guarded_pr_message(&winner, None, true)
14919            .await
14920            .unwrap()
14921            .unwrap();
14922        assert_eq!(again.title, message.title);
14923        assert_eq!(
14924            store
14925                .list()
14926                .into_iter()
14927                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
14928                .count(),
14929            2
14930        );
14931    }
14932
14933    #[test]
14934    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
14935        // What magi itself writes stays English under any configured language,
14936        // so a future localisation of these headings fails here. (The agents'
14937        // own text is also checked by the posting gate.)
14938        let mut state = state_with_summary(
14939            "add retries",
14940            "TITLE: fix(web): batch reads\n- reads run.json once",
14941        );
14942        state.config.graph.language = "ja".to_owned();
14943        let m = pr_message(&state, 'A');
14944        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
14945        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
14946
14947        // The task is the operator's own text: it goes in untouched, and the
14948        // fallback title (no summary) may be in its language too.
14949        let task = "今回やってほしいこと: results projector を直す";
14950        let mut state = state_with_summary(task, "- no title line");
14951        state.config.graph.language = "ja".to_owned();
14952        let m = pr_message(&state, 'A');
14953        assert_eq!(
14954            m.title,
14955            format!("chore: land candidate A of run {}", state.id)
14956        );
14957        assert!(
14958            m.body.contains(&format!(
14959                "<summary>Original task</summary>\n\n{task}\n\n</details>"
14960            )),
14961            "{}",
14962            m.body
14963        );
14964    }
14965
14966    #[test]
14967    fn pr_message_scrubs_home_paths_and_addresses() {
14968        let state = state_with_summary(
14969            "fix it in /Users/someone/src/x",
14970            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
14971        );
14972        let m = pr_message(&state, 'A');
14973        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
14974            assert!(!m.body.contains(leak), "{}", m.body);
14975        }
14976        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
14977    }
14978
14979    #[test]
14980    fn pr_message_survives_a_task_that_closes_details() {
14981        let state = state_with_summary("a </details> b", "TITLE: fix: x");
14982        let m = pr_message(&state, 'A');
14983        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
14984    }
14985
14986    #[test]
14987    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
14988        let cmd = manual_merge_command(
14989            MergeStyle::Squash,
14990            Path::new("/repo"),
14991            "b",
14992            "fix: \"quoted\" $(x) `y`\n\nbody",
14993        );
14994        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
14995    }
14996
14997    #[test]
14998    fn manual_merge_command_matches_the_configured_style() {
14999        let repo = Path::new("/repo");
15000        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15001
15002        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15003        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15004
15005        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15006        assert_eq!(
15007            squash,
15008            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15009             \"Merge magi run 0832 (candidate A)\""
15010        );
15011
15012        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15013        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15014    }
15015
15016    #[test]
15017    fn a_nudge_gets_a_quarter_of_the_budget() {
15018        // The judge and implement budgets magi ships with.
15019        assert_eq!(retry_budget(secs(1200), true), secs(300));
15020        assert_eq!(retry_budget(secs(3600), true), secs(900));
15021    }
15022
15023    #[test]
15024    fn a_resent_prompt_keeps_the_whole_budget() {
15025        // The seat kept no context, so the retry is the original job again and
15026        // shortening it would only guarantee a second failure.
15027        assert_eq!(retry_budget(secs(1200), false), secs(1200));
15028        assert_eq!(retry_budget(secs(60), false), secs(60));
15029    }
15030
15031    #[test]
15032    fn the_floor_never_exceeds_the_original_budget() {
15033        // A short configured timeout must not be *raised* by the floor: the
15034        // operator asked for a bound, and a retry may not outlast the attempt
15035        // it is retrying.
15036        assert_eq!(retry_budget(secs(60), true), secs(60));
15037        assert_eq!(retry_budget(secs(480), true), secs(120));
15038        assert_eq!(retry_budget(secs(0), true), secs(0));
15039    }
15040
15041    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15042        agent::CommandEvidence {
15043            id: "item1".to_owned(),
15044            description: "cargo test".to_owned(),
15045            exit_code,
15046            result_summary: String::new(),
15047            source: "codex".to_owned(),
15048        }
15049    }
15050
15051    #[test]
15052    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15053        // No evidence is not the same fact as unconfirmed evidence: a
15054        // backend with no adapter, or a reply that ran no commands at all,
15055        // must not be misread as carrying a dangling job.
15056        assert!(!has_unconfirmed_command(&[]));
15057    }
15058
15059    #[test]
15060    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15061        // Deliberately not a check on the exit code's *value*: a fixer
15062        // legitimately runs something that fails mid-iteration before it
15063        // succeeds, and that must never by itself reopen a valid report.
15064        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15065        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15066        assert!(!has_unconfirmed_command(&[
15067            evidence(Some(0)),
15068            evidence(Some(101))
15069        ]));
15070    }
15071
15072    #[test]
15073    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15074        assert!(has_unconfirmed_command(&[
15075            evidence(Some(0)),
15076            evidence(None)
15077        ]));
15078    }
15079
15080    #[test]
15081    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15082        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15083        assert_eq!(
15084            verified_noop_claim(true, &[], text).as_deref(),
15085            Some("already fixed by b32cfc4, on main.")
15086        );
15087    }
15088
15089    #[test]
15090    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15091        // A timeout or a bad exit code reads as the ordinary loss it is,
15092        // whatever the reply's own prose claims.
15093        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15094        assert!(verified_noop_claim(false, &[], text).is_none());
15095    }
15096
15097    #[test]
15098    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15099        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15100        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15101        // A confirmed command alongside the marker is fine.
15102        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15103    }
15104
15105    #[test]
15106    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15107        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15108    }
15109
15110    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
15111    /// pair, in order, labelled A, B, C, ...
15112    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15113        runner.state.candidates = shape
15114            .iter()
15115            .enumerate()
15116            .map(|(i, &(empty, verified))| Candidate {
15117                index: i,
15118                label: (b'A' + i as u8) as char,
15119                agent: "sonnet".to_owned(),
15120                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15121                worktree: PathBuf::from(format!("/wt/{i}")),
15122                summary: String::new(),
15123                stat: String::new(),
15124                files: 0,
15125                commits: 0,
15126                empty,
15127                failed: None,
15128                verified_noop: verified.map(str::to_owned),
15129                duration_ms: 0,
15130                folded: false,
15131            })
15132            .collect();
15133    }
15134
15135    #[test]
15136    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15137        ask_test_home();
15138        let mut runner = runner_at(RunStatus::Implementing);
15139        set_candidates(
15140            &mut runner,
15141            &[
15142                (true, Some("already on main at b32cfc4")),
15143                (true, Some("same fix, see the existing test")),
15144            ],
15145        );
15146
15147        runner
15148            .after_implement()
15149            .expect("a verified no-op is not an error");
15150
15151        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
15152    }
15153
15154    #[test]
15155    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
15156        ask_test_home();
15157        let mut runner = runner_at(RunStatus::Implementing);
15158        // Candidate A declares a verified no-op; candidate B simply wrote
15159        // nothing and said nothing about why. One candidate's claim is not
15160        // the whole run's agreement.
15161        set_candidates(
15162            &mut runner,
15163            &[(true, Some("already on main at b32cfc4")), (true, None)],
15164        );
15165
15166        let err = runner
15167            .after_implement()
15168            .expect_err("an unverified empty candidate must still fail the run");
15169
15170        assert!(
15171            err.to_string().contains("no candidate produced a change"),
15172            "{err}"
15173        );
15174        assert_eq!(runner.state.status, RunStatus::Failed);
15175    }
15176
15177    #[test]
15178    fn after_implement_still_fails_an_ordinary_all_empty_run() {
15179        ask_test_home();
15180        let mut runner = runner_at(RunStatus::Implementing);
15181        set_candidates(&mut runner, &[(true, None), (true, None)]);
15182
15183        let err = runner
15184            .after_implement()
15185            .expect_err("no candidate declared anything; this is an ordinary failure");
15186
15187        assert!(
15188            err.to_string().contains("no candidate produced a change"),
15189            "{err}"
15190        );
15191        assert_eq!(runner.state.status, RunStatus::Failed);
15192    }
15193}
15194
15195/// A graft rollback whose restore could not be confirmed (see
15196/// [`Runner::graft`]).
15197#[derive(Debug)]
15198struct GraftRestoreFailed(String);
15199
15200impl std::fmt::Display for GraftRestoreFailed {
15201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15202        write!(
15203            f,
15204            "could not restore the winner to its pre-graft commit: {}",
15205            self.0
15206        )
15207    }
15208}
15209
15210impl std::error::Error for GraftRestoreFailed {}
15211
15212/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
15213/// not the agent: they never draw on an agent's own timeout, and running out of
15214/// them never reads as an agent failure.
15215#[cfg(test)]
15216mod resource_wait_tests {
15217    use super::*;
15218    use std::collections::BTreeMap;
15219
15220    fn sh(id: &str, script: &str) -> AgentSpec {
15221        AgentSpec {
15222            id: id.to_owned(),
15223            kind: crate::config::AgentKind::Command,
15224            model: None,
15225            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
15226            extra_args: Vec::new(),
15227            env: BTreeMap::new(),
15228            prompt_delivery: None,
15229        }
15230    }
15231
15232    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
15233        SeatJob {
15234            seat: SeatState::new("impl-A", &spec.id, 1),
15235            spec,
15236            cwd: dir.to_path_buf(),
15237            prompt: "do it".to_owned(),
15238            timeout,
15239            allow_write: true,
15240            sessions: false,
15241            artifacts: dir.join("art"),
15242            stem: "impl-A".to_owned(),
15243            handover: None,
15244        }
15245    }
15246
15247    fn state(resource_wait_secs: u64) -> RunState {
15248        let mut config = Config::default();
15249        config.graph.timeout_resource_wait = resource_wait_secs;
15250        RunState::new(
15251            PathBuf::from("/repo"),
15252            "main".to_owned(),
15253            "abc1234".to_owned(),
15254            "task".to_owned(),
15255            config,
15256        )
15257    }
15258
15259    fn hold(cache: &Path) -> crate::cache::Guard {
15260        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
15261        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
15262            crate::cache::AcquireOutcome::Acquired(g) => g,
15263            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
15264        }
15265    }
15266
15267    async fn run(
15268        jobs: Vec<SeatJob>,
15269        sem: Arc<Semaphore>,
15270        cache: Option<&Path>,
15271        st: &mut RunState,
15272    ) -> Vec<(usize, SeatState, AgentOutcome)> {
15273        let prompts = Prompts::default();
15274        let ctx = WaveCtx {
15275            run: "r",
15276            node: "implement",
15277            prompts: &prompts,
15278            cache,
15279            round: None,
15280            carry_seats: false,
15281            pauses: None,
15282        };
15283        wave(jobs, sem, &ctx, st, 0).await
15284    }
15285
15286    #[tokio::test]
15287    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
15288        crate::run::pin_test_home();
15289        let tmp = tempfile::tempdir().unwrap();
15290        let cache = tmp.path().join("target-a");
15291        let guard = hold(&cache);
15292        // Held for longer than the agent's whole 1s budget.
15293        tokio::spawn(async move {
15294            tokio::time::sleep(Duration::from_millis(1500)).await;
15295            guard.release();
15296        });
15297        let mut st = state(60);
15298        let results = run(
15299            vec![job(
15300                sh("alpha", "echo ok"),
15301                tmp.path(),
15302                Duration::from_secs(1),
15303            )],
15304            Arc::new(Semaphore::new(1)),
15305            Some(&cache),
15306            &mut st,
15307        )
15308        .await;
15309        assert!(
15310            matches!(results[0].2, AgentOutcome::Ok(_)),
15311            "the wait must not be charged to the agent"
15312        );
15313        assert!(st.resource_block.is_none());
15314        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
15315        assert!(st.resource_wait.is_none() && st.active.is_empty());
15316    }
15317
15318    #[tokio::test]
15319    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
15320        crate::run::pin_test_home();
15321        let tmp = tempfile::tempdir().unwrap();
15322        let cache = tmp.path().join("target-b");
15323        let _held = hold(&cache);
15324        let mut st = state(1);
15325        let results = run(
15326            vec![job(
15327                sh("alpha", "echo ok"),
15328                tmp.path(),
15329                Duration::from_secs(1),
15330            )],
15331            Arc::new(Semaphore::new(1)),
15332            Some(&cache),
15333            &mut st,
15334        )
15335        .await;
15336        let (_, seat, out) = &results[0];
15337        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
15338        assert!(wave_blocked(&results));
15339        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
15340        assert_eq!(seat.turns, 0, "nothing was asked");
15341        assert!(
15342            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
15343            "no agent was spawned"
15344        );
15345        assert!(FailClass::of(out).is_none(), "not a failure class");
15346        let block = st.resource_block.as_ref().expect("recorded for the resume");
15347        assert_eq!(block.kind, "shared build cache");
15348        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
15349        assert!(st.active.is_empty());
15350    }
15351
15352    #[tokio::test]
15353    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
15354        crate::run::pin_test_home();
15355        let tmp = tempfile::tempdir().unwrap();
15356        let mut st = state(1);
15357        let results = run(
15358            vec![job(
15359                sh("alpha", "echo ok"),
15360                tmp.path(),
15361                Duration::from_secs(1),
15362            )],
15363            Arc::new(Semaphore::new(0)),
15364            None,
15365            &mut st,
15366        )
15367        .await;
15368        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
15369        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
15370        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
15371    }
15372
15373    #[tokio::test]
15374    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
15375        crate::run::pin_test_home();
15376        let tmp = tempfile::tempdir().unwrap();
15377        let mut st = state(60);
15378        let results = run(
15379            vec![job(
15380                sh("alpha", "sleep 30"),
15381                tmp.path(),
15382                Duration::from_secs(1),
15383            )],
15384            Arc::new(Semaphore::new(1)),
15385            None,
15386            &mut st,
15387        )
15388        .await;
15389        let out = &results[0].2;
15390        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
15391        let class = FailClass::of(out).expect("a failure");
15392        assert_eq!(class, FailClass::Timeout);
15393        assert!(should_hand_over(None, &class), "existing handover policy");
15394        assert!(st.resource_block.is_none());
15395    }
15396}