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, QuotaLoss,
53    ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory, Tally,
54    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}
182
183/// A request to park the run at its next node boundary.
184///
185/// Cloning is how the request travels: the loop keeps one handle and hands a
186/// clone to each [`Runner`], and every clone points at the same flag. There
187/// is no channel because there is nothing to send - the only message is
188/// "park", it is idempotent, and a flag cannot be missed by a receiver that
189/// was not listening yet.
190///
191/// The boundary is what makes this cheap. Every node writes the run's state
192/// before the next one starts, and every node skips what is already recorded:
193/// `prep` returns early once candidates exist, `implement` asks only the seats
194/// with nothing on disk, `judge` returns early once judgements exist. So a
195/// parked run resumes into exactly the node it stopped before, and no agent
196/// work is thrown away. Killing the process mid-node, by contrast, loses
197/// whatever the seats in flight had not yet written - which for an implement
198/// wave is an hour of paid work.
199///
200/// A [`Runner`] watches two independent handles of this type - see
201/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
202/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
203/// clone covering the whole daemon's lifetime and is never asked to un-park,
204/// which is correct exactly because nothing is dispatched after it fires.
205/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
206/// that parks for an interrupted task must go on to run other tasks
207/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
208/// reusing the daemon-wide one.
209#[derive(Debug, Clone, Default)]
210pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
211
212impl Pause {
213    /// A pause nobody has asked for yet.
214    #[must_use]
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// Ask the run to park at its next node boundary. Idempotent.
220    pub fn park(&self) {
221        self.0.store(true, Ordering::SeqCst);
222    }
223
224    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
225    /// fold into the run's own `park` event - so an operator reading the run
226    /// later knows this was a deliberate interrupt rather than a shutdown or
227    /// a binary swap. The first reason recorded wins; a park already in
228    /// flight is not relabelled by a second, unrelated request.
229    pub fn park_because(&self, reason: impl Into<String>) {
230        let mut reason_guard = self
231            .1
232            .lock()
233            .unwrap_or_else(std::sync::PoisonError::into_inner);
234        if reason_guard.is_none() {
235            *reason_guard = Some(reason.into());
236        }
237        drop(reason_guard);
238        self.park();
239    }
240
241    /// Has a park been asked for?
242    #[must_use]
243    pub fn parked(&self) -> bool {
244        self.0.load(Ordering::SeqCst)
245    }
246
247    /// Why the park was asked for, when the caller used [`Pause::park_because`].
248    #[must_use]
249    pub fn reason(&self) -> Option<String> {
250        self.1
251            .lock()
252            .unwrap_or_else(std::sync::PoisonError::into_inner)
253            .clone()
254    }
255}
256
257/// Drives one run.
258pub struct Runner {
259    /// Run state; public so the CLI can report on it.
260    pub state: RunState,
261    roles: ResolvedRoles,
262    sem: Arc<Semaphore>,
263    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
264    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
265    /// this is never the same handle as `interrupt`.
266    pause: Pause,
267    /// Set when `magi serve`'s interrupt scheduler wants this specific run
268    /// parked at its next node boundary, to let a task marked
269    /// [`crate::queue::Task::interrupt`] run alone before this one carries
270    /// on. Unlike `pause`, a fresh, unshared handle per run - see
271    /// [`Runner::watch_interrupt`].
272    interrupt: Pause,
273}
274
275/// Where the branch's own commits start: its merge base with the base branch
276/// as the remote has it now, else with the recorded `base_commit`. A branch
277/// rebased onto a base that moved past `base_commit` would otherwise count
278/// the base's commits as its own under `base_commit..branch`.
279async fn review_base(
280    repo: &Path,
281    remote: &str,
282    base_branch: &str,
283    base_commit: &str,
284    branch: &str,
285) -> String {
286    review_base_checked(repo, remote, base_branch, base_commit, branch)
287        .await
288        .0
289}
290
291/// [`review_base`] plus whether the base was read from a freshly fetched
292/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
293/// never older than `base_commit`'s view of the base), but the answer is then
294/// not trusted to rewrite a pull request's title.
295async fn review_base_checked(
296    repo: &Path,
297    remote: &str,
298    base_branch: &str,
299    base_commit: &str,
300    branch: &str,
301) -> (String, bool) {
302    let tracking = format!("{remote}/{base_branch}");
303    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
304    if git::rev_exists(repo, &tracking).await
305        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
306        && !mb.is_empty()
307    {
308        return (mb, fresh);
309    }
310    let mb = git::merge_base(repo, base_commit, branch)
311        .await
312        .ok()
313        .filter(|mb| !mb.is_empty())
314        .unwrap_or_else(|| base_commit.to_owned());
315    (mb, false)
316}
317
318/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
319/// when git cannot say or finds nothing: a stale list is better than a wrong
320/// or empty one.
321pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
322    if !is_review_run(state) {
323        return;
324    }
325    let base = review_base(
326        &state.repo,
327        &state.config.merge.remote,
328        &state.base_branch,
329        &state.base_commit,
330        branch,
331    )
332    .await;
333    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
334        && !subjects.is_empty()
335        && state.reviewed_commits.as_ref() != Some(&subjects)
336    {
337        state.reviewed_commits = Some(subjects);
338        state.save().ok();
339    }
340}
341
342/// Subjects of the base's commits between the recorded start and the branch's
343/// merge base: what a stale `base_commit..branch` would have mistaken for the
344/// branch's own work.
345async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
346    let (base, trusted) = review_base_checked(
347        &state.repo,
348        &state.config.merge.remote,
349        &state.base_branch,
350        &state.base_commit,
351        branch,
352    )
353    .await;
354    if !trusted {
355        return None;
356    }
357    git::subjects(&state.repo, &state.base_commit, &base)
358        .await
359        .ok()
360}
361
362/// May an adopted pull request's title be replaced with `computed`? Only when
363/// it is empty, magi's own shape, or a base commit's subject that leaked in;
364/// a title a person wrote stays. Never when `computed` is itself a leak.
365fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
366    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
367    if is_leak(computed) {
368        return false;
369    }
370    let cur = current.trim();
371    cur.is_empty()
372        || cur.starts_with(REVIEW_PROMPT_OPENING)
373        || cur.starts_with("chore: land candidate")
374        || cur.starts_with("magi: candidate")
375        || is_leak(cur)
376}
377
378/// The commit a run branches from: the base branch as the remote has it.
379///
380/// Two failures this replaces. A run used to branch off `HEAD` and so refused
381/// to start on a dirty tree, which made `magi serve` decline every task for as
382/// long as the operator had work in progress - most of the time. Branching off
383/// the *local* base branch fixed that and introduced a worse one: `land` merges
384/// the winner on GitHub, nothing updates the local ref, and the next run
385/// branches off a base missing everything the previous runs landed. Two tasks
386/// in a row from a phone would have had the second silently re-implementing
387/// against stale code and opening a pull request that reverted the first.
388///
389/// Only refs move here - no checkout, no local branch, no merge - so it is safe
390/// with uncommitted work in the tree. A machine with no network still starts:
391/// the fetch may fail and the local tip is used with a warning, because
392/// refusing to run offline is a worse failure than running against a base the
393/// operator can see for themselves.
394///
395/// One function, called by both entry points. Two answers to "where does a run
396/// branch from" is the kind of drift nobody notices until a diff is wrong.
397/// Bring the local `branch` in line with `<remote>/<branch>` before a review
398/// checks it out.
399///
400/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
401/// anything other than plain `git push` from this checkout (a jj colocated
402/// workspace, another clone) moves only the remote-tracking ref - so the local
403/// one can be a stale placeholder. It moves only when local is behind the remote or is an
404/// empty placeholder that diverged from it; unpushed local work is kept, and a real
405/// divergence is refused rather than guessed at.
406async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
407    let tracking = format!("{remote}/{branch}");
408    let fetched = git::fetch(repo, remote, branch).await;
409    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
410    let local_exists = git::branch_exists(repo, branch).await?;
411    if !fresh {
412        if !local_exists {
413            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
414        }
415        tracing::warn!(
416            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
417        );
418        return Ok(());
419    }
420    let remote_sha = git::rev_parse(repo, &tracking).await?;
421    if !local_exists {
422        git::git(repo, &["branch", branch, &tracking]).await?;
423        return Ok(());
424    }
425    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
426    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
427        return Ok(());
428    }
429    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
430        // Diverged. `reconcile` settles it only when it can prove nothing is
431        // lost: a local tip that is the remote's change rebased is pushed over
432        // it (lease pinned to the tip read here), a tip whose every extra
433        // commit is empty is a placeholder the remote's work replaced, and
434        // anything else is two different changes - a question for a person.
435        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
436            .await?
437        {
438            crate::reconcile::Reconciliation::Pushed => {
439                tracing::warn!(
440                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
441                    short(&local_sha),
442                    short(&remote_sha)
443                );
444                return Ok(());
445            }
446            crate::reconcile::Reconciliation::Placeholder => {}
447            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
448        }
449    }
450    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
451    if !out.ok() {
452        bail!(
453            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
454            short(&local_sha),
455            short(&remote_sha),
456            out.stderr
457        );
458    }
459    tracing::warn!(
460        "local `{branch}` was stale: fast-forwarded {} -> {}",
461        short(&local_sha),
462        short(&remote_sha)
463    );
464    Ok(())
465}
466
467async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
468    let tracking = format!("{remote}/{base_branch}");
469    let fetched = git::fetch(repo, remote, base_branch).await;
470    if let Ok(out) = &fetched
471        && out.ok()
472        && git::rev_exists(repo, &tracking).await
473    {
474        return git::rev_parse(repo, &tracking).await;
475    }
476    let why = match &fetched {
477        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
478        Ok(_) => format!("{remote} has no {base_branch}"),
479        Err(e) => e.to_string(),
480    };
481    // No fallback to the local branch: it may be behind, and branching off an
482    // old commit is the stale-checkout bug this check exists to prevent.
483    bail!(
484        "cannot read {tracking} ({why}); refusing to branch off the local \
485         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
486         base / remote in magi.toml"
487    )
488}
489
490/// Exclusive claim on one run's `magi fix` step, released on drop — including
491/// on an early return or a panic.
492///
493/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
494/// manual `magi fix` invocations against the same run are otherwise
495/// invisible to each other and would race to remove and recreate the same
496/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
497/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
498/// which is only ever reclaimed later, out of band, by
499/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
500/// `magi fix` invocation is not necessarily running under either of those, so
501/// nothing would ever sweep a lock a killed or crashed process left behind.
502/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
503/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
504/// lease use: an unreadable or unparsable pid, or a liveness query the
505/// platform cannot answer, reads as alive and the lock is left in place.
506struct FixClaim {
507    path: PathBuf,
508}
509
510impl FixClaim {
511    fn acquire(dir: &Path) -> Result<Self> {
512        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
513        let path = dir.join("fix.lock");
514        match Self::create(&path) {
515            Ok(claim) => Ok(claim),
516            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
517                if Self::reclaim_if_dead(&path) {
518                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
519                } else {
520                    bail!(
521                        "another `magi fix` is already running for this run ({} exists)",
522                        path.display()
523                    )
524                }
525            }
526            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
527        }
528    }
529
530    fn create(path: &Path) -> std::io::Result<Self> {
531        let mut f = std::fs::OpenOptions::new()
532            .write(true)
533            .create_new(true)
534            .open(path)?;
535        use std::io::Write as _;
536        // Read back by `reclaim_if_dead` on a later, stuck invocation.
537        writeln!(f, "{}", std::process::id())?;
538        Ok(Self {
539            path: path.to_owned(),
540        })
541    }
542
543    /// True if the lock named a process confirmed dead, in which case it was
544    /// also removed. Never true on an unreadable file, an unparsable pid, or
545    /// a liveness query the platform cannot answer — see this type's own doc.
546    fn reclaim_if_dead(path: &Path) -> bool {
547        let dead = std::fs::read_to_string(path)
548            .ok()
549            .and_then(|body| body.trim().parse::<u32>().ok())
550            .is_some_and(|pid| !crate::proc::pid_alive(pid));
551        dead && std::fs::remove_file(path).is_ok()
552    }
553}
554
555impl Drop for FixClaim {
556    fn drop(&mut self) {
557        let _ = std::fs::remove_file(&self.path);
558    }
559}
560
561impl Runner {
562    /// Start a fresh run against `repo`.
563    pub async fn start(
564        repo: &Path,
565        instruction: String,
566        config: Config,
567        origin: Origin,
568    ) -> Result<Self> {
569        Self::start_naming(repo, instruction, "", config, origin).await
570    }
571
572    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
573    /// searched for branch and commit references along with the instruction,
574    /// since a task may name the work it is about only in its title.
575    pub async fn start_naming(
576        repo: &Path,
577        instruction: String,
578        also_scan: &str,
579        config: Config,
580        origin: Origin,
581    ) -> Result<Self> {
582        let repo = git::toplevel(repo).await?;
583        let missing = agent::missing_programs(&config.agents);
584        if !missing.is_empty() {
585            bail!(
586                "these agent programs are not on PATH: {}. Fix the roster in \
587                 magi.toml or install them.",
588                missing.join(", ")
589            );
590        }
591        let base_branch =
592            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
593                .await?;
594        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
595        let roles = config.resolve_roles()?;
596        let max_parallel = config.graph.max_parallel.max(1);
597        // A task that points at work already in the repository starts from
598        // it; what the repository says about each reference is recorded.
599        let seeds = refs::resolve(
600            &repo,
601            &base_commit,
602            &config.merge.remote,
603            &format!("{also_scan}\n{instruction}"),
604        )
605        .await;
606        refs::plan(&repo, &seeds).await?;
607        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
608        // Recorded before the first save, so a crash right after minting
609        // cannot leave a run with no origin. Legibility only: nothing reads it
610        // to decide anything.
611        state.origin = Some(origin);
612        for seed in &seeds {
613            state.event(
614                "seed",
615                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
616            );
617        }
618        state.seeds = seeds;
619        state.event("start", format!("run {} created", state.id));
620        state.save()?;
621        Ok(Self {
622            state,
623            roles,
624            sem: Arc::new(Semaphore::new(max_parallel)),
625            pause: Pause::new(),
626            interrupt: Pause::new(),
627        })
628    }
629
630    /// Open a review-only run against work that already exists on `branch`.
631    ///
632    /// The expensive half of the graph is the implement wave — measured at
633    /// 111 and 134 internal tool-loop turns on this repository, against a
634    /// handful for a judge or a reviewer. The cheap half is worth running on
635    /// hand-written work too, and there was no way to reach it.
636    ///
637    /// No new state and no schema change are needed: a run with **one** viable
638    /// candidate and a tally already decided degrades `execute` to exactly
639    /// review → gate → merge, because `judge` skips a single-candidate field,
640    /// `deliberate` has fewer than two first choices to reconcile, `vote`
641    /// returns early, `tally` is already present and `fold_losers` has no
642    /// losers. Resuming such a run therefore does the right thing as well.
643    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
644        Self::review_taking_over(repo, branch, config, None, origin).await
645    }
646
647    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
648    /// at the same task still has `branch` checked out, its worktree is
649    /// released first if that is safe (see [`crate::handover`]), and the
650    /// review refuses with the reason if it is not. `None` is a hand-run
651    /// review: it has no earlier attempts, so only a worktree of a dead run
652    /// magi recorded itself can be released.
653    pub async fn review_taking_over(
654        repo: &Path,
655        branch: &str,
656        config: Config,
657        takeover: Option<crate::handover::Takeover>,
658        origin: Origin,
659    ) -> Result<Self> {
660        let repo = git::toplevel(repo).await?;
661        let missing = agent::missing_programs(&config.agents);
662        if !missing.is_empty() {
663            bail!(
664                "these agent programs are not on PATH: {}. Fix the roster in \
665                 magi.toml or install them.",
666                missing.join(", ")
667            );
668        }
669        let base_branch =
670            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
671                .await?;
672        if base_branch == branch {
673            bail!("`{branch}` is the base branch; there is nothing to review against");
674        }
675        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
676
677        let roles = config.resolve_roles()?;
678        let max_parallel = config.graph.max_parallel.max(1);
679        let mut state = RunState::new(
680            repo.clone(),
681            base_branch,
682            base_commit.clone(),
683            String::new(),
684            config,
685        );
686        state.origin = Some(origin);
687
688        // Released before anything else touches the branch: a stale local
689        // branch is moved with `git branch -f`, which git refuses while an
690        // earlier attempt's worktree still has it checked out. Everything
691        // after this point that can fail puts the old run back.
692        // A hand-run review has no task, hence no earlier attempts, but a
693        // worktree of a dead run magi made may still be released.
694        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
695            earlier: Vec::new(),
696            home: crate::run::home(),
697            choice: None,
698        });
699        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
700        if let Some(released) = &released {
701            state.event(
702                "release",
703                format!(
704                    "took `{branch}` over from run {}: its worktree was released: {}",
705                    crate::run::short_of(&released.old_id),
706                    released.audit
707                ),
708            );
709        }
710        // The owner's answer to an earlier divergence question is applied
711        // here: after the release (git will not move a checked-out branch)
712        // and before the sync that would otherwise ask again.
713        if let Some(choice) = takeover.choice.as_ref()
714            && let Err(e) =
715                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
716                    .await
717        {
718            if let Some(released) = &released {
719                released.restore(&repo, branch).await;
720            }
721            return Err(e.context("applying the owner's answer about the diverged branch"));
722        }
723        let opened =
724            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
725        if opened.is_err()
726            && let Some(released) = &released
727        {
728            released.restore(&repo, branch).await;
729        }
730        opened
731    }
732
733    /// The half of [`Runner::review_taking_over`] that can fail after an
734    /// earlier attempt's worktree was released.
735    async fn open_review(
736        repo: &Path,
737        branch: &str,
738        mut state: RunState,
739        roles: ResolvedRoles,
740        max_parallel: usize,
741        base_commit: String,
742    ) -> Result<Self> {
743        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
744        // The commit subjects are the closest thing to a task statement that
745        // existing work carries, and the reviewers are told as much.
746        let start = review_base(
747            repo,
748            &state.config.merge.remote,
749            &state.base_branch,
750            &base_commit,
751            branch,
752        )
753        .await;
754        let log = git::log_oneline(repo, &start, branch)
755            .await
756            .unwrap_or_default();
757        let instruction = format!(
758            "Review the work already on branch `{branch}`. There is no task \
759             statement: what the change claims to do is whatever its commits \
760             say.\n\n{}",
761            if log.trim().is_empty() {
762                "(no commit messages)"
763            } else {
764                log.trim()
765            }
766        );
767        state.instruction = instruction;
768        state.reviewed_commits = Some(
769            git::subjects(repo, &start, branch)
770                .await
771                .unwrap_or_default(),
772        );
773
774        // An attached worktree, so the fixer's commits land on the branch under
775        // review rather than on a detached head nobody will look at again.
776        let worktree = state.worktree_root().join("under-review");
777        if let Some(parent) = worktree.parent() {
778            tokio::fs::create_dir_all(parent).await.ok();
779        }
780        let path = worktree.to_string_lossy().to_string();
781        git::git(repo, &["worktree", "add", &path, branch])
782            .await
783            .with_context(|| {
784                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
785            })?;
786
787        let setup = match ensure_setup_config(&mut state, repo).await {
788            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
789            Err(e) => Err(e),
790        };
791        if let Err(e) = setup {
792            git::worktree_remove(repo, &worktree).await.ok();
793            return Err(e);
794        }
795        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
796            .await
797            .unwrap_or(0);
798        if commits == 0 {
799            git::worktree_remove(repo, &worktree).await.ok();
800            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
801        }
802        let files = git::changed_files(&worktree, &base_commit, "HEAD")
803            .await
804            .map(|f| f.len())
805            .unwrap_or(0);
806        if files == 0
807            && let (Ok(head_tree), Ok(base_tree)) = (
808                git::tree_of(&worktree, "HEAD").await,
809                git::tree_of(&worktree, &base_commit).await,
810            )
811            && head_tree == base_tree
812        {
813            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
814            git::worktree_remove(repo, &worktree).await.ok();
815            bail!(
816                "`{branch}` at {} has a tree identical to base {}; this usually means \
817                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
818                 against `{}/{branch}`) rather than an empty change",
819                short(&head),
820                short(&base_commit),
821                state.config.merge.remote
822            );
823        }
824        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
825            .await
826            .unwrap_or_default();
827
828        state.candidates.push(Candidate {
829            index: 0,
830            label: 'A',
831            // Not an agent id on purpose: nothing in the roster wrote this, and
832            // the stats tables must not credit anyone with a win for it.
833            agent: EXISTING_BRANCH.to_owned(),
834            branch: branch.to_owned(),
835            worktree,
836            summary: String::new(),
837            stat,
838            files,
839            commits,
840            empty: false,
841            failed: None,
842            verified_noop: None,
843            duration_ms: 0,
844            folded: false,
845        });
846        state.tally = Some(Tally {
847            first_choice: BTreeMap::from([('A', 0)]),
848            borda: BTreeMap::new(),
849            winner: 'A',
850            rankings: 0,
851            unanimous_initial: false,
852            deliberated: false,
853            changed_votes: 0,
854            unanimous_final: false,
855            tie_break: None,
856            // No panel sat, so no quorum applies. Zero judges is the correct
857            // number for work that never competed, and must not be reported as
858            // a collapsed panel.
859            judges: 0,
860            present: 0,
861            quorum: 0,
862            met_quorum: true,
863            uncontested: Some("review-only run: nothing competed".to_owned()),
864        });
865        state.status = RunStatus::Reviewing;
866        state.event(
867            "start",
868            format!(
869                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
870                state.id
871            ),
872        );
873        state.save()?;
874        Ok(Self {
875            state,
876            roles,
877            sem: Arc::new(Semaphore::new(max_parallel)),
878            pause: Pause::new(),
879            interrupt: Pause::new(),
880        })
881    }
882
883    /// Reopen an existing run.
884    pub fn resume(id: &str) -> Result<Self> {
885        let state = RunState::load(id)?;
886        if let Some(to) = &state.released_to {
887            bail!(
888                "run {} cannot be resumed: its worktree was released to run {}",
889                state.short(),
890                crate::run::short_of(to)
891            );
892        }
893        let roles = state.config.resolve_roles()?;
894        let max_parallel = state.config.graph.max_parallel.max(1);
895        Ok(Self {
896            state,
897            roles,
898            sem: Arc::new(Semaphore::new(max_parallel)),
899            pause: Pause::new(),
900            interrupt: Pause::new(),
901        })
902    }
903
904    /// Walk the graph to a terminal state, skipping nodes already recorded.
905    ///
906    /// Every way a run is driven - the queue loop, `magi run`, a resume from
907    /// the phone - ends here, so this is the one place a run that ended
908    /// Blocked / Stalled / Failed, or died with an error, is announced to the
909    /// notification centre. Best-effort: see [`crate::notices::raise`].
910    pub async fn execute(&mut self) -> Result<()> {
911        let result = self.execute_graph().await;
912        self.mark_driver_exited();
913        let ended = if result.is_err() {
914            Some(crate::notices::run_stopped(&self.state.id, &self.state))
915        } else {
916            crate::notices::run_ended(&self.state)
917        };
918        if let Some(notice) = ended {
919            crate::notices::raise(notice);
920        }
921        result
922    }
923
924    /// Record that this process no longer drives the run, so its pid (a
925    /// daemon's outlives the run) is not read as a live driver.
926    ///
927    /// Written onto the record as it is on disk, never this copy: another
928    /// process may have resumed the run (recording its own pid and clearing
929    /// the flag) or released its worktree since this copy was read, and
930    /// saving over that would mark a running driver dead. Only a record still
931    /// naming this process as the driver is touched.
932    fn mark_driver_exited(&mut self) {
933        self.state.driver_exited = true;
934        let pid = std::process::id();
935        let Ok(mut disk) = RunState::load(&self.state.id) else {
936            return;
937        };
938        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
939            return;
940        }
941        disk.driver_exited = true;
942        if let Err(e) = disk.save() {
943            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
944        }
945    }
946
947    async fn execute_graph(&mut self) -> Result<()> {
948        // Moving again, so it is no longer parked. Set before the walk rather
949        // than in `resume`, so every way of re-entering the graph clears it
950        // and a card cannot claim a run is waiting to be resumed while the
951        // agents are already working.
952        self.state.parked = false;
953        // Any seat this state still lists as answering belongs to whatever
954        // process last drove this run — this one included, if it crashed
955        // mid-wave. Cleared and flushed immediately, before anything else
956        // runs, so a resume can never show a seat as live when nothing is
957        // asking it anything yet; the node that actually dispatches the next
958        // wave repopulates it.
959        self.state.clear_active();
960        // Recorded in the same spot, and flushed together with the clear
961        // above: this is the pid a reader checks (`RunState::liveness`) when
962        // no daemon claim exists to answer "is a process still driving this
963        // run" — a plain `magi run` / `magi review` typed into a terminal
964        // claims nothing there. Always overwritten, never only-if-absent, so
965        // a resumed run's stale pid from a previous, possibly-dead process
966        // can never survive into this one's own report. Unlike
967        // `clear_active`, this changes on every single `execute()` call, so
968        // the save below is now unconditional rather than only-if-cleared.
969        //
970        // `driver_started_at` is recorded in the same breath, from this same
971        // pid, so `liveness` can tell a live pid that is genuinely still us
972        // apart from one the OS has since handed to an unrelated process —
973        // see that field's own doc for why the pid alone is not enough.
974        // A resume that raced a takeover: the record on disk says the worktree
975        // was handed to a later run after this copy was read. Saving over it
976        // would erase that and drive a run with nothing to run in.
977        if let Ok(disk) = RunState::load(&self.state.id)
978            && let Some(to) = &disk.released_to
979        {
980            bail!(
981                "run {} cannot continue: its worktree was released to run {}",
982                self.state.short(),
983                crate::run::short_of(to)
984            );
985        }
986        let pid = std::process::id();
987        self.state.driver_pid = Some(pid);
988        self.state.driver_started_at = crate::proc::process_started_at(pid);
989        self.state.driver_exited = false;
990        self.state.save()?;
991        // A run that already lost its quorum never resumes into the verdict
992        // machinery: `deliberate` and `vote` would otherwise clobber the
993        // stalled marker back to Voting and the run would keep going past a
994        // verdict that is no longer trustworthy. Everything already recorded is
995        // kept, so the run stays resumable (or foldable) for a human to pick up.
996        //
997        // On --resume the run gets one chance to repair itself: the seats a
998        // rate limit took out are re-asked. If their quota has since reset and
999        // the quorum is restored, the run picks up and finishes; otherwise it
1000        // stays stale and still-resumable for a later retry. If it does not
1001        // recover, the returned status stays `Stalled` and nothing was
1002        // clobbered (the recovery only mutates entries for the lost seats).
1003        if self.state.status == RunStatus::Stalled {
1004            if self.recover_stall().await? {
1005                self.finish_after_tally().await?;
1006            } else {
1007                // Still below quorum: persist the marker and stay resumable.
1008                self.state.save()?;
1009            }
1010            return Ok(());
1011        }
1012        // A run parked inside `land` - watching CI, mid fix-round, or
1013        // waiting on the owner's merge approval - resumes directly into it,
1014        // never back through `prep`. Everything before `merge` already
1015        // concluded; that is the only way `status` reaches `Landing` in the
1016        // first place. Re-walking `review_loop` first would also be actively
1017        // wrong: its own status recomputation (see its doc) treats any
1018        // clean round as reason to set `status` to `Gating`, which would
1019        // clobber this marker before `merge` ever ran, and this run would
1020        // never find its way back into `land` at all.
1021        if self.state.status == RunStatus::Landing {
1022            self.run_land().await?;
1023            // `run_land` may have settled the run right here - CI came back
1024            // green and the PR merged, say - without ever passing back
1025            // through `merge`'s own trailing call. Whatever it left `status`
1026            // as is what this has to read.
1027            self.settle_questions();
1028            return Ok(());
1029        }
1030        // A run parked at `merge` on the owner's word about a withheld PR text
1031        // resumes straight into `merge`. Re-walking the tail would sync to the
1032        // base again (possibly rebasing, possibly spending the fixer) after the
1033        // gate had passed, and then push a tree nobody reviewed or gated.
1034        if self.state.github_text.is_some()
1035            && self.state.merge.is_none()
1036            && self.state.status == RunStatus::Gating
1037        {
1038            self.merge().await?;
1039            self.state.save()?;
1040            return Ok(());
1041        }
1042        self.prep().await?;
1043        if self.park_here()? {
1044            return Ok(());
1045        }
1046        self.advise().await?;
1047        if self.park_here()? {
1048            return Ok(());
1049        }
1050        self.implement().await?;
1051        if self.park_here()? {
1052            return Ok(());
1053        }
1054        // `after_implement` already saved the state and settled any open
1055        // questions when it set this; nothing later in the graph has
1056        // anything to judge.
1057        if self.state.status == RunStatus::VerifiedNoop {
1058            return Ok(());
1059        }
1060        self.judge().await?;
1061        if self.park_here()? {
1062            return Ok(());
1063        }
1064        self.deliberate().await?;
1065        if self.park_here()? {
1066            return Ok(());
1067        }
1068        self.vote().await?;
1069        if self.park_here()? {
1070            return Ok(());
1071        }
1072        self.tally()?;
1073        // A verdict that lost its quorum is not trustworthy: do not review,
1074        // gate, or merge on it. Everything already done is kept, so the run
1075        // stays resumable (or foldable); the human can replace the agent that
1076        // ran out of quota and pick it up.
1077        if self.state.status == RunStatus::Stalled {
1078            // Persist the stalled marker now — the normal end-of-execute save
1079            // below is below this early return, and without it a resumed run
1080            // would reload a pre-tally status and keep going.
1081            self.state.save()?;
1082            return Ok(());
1083        }
1084        self.finish_after_tally().await?;
1085        Ok(())
1086    }
1087
1088    /// Park here if asked to, recording it in the run's own timeline.
1089    ///
1090    /// Returns whether the caller should stop walking the graph. The state is
1091    /// saved either way by the node that just finished; this adds the event so
1092    /// the operator's card says why a run that is neither finished nor moving
1093    /// is sitting where it is.
1094    fn park_here(&mut self) -> Result<bool> {
1095        // Either handle asking is enough - see `Pause`'s own doc for why
1096        // they are never the same one. `interrupt` is checked second so a
1097        // reason it carries is preferred in the message below over a plain
1098        // shutdown park racing it at the same boundary.
1099        if !self.pause.parked() && !self.interrupt.parked() {
1100            return Ok(false);
1101        }
1102        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1103            Some(reason) => format!(
1104                "parked after `{}` ({reason}) — resume to carry on from here",
1105                self.state.status.as_str()
1106            ),
1107            None => format!(
1108                "parked after `{}` — resume to carry on from here",
1109                self.state.status.as_str()
1110            ),
1111        };
1112        self.state.event("park", why);
1113        self.state.parked = true;
1114        self.state.save()?;
1115        Ok(true)
1116    }
1117
1118    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1119    pub fn on_pause(&mut self, pause: Pause) {
1120        self.pause = pause;
1121    }
1122
1123    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1124    /// scheduler asking this one run - and no other - to park so a task
1125    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1126    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1127    /// handle.
1128    pub fn watch_interrupt(&mut self, pause: Pause) {
1129        self.interrupt = pause;
1130    }
1131
1132    /// Abandon this run's own open questions, once `status` has actually
1133    /// settled rather than merely paused.
1134    ///
1135    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1136    /// either back up with the candidates, the review round and the seat
1137    /// sessions already on disk, so a question an implementer asked mid-round
1138    /// may still get a real answer read by a real resume. Only the statuses
1139    /// `resumable` excludes are actually final: the run merged, it reached
1140    /// `Ready` with nothing left to do, it failed outright with no
1141    /// established point to continue from, or every candidate agreed, with
1142    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1143    /// every one of those the seat that asked is gone for good, exactly like
1144    /// the run being deleted under `magi run rm` - so the same cleanup
1145    /// applies, worded for what actually happened instead of "the run was
1146    /// deleted".
1147    ///
1148    /// Best-effort and silent on success: called from every place `status`
1149    /// can land on one of those three, including ones a resumed run revisits,
1150    /// so it must cost nothing when there was nothing open to begin with.
1151    fn settle_questions(&mut self) {
1152        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1153            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1154        }
1155    }
1156
1157    /// The tail of the graph after a trustworthy tally: fold losers, review,
1158    /// gate, merge, and persist.
1159    async fn finish_after_tally(&mut self) -> Result<()> {
1160        // Before the losers are folded: their branches are what the graft
1161        // reads from.
1162        self.graft().await?;
1163        self.fold_losers().await?;
1164        // Before review starts, and again right before the gate: a run's
1165        // review rounds can themselves take long enough for the base to move
1166        // a second time, and the gate is the one node whose "green" gets
1167        // acted on.
1168        self.sync_to_base().await?;
1169        if self.state.status == RunStatus::AlreadyInBase {
1170            return Ok(());
1171        }
1172        self.review_loop().await?;
1173        self.sync_to_base().await?;
1174        if self.state.status == RunStatus::AlreadyInBase {
1175            return Ok(());
1176        }
1177        self.gate().await?;
1178        self.merge().await?;
1179        self.state.save()?;
1180        Ok(())
1181    }
1182
1183    // ---------------------------------------------------------------- prep
1184
1185    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1186    /// run with the step and its output in the event; it is never swallowed.
1187    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1188    /// resume starts over instead of finding candidates and missing seats.
1189    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1190        let result = match ensure_setup_config(&mut self.state, repo).await {
1191            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1192            Err(e) => Err(e),
1193        };
1194        if let Err(e) = result {
1195            git::worktree_remove(repo, wt).await.ok();
1196            if in_prep {
1197                self.rollback_prep(repo).await;
1198            }
1199            self.state.status = RunStatus::Blocked;
1200            self.state.event(
1201                "setup",
1202                format!("worktree setup failed in {}: {e:#}", wt.display()),
1203            );
1204            self.state.save()?;
1205            return Err(e);
1206        }
1207        Ok(())
1208    }
1209
1210    /// Undo everything `prep` created, so a resume re-runs it whole.
1211    async fn rollback_prep(&mut self, repo: &Path) {
1212        let root = self.state.worktree_root();
1213        for c in std::mem::take(&mut self.state.candidates) {
1214            git::worktree_remove(repo, &c.worktree).await.ok();
1215            git::branch_delete(repo, &c.branch).await.ok();
1216        }
1217        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1218        for j in 1..=seats {
1219            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1220                .await
1221                .ok();
1222        }
1223        for k in 1..=self.state.config.graph.advisors {
1224            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1225                .await
1226                .ok();
1227        }
1228    }
1229
1230    async fn prep(&mut self) -> Result<()> {
1231        if !self.state.candidates.is_empty() {
1232            return Ok(());
1233        }
1234        self.state.status = RunStatus::Prep;
1235        let repo = self.state.repo.clone();
1236        let base = self.state.base_commit.clone();
1237        let plan = refs::plan(&repo, &self.state.seeds).await?;
1238        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1239        let root = self.state.worktree_root();
1240        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1241
1242        // The hook is the write-time half of the blindness contract; the
1243        // presentation filter in `blind` is the half that cannot be bypassed.
1244        let hooks_dir = self.state.dir().join("hooks");
1245        if self.state.config.blind.commit_msg_hook {
1246            std::fs::create_dir_all(&hooks_dir)
1247                .with_context(|| format!("create {}", hooks_dir.display()))?;
1248            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1249            let path = hooks_dir.join("commit-msg");
1250            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1251            make_executable(&path)?;
1252            // Ref-counted rather than a plain idempotent set: with more than
1253            // one run able to be in flight in the same repository at once
1254            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1255            // true?" check cannot tell "another run of mine still needs
1256            // this" from "nobody does", and the run that happens to finish
1257            // first would disable the hook out from under a sibling still
1258            // relying on it.
1259            git::acquire_worktree_config(&repo).await?;
1260            self.state.enabled_worktree_config = true;
1261        }
1262
1263        // `[worktree] setup` hides its products per worktree, which needs the
1264        // same per-worktree config the hook does. Held until fold, like it.
1265        ensure_setup_config(&mut self.state, &repo).await?;
1266
1267        for (index, (spec, label)) in self
1268            .roles
1269            .implementers
1270            .clone()
1271            .into_iter()
1272            .zip(labels)
1273            .enumerate()
1274        {
1275            let branch = self.state.branch_for(label);
1276            let worktree = root.join(format!("cand-{label}"));
1277            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1278            if self.state.config.blind.commit_msg_hook {
1279                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1280            }
1281            git::local_exclude(&worktree, "/.magi/").await?;
1282            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1283                // Nothing of this prep may survive: a resume must start over,
1284                // not find half the candidates and skip the rest.
1285                git::worktree_remove(&repo, &worktree).await.ok();
1286                git::branch_delete(&repo, &branch).await.ok();
1287                self.rollback_prep(&repo).await;
1288                self.state.status = RunStatus::Blocked;
1289                self.state
1290                    .event("prep", format!("worktree setup failed: {e:#}"));
1291                self.state.save()?;
1292                return Err(e);
1293            }
1294            for pick in &plan.picks {
1295                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1296                    self.state.status = RunStatus::Blocked;
1297                    self.state
1298                        .event("prep", format!("cannot apply referenced commit: {e}"));
1299                    self.state.save()?;
1300                    return Err(e);
1301                }
1302            }
1303            self.state.candidates.push(Candidate {
1304                index,
1305                label,
1306                agent: spec.id.clone(),
1307                branch,
1308                worktree,
1309                summary: String::new(),
1310                stat: String::new(),
1311                files: 0,
1312                commits: 0,
1313                empty: false,
1314                failed: None,
1315                verified_noop: None,
1316                duration_ms: 0,
1317                folded: false,
1318            });
1319        }
1320
1321        for j in 1..=self.roles.judges.len() {
1322            let wt = root.join(format!("judge-{j}"));
1323            if !wt.exists() {
1324                git::worktree_add_detached(&repo, &wt, &base).await?;
1325                self.setup_seat_worktree(&repo, &wt, true).await?;
1326            }
1327        }
1328
1329        // Disposable, detached checkouts for the design-deliberation stage's
1330        // advisor seats — the same shape as the judges' above, at the same
1331        // base commit, since advisors also only ever read. Sized off the
1332        // configured count directly rather than a resolved roster: unlike
1333        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1334        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1335        // `prep` has no `ResolvedRoles` field to read a count from here.
1336        if self.state.config.graph.advise {
1337            for k in 1..=self.state.config.graph.advisors {
1338                let wt = root.join(format!("advisor-{k}"));
1339                if !wt.exists() {
1340                    git::worktree_add_detached(&repo, &wt, &base).await?;
1341                    self.setup_seat_worktree(&repo, &wt, true).await?;
1342                }
1343            }
1344        }
1345
1346        // A judge cannot tell it is looking at its own patch — the seats keep
1347        // separate conversations — but a panel that shares agents with the
1348        // field is less independent than it looks, and that is worth saying out
1349        // loud once per run rather than leaving it in the config.
1350        let authors: Vec<&str> = self
1351            .roles
1352            .implementers
1353            .iter()
1354            .map(|a| a.id.as_str())
1355            .collect();
1356        let overlap: Vec<String> = self
1357            .roles
1358            .judges
1359            .iter()
1360            .enumerate()
1361            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1362            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1363            .collect();
1364        if !overlap.is_empty() {
1365            let note = format!(
1366                "{} also authored a candidate; blind, but the panel is less \
1367                 independent than {} distinct agents would be",
1368                overlap.join(", "),
1369                self.roles.judges.len()
1370            );
1371            self.state.event("prep", note);
1372        }
1373
1374        self.state.event(
1375            "prep",
1376            format!(
1377                "{} candidates, {} judges, base {} ({})",
1378                self.state.candidates.len(),
1379                self.roles.judges.len(),
1380                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1381                self.state.base_branch
1382            ),
1383        );
1384        self.state.status = RunStatus::Implementing;
1385        self.state.save()?;
1386        Ok(())
1387    }
1388
1389    // -------------------------------------------------------------- advise
1390
1391    /// The design-deliberation stage: independent, read-only advisor seats
1392    /// each sketch a design before any implementer touches the repository,
1393    /// and (when at least one produced a usable proposal) a synthesis seat
1394    /// blends them into a brief `implement` carries in every candidate's
1395    /// prompt.
1396    ///
1397    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1398    /// advisors` is the proposal count. Everything here is best-effort and
1399    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1400    /// that cannot reach quota, or a synthesis seat that produced nothing
1401    /// usable all leave `implement` exactly as it was before this stage
1402    /// existed — the task instruction alone — rather than failing the whole
1403    /// competition over an enrichment stage. Every outcome is still recorded
1404    /// as an event, so a run that got nothing from this stage says why.
1405    ///
1406    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1407    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1408    /// resumed run whose stage failed would re-run it, and re-spend the
1409    /// agent calls, on every reentry before `implement`.
1410    ///
1411    /// Also skipped once any candidate shows implementation progress — the
1412    /// exact predicate `implement` itself uses to decide a candidate is no
1413    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1414    /// is not enough: a run created by an older binary that predates this
1415    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1416    /// an already-`Implementing`-or-later run under this build would
1417    /// otherwise walk straight back through `prep` (a no-op once candidates
1418    /// exist) into this node and spawn every advisor seat against worktrees
1419    /// `prep` never recreated — after implementation has already started,
1420    /// which is exactly the invariant this stage exists to guarantee.
1421    async fn advise(&mut self) -> Result<()> {
1422        let implement_untouched = self
1423            .state
1424            .candidates
1425            .iter()
1426            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1427        if !self.state.config.graph.advise || self.state.advise_attempted {
1428            return Ok(());
1429        }
1430        if !implement_untouched {
1431            self.state.event(
1432                "advise",
1433                "skipping the design-deliberation stage: at least one \
1434                 candidate already shows implementation progress, so this \
1435                 run is past the point the stage exists to run before"
1436                    .to_owned(),
1437            );
1438            self.state.advise_attempted = true;
1439            self.state.save()?;
1440            return Ok(());
1441        }
1442        let run_id = self.state.id.clone();
1443        let prompts = self.state.config.prompts.clone();
1444        let instruction = self.state.instruction.clone();
1445        let language = self.state.config.graph.language.clone();
1446        let root = self.state.worktree_root();
1447        let n = self.state.config.graph.advisors;
1448        let where_recorded = self.state.dir().join("run.json");
1449
1450        let seats = match self.state.config.advisors() {
1451            Ok(seats) if !seats.is_empty() => seats,
1452            Ok(_) => {
1453                self.state.event(
1454                    "advise",
1455                    format!(
1456                        "[graph] advisors is 0; skipping the design-deliberation \
1457                         stage and continuing without a synthesis brief (see {})",
1458                        where_recorded.display()
1459                    ),
1460                );
1461                self.state.advise_attempted = true;
1462                self.state.save()?;
1463                return Ok(());
1464            }
1465            Err(e) => {
1466                self.state.event(
1467                    "advise",
1468                    format!(
1469                        "could not resolve advisor seats ({e:#}); continuing \
1470                         without a design-deliberation brief (see {})",
1471                        where_recorded.display()
1472                    ),
1473                );
1474                self.state.advise_attempted = true;
1475                self.state.save()?;
1476                return Ok(());
1477            }
1478        };
1479
1480        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1481        let artifacts = agent::artifacts_dir(&self.state.dir());
1482        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1483
1484        let mut jobs = Vec::new();
1485        for (i, spec) in seats.iter().cloned().enumerate() {
1486            let seat_key = format!("advisor-{}", i + 1);
1487            let seat = self.seat(&seat_key, &spec.id);
1488            jobs.push(SeatJob {
1489                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1490                spec,
1491                seat,
1492                cwd: worktrees[i % worktrees.len()].clone(),
1493                timeout,
1494                allow_write: false,
1495                sessions: false,
1496                artifacts: artifacts.clone(),
1497                stem: seat_key,
1498                handover: None,
1499            });
1500        }
1501
1502        self.state.event(
1503            "advise",
1504            format!(
1505                "{} advisor seat(s) sketching a design in parallel",
1506                jobs.len()
1507            ),
1508        );
1509        let mut quota_losses = Vec::new();
1510        let cache = self.state.config.cache_dir();
1511        let ctx = WaveCtx {
1512            carry_seats: false,
1513            run: &run_id,
1514            node: "advise",
1515            prompts: &prompts,
1516            cache: cache.as_deref(),
1517            round: None,
1518        };
1519        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1520        let results = ask_json_wave::<Proposal>(
1521            jobs,
1522            Arc::clone(&self.sem),
1523            self.state.config.graph.retries,
1524            &advisor_roster,
1525            &ctx,
1526            &mut quota_losses,
1527            &mut self.state,
1528            &|p: &Proposal| p.validate(),
1529        )
1530        .await;
1531        self.state.quota.extend(quota_losses);
1532
1533        let mut records = Vec::with_capacity(results.len());
1534        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1535            let agent_id = seat.agent.clone();
1536            self.state.seats.insert(seat.key.clone(), seat);
1537            match res {
1538                Ok((proposal, out)) => {
1539                    self.state
1540                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1541                    records.push(advise::AdvisorRecord::proposed(
1542                        i + 1,
1543                        agent_id,
1544                        proposal,
1545                        out.duration_ms,
1546                    ));
1547                }
1548                Err(e) => {
1549                    self.state.event(
1550                        "advise",
1551                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1552                    );
1553                    records.push(advise::AdvisorRecord::failed(
1554                        i + 1,
1555                        agent_id,
1556                        e.to_string(),
1557                    ));
1558                }
1559            }
1560        }
1561
1562        let mut advice = advise::Advice {
1563            records,
1564            synthesis: None,
1565        };
1566        if advice.proposals().is_empty() {
1567            self.state.event(
1568                "advise",
1569                "no advisor produced a usable proposal; continuing without a \
1570                 synthesis brief"
1571                    .to_owned(),
1572            );
1573        } else {
1574            match self
1575                .synthesize_brief(
1576                    &advice,
1577                    &instruction,
1578                    &language,
1579                    &worktrees[0],
1580                    &artifacts,
1581                    &run_id,
1582                    &prompts,
1583                    cache.as_deref(),
1584                )
1585                .await
1586            {
1587                Ok(Some(text)) => {
1588                    self.state.event(
1589                        "advise",
1590                        "synthesized a design brief for the implementer".to_owned(),
1591                    );
1592                    advice.synthesis = Some(text);
1593                }
1594                Ok(None) => {
1595                    self.state.event(
1596                        "advise",
1597                        "the synthesis seat produced nothing usable; continuing \
1598                         without a design brief"
1599                            .to_owned(),
1600                    );
1601                }
1602                Err(e) => {
1603                    self.state.event(
1604                        "advise",
1605                        format!("could not synthesize a design brief: {e:#}"),
1606                    );
1607                }
1608            }
1609        }
1610        advise::apply_reflection(&mut advice);
1611
1612        self.state.advice = Some(advice);
1613        self.state.advise_attempted = true;
1614        self.state.save()?;
1615        Ok(())
1616    }
1617
1618    /// The synthesis seat: reads every advisor's proposal and blends them
1619    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1620    /// out of [`Runner::advise`] only for readability — it is not called
1621    /// anywhere else.
1622    ///
1623    /// Picked the same way [`crate::talk`]'s standing conversation and
1624    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1625    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1626    /// order (a claude seat, else the first runnable agent in roster order)
1627    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1628    /// in [`crate::config`] for why a dedicated field exists here at all.
1629    #[allow(clippy::too_many_arguments)]
1630    async fn synthesize_brief(
1631        &mut self,
1632        advice: &advise::Advice,
1633        instruction: &str,
1634        language: &str,
1635        cwd: &Path,
1636        artifacts: &Path,
1637        run_id: &str,
1638        prompts: &Prompts,
1639        cache: Option<&Path>,
1640    ) -> Result<Option<String>> {
1641        let chain = agent::pick_chain(
1642            &self.state.config.agents,
1643            self.state.config.roles.synthesizer.as_ref(),
1644            &agent::installed,
1645            "synthesizer",
1646        )?;
1647        let proposals = advice.proposals();
1648        let mut prompt = prompt::with_overlay(
1649            prompt::synthesize_brief(instruction, &proposals, language),
1650            prompts.overlay("advise"),
1651        );
1652        if cache.is_some() {
1653            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1654            // below — see `prompt::build_cache_note`'s doc for why telling a
1655            // read-only seat to build through the shared cache is exactly how
1656            // a sandbox's write refusal gets misread as a defect.
1657            prompt.push('\n');
1658            prompt.push_str(&prompt::build_cache_note("advise", false));
1659        }
1660        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1661        // Each id is tried once, in order; a quota hit, error or unusable
1662        // answer moves to the next. The seat is single-turn (`sessions:
1663        // false`) and the prompt is the whole context, so a fallback agent
1664        // needs nothing carried over.
1665        let mut last = None;
1666        for (n, spec) in chain.iter().enumerate() {
1667            if n > 0 {
1668                self.state
1669                    .event("advise", format!("synthesis falling back to {}", spec.id));
1670            }
1671            let mut seat = self.seat("advise-synthesis", &spec.id);
1672            let outcome = agent::invoke(
1673                spec,
1674                &mut seat,
1675                &Invocation {
1676                    cwd,
1677                    prompt: &prompt,
1678                    timeout,
1679                    allow_write: false,
1680                    unsandboxed: false,
1681                    sessions: false,
1682                    artifacts,
1683                    stem: &if n == 0 {
1684                        "advise-synthesis".to_owned()
1685                    } else {
1686                        format!("advise-synthesis-{}", spec.id)
1687                    },
1688                    run: run_id,
1689                    node: "advise",
1690                    cache_dir: None,
1691                    attachments: &[],
1692                    writable: &[],
1693                },
1694            )
1695            .await;
1696            if outcome.is_ok() {
1697                self.state.seats.insert(seat.key.clone(), seat);
1698            }
1699            let advance = agent::chain_advances(&outcome);
1700            last = Some(outcome);
1701            if !advance {
1702                break;
1703            }
1704        }
1705        // Exhausted: the last attempt's result is what a single failed seat
1706        // would have produced.
1707        let out = last.expect("a chain holds at least one agent")?;
1708        if !out.usable() {
1709            return Ok(None);
1710        }
1711        let text =
1712            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1713        Ok((!text.trim().is_empty()).then_some(text))
1714    }
1715
1716    // ----------------------------------------------------------- implement
1717
1718    async fn implement(&mut self) -> Result<()> {
1719        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1720        // agent files with `magi task add` name the run that paid for it. The
1721        // prompt overlay is cloned alongside it because the waves borrow it
1722        // while `self` is mutably borrowed by the node's own bookkeeping.
1723        let run_id = self.state.id.clone();
1724        let prompts = self.state.config.prompts.clone();
1725        let todo: Vec<usize> = self
1726            .state
1727            .candidates
1728            .iter()
1729            .enumerate()
1730            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1731            .map(|(i, _)| i)
1732            .collect();
1733        if todo.is_empty() {
1734            return self.after_implement();
1735        }
1736        self.state.status = RunStatus::Implementing;
1737
1738        let language = self.state.config.graph.language.clone();
1739        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1740        let sessions = self.state.config.graph.sessions;
1741        let artifacts = agent::artifacts_dir(&self.state.dir());
1742        // The design-deliberation stage's blended brief, when `advise` found
1743        // one — carried into every implementer's prompt the same way
1744        // regardless of which candidate it is.
1745        let brief = self
1746            .state
1747            .advice
1748            .as_ref()
1749            .and_then(|a| a.synthesis.as_deref())
1750            .map(str::to_owned);
1751        let attachments = self.state.attachments.clone();
1752
1753        let mut jobs = Vec::new();
1754        for &i in &todo {
1755            let (index, label, worktree) = {
1756                let c = &self.state.candidates[i];
1757                (c.index, c.label, c.worktree.clone())
1758            };
1759            let spec = self.roles.implementers[index].clone();
1760            let seat_key = format!("impl-{label}");
1761            let seat = self.seat(&seat_key, &spec.id);
1762            let instruction = seeded_instruction(&self.state);
1763            jobs.push(SeatJob {
1764                spec,
1765                seat,
1766                prompt: prompt::implement(
1767                    &instruction,
1768                    &worktree.to_string_lossy(),
1769                    &language,
1770                    brief.as_deref(),
1771                    &attachments,
1772                ),
1773                cwd: worktree,
1774                timeout,
1775                allow_write: true,
1776                sessions,
1777                artifacts: artifacts.clone(),
1778                stem: format!("impl-{label}"),
1779                handover: None,
1780            });
1781        }
1782
1783        self.state.event(
1784            "implement",
1785            format!("{} candidates in parallel", jobs.len()),
1786        );
1787        // Kept so a seat whose CLI hung up can be asked again from the same
1788        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1789        // can update a seat's own entry once a fallback agent takes it over —
1790        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1791        // whichever agent actually answered, not the one that quota'd out.
1792        let mut sent = jobs.clone();
1793        let cache = self.state.config.cache_dir();
1794        let ctx = WaveCtx {
1795            carry_seats: false,
1796            run: &run_id,
1797            node: "implement",
1798            prompts: &prompts,
1799            cache: cache.as_deref(),
1800            round: None,
1801        };
1802        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1803        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1804            .await;
1805        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1806            .await;
1807        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1808            .await;
1809
1810        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1811            let seat_key = seat.key.clone();
1812            // A quota fallback (`resume_seat_handovers`) may have handed this
1813            // seat to a different agent than the one `prep` recorded on the
1814            // candidate; the stats tables and any later fixer-defaults-to-
1815            // winner's-author lookup must credit whoever actually answered —
1816            // unless every fallback also quota'd out, in which case nobody
1817            // actually answered and crediting the last agent tried would
1818            // erase every earlier agent's own quota loss from the stats
1819            // tables instead of just this one seat's.
1820            let agent = seat.agent.clone();
1821            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1822            self.state.seats.insert(seat.key.clone(), seat);
1823            let label = self.state.candidates[i].label;
1824            let worktree = self.state.candidates[i].worktree.clone();
1825            let base = self.state.base_commit.clone();
1826
1827            let (summary, duration, failed, verified_claim) = match out {
1828                AgentOutcome::Ok(o) => {
1829                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1830                    let failed = (!o.usable()).then(|| {
1831                        if o.timed_out {
1832                            "agent timed out".to_owned()
1833                        } else {
1834                            format!("agent exited with {:?}", o.exit_code)
1835                        }
1836                    });
1837                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1838                    (text, o.duration_ms, failed, verified_claim)
1839                }
1840                // Left un-resumed by `resume_undelivered` (a dirty tree
1841                // already rescues the work, or there was no session left to
1842                // resume into) — reported like the ordinary failure it is,
1843                // never as if `o.text` (the CLI's raw error JSON) were an
1844                // answer.
1845                AgentOutcome::Dropped(o) => {
1846                    let why = o
1847                        .dropped
1848                        .as_ref()
1849                        .map(|d| d.why.as_str())
1850                        .unwrap_or("the CLI ended the stream without delivering its answer");
1851                    (
1852                        String::new(),
1853                        o.duration_ms,
1854                        Some(format!("the CLI dropped the stream ({why})")),
1855                        None,
1856                    )
1857                }
1858                AgentOutcome::Quota(o) => {
1859                    self.state.quota.push(QuotaLoss {
1860                        seat: seat_key,
1861                        node: "implement".to_owned(),
1862                        at: Timestamp::now(),
1863                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1864                    });
1865                    (
1866                        String::new(),
1867                        o.duration_ms,
1868                        Some("rate limited (quota); produced no change".to_owned()),
1869                        None,
1870                    )
1871                }
1872                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1873            };
1874
1875            // Rescue anything the agent edited but never committed: an
1876            // uncommitted candidate would silently be an empty one.
1877            let rescued = match git::rescue_commit(
1878                &worktree,
1879                &format!("magi: candidate {label} (uncommitted work)"),
1880            )
1881            .await
1882            {
1883                Ok(r) => {
1884                    self.state.note_withheld("implement", &r.withheld);
1885                    r.committed
1886                }
1887                Err(_) => false,
1888            };
1889            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1890                .await
1891                .unwrap_or(0);
1892            let patch = git::diff(&worktree, &base, "HEAD")
1893                .await
1894                .unwrap_or_default();
1895            let stat = git::diff_stat(&worktree, &base, "HEAD")
1896                .await
1897                .unwrap_or_default();
1898            let files = git::changed_files(&worktree, &base, "HEAD")
1899                .await
1900                .map(|f| f.len())
1901                .unwrap_or(0);
1902            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1903
1904            let c = &mut self.state.candidates[i];
1905            if !exhausted_the_fallback_chain {
1906                c.agent = agent;
1907            }
1908            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1909            c.stat = stat;
1910            c.files = files;
1911            c.commits = commits;
1912            c.duration_ms = duration;
1913            c.empty = commits == 0 || patch.trim().is_empty();
1914            // An agent that failed but still produced a committed change stays
1915            // in the running: the patch is what gets judged, not the exit code.
1916            c.failed = match failed {
1917                Some(_) if c.empty => failed,
1918                _ => None,
1919            };
1920            // Only an empty candidate can be a verified no-op: a claim next
1921            // to a real patch is not what the marker is for, and `c.failed`
1922            // being `Some` here already implies `verified_claim` was never
1923            // set (see the guard above the match that produced it).
1924            c.verified_noop = if c.empty { verified_claim } else { None };
1925            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1926                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1927                (None, true, Some(_), _) => {
1928                    format!("candidate {label}: no change produced (agent-verified no-op)")
1929                }
1930                (None, true, None, _) => format!("candidate {label}: no change produced"),
1931                (None, false, _, true) => {
1932                    format!(
1933                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1934                    )
1935                }
1936                (None, false, _, false) => {
1937                    format!("candidate {label}: {files} files, {commits} commits")
1938                }
1939            };
1940            self.state.event("implement", note);
1941            self.state.save()?;
1942        }
1943
1944        self.after_implement()
1945    }
1946
1947    /// Ask again, once, for work a CLI did and then failed to hand over.
1948    ///
1949    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1950    /// status with an empty response and a usage report showing output tokens,
1951    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1952    /// seven minutes and 14,267 output tokens that arrived as an empty
1953    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1954    ///
1955    /// Two conditions, and both matter:
1956    ///
1957    /// - **Only when the tree is untouched.** Often the agent has already
1958    ///   written its files and only the closing message was lost; the rescue
1959    ///   commit below picks that up and there is nothing to ask for. Re-asking
1960    ///   then would pay for a second implementation of work already on disk.
1961    /// - **Once.** A CLI that drops one stream can drop the next, and this
1962    ///   node is the most expensive in the graph.
1963    ///
1964    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1965    /// dropped reply still carried its `conversation_id`, so the seat is asked
1966    /// to finish what it was doing rather than sent the whole task again. It
1967    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
1968    /// node's - for the same reason a re-ranked judge does: restating finished
1969    /// work is not the work.
1970    ///
1971    /// Unlike a quota this is worth retrying at all: a rate limit fails the
1972    /// same way until it resets, while an abandoned conversation is still
1973    /// there to be picked up.
1974    async fn resume_undelivered(
1975        &mut self,
1976        results: &mut [(usize, SeatState, AgentOutcome)],
1977        sent: &[SeatJob],
1978        prompts: &Prompts,
1979        run_id: &str,
1980    ) {
1981        for (wi, seat, out) in results.iter_mut() {
1982            let Some(dropped) = (match &*out {
1983                AgentOutcome::Dropped(o) => o.dropped.clone(),
1984                _ => None,
1985            }) else {
1986                continue;
1987            };
1988            let Some(job) = sent.get(*wi) else { continue };
1989            // Already on disk? Then only the closing message was lost.
1990            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1991                self.state.event(
1992                    "implement",
1993                    format!(
1994                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1995                         work is in the tree",
1996                        seat.key, dropped.output_tokens, dropped.why
1997                    ),
1998                );
1999                continue;
2000            }
2001            // The re-ask only makes sense as a resume: `resume_after_drop`
2002            // says nothing about the task, trusting the seat to still hold it.
2003            // Without a session to resume — sessions disabled, or this CLI's
2004            // drop shape happened not to carry a session id — that prompt
2005            // would open a brand-new conversation with no context at all,
2006            // which is worse than leaving this as the ordinary failure it
2007            // already is.
2008            if !has_context(&job.spec, seat, job.sessions) {
2009                self.state.event(
2010                    "implement",
2011                    format!(
2012                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2013                         is no session left to resume",
2014                        seat.key, dropped.output_tokens, dropped.why
2015                    ),
2016                );
2017                continue;
2018            }
2019            self.state.event(
2020                "implement",
2021                format!(
2022                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2023                     conversation",
2024                    seat.key, dropped.output_tokens, dropped.why
2025                ),
2026            );
2027            let mut retry = job.clone();
2028            retry.seat = seat.clone();
2029            retry.prompt = prompt::resume_after_drop(&dropped.why);
2030            retry.timeout = retry_budget(job.timeout, true);
2031            retry.stem = format!("{}-resume", job.stem);
2032            let cache = self.state.config.cache_dir();
2033            let ctx = WaveCtx {
2034                carry_seats: false,
2035                run: run_id,
2036                node: "implement",
2037                prompts,
2038                cache: cache.as_deref(),
2039                round: None,
2040            };
2041            let (resumed_seat, resumed) =
2042                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2043            *seat = resumed_seat;
2044            *out = resumed;
2045        }
2046    }
2047
2048    /// Fall an implement seat through to the next untried agent in the
2049    /// implementer roster when it lost to quota — or, since the handover was
2050    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2051    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2052    /// quota path itself being unchanged — instead of leaving the
2053    /// seat's loss final the moment one agent's account runs dry.
2054    ///
2055    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2056    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2057    /// truncates to the single slot rotation picked, so a solo task whose one
2058    /// implementer hits quota mid-run used to have nothing else to try. This
2059    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2060    /// unrotated roster — which is the only place the *other* candidates in
2061    /// the machine's roster still exist once `implementers` has been cut down
2062    /// to size.
2063    ///
2064    /// Walks forward from just past the seat's own original position in the
2065    /// roster, never wrapping back to the front: a later candidate slot (say
2066    /// `beta`, the roster's second entry) must fall through to the *next*
2067    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2068    /// almost certainly a different candidate's own agent already — and once
2069    /// the roster's tail is exhausted there is nothing left to fall through
2070    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2071    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2072    /// let this retry that id forever. The loop keeps falling through until
2073    /// an attempt lands something other than `Quota` or the roster's tail
2074    /// runs out of untried ids, at which point the seat is left exactly as
2075    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2076    /// `QuotaLoss` recorded, the candidate failed/empty.
2077    ///
2078    /// `sent` is taken mutably and updated with the fallback agent's spec:
2079    /// `resume_unconfirmed_commands`, which runs after this and also reads
2080    /// `sent`, must see whichever agent actually ended up answering the seat
2081    /// — reading the stale, original spec there would check session
2082    /// eligibility against the wrong CLI and could hand a fallback agent's
2083    /// session id to the agent that just lost the seat to quota.
2084    ///
2085    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2086    /// — `self.seat` only reuses state when the agent id is unchanged, so
2087    /// handing it a different id already gets this for free. Reusing the old
2088    /// seat would resume a different CLI's session as if it were a
2089    /// continuation of this one.
2090    ///
2091    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2092    /// a quota loss cuts an agent off mid-turn, so anything already in the
2093    /// tree is unfinished work, not a completed candidate a re-ask would pay
2094    /// for twice. A dirty tree is rescued into a commit first (the same
2095    /// neutral-identity rescue `implement`'s own outcome loop gives every
2096    /// candidate) so the next agent starts clean.
2097    ///
2098    /// The new agent gets the implementer's full prompt and full
2099    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2100    /// it has no session and no context, and is implementing the task from
2101    /// nothing, unlike a resumed drop which is only restating work already
2102    /// done.
2103    ///
2104    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2105    /// `implement` event, never into `self.state.quota` — that is what
2106    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2107    /// go unspent, and a seat that ultimately recovered on its second or
2108    /// third agent is not the stalled panel that check exists to catch. Only
2109    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2110    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2111    /// outcome loop already has — this helper never pushes to it itself.
2112    async fn resume_seat_handovers(
2113        &mut self,
2114        results: &mut [(usize, SeatState, AgentOutcome)],
2115        sent: &mut [SeatJob],
2116        prompts: &Prompts,
2117        run_id: &str,
2118    ) {
2119        let instruction = seeded_instruction(&self.state);
2120        let language = self.state.config.graph.language.clone();
2121        let brief = self
2122            .state
2123            .advice
2124            .as_ref()
2125            .and_then(|a| a.synthesis.as_deref())
2126            .map(str::to_owned);
2127        let attachments = self.state.attachments.clone();
2128        for (wi, seat, out) in results.iter_mut() {
2129            // Who holds the other candidate seats of this wave right now
2130            // (earlier handovers already written back to `sent`).
2131            let others: BTreeSet<String> = sent
2132                .iter()
2133                .enumerate()
2134                .filter(|(j, _)| j != wi)
2135                .map(|(_, j)| j.spec.id.clone())
2136                .collect();
2137            let Some(job) = sent.get_mut(*wi) else {
2138                continue;
2139            };
2140            // Where the seat's own original agent sits in the roster — the
2141            // fallback walk starts just past here, never at the front, so a
2142            // later candidate slot's quota loss does not fall back onto an
2143            // earlier slot's own agent.
2144            let start = self
2145                .roles
2146                .implementer_roster
2147                .iter()
2148                .position(|s| s.id == job.spec.id)
2149                .unwrap_or(0);
2150            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2151            let mut fallback_attempt = 0usize;
2152            let mut prev: Option<FailClass> = None;
2153            while let Some(cur) = FailClass::of(&*out) {
2154                if !should_hand_over(prev.as_ref(), &cur) {
2155                    break;
2156                }
2157                let Some(next) =
2158                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2159                        .cloned()
2160                else {
2161                    break;
2162                };
2163                tried.insert(next.id.clone());
2164                fallback_attempt += 1;
2165
2166                if let Ok(r) = git::rescue_commit(
2167                    &job.cwd,
2168                    &format!(
2169                        "magi: candidate {} (uncommitted work before {} fallback)",
2170                        seat.key,
2171                        if cur == FailClass::Quota {
2172                            "quota"
2173                        } else {
2174                            "handover"
2175                        }
2176                    ),
2177                )
2178                .await
2179                {
2180                    self.state.note_withheld("implement", &r.withheld);
2181                }
2182
2183                record_handover(
2184                    &mut self.state,
2185                    "implement",
2186                    &seat.key,
2187                    &seat.agent,
2188                    &next.id,
2189                    &cur,
2190                    &fail_reason(&*out),
2191                );
2192                prev = Some(cur.clone());
2193
2194                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2195                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2196                // Kept in sync on `sent` itself, not just the local retry: a
2197                // later helper (`resume_unconfirmed_commands`) reads `sent`
2198                // after this one returns and must see whichever agent is now
2199                // occupying the seat, not the one that just quota'd out —
2200                // otherwise it would judge session/continuation eligibility
2201                // by the wrong CLI and could resend a fallback's session id
2202                // to the agent that lost it the seat in the first place.
2203                job.spec = next.clone();
2204                let mut retry = job.clone();
2205                retry.seat = new_seat;
2206                retry.prompt = prompt::implement(
2207                    &instruction,
2208                    &job.cwd.to_string_lossy(),
2209                    &language,
2210                    brief.as_deref(),
2211                    &attachments,
2212                );
2213                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2214                let cache = self.state.config.cache_dir();
2215                let ctx = WaveCtx {
2216                    carry_seats: false,
2217                    run: run_id,
2218                    node: "implement",
2219                    prompts,
2220                    cache: cache.as_deref(),
2221                    round: None,
2222                };
2223                let (fallback_seat, fallback_out) = run_one(
2224                    retry,
2225                    Arc::clone(&self.sem),
2226                    &ctx,
2227                    &mut self.state,
2228                    fallback_attempt,
2229                )
2230                .await;
2231                *seat = fallback_seat;
2232                *out = fallback_out;
2233            }
2234        }
2235    }
2236
2237    /// Ask an implement seat's own CLI to confirm what it started, once, when
2238    /// its reply reported a command whose completion status it never
2239    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2240    /// that does and does not mean.
2241    ///
2242    /// The completion contract this task asks for, extended to `implement`
2243    /// with the same signal `continue_fix_report` reads for the fixer,
2244    /// rather than a keyword search over the reply or a hard requirement on
2245    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2246    /// a candidate's CLI turn ended cleanly while a test run it had started
2247    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2248    /// named in it at all is untouched by this: `commands` is empty, so
2249    /// there is nothing to be unconfirmed.
2250    ///
2251    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2252    /// this is not about recovering edits that might already be on disk, it
2253    /// is about a result the seat itself never vouched for, which resuming
2254    /// asks for regardless of what the tree already holds. Bounded to one
2255    /// attempt for the same reason `resume_undelivered` is — this is the
2256    /// most expensive node in the graph — and a seat that still cannot
2257    /// confirm on that attempt is left as whatever its (possibly still
2258    /// unconfirmed) reply says; this does not invent a new "failed" reason
2259    /// for a candidate that otherwise produced a real, committed change.
2260    async fn resume_unconfirmed_commands(
2261        &mut self,
2262        results: &mut [(usize, SeatState, AgentOutcome)],
2263        sent: &[SeatJob],
2264        prompts: &Prompts,
2265        run_id: &str,
2266    ) {
2267        for (wi, seat, out) in results.iter_mut() {
2268            let AgentOutcome::Ok(o) = &*out else {
2269                continue;
2270            };
2271            if !has_unconfirmed_command(&o.commands) {
2272                continue;
2273            }
2274            let Some(job) = sent.get(*wi) else { continue };
2275            if !has_context(&job.spec, seat, job.sessions) {
2276                self.state.event(
2277                    "implement",
2278                    format!(
2279                        "{}: the reply named a command whose own CLI never confirmed the exit \
2280                         status of, but there is no session left to resume",
2281                        seat.key
2282                    ),
2283                );
2284                continue;
2285            }
2286            self.state.event(
2287                "implement",
2288                format!(
2289                    "{}: the reply named a command whose own CLI never confirmed the exit \
2290                     status of; resuming the conversation",
2291                    seat.key
2292                ),
2293            );
2294            let mut retry = job.clone();
2295            retry.seat = seat.clone();
2296            retry.prompt = prompt::resume_incomplete(
2297                "a command in your last reply had no confirmed exit status",
2298            );
2299            retry.timeout = retry_budget(job.timeout, true);
2300            retry.stem = format!("{}-confirm", job.stem);
2301            let cache = self.state.config.cache_dir();
2302            let ctx = WaveCtx {
2303                carry_seats: false,
2304                run: run_id,
2305                node: "implement",
2306                prompts,
2307                cache: cache.as_deref(),
2308                round: None,
2309            };
2310            let (resumed_seat, resumed) =
2311                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2312            *seat = resumed_seat;
2313            *out = resumed;
2314        }
2315    }
2316
2317    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2318    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2319    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2320    /// that motivated this.
2321    ///
2322    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2323    /// own nudge loop already covers for judge/review/vote seats, and not a
2324    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2325    /// implement seats: here the CLI turn genuinely finished while the node's
2326    /// own work — the fixer's account of what it did — had not. Gated purely
2327    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2328    /// reply, never on any wording in it, so a fixer whose valid, first-try
2329    /// `FixReport` happens to mention having waited on a background test is
2330    /// never resumed — the `Ok(report)` branch at the call site returns
2331    /// before this is ever invoked.
2332    ///
2333    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2334    /// attempt ([`retry_budget`]), nothing attempted once the session is
2335    /// gone, and a quota hit ends the loop immediately rather than retrying a
2336    /// rate limit that fails the same way again.
2337    async fn continue_fix_report(
2338        &mut self,
2339        mut seat: SeatState,
2340        parse_err: String,
2341        job: &SeatJob,
2342        prompts: &Prompts,
2343        run_id: &str,
2344        round: usize,
2345    ) -> (
2346        SeatState,
2347        Option<FixReport>,
2348        Option<String>,
2349        ContinuationRecord,
2350    ) {
2351        let mut last_err = parse_err;
2352        let mut cumulative_wait_ms = 0u64;
2353        let mut attempts = 0usize;
2354        loop {
2355            if !has_context(&job.spec, &seat, job.sessions) {
2356                self.state.event(
2357                    "fix",
2358                    format!(
2359                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2360                         session left to resume into"
2361                    ),
2362                );
2363                let outcome = if attempts == 0 {
2364                    ContinuationOutcome::NoSession
2365                } else {
2366                    ContinuationOutcome::Exhausted
2367                };
2368                return (
2369                    seat,
2370                    None,
2371                    Some(format!("unparsable fix report: {last_err}")),
2372                    ContinuationRecord {
2373                        attempts,
2374                        cumulative_wait_ms,
2375                        outcome,
2376                    },
2377                );
2378            }
2379            if attempts >= MAX_FIX_CONTINUATIONS {
2380                self.state.event(
2381                    "fix",
2382                    format!(
2383                        "round {round}: fixer's reply still had no adoption report after \
2384                         {attempts} continuation(s) ({last_err}); giving up"
2385                    ),
2386                );
2387                return (
2388                    seat,
2389                    None,
2390                    Some(format!(
2391                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2392                    )),
2393                    ContinuationRecord {
2394                        attempts,
2395                        cumulative_wait_ms,
2396                        outcome: ContinuationOutcome::Exhausted,
2397                    },
2398                );
2399            }
2400            attempts += 1;
2401            self.state.event(
2402                "fix",
2403                format!(
2404                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2405                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2406                ),
2407            );
2408            let mut retry = job.clone();
2409            retry.seat = seat.clone();
2410            retry.prompt = prompt::resume_incomplete(&last_err);
2411            retry.timeout = retry_budget(job.timeout, true);
2412            retry.stem = format!("{}-continue{attempts}", job.stem);
2413            let cache = self.state.config.cache_dir();
2414            let ctx = WaveCtx {
2415                carry_seats: false,
2416                run: run_id,
2417                node: "fix",
2418                prompts,
2419                cache: cache.as_deref(),
2420                round: Some(round),
2421            };
2422            let (resumed_seat, resumed_out) = run_one(
2423                retry,
2424                Arc::clone(&self.sem),
2425                &ctx,
2426                &mut self.state,
2427                attempts,
2428            )
2429            .await;
2430            seat = resumed_seat;
2431            match resumed_out {
2432                AgentOutcome::Ok(o) => {
2433                    cumulative_wait_ms += o.duration_ms;
2434                    match verdict::extract_json::<FixReport>(&o.text) {
2435                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2436                            self.state.event(
2437                                "fix",
2438                                format!(
2439                                    "round {round}: fixer's adoption report recovered after \
2440                                     {attempts} continuation(s)"
2441                                ),
2442                            );
2443                            return (
2444                                seat,
2445                                Some(report),
2446                                None,
2447                                ContinuationRecord {
2448                                    attempts,
2449                                    cumulative_wait_ms,
2450                                    outcome: ContinuationOutcome::Resumed,
2451                                },
2452                            );
2453                        }
2454                        // The report parsed, but this same reply's own
2455                        // CommandEvidence — the identical record `state.jobs`
2456                        // renders — names a command whose CLI never
2457                        // confirmed an exit status. Read together, that is
2458                        // not a resolved answer: keep nudging rather than
2459                        // accept a report standing next to a command the
2460                        // seat's own CLI cannot vouch for.
2461                        Ok(_) => {
2462                            last_err = "the reply parsed, but it reported a command whose own CLI \
2463                                 never confirmed an exit status"
2464                                .to_owned();
2465                        }
2466                        Err(e) => last_err = e.to_string(),
2467                    }
2468                }
2469                AgentOutcome::Quota(o) => {
2470                    cumulative_wait_ms += o.duration_ms;
2471                    self.state.quota.push(QuotaLoss {
2472                        seat: seat.key.clone(),
2473                        node: "fix".to_owned(),
2474                        at: Timestamp::now(),
2475                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2476                    });
2477                    self.state.event(
2478                        "fix",
2479                        format!(
2480                            "round {round}: continuation rate limited (quota); not retrying now"
2481                        ),
2482                    );
2483                    return (
2484                        seat,
2485                        None,
2486                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2487                        ContinuationRecord {
2488                            attempts,
2489                            cumulative_wait_ms,
2490                            outcome: ContinuationOutcome::QuotaLost,
2491                        },
2492                    );
2493                }
2494                AgentOutcome::Dropped(o) => {
2495                    cumulative_wait_ms += o.duration_ms;
2496                    let why = o
2497                        .dropped
2498                        .as_ref()
2499                        .map(|d| d.why.as_str())
2500                        .unwrap_or("the CLI ended the stream without delivering its answer");
2501                    last_err = format!("the CLI dropped the stream ({why})");
2502                }
2503                AgentOutcome::Failed(e) => last_err = e,
2504            }
2505        }
2506    }
2507
2508    fn after_implement(&mut self) -> Result<()> {
2509        // Scan every candidate patch once the set is complete.
2510        if self.state.leaks.is_empty() {
2511            let cfg = self.state.config.blind.clone();
2512            let mut leaks = Vec::new();
2513            for c in &self.state.candidates {
2514                let Some(patch) =
2515                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2516                else {
2517                    continue;
2518                };
2519                leaks.extend(blind::scan(
2520                    &format!("candidate {} patch", c.label),
2521                    &patch,
2522                    &cfg.vendor_tokens,
2523                ));
2524            }
2525            if !leaks.is_empty() {
2526                let summary = leaks
2527                    .iter()
2528                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2529                    .collect::<Vec<_>>()
2530                    .join(", ");
2531                match cfg.on_leak {
2532                    LeakPolicy::Fail => {
2533                        self.state.status = RunStatus::Failed;
2534                        self.state
2535                            .event("blind", format!("vendor text in a patch: {summary}"));
2536                        self.state.leaks = leaks;
2537                        self.state.save()?;
2538                        self.settle_questions();
2539                        bail!(
2540                            "blind.on_leak = \"fail\" and vendor text reached a \
2541                             judged patch: {summary}"
2542                        );
2543                    }
2544                    LeakPolicy::Redact => self.state.event(
2545                        "blind",
2546                        format!("redacting vendor text for judging: {summary}"),
2547                    ),
2548                    LeakPolicy::Warn => self.state.event(
2549                        "blind",
2550                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2551                    ),
2552                }
2553                self.state.leaks = leaks;
2554            }
2555        }
2556
2557        if self.state.viable().is_empty() {
2558            if self.state.all_candidates_verified_noop() {
2559                // Every candidate agreed, with evidence the adoption guard
2560                // accepted, that nothing belongs in this worktree. That is
2561                // not the same fact as a candidate that simply failed to
2562                // write anything, and settling it as an ordinary `Failed`
2563                // (see `SCHEMA`'s doc for schema 10) is what let two of
2564                // task 391f's attempts burn a retry each re-discovering the
2565                // same already-landed fix. Terminal either way, so `judge`
2566                // must never run over an empty candidate set — unlike the
2567                // `Failed` branch below this returns `Ok`, not an error:
2568                // nothing here failed.
2569                self.state.status = RunStatus::VerifiedNoop;
2570                self.state.save()?;
2571                self.settle_questions();
2572                return Ok(());
2573            }
2574            self.state.status = RunStatus::Failed;
2575            self.state.save()?;
2576            self.settle_questions();
2577            bail!("no candidate produced a change; nothing to judge");
2578        }
2579        self.state.status = RunStatus::Judging;
2580        self.state.save()?;
2581        Ok(())
2582    }
2583
2584    // --------------------------------------------------------------- judge
2585
2586    async fn judge(&mut self) -> Result<()> {
2587        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2588        // agent files with `magi task add` name the run that paid for it. The
2589        // prompt overlay is cloned alongside it because the waves borrow it
2590        // while `self` is mutably borrowed by the node's own bookkeeping.
2591        let run_id = self.state.id.clone();
2592        let prompts = self.state.config.prompts.clone();
2593        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2594            return Ok(());
2595        }
2596        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2597        if viable.len() == 1 {
2598            // Recorded so this is a one-time event: `judgements` stays empty
2599            // either way, which without this flag is indistinguishable from
2600            // "not yet judged" on the next reentry — and status is left
2601            // untouched, so a later node's conclusion (e.g. `Blocked` after
2602            // the review budget ran out) survives a resume instead of being
2603            // clobbered back to `Judging` by this node running again.
2604            self.state.judge_skipped = true;
2605            self.state.event(
2606                "judge",
2607                format!(
2608                    "only candidate {} produced a change; judging skipped",
2609                    viable[0].label
2610                ),
2611            );
2612            self.state.save()?;
2613            return Ok(());
2614        }
2615        self.state.status = RunStatus::Judging;
2616
2617        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2618        let language = self.state.config.graph.language.clone();
2619        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2620        let sessions = self.state.config.graph.sessions;
2621        let artifacts = agent::artifacts_dir(&self.state.dir());
2622        let root = self.state.worktree_root();
2623        let base_short = short(&self.state.base_commit);
2624
2625        let mut jobs = Vec::new();
2626        let mut orders = Vec::new();
2627        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2628            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2629            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2630            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2631            let seat_key = format!("judge-{}", j + 1);
2632            let seat = self.seat(&seat_key, &spec.id);
2633            jobs.push(SeatJob {
2634                prompt: prompt::judge(
2635                    &self.state.instruction,
2636                    &views,
2637                    self.roles.judges.len(),
2638                    &base_short,
2639                    &language,
2640                ),
2641                spec,
2642                seat,
2643                cwd: root.join(format!("judge-{}", j + 1)),
2644                timeout,
2645                allow_write: false,
2646                sessions,
2647                artifacts: artifacts.clone(),
2648                stem: format!("judge-{}", j + 1),
2649                handover: None,
2650            });
2651        }
2652
2653        self.state.event(
2654            "judge",
2655            format!(
2656                "{} judges ranking {} candidates blind",
2657                jobs.len(),
2658                viable.len()
2659            ),
2660        );
2661        let labels_for_check = labels.clone();
2662        let mut quota_losses = Vec::new();
2663        let cache = self.state.config.cache_dir();
2664        let ctx = WaveCtx {
2665            carry_seats: false,
2666            run: &run_id,
2667            node: "judge",
2668            prompts: &prompts,
2669            cache: cache.as_deref(),
2670            round: None,
2671        };
2672        let results = ask_json_wave::<Ranking>(
2673            jobs,
2674            Arc::clone(&self.sem),
2675            self.state.config.graph.retries,
2676            &self.roles.judge_roster,
2677            &ctx,
2678            &mut quota_losses,
2679            &mut self.state,
2680            &move |r: &Ranking| r.validate(&labels_for_check),
2681        )
2682        .await;
2683        self.state.quota.extend(quota_losses);
2684
2685        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2686            let agent_id = seat.agent.clone();
2687            self.state.seats.insert(seat.key.clone(), seat);
2688            let mut record = Judgement {
2689                judge: j + 1,
2690                seat: format!("judge-{}", j + 1),
2691                agent: agent_id,
2692                ranking: Vec::new(),
2693                reasons: BTreeMap::new(),
2694                confidence: None,
2695                strengths: BTreeMap::new(),
2696                order: orders[j].clone(),
2697                failed: None,
2698                duration_ms: 0,
2699            };
2700            match res {
2701                Ok((ranking, out)) => {
2702                    record.ranking = ranking.normalized();
2703                    record.reasons = ranking.reasons;
2704                    record.strengths = ranking.strengths;
2705                    record.confidence = ranking.confidence;
2706                    record.duration_ms = out.duration_ms;
2707                    self.state.event(
2708                        "judge",
2709                        format!(
2710                            "judge {} ranked {}",
2711                            j + 1,
2712                            record.ranking.iter().collect::<String>()
2713                        ),
2714                    );
2715                }
2716                Err(e) => {
2717                    record.failed = Some(e.to_string());
2718                    self.state
2719                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2720                }
2721            }
2722            self.state.judgements.push(record);
2723            self.state.save()?;
2724        }
2725        Ok(())
2726    }
2727
2728    // ---------------------------------------------------------- deliberate
2729
2730    async fn deliberate(&mut self) -> Result<()> {
2731        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2732        // agent files with `magi task add` name the run that paid for it. The
2733        // prompt overlay is cloned alongside it because the waves borrow it
2734        // while `self` is mutably borrowed by the node's own bookkeeping.
2735        let run_id = self.state.id.clone();
2736        let prompts = self.state.config.prompts.clone();
2737        if !self.state.deliberation.is_empty() {
2738            return Ok(());
2739        }
2740        let tops: Vec<char> = self
2741            .state
2742            .judgements
2743            .iter()
2744            .filter_map(|j| j.ranking.first().copied())
2745            .collect();
2746        let rounds = self.state.config.graph.deliberate_rounds;
2747        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2748            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2749                self.state.event(
2750                    "deliberate",
2751                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2752                );
2753            }
2754            self.state.status = RunStatus::Voting;
2755            self.state.save()?;
2756            return Ok(());
2757        }
2758
2759        self.state.status = RunStatus::Deliberating;
2760        self.state.event(
2761            "deliberate",
2762            format!(
2763                "split: first choices were {} — opening {rounds} round(s)",
2764                tops.iter().collect::<String>()
2765            ),
2766        );
2767
2768        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2769        let language = self.state.config.graph.language.clone();
2770        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2771        let sessions = self.state.config.graph.sessions;
2772        let artifacts = agent::artifacts_dir(&self.state.dir());
2773        let root = self.state.worktree_root();
2774        let base_short = short(&self.state.base_commit);
2775
2776        // Judges argue in sequence so that a turn can answer the one before it;
2777        // that is the difference between deliberation and three parallel
2778        // monologues.
2779        for round in 1..=rounds {
2780            let mut turns: Vec<DeliberationTurn> = Vec::new();
2781            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2782                if self.state.judgements[j].failed.is_some() {
2783                    continue;
2784                }
2785                let seat_key = format!("judge-{}", j + 1);
2786                let spec = self.occupant(&seat_key, spec);
2787                let mut seat = self.seat(&seat_key, &spec.id);
2788                let transcript = self.transcript(&turns, j);
2789                let build = |context: Option<&str>| {
2790                    prompt::deliberate(
2791                        &self.state.instruction,
2792                        context,
2793                        &transcript,
2794                        round,
2795                        rounds,
2796                        &language,
2797                    )
2798                };
2799                let block = self.candidate_block(&viable, &base_short);
2800                let full = build(Some(&block));
2801                let text = if has_context(&spec, &seat, sessions) {
2802                    build(None)
2803                } else {
2804                    full.clone()
2805                };
2806                let job = SeatJob {
2807                    spec,
2808                    seat: seat.clone(),
2809                    prompt: text,
2810                    cwd: root.join(format!("judge-{}", j + 1)),
2811                    timeout,
2812                    allow_write: false,
2813                    sessions,
2814                    artifacts: artifacts.clone(),
2815                    stem: format!("delib-{round}-judge-{}", j + 1),
2816                    handover: Some(full),
2817                };
2818                let cache = self.state.config.cache_dir();
2819                let ctx = WaveCtx {
2820                    carry_seats: false,
2821                    run: &run_id,
2822                    node: "deliberate",
2823                    prompts: &prompts,
2824                    cache: cache.as_deref(),
2825                    round: None,
2826                };
2827                // A turn is never nudged (`retries` 0): a failed seat is
2828                // handed to the next roster agent, which gets the full
2829                // context. An empty answer is a turn, not a failure.
2830                let mut losses = Vec::new();
2831                let mut results = ask_wave_with::<String>(
2832                    vec![job],
2833                    Arc::clone(&self.sem),
2834                    0,
2835                    &self.roles.judge_roster,
2836                    &ctx,
2837                    &mut losses,
2838                    &mut self.state,
2839                    &|text: &str| {
2840                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2841                    },
2842                )
2843                .await;
2844                self.state.quota.extend(losses);
2845                let (updated, res, _) = results.pop().expect("one job in, one result out");
2846                seat = updated;
2847                let agent_id = seat.agent.clone();
2848                self.state.seats.insert(seat.key.clone(), seat);
2849                let body = match res {
2850                    Ok((body, _)) => body,
2851                    // Skip the seat; a CLI's raw error JSON is never read as
2852                    // this judge's position.
2853                    Err(e) => {
2854                        self.state
2855                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2856                        continue;
2857                    }
2858                };
2859                let tentative = verdict::extract_json::<Position>(&body)
2860                    .ok()
2861                    .and_then(|p| p.tentative)
2862                    .and_then(|s| s.trim().chars().next())
2863                    .map(|c| c.to_ascii_uppercase());
2864                self.state.event(
2865                    "deliberate",
2866                    format!(
2867                        "round {round}: judge {} now favours {}",
2868                        j + 1,
2869                        tentative.map_or("—".to_owned(), |c| c.to_string())
2870                    ),
2871                );
2872                turns.push(DeliberationTurn {
2873                    judge: j + 1,
2874                    agent: agent_id,
2875                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2876                    tentative,
2877                });
2878            }
2879            self.state
2880                .deliberation
2881                .push(DeliberationRound { round, turns });
2882            self.state.save()?;
2883        }
2884
2885        self.state.status = RunStatus::Voting;
2886        self.state.save()?;
2887        Ok(())
2888    }
2889
2890    // ---------------------------------------------------------------- vote
2891
2892    async fn vote(&mut self) -> Result<()> {
2893        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2894        // agent files with `magi task add` name the run that paid for it. The
2895        // prompt overlay is cloned alongside it because the waves borrow it
2896        // while `self` is mutably borrowed by the node's own bookkeeping.
2897        let run_id = self.state.id.clone();
2898        let prompts = self.state.config.prompts.clone();
2899        if !self.state.votes.is_empty() {
2900            return Ok(());
2901        }
2902        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2903        if viable.len() == 1 {
2904            return Ok(());
2905        }
2906        self.state.status = RunStatus::Voting;
2907
2908        let language = self.state.config.graph.language.clone();
2909        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2910        let sessions = self.state.config.graph.sessions;
2911        let artifacts = agent::artifacts_dir(&self.state.dir());
2912        let root = self.state.worktree_root();
2913        let base_short = short(&self.state.base_commit);
2914        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2915
2916        let mut jobs = Vec::new();
2917        let mut seats_at = Vec::new();
2918        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2919            if self
2920                .state
2921                .judgements
2922                .get(j)
2923                .is_some_and(|r| r.failed.is_some())
2924            {
2925                continue;
2926            }
2927            let seat_key = format!("judge-{}", j + 1);
2928            let spec = self.occupant(&seat_key, spec);
2929            let seat = self.seat(&seat_key, &spec.id);
2930            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2931            let text = if has_context(&spec, &seat, sessions) {
2932                prompt::final_vote(&viable, &language)
2933            } else {
2934                full.clone()
2935            };
2936            jobs.push(SeatJob {
2937                spec,
2938                seat,
2939                prompt: text,
2940                cwd: root.join(format!("judge-{}", j + 1)),
2941                timeout,
2942                allow_write: false,
2943                sessions,
2944                artifacts: artifacts.clone(),
2945                stem: format!("vote-judge-{}", j + 1),
2946                handover: Some(full),
2947            });
2948            seats_at.push(j);
2949        }
2950
2951        self.state.event(
2952            "vote",
2953            format!(
2954                "collecting {} final votes one by one, privately",
2955                jobs.len()
2956            ),
2957        );
2958        let allowed = viable.clone();
2959        let mut quota_losses = Vec::new();
2960        let cache = self.state.config.cache_dir();
2961        let ctx = WaveCtx {
2962            carry_seats: false,
2963            run: &run_id,
2964            node: "vote",
2965            prompts: &prompts,
2966            cache: cache.as_deref(),
2967            round: None,
2968        };
2969        let results = ask_json_wave::<FinalVote>(
2970            jobs,
2971            Arc::clone(&self.sem),
2972            self.state.config.graph.retries,
2973            &self.roles.judge_roster,
2974            &ctx,
2975            &mut quota_losses,
2976            &mut self.state,
2977            &move |v: &FinalVote| match v.label() {
2978                Some(c) if allowed.contains(&c) => Ok(()),
2979                other => bail!("vote {other:?} is not one of {allowed:?}"),
2980            },
2981        )
2982        .await;
2983        self.state.quota.extend(quota_losses);
2984
2985        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2986            let agent_id = seat.agent.clone();
2987            self.state.seats.insert(seat.key.clone(), seat);
2988            let initial = self
2989                .state
2990                .judgements
2991                .get(j)
2992                .and_then(|r| r.ranking.first().copied());
2993            let mut record = VoteRecord {
2994                judge: j + 1,
2995                agent: agent_id,
2996                vote: None,
2997                reason: String::new(),
2998                changed: false,
2999            };
3000            match res {
3001                Ok((v, _)) => {
3002                    record.vote = v.label();
3003                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3004                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3005                    self.state.event(
3006                        "vote",
3007                        format!(
3008                            "judge {} voted {}{}",
3009                            j + 1,
3010                            record.vote.unwrap_or('?'),
3011                            if record.changed { " (changed)" } else { "" }
3012                        ),
3013                    );
3014                }
3015                Err(e) => {
3016                    self.state
3017                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3018                }
3019            }
3020            self.state.votes.push(record);
3021            self.state.save()?;
3022        }
3023        Ok(())
3024    }
3025
3026    // --------------------------------------------------------------- tally
3027
3028    fn tally(&mut self) -> Result<()> {
3029        if self.state.tally.is_some() {
3030            return Ok(());
3031        }
3032        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3033        let tops: Vec<char> = self
3034            .state
3035            .judgements
3036            .iter()
3037            .filter_map(|j| j.ranking.first().copied())
3038            .collect();
3039        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3040
3041        // A judge whose private vote failed still counted once, in the initial
3042        // ranking; using it beats discarding a whole seat.
3043        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3044        let mut cast: Vec<char> = Vec::new();
3045        for (i, j) in self.state.judgements.iter().enumerate() {
3046            let vote = self
3047                .state
3048                .votes
3049                .iter()
3050                .find(|v| v.judge == i + 1)
3051                .and_then(|v| v.vote)
3052                .or_else(|| j.ranking.first().copied());
3053            if let Some(v) = vote {
3054                *first_choice.entry(v).or_insert(0) += 1;
3055                cast.push(v);
3056            }
3057        }
3058
3059        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3060        for j in &self.state.judgements {
3061            let n = j.ranking.len();
3062            for (pos, label) in j.ranking.iter().enumerate() {
3063                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3064            }
3065        }
3066
3067        let best = first_choice.values().copied().max().unwrap_or(0);
3068        let mut leaders: Vec<char> = first_choice
3069            .iter()
3070            .filter(|(_, v)| **v == best)
3071            .map(|(k, _)| *k)
3072            .collect();
3073        let mut tie_break = None;
3074        if leaders.len() > 1 {
3075            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3076            let borda_leaders: Vec<char> = leaders
3077                .iter()
3078                .copied()
3079                .filter(|l| borda[l] == top_borda)
3080                .collect();
3081            tie_break = Some(if borda_leaders.len() == 1 {
3082                format!(
3083                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3084                    leaders.len()
3085                )
3086            } else {
3087                format!(
3088                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3089                    leaders.len()
3090                )
3091            });
3092            leaders = borda_leaders;
3093            leaders.sort_unstable();
3094        }
3095        let winner = *leaders
3096            .first()
3097            .or(viable.first())
3098            .context("no candidate to declare a winner from")?;
3099
3100        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3101        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3102        let deliberated = !self.state.deliberation.is_empty();
3103
3104        // Whose verdict is this? A rate-limited seat is absent even if it
3105        // ranked before the limit hit, so presence is measured against the
3106        // recorded losses, not just "did a ranking ever appear".
3107        let quota_seats: std::collections::BTreeSet<&str> =
3108            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3109        let mut present = 0usize;
3110        for (i, j) in self.state.judgements.iter().enumerate() {
3111            if quota_seats.contains(j.seat.as_str()) {
3112                continue;
3113            }
3114            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3115            let voted = self
3116                .state
3117                .votes
3118                .iter()
3119                .any(|v| v.judge == i + 1 && v.vote.is_some());
3120            if ranked || voted {
3121                present += 1;
3122            }
3123        }
3124        // Strict majority of the configured panel. A bare majority is real
3125        // signal we can act on, while a minority verdict must never stand in
3126        // for a healthy one. A one-candidate run needs no panel at all, and
3127        // `judges` stays `0` rather than the roster size a panel that never
3128        // sat would otherwise be credited with.
3129        let needs_quorum = viable.len() > 1;
3130        let judges_total = if needs_quorum {
3131            self.roles.judges.len()
3132        } else {
3133            0
3134        };
3135        let quorum = if needs_quorum {
3136            judges_total / 2 + 1
3137        } else {
3138            0
3139        };
3140        let met_quorum = !needs_quorum || present >= quorum;
3141        let uncontested = (!needs_quorum).then(|| {
3142            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3143        });
3144
3145        self.state.event(
3146            "tally",
3147            match &uncontested {
3148                Some(reason) => format!("winner {winner} — {reason}"),
3149                None => format!(
3150                    "winner {winner} — votes {} | initial {} | {} changed | \
3151                     {present}/{judges_total} judges{}",
3152                    first_choice
3153                        .iter()
3154                        .map(|(k, v)| format!("{k}:{v}"))
3155                        .collect::<Vec<_>>()
3156                        .join(" "),
3157                    if unanimous_initial {
3158                        "unanimous"
3159                    } else {
3160                        "split"
3161                    },
3162                    changed_votes,
3163                    if met_quorum {
3164                        String::new()
3165                    } else {
3166                        format!(" — below quorum ({quorum} required)")
3167                    },
3168                ),
3169            },
3170        );
3171        if !met_quorum {
3172            self.state.event(
3173                "stall",
3174                format!(
3175                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3176                     the run stops here, resumable"
3177                ),
3178            );
3179        }
3180        self.state.tally = Some(Tally {
3181            first_choice,
3182            borda,
3183            winner,
3184            rankings: tops.len(),
3185            unanimous_initial,
3186            deliberated,
3187            changed_votes,
3188            unanimous_final,
3189            tie_break,
3190            judges: judges_total,
3191            present,
3192            quorum,
3193            met_quorum,
3194            uncontested,
3195        });
3196        self.state.status = if met_quorum {
3197            RunStatus::Reviewing
3198        } else {
3199            RunStatus::Stalled
3200        };
3201        self.state.save()?;
3202        Ok(())
3203    }
3204
3205    // ------------------------------------------------------------- recover
3206
3207    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3208    /// resumed toward completion once the transient cause clears.
3209    ///
3210    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3211    /// it toward the quorum, which is exactly the set of seats whose absence
3212    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3213    /// not depend on which node happened to hit the limit), or an ordinary
3214    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3215    /// seat is never disturbed.
3216    ///
3217    /// A seat that now answers with a usable ranking is "recovered": its
3218    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3219    /// `tally` counts it present again), and its vote re-collected. A seat that
3220    /// still fails keeps its loss and stays absent.
3221    ///
3222    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3223    /// to review/gate/merge), `false` when it is still below quorum (the run
3224    /// stays `Stalled`, still resumable for a later retry).
3225    #[allow(clippy::too_many_lines)]
3226    async fn recover_stall(&mut self) -> Result<bool> {
3227        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3228        // agent files with `magi task add` name the run that paid for it. The
3229        // prompt overlay is cloned alongside it because the waves borrow it
3230        // while `self` is mutably borrowed by the node's own bookkeeping.
3231        let run_id = self.state.id.clone();
3232        let prompts = self.state.config.prompts.clone();
3233        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3234        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3235        // is what keeps a non-quota collapse — or a quota loss recorded at the
3236        // deliberate node — from being a permanent dead-end on `--resume`.
3237        let quota_seats: BTreeSet<&str> =
3238            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3239        let absent: Vec<String> = self
3240            .state
3241            .judgements
3242            .iter()
3243            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3244            .map(|j| j.seat.clone())
3245            .collect();
3246        if absent.is_empty() {
3247            return Ok(false);
3248        }
3249        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3250        if viable.len() <= 1 {
3251            return Ok(false);
3252        }
3253        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3254        let language = self.state.config.graph.language.clone();
3255        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3256        let sessions = self.state.config.graph.sessions;
3257        let artifacts = agent::artifacts_dir(&self.state.dir());
3258        let root = self.state.worktree_root();
3259        let base_short = short(&self.state.base_commit);
3260        let candidates: Vec<Candidate> = viable.clone();
3261
3262        // Map each absent seat key to its 0-based position in `roles.judges`.
3263        let mut positions: Vec<usize> = absent
3264            .iter()
3265            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3266            .collect();
3267        if positions.is_empty() {
3268            return Ok(false);
3269        }
3270        positions.sort_unstable();
3271        positions.dedup();
3272
3273        // Re-rank the lost seats, one blind prompt each.
3274        let mut judge_jobs = Vec::new();
3275        for &j in &positions {
3276            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3277            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3278            let seat_key = format!("judge-{}", j + 1);
3279            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3280            let seat = self.seat(&seat_key, &spec.id);
3281            judge_jobs.push(SeatJob {
3282                spec,
3283                seat,
3284                prompt: prompt::judge(
3285                    &self.state.instruction,
3286                    &views,
3287                    self.roles.judges.len(),
3288                    &base_short,
3289                    &language,
3290                ),
3291                cwd: root.join(seat_key),
3292                timeout,
3293                allow_write: false,
3294                sessions,
3295                artifacts: artifacts.clone(),
3296                stem: format!("judge-{}-recover", j + 1),
3297                handover: None,
3298            });
3299        }
3300
3301        let labels_for_check = labels.clone();
3302        let mut judge_losses = Vec::new();
3303        let retries = self.state.config.graph.retries;
3304        let cache = self.state.config.cache_dir();
3305        let ctx = WaveCtx {
3306            carry_seats: false,
3307            run: &run_id,
3308            node: "judge",
3309            prompts: &prompts,
3310            cache: cache.as_deref(),
3311            round: None,
3312        };
3313        let results = ask_json_wave::<Ranking>(
3314            judge_jobs,
3315            Arc::clone(&self.sem),
3316            retries,
3317            &self.roles.judge_roster,
3318            &ctx,
3319            &mut judge_losses,
3320            &mut self.state,
3321            &move |r: &Ranking| r.validate(&labels_for_check),
3322        )
3323        .await;
3324
3325        // Refresh the judgement of every seat that ranked again.
3326        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3327        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3328            let agent_id = seat.agent.clone();
3329            self.state.seats.insert(seat.key.clone(), seat);
3330            let record = &mut self.state.judgements[j];
3331            match res {
3332                Ok((ranking, out)) => {
3333                    record.agent = agent_id;
3334                    record.ranking = ranking.normalized();
3335                    record.reasons = ranking.reasons;
3336                    record.strengths = ranking.strengths;
3337                    record.confidence = ranking.confidence;
3338                    record.failed = None;
3339                    record.duration_ms = out.duration_ms;
3340                    recovered.insert(j);
3341                    self.state.event(
3342                        "recover",
3343                        format!("judge {} ranked again after the limit", j + 1),
3344                    );
3345                }
3346                Err(e) => {
3347                    self.state
3348                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3349                }
3350            }
3351        }
3352
3353        // Re-ask the votes of the seats that recovered a ranking.
3354        let mut vote_jobs = Vec::new();
3355        let mut vote_pos: Vec<usize> = Vec::new();
3356        for &j in &recovered {
3357            let seat_key = format!("judge-{}", j + 1);
3358            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3359            let seat = self.seat(&seat_key, &spec.id);
3360            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3361            let text = if has_context(&spec, &seat, sessions) {
3362                prompt::final_vote(&labels, &language)
3363            } else {
3364                full.clone()
3365            };
3366            vote_jobs.push(SeatJob {
3367                spec,
3368                seat,
3369                prompt: text,
3370                cwd: root.join(seat_key),
3371                timeout,
3372                allow_write: false,
3373                sessions,
3374                artifacts: artifacts.clone(),
3375                stem: format!("vote-judge-{}-recover", j + 1),
3376                handover: Some(full),
3377            });
3378            vote_pos.push(j);
3379        }
3380        let allowed = labels.clone();
3381        let mut vote_losses = Vec::new();
3382        let vote_retries = self.state.config.graph.retries;
3383        let vote_cache = self.state.config.cache_dir();
3384        let ctx = WaveCtx {
3385            carry_seats: false,
3386            run: &run_id,
3387            node: "vote",
3388            prompts: &prompts,
3389            cache: vote_cache.as_deref(),
3390            round: None,
3391        };
3392        let votes = ask_json_wave::<FinalVote>(
3393            vote_jobs,
3394            Arc::clone(&self.sem),
3395            vote_retries,
3396            &self.roles.judge_roster,
3397            &ctx,
3398            &mut vote_losses,
3399            &mut self.state,
3400            &move |v: &FinalVote| match v.label() {
3401                Some(c) if allowed.contains(&c) => Ok(()),
3402                other => bail!("vote {other:?} is not one of {allowed:?}"),
3403            },
3404        )
3405        .await;
3406        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3407            let agent_id = seat.agent.clone();
3408            self.state.seats.insert(seat.key.clone(), seat);
3409            match res {
3410                Ok((v, _)) => {
3411                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3412                        rec.vote = v.label();
3413                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3414                    } else {
3415                        self.state.votes.push(VoteRecord {
3416                            judge: j + 1,
3417                            agent: agent_id,
3418                            vote: v.label(),
3419                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3420                            changed: false,
3421                        });
3422                    }
3423                    self.state.event(
3424                        "recover",
3425                        format!("judge {} voted again after the limit", j + 1),
3426                    );
3427                }
3428                Err(e) => {
3429                    self.state
3430                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3431                }
3432            }
3433        }
3434
3435        // A seat that ranked again is present even if its re-vote failed —
3436        // `tally` falls back to the initial ranking's first choice — so clear
3437        // its quota loss. Seats that still fail keep theirs and stay absent.
3438        let recovered_keys: BTreeSet<String> = recovered
3439            .iter()
3440            .map(|&j| format!("judge-{}", j + 1))
3441            .collect();
3442        self.state
3443            .quota
3444            .retain(|q| !recovered_keys.contains(&q.seat));
3445        // A seat that hit the limit again is a fresh loss, not the old one:
3446        // replace the stale entry so the history stays one-per-seat and the
3447        // daemon can tell this attempt's loss from a previous session's.
3448        for loss in judge_losses.into_iter().chain(vote_losses) {
3449            if recovered_keys.contains(&loss.seat) {
3450                continue;
3451            }
3452            self.state.quota.retain(|q| q.seat != loss.seat);
3453            self.state.quota.push(loss);
3454        }
3455
3456        // Recompute the verdict from the refreshed panel.
3457        self.state.tally = None;
3458        self.tally()?;
3459        Ok(self
3460            .state
3461            .tally
3462            .as_ref()
3463            .map(|t| t.met_quorum)
3464            .unwrap_or(false))
3465    }
3466
3467    // ---------------------------------------------------------------- graft
3468
3469    /// Port the strengths the judges attributed to the losing candidates onto
3470    /// the winner, once, between the tally and the review loop.
3471    ///
3472    /// Best-effort by construction: it returns nothing and fails nothing. A
3473    /// skip, a fixer that failed or hit quota, a round that changed nothing
3474    /// and a result over the size limit ([`graft::limit`]) all leave the
3475    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
3476    /// run carries on as if the phase were off. See [`graft`] for the
3477    /// decisions and `AGENTS.md` for the invariants.
3478    ///
3479    /// The one exception is a rollback that could not be confirmed: the
3480    /// rejected commit may still be the winner's tip, so the run is blocked
3481    /// (resumable; the record stays `Running`, which makes a resume retry the
3482    /// restore) instead of reviewing, gating and merging it.
3483    async fn graft(&mut self) -> Result<()> {
3484        let Err(e) = self.graft_inner().await else {
3485            return Ok(());
3486        };
3487        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3488            self.state.status = RunStatus::Blocked;
3489            self.state.event("graft", format!("blocked: {e:#}"));
3490            self.state.save()?;
3491            return Err(e);
3492        }
3493        // An error after the `Running` record was saved may have left the
3494        // fixer's work in the tree: the record must not stay unsettled
3495        // (a later resume would reset past the review fixes), so confirm the
3496        // restore now, or block.
3497        if let Some(rec) = self.state.graft.clone()
3498            && !rec.outcome.settled()
3499        {
3500            if let Some(winner) = self.state.winner().cloned() {
3501                if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3502                    self.state.status = RunStatus::Blocked;
3503                    self.state.event("graft", format!("blocked: {re:#}"));
3504                    self.state.save()?;
3505                    return Err(re);
3506                }
3507            }
3508            if let Some(g) = self.state.graft.as_mut() {
3509                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3510            }
3511        }
3512        self.state.event("graft", format!("skipped: {e:#}"));
3513        if let Err(e) = self.state.save() {
3514            tracing::warn!("save after graft: {e:#}");
3515        }
3516        Ok(())
3517    }
3518
3519    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
3520    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
3521    async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3522        let restored = git::restore_to(wt, pre).await;
3523        let tip = git::rev_parse(wt, "HEAD").await;
3524        match (restored, tip) {
3525            (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3526            (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3527            (Ok(()), Ok(tip)) => {
3528                Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3529            }
3530            (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3531        }
3532    }
3533
3534    /// A skip is a run event, recorded once however often the node is re-entered.
3535    fn graft_skipped(&mut self, why: &str) {
3536        let message = format!("skipped: {why}");
3537        if !self
3538            .state
3539            .events
3540            .iter()
3541            .any(|e| e.node == "graft" && e.message == message)
3542        {
3543            self.state.event("graft", message);
3544        }
3545    }
3546
3547    async fn graft_inner(&mut self) -> Result<()> {
3548        // A round that was started and never finished (crash, kill): the
3549        // fixer may have left anything in the tree, so put the winner back.
3550        if let Some(rec) = self.state.graft.clone()
3551            && !rec.outcome.settled()
3552        {
3553            // Reviews exist only if the graft ended without its record being
3554            // settled (a swallowed error): the tree has moved on since, and
3555            // a reset to `pre` would discard the review fixes.
3556            if self.state.reviews.is_empty()
3557                && let Some(winner) = self.state.winner().cloned()
3558            {
3559                Self::graft_restore(&winner.worktree, &rec.pre).await?;
3560            }
3561            if let Some(g) = self.state.graft.as_mut() {
3562                g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3563            }
3564            self.state.event(
3565                "graft",
3566                "rolled back: the previous round was interrupted; the winner is untouched",
3567            );
3568            self.state.save()?;
3569            return Ok(());
3570        }
3571        let plan = match graft::plan(&self.state) {
3572            Ok(plan) => plan,
3573            Err(skip) => {
3574                if skip != graft::Skip::AlreadyDone {
3575                    self.graft_skipped(skip.reason());
3576                    self.state.save()?;
3577                }
3578                return Ok(());
3579            }
3580        };
3581        let Some(winner) = self.state.winner().cloned() else {
3582            return Ok(());
3583        };
3584        let wt = winner.worktree.clone();
3585        if !git::is_clean(&wt).await? {
3586            self.graft_skipped("the winner's worktree has uncommitted changes");
3587            self.state.save()?;
3588            return Ok(());
3589        }
3590        let pre = git::rev_parse(&wt, "HEAD").await?;
3591        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3592        else {
3593            self.graft_skipped("the winner's diff could not be measured");
3594            self.state.save()?;
3595            return Ok(());
3596        };
3597        let limit = graft::limit(winner_lines);
3598        // Saved before the fixer is called: a stop from here on is repaired
3599        // by the branch above on the next entry.
3600        self.state.graft = Some(GraftRecord {
3601            pre: pre.clone(),
3602            post: None,
3603            lines: None,
3604            limit: Some(limit),
3605            agent: None,
3606            outcome: GraftOutcome::Running,
3607        });
3608        self.state.event(
3609            "graft",
3610            format!(
3611                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3612                plan.strengths
3613                    .iter()
3614                    .map(|(l, _, _)| l.to_string())
3615                    .collect::<Vec<_>>()
3616                    .join(", "),
3617                plan.winner
3618            ),
3619        );
3620        self.state.save()?;
3621
3622        let fix_prompt = prompt::graft(
3623            &self.state.instruction,
3624            plan.winner,
3625            &plan.strengths,
3626            limit,
3627            &self.state.config.graph.language,
3628        );
3629        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3630        let sessions = self.state.config.graph.sessions;
3631        let artifacts = agent::artifacts_dir(&self.state.dir());
3632        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3633        let (_, seat, out) = self
3634            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3635                SeatJob {
3636                    prompt: fix_prompt.clone(),
3637                    spec,
3638                    seat,
3639                    cwd: wt.clone(),
3640                    timeout,
3641                    allow_write: true,
3642                    sessions,
3643                    artifacts: artifacts.clone(),
3644                    stem: "graft".to_owned(),
3645                    handover: None,
3646                }
3647            })
3648            .await;
3649        let mut why: Option<String> = None;
3650        match &out {
3651            AgentOutcome::Ok(o) if agent::output_advances(o) => {
3652                why = Some("the fixer produced nothing usable".to_owned());
3653            }
3654            AgentOutcome::Ok(_) => {}
3655            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3656            AgentOutcome::Quota(o) => {
3657                self.state.quota.push(QuotaLoss {
3658                    seat: seat.key.clone(),
3659                    node: "graft".to_owned(),
3660                    at: Timestamp::now(),
3661                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3662                });
3663                why = Some("rate limited (quota)".to_owned());
3664            }
3665            AgentOutcome::Failed(e) => {
3666                why = Some(format!(
3667                    "the fixer failed: {}",
3668                    e.lines()
3669                        .next()
3670                        .unwrap_or("")
3671                        .chars()
3672                        .take(160)
3673                        .collect::<String>()
3674                ));
3675            }
3676        }
3677        let agent_id = seat.agent.clone();
3678        self.state.seats.insert(seat.key.clone(), seat);
3679
3680        let mut post = None;
3681        let mut lines = None;
3682        if why.is_none() {
3683            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3684            if let Ok(r) = &rescued {
3685                self.state.note_withheld("graft", &r.withheld);
3686            }
3687            let head = git::rev_parse(&wt, "HEAD").await?;
3688            // What is measured must be what gets reviewed and gated: a failed
3689            // rescue, or anything still uncommitted (withheld lockfiles
3690            // aside, which are never part of the tree), rejects the graft.
3691            let dirty = match &rescued {
3692                Ok(r) => git::status_porcelain(&wt)
3693                    .await
3694                    .map(|st| {
3695                        st.lines().any(|l| {
3696                            !r.withheld
3697                                .iter()
3698                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
3699                        })
3700                    })
3701                    .unwrap_or(true),
3702                Err(_) => true,
3703            };
3704            if dirty {
3705                why = Some("the fixer left changes that could not be committed".to_owned());
3706            } else if head == pre {
3707                why = Some("the fixer changed nothing".to_owned());
3708            } else {
3709                lines = git::changed_lines(&wt, &pre, &head).await;
3710                if graft::within_budget(lines, limit) {
3711                    post = Some(head);
3712                } else {
3713                    why = Some(format!(
3714                        "{} changed lines against a limit of {limit}",
3715                        lines.map_or_else(
3716                            || "an unmeasurable number of".to_owned(),
3717                            |l| l.to_string()
3718                        )
3719                    ));
3720                }
3721            }
3722        }
3723        let outcome = match why {
3724            None => {
3725                self.state.event(
3726                    "graft",
3727                    format!(
3728                        "applied: {} changed lines (limit {limit})",
3729                        lines.unwrap_or(0)
3730                    ),
3731                );
3732                GraftOutcome::Applied
3733            }
3734            Some(why) => {
3735                // Not settled until the restore is confirmed: the record
3736                // stays `Running`, so a resume retries it.
3737                Self::graft_restore(&wt, &pre).await?;
3738                self.state.event(
3739                    "graft",
3740                    format!("rolled back: {why}; the winner is untouched"),
3741                );
3742                GraftOutcome::RolledBack(why)
3743            }
3744        };
3745        self.state.graft = Some(GraftRecord {
3746            pre,
3747            post,
3748            lines,
3749            limit: Some(limit),
3750            agent: Some(agent_id),
3751            outcome,
3752        });
3753        self.state.save()?;
3754        Ok(())
3755    }
3756
3757    // ----------------------------------------------------------------- fold
3758
3759    async fn fold_losers(&mut self) -> Result<()> {
3760        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3761            return Ok(());
3762        };
3763        let repo = self.state.repo.clone();
3764        let mut folded = Vec::new();
3765        for i in 0..self.state.candidates.len() {
3766            let c = &self.state.candidates[i];
3767            if c.label == winner || c.folded {
3768                continue;
3769            }
3770            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3771            git::worktree_remove(&repo, &wt).await.ok();
3772            git::branch_delete(&repo, &branch).await.ok();
3773            self.state.candidates[i].folded = true;
3774            folded.push(label.to_string());
3775        }
3776        // The judges are finished; their checkouts are pure cost from here.
3777        let root = self.state.worktree_root();
3778        for j in 1..=self.roles.judges.len() {
3779            let wt = root.join(format!("judge-{j}"));
3780            if wt.exists() {
3781                git::worktree_remove(&repo, &wt).await.ok();
3782            }
3783        }
3784        // The design-deliberation stage is finished by the time a tally
3785        // exists — same reasoning as the judges above.
3786        if self.state.config.graph.advise {
3787            for k in 1..=self.state.config.graph.advisors {
3788                let wt = root.join(format!("advisor-{k}"));
3789                if wt.exists() {
3790                    git::worktree_remove(&repo, &wt).await.ok();
3791                }
3792            }
3793        }
3794        if !folded.is_empty() {
3795            self.state
3796                .event("fold", format!("folded candidates {}", folded.join(", ")));
3797            self.state.save()?;
3798        }
3799        Ok(())
3800    }
3801
3802    // ------------------------------------------------------------ base sync
3803
3804    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3805    /// anything verifies it.
3806    ///
3807    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3808    /// read whatever is checked out in the winner's worktree. Left alone that
3809    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3810    /// it when the run *branched* - and a run takes long enough that the base
3811    /// has usually moved by the time it gets here. A gate that ran there
3812    /// answers "green on the commit this run started from", not "green on
3813    /// what is about to land", and the difference showed up three times in
3814    /// one day as a green run whose merge would have reverted a file another
3815    /// pull request had already landed.
3816    ///
3817    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3818    /// `land::Step::Rebase` calls, rather than a second implementation of the
3819    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3820    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3821    /// doc warns about ("two answers to a question nobody notices until a
3822    /// diff is wrong").
3823    ///
3824    /// A conflict is not the end of the road: the standing rebase is handed
3825    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3826    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3827    /// land). Once it finishes, review and the gate run as usual on the
3828    /// rebased tree, which is where a breakage the new base caused is caught
3829    /// by the ordinary gate-fix round. magi resolves nothing itself.
3830    ///
3831    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3832    /// `state.base_sync.attempts`, is how many times the base is *rebased
3833    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3834    /// *conflict* was given to a fixer. When the fixer cannot finish the
3835    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3836    /// what was tried (rounds spent, paths still conflicted) and the branch
3837    /// and worktree stay exactly as they were - untouched, for a person to
3838    /// look at - which is also what makes re-entering this function
3839    /// afterwards a no-op instead of a second attempt at the same wall. A
3840    /// push failure ends the same way.
3841    async fn sync_to_base(&mut self) -> Result<()> {
3842        if self.state.status == RunStatus::AlreadyInBase {
3843            return Ok(());
3844        }
3845        let conflicted = self
3846            .state
3847            .base_sync
3848            .as_ref()
3849            .is_some_and(|s| s.conflict.is_some());
3850        let Some(winner) = self.state.winner().cloned() else {
3851            return Ok(());
3852        };
3853
3854        let repo = self.state.repo.clone();
3855        let remote = self.state.config.merge.remote.clone();
3856        let base_branch = self.state.base_branch.clone();
3857        let tracking = format!("{remote}/{base_branch}");
3858
3859        git::fetch(&repo, &remote, &base_branch).await.ok();
3860        // No network, or the remote never had this branch: the run already
3861        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
3862        // otherwise), and one that got this far is not blocked by a fetch
3863        // that fails now. It just has no newer tip to compare against.
3864        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3865            return Ok(());
3866        };
3867
3868        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3869        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3870        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3871
3872        // Before any rebase, and before a recorded conflict is honoured: a
3873        // branch whose change reached the base under other commit ids has
3874        // nothing to rebase and nothing to conflict with, and a run that
3875        // already stopped on that phantom conflict recovers here on resume.
3876        // `behind == 0` with head == tip is a branch the base has since taken
3877        // in whole, whether or not a conflict was ever recorded: the ancestry
3878        // proof must still run (`classify` ignores a head still on the start
3879        // commit).
3880        if (behind > 0 || conflicted || head == tip)
3881            && self
3882                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3883                .await?
3884        {
3885            return Ok(());
3886        }
3887        if conflicted {
3888            return Ok(());
3889        }
3890
3891        if behind == 0 {
3892            // A fixer-finished rebase moves the branch ref before the
3893            // winner's worktree is told (`sync_to_head` below). A run that
3894            // died in between resumes here with `behind == 0` and a tree still
3895            // holding the pre-rebase files, which review and the gate would
3896            // then read. That state is exactly: HEAD moved off the tip the
3897            // rebase started from, yet the tree is still identical to that
3898            // tip. A tree with edits of its own differs from it, so nothing
3899            // is thrown away.
3900            if let Some(from) = self
3901                .state
3902                .rebase_fixes
3903                .iter()
3904                .rev()
3905                .find_map(|r| r.from.clone())
3906                && from != head
3907                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3908                    .await
3909                    .is_ok_and(|o| o.ok())
3910            {
3911                git::sync_to_head(&winner.worktree).await?;
3912            }
3913            // An earlier attempt may have rebased the branch locally and died
3914            // before pushing it (only the fresh-rebase arm below pushes).
3915            // Publish it now, so the plain push at PR time is not refused as
3916            // a non-fast-forward. A run already holding a recorded conflict
3917            // never reaches here; that case is out of scope.
3918            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3919            if let Some(why) = &conflict {
3920                self.state.status = RunStatus::Blocked;
3921                self.state.event("land", why.clone());
3922            }
3923            self.state.base_sync = Some(BaseSync {
3924                tip,
3925                behind: 0,
3926                attempts,
3927                conflict,
3928                already_in: None,
3929            });
3930            self.state.save()?;
3931            return Ok(());
3932        }
3933
3934        if attempts >= BASE_SYNC_ROUNDS {
3935            let why = format!(
3936                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3937                 rebase(s); rebasing again would only race it",
3938                winner.branch
3939            );
3940            self.state.status = RunStatus::Blocked;
3941            self.state.base_sync = Some(BaseSync {
3942                tip,
3943                behind,
3944                attempts,
3945                conflict: Some(why.clone()),
3946                already_in: None,
3947            });
3948            self.state.event("land", why);
3949            self.state.save()?;
3950            return Ok(());
3951        }
3952
3953        self.state.event(
3954            "land",
3955            format!(
3956                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3957                winner.branch
3958            ),
3959        );
3960        self.state.save()?;
3961
3962        // The remote's copy of the branch, read now and only if the fetch
3963        // really succeeded (a stale tracking ref must never pin a lease). It is
3964        // pushed over after a rebase only when it is a commit this branch
3965        // already contains, by ancestry or by patch (an earlier rebase of ours
3966        // that never reached the remote): anything else is somebody else's work.
3967        let branch_tracking = format!("{remote}/{}", winner.branch);
3968        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3969        let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3970            git::rev_parse(&repo, &branch_tracking).await.ok()
3971        } else {
3972            None
3973        };
3974        // A remote tip this branch does not contain is somebody else's work:
3975        // rebasing would leave a local tip that can never be pushed. Stop
3976        // before touching anything and say so.
3977        if let Some(theirs) = &remote_tip
3978            && !git::is_ancestor(&repo, theirs, &head).await
3979            && !crate::reconcile::origin_missing(&repo, &head, theirs)
3980                .await
3981                .is_ok_and(|missing| missing.is_empty())
3982        {
3983            let why = format!(
3984                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3985                 them",
3986                short(theirs),
3987                winner.branch
3988            );
3989            self.state.status = RunStatus::Blocked;
3990            self.state.base_sync = Some(BaseSync {
3991                tip,
3992                behind,
3993                attempts,
3994                conflict: Some(why.clone()),
3995                already_in: None,
3996            });
3997            self.state.event("land", why);
3998            self.state.save()?;
3999            return Ok(());
4000        }
4001
4002        let scratch = self.state.dir().join("base-sync");
4003        let rebased = match crate::rebase::rebase_with_fixer(
4004            &mut self.state,
4005            &scratch,
4006            &winner.branch,
4007            &tracking,
4008        )
4009        .await
4010        {
4011            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4012            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4013            Err(e) => Err(e),
4014        };
4015        let attempts = attempts + 1;
4016        match rebased {
4017            Ok(None) => {
4018                // The branch ref moved, but a worktree that already had it
4019                // checked out (the winner's) was not told; sync its index and
4020                // files before anything reads them.
4021                git::sync_to_head(&winner.worktree).await?;
4022                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4023                let mut conflict = None;
4024                if let Some(pinned) = &remote_tip {
4025                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4026                    match pushed {
4027                        Ok(o) if o.ok() => self.state.event(
4028                            "land",
4029                            format!("pushed rebased {} to {remote}", winner.branch),
4030                        ),
4031                        Ok(o) => {
4032                            conflict = Some(format!(
4033                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4034                                winner.branch,
4035                                short(pinned),
4036                                o.stderr.chars().take(600).collect::<String>()
4037                            ));
4038                        }
4039                        Err(e) => {
4040                            conflict = Some(format!(
4041                                "rebased {} locally but could not push it: {e:#}",
4042                                winner.branch
4043                            ));
4044                        }
4045                    }
4046                }
4047                if let Some(why) = &conflict {
4048                    self.state.status = RunStatus::Blocked;
4049                    self.state.event("land", why.clone());
4050                }
4051                self.state.base_sync = Some(BaseSync {
4052                    tip: tip.clone(),
4053                    behind: 0,
4054                    attempts,
4055                    conflict,
4056                    already_in: None,
4057                });
4058                self.state
4059                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4060            }
4061            Ok(Some(conflict)) => {
4062                let why = format!(
4063                    "{} conflicts with {tracking} and did not rebase: {}",
4064                    winner.branch,
4065                    conflict.chars().take(600).collect::<String>()
4066                );
4067                self.state.status = RunStatus::Blocked;
4068                self.state.base_sync = Some(BaseSync {
4069                    tip,
4070                    behind,
4071                    attempts,
4072                    conflict: Some(why.clone()),
4073                    already_in: None,
4074                });
4075                self.state.event("land", why);
4076            }
4077            Err(e) => {
4078                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4079                self.state.status = RunStatus::Blocked;
4080                self.state.base_sync = Some(BaseSync {
4081                    tip,
4082                    behind,
4083                    attempts,
4084                    conflict: Some(why.clone()),
4085                    already_in: None,
4086                });
4087                self.state.event("land", why);
4088            }
4089        }
4090        self.state.save()?;
4091        Ok(())
4092    }
4093
4094    /// Push a branch an earlier attempt rebased locally but never published,
4095    /// pinned to the remote tip read right after a successful fetch. Returns
4096    /// the reason when the run must stop; `None` when there was nothing to do
4097    /// (no remote copy, the same tip, or a remote copy this branch already
4098    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4099    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4100        let repo = self.state.repo.clone();
4101        let remote = self.state.config.merge.remote.clone();
4102        let fetched = git::fetch(&repo, &remote, branch).await;
4103        if !matches!(&fetched, Ok(o) if o.ok()) {
4104            return None;
4105        }
4106        let branch_tracking = format!("{remote}/{branch}");
4107        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4108        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4109            return None;
4110        }
4111        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4112            .await
4113            .is_ok_and(|missing| missing.is_empty())
4114        {
4115            return Some(format!(
4116                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4117                 them",
4118                short(&theirs)
4119            ));
4120        }
4121        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4122            Ok(o) if o.ok() => {
4123                self.state
4124                    .event("land", format!("pushed rebased {branch} to {remote}"));
4125                None
4126            }
4127            Ok(o) => Some(format!(
4128                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4129                 someone may have pushed): {}",
4130                short(&theirs),
4131                o.stderr.chars().take(600).collect::<String>()
4132            )),
4133            Err(e) => Some(format!(
4134                "{branch} is rebased locally but could not be pushed: {e:#}"
4135            )),
4136        }
4137    }
4138
4139    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4140    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4141    /// whether it did.
4142    ///
4143    /// Checked only when the base is ahead of the branch. A failing check is
4144    /// "not proven" - the ordinary rebase path then decides - never a reason to
4145    /// stop the run.
4146    ///
4147    /// The remote copy of the branch is held to the same standard as the local
4148    /// one: if it carries a tip this worktree does not, that tip must itself be
4149    /// proven in the base, or nothing is settled (a pull request would
4150    /// otherwise be closed over commits nobody checked). The pull request is
4151    /// closed *before* the terminal status is saved; if that fails for a
4152    /// reason other than a refusal (no network, a `gh` error) the run is left
4153    /// `Blocked` with the reason as its conflict, which a resume retries -
4154    /// the same recovery a phantom conflict gets.
4155    async fn settle_already_in(
4156        &mut self,
4157        branch: &str,
4158        tip: &str,
4159        head: &str,
4160        attempts: usize,
4161        behind: usize,
4162    ) -> Result<bool> {
4163        let repo = self.state.repo.clone();
4164        let remote = self.state.config.merge.remote.clone();
4165        let start = self.state.base_commit.clone();
4166        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4167            Ok(Some(e)) => e,
4168            Ok(None) => return Ok(false),
4169            Err(e) => {
4170                tracing::warn!("already-in-base check for {branch}: {e:#}");
4171                return Ok(false);
4172            }
4173        };
4174        let mut verified = vec![head.to_owned()];
4175        let fetched = git::fetch(&repo, &remote, branch).await;
4176        if matches!(&fetched, Ok(o) if o.ok())
4177            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
4178            && theirs != head
4179        {
4180            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4181                Ok(Some(_)) => verified.push(theirs),
4182                _ => return Ok(false),
4183            }
4184        }
4185        let base_branch = self.state.base_branch.clone();
4186        let message = format!(
4187            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4188             land",
4189            evidence.names(),
4190            evidence.proof.as_str()
4191        );
4192        let closed =
4193            crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
4194        match closed {
4195            Ok(Ok(url)) => self
4196                .state
4197                .event("land", format!("closed {url}: superseded on {base_branch}")),
4198            Ok(Err(why)) => self
4199                .state
4200                .event("land", format!("did not close a pull request: {why}")),
4201            Err(e) => {
4202                let why = format!(
4203                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4204                     not be closed ({e:#}); resume to retry"
4205                );
4206                self.state.status = RunStatus::Blocked;
4207                self.state.base_sync = Some(BaseSync {
4208                    tip: tip.to_owned(),
4209                    behind,
4210                    attempts,
4211                    conflict: Some(why.clone()),
4212                    already_in: None,
4213                });
4214                self.state.event("land", why);
4215                self.state.save()?;
4216                return Ok(true);
4217            }
4218        }
4219        self.state.status = RunStatus::AlreadyInBase;
4220        self.state.base_sync = Some(BaseSync {
4221            tip: tip.to_owned(),
4222            behind,
4223            attempts,
4224            conflict: None,
4225            already_in: Some(evidence),
4226        });
4227        self.state.event("land", message);
4228        self.state.save()?;
4229        self.settle_questions();
4230        Ok(true)
4231    }
4232
4233    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4234    /// last landed the winner on, once it has run, else the commit the run
4235    /// branched from.
4236    ///
4237    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4238    /// and `vote` all happen before there is a winner to rebase, so they
4239    /// compare every candidate against the branch point on purpose, and a
4240    /// base that moves after they are already done cannot change an answer
4241    /// they already gave.
4242    fn landing_base(&self) -> String {
4243        self.state
4244            .base_sync
4245            .as_ref()
4246            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4247    }
4248
4249    // ------------------------------------------------------- operator fix
4250
4251    /// Route specific, already-recorded review findings to a fixer for a
4252    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4253    /// entry point.
4254    ///
4255    /// Distinct from `review_loop`'s own fix step in three ways: it never
4256    /// runs a reviewer wave, it never spends review-round budget, and what
4257    /// happened is recorded as an [`OperatorFixRequest`] appended to
4258    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4259    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4260    /// and vote must never be rewritten to look like a manufactured blocking
4261    /// verdict.
4262    ///
4263    /// Only meaningful once review has actually concluded: `Ready` (handed
4264    /// off with findings still open, or simply concluded clean while minor
4265    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4266    /// gate failed). Everything else is refused: a run still in progress
4267    /// should simply be resumed, and a `Merged` run's branch has already
4268    /// landed — reopening *this* run's own record cannot change that, so the
4269    /// answer there is a fresh `magi review <branch>`.
4270    ///
4271    /// A real commit here re-verifies through a fresh, ordinary review-only
4272    /// run on the same branch ([`Self::review`]) rather than reopening this
4273    /// run's own `review_loop`: once any round in this run's history went
4274    /// clean, `review_conclusion` treats that as permanent by design (the
4275    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4276    /// way to force one more genuine reviewer wave out of *this* run without
4277    /// either rewriting history or weakening that guarantee for every other
4278    /// caller. A review-only run costs nothing extra — no implementation, no
4279    /// judging, no vote — and exercises the exact same review → verify →
4280    /// gate → (human) merge path, unmodified.
4281    pub async fn fix_selected(
4282        &mut self,
4283        ids: &[String],
4284        reason: &str,
4285        allow_stale: bool,
4286    ) -> Result<()> {
4287        let reason = reason.trim();
4288        if reason.is_empty() {
4289            bail!("a fix request needs a reason — that is the operator's own record of why");
4290        }
4291        if ids.is_empty() {
4292            bail!("no finding id given");
4293        }
4294        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4295            bail!(
4296                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4297                 has already concluded — can be given a targeted fix. A run still \
4298                 in progress should simply be resumed; a `merged` run's branch has \
4299                 already landed, so its answer is a fresh `magi review <branch>`, \
4300                 not reopening this run's own record",
4301                self.state.id,
4302                self.state.status.as_str()
4303            );
4304        }
4305        let Some(winner) = self.state.winner().cloned() else {
4306            bail!("run {} has no winning candidate to fix", self.state.id);
4307        };
4308        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4309            bail!(
4310                "branch `{}` no longer exists; this run cannot be extended",
4311                winner.branch
4312            );
4313        }
4314        let home = crate::run::home();
4315        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4316            bail!(
4317                "run {} is currently being worked on by another magi process",
4318                self.state.id
4319            );
4320        }
4321        // Held for the rest of this call, including the follow-up review
4322        // below: two `magi fix` invocations against the same run must not
4323        // both reach the worktree manipulation further down, which would
4324        // otherwise race to remove and recreate the same directory — see
4325        // [`FixClaim`]'s own doc.
4326        let _claim = FixClaim::acquire(&self.state.dir())?;
4327
4328        // Resolve every id before spending anything — an unknown id refuses
4329        // the whole request rather than silently dropping it — and dedup
4330        // while keeping the operator's own order.
4331        let mut seen = BTreeSet::new();
4332        let mut findings = Vec::new();
4333        let mut missing = Vec::new();
4334        for id in ids {
4335            if !seen.insert(id.clone()) {
4336                continue;
4337            }
4338            match self.state.finding(id) {
4339                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4340                    id: f.id.clone(),
4341                    severity: f.severity,
4342                    reviewer_vote: rec.vote,
4343                    round: round.round,
4344                    round_head: round.head.clone(),
4345                    reviewer: rec.reviewer,
4346                    agent: rec.agent.clone(),
4347                    file: f.file.clone(),
4348                    line: f.line,
4349                    title: f.title.clone(),
4350                    detail: f.detail.clone(),
4351                    outcome: OperatorFixOutcome::Pending,
4352                }),
4353                None => missing.push(id.clone()),
4354            }
4355        }
4356        if !missing.is_empty() {
4357            bail!(
4358                "unknown finding id(s): {}; nothing was changed",
4359                missing.join(", ")
4360            );
4361        }
4362
4363        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4364        let stale_details: Vec<(String, String)> = findings
4365            .iter()
4366            .filter(|f| f.round_head != head_at_request)
4367            .map(|f| (f.id.clone(), f.round_head.clone()))
4368            .collect();
4369        let stale = !stale_details.is_empty();
4370        if stale && !allow_stale {
4371            bail!(
4372                "the branch has moved since some finding(s) were raised — {} — now \
4373                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4374                stale_details
4375                    .iter()
4376                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4377                    .collect::<Vec<_>>()
4378                    .join(", "),
4379                short(&head_at_request)
4380            );
4381        }
4382
4383        let request = OperatorFixRequest {
4384            requested_at: Timestamp::now(),
4385            reason: reason.to_owned(),
4386            findings,
4387            head_at_request: head_at_request.clone(),
4388            allow_stale,
4389            stale,
4390            fix: None,
4391            result_head: None,
4392            follow_up_review_run: None,
4393        };
4394        self.state.event(
4395            "fix",
4396            format!(
4397                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4398                request.findings.len(),
4399                request
4400                    .findings
4401                    .iter()
4402                    .map(|f| f.id.as_str())
4403                    .collect::<Vec<_>>()
4404                    .join(", "),
4405            ),
4406        );
4407        // Recorded now, before any worktree work or the fixer call itself —
4408        // and re-saved at each checkpoint below: a crash at any point after
4409        // this (mid fixer call, mid follow-up review) must not lose the fact
4410        // that this was requested, for which findings, and why. Everything
4411        // past this point reads and writes through `request_index` rather
4412        // than a local variable, since `request` itself is moved here.
4413        self.state.operator_fixes.push(request);
4414        self.state.save()?;
4415        let request_index = self.state.operator_fixes.len() - 1;
4416
4417        // A fresh, dedicated worktree for this one call, never the winner's
4418        // own worktree in place: that one may already be gone (folded away),
4419        // and reusing it in place would leave the branch checked out there
4420        // when the follow-up review below tries to check it out again. Freed
4421        // immediately after, either way — but only once confirmed clean:
4422        // `worktree_remove` is a `git worktree remove --force`, which would
4423        // otherwise discard uncommitted work left there by the operator or
4424        // another process before this had a chance to even look at it.
4425        if winner.worktree.exists() {
4426            // Lockfiles a rescue commit withheld stay untracked on purpose and
4427            // are already recorded; they are not the operator's work to protect.
4428            let dirty = git::git(
4429                &winner.worktree,
4430                &["status", "--porcelain", "--untracked-files=all"],
4431            )
4432            .await?;
4433            let only_withheld = dirty.lines().all(|l| {
4434                l.strip_prefix("?? ")
4435                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4436            });
4437            if !only_withheld {
4438                bail!(
4439                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4440                     discard them first",
4441                    winner.worktree.display()
4442                );
4443            }
4444            git::worktree_remove(&self.state.repo, &winner.worktree)
4445                .await
4446                .ok();
4447        }
4448        let fix_worktree = self.state.worktree_root().join("operator-fix");
4449        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4450        git::git(
4451            &self.state.repo,
4452            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4453        )
4454        .await
4455        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4456        if !git::is_clean(&fix_worktree).await? {
4457            git::worktree_remove(&self.state.repo, &fix_worktree)
4458                .await
4459                .ok();
4460            bail!(
4461                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4462                winner.branch
4463            );
4464        }
4465        let repo_for_setup = self.state.repo.clone();
4466        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4467            Ok(()) => {
4468                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4469            }
4470            Err(e) => Err(e),
4471        };
4472        if let Err(e) = setup {
4473            git::worktree_remove(&self.state.repo, &fix_worktree)
4474                .await
4475                .ok();
4476            return Err(e);
4477        }
4478
4479        let run_id = self.state.id.clone();
4480        let prompts = self.state.config.prompts.clone();
4481        let language = self.state.config.graph.language.clone();
4482        let sessions = self.state.config.graph.sessions;
4483        let artifacts = agent::artifacts_dir(&self.state.dir());
4484        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4485            .findings
4486            .iter()
4487            .map(|f| Finding {
4488                id: f.id.clone(),
4489                severity: f.severity,
4490                file: f.file.clone(),
4491                line: f.line,
4492                title: f.title.clone(),
4493                detail: f.detail.clone(),
4494            })
4495            .collect();
4496        let fix_prompt = prompt::operator_fix(
4497            &self.state.instruction,
4498            &finding_list,
4499            reason,
4500            &stale_details,
4501            &head_at_request,
4502            &language,
4503        );
4504        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4505        let (job, seat, out) = self
4506            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4507                prompt: fix_prompt.clone(),
4508                spec,
4509                seat,
4510                cwd: fix_worktree.clone(),
4511                timeout,
4512                allow_write: true,
4513                sessions,
4514                artifacts: artifacts.clone(),
4515                stem: "operator-fix".to_owned(),
4516                handover: None,
4517            })
4518            .await;
4519        let agent_id = seat.agent.clone();
4520
4521        let mut fix = FixRecord {
4522            agent: agent_id,
4523            addressed: Vec::new(),
4524            rejected: Vec::new(),
4525            notes: String::new(),
4526            committed: false,
4527            failed: None,
4528            duration_ms: 0,
4529            continuation: None,
4530        };
4531        let mut final_seat = seat.clone();
4532        match out {
4533            AgentOutcome::Ok(o) => {
4534                fix.duration_ms = o.duration_ms;
4535                let parsed = verdict::extract_json::<FixReport>(&o.text);
4536                let incomplete_reason = match &parsed {
4537                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4538                        "the reply parsed, but it reported a command whose own CLI \
4539                         never confirmed an exit status"
4540                            .to_owned(),
4541                    ),
4542                    Ok(_) => None,
4543                    Err(e) => Some(e.to_string()),
4544                };
4545                match incomplete_reason {
4546                    None => {
4547                        let report = parsed.expect("checked Ok above");
4548                        fix.addressed = report.addressed;
4549                        fix.rejected = report.rejected;
4550                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4551                    }
4552                    Some(reason) => {
4553                        let (resumed_seat, resolved, failure, cont) = self
4554                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4555                            .await;
4556                        fix.duration_ms += cont.cumulative_wait_ms;
4557                        fix.continuation = Some(cont);
4558                        final_seat = resumed_seat;
4559                        match resolved {
4560                            Some(report) => {
4561                                fix.addressed = report.addressed;
4562                                fix.rejected = report.rejected;
4563                                fix.notes =
4564                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4565                            }
4566                            None => fix.failed = failure,
4567                        }
4568                    }
4569                }
4570            }
4571            AgentOutcome::Dropped(o) => {
4572                fix.duration_ms = o.duration_ms;
4573                let why = o
4574                    .dropped
4575                    .as_ref()
4576                    .map(|d| d.why.as_str())
4577                    .unwrap_or("the CLI ended the stream without delivering its answer");
4578                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4579            }
4580            AgentOutcome::Quota(o) => {
4581                self.state.quota.push(QuotaLoss {
4582                    seat: final_seat.key.clone(),
4583                    node: "fix".to_owned(),
4584                    at: Timestamp::now(),
4585                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4586                });
4587                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4588            }
4589            AgentOutcome::Failed(e) => fix.failed = Some(e),
4590        }
4591        if fix.continuation.is_none() {
4592            fix.continuation = Some(ContinuationRecord::not_needed());
4593        }
4594        self.state.seats.insert(final_seat.key.clone(), final_seat);
4595
4596        let rescue_message = format!(
4597            "magi: operator-selected fix ({}) (uncommitted work)",
4598            self.state.operator_fixes[request_index]
4599                .findings
4600                .iter()
4601                .map(|f| f.id.as_str())
4602                .collect::<Vec<_>>()
4603                .join(", ")
4604        );
4605        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4606            self.state.note_withheld("fix", &r.withheld);
4607        }
4608        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4609        fix.committed = after != head_at_request;
4610        git::worktree_remove(&self.state.repo, &fix_worktree)
4611            .await
4612            .ok();
4613
4614        self.state.event(
4615            "fix",
4616            match &fix.failed {
4617                Some(reason) => format!(
4618                    "operator fix: adoption report was lost ({reason}); {}",
4619                    if fix.committed {
4620                        "committed"
4621                    } else {
4622                        "NO new commit"
4623                    }
4624                ),
4625                None => format!(
4626                    "operator fix: {} addressed, {} rejected, {}",
4627                    fix.addressed.len(),
4628                    fix.rejected.len(),
4629                    if fix.committed {
4630                        "committed"
4631                    } else {
4632                        "NO new commit"
4633                    }
4634                ),
4635            },
4636        );
4637
4638        // Every selected finding gets an outcome — never left `Pending` once
4639        // the fixer's own turn is over. A report that never came back at all
4640        // marks every one of them `Unreported`, not silently "not addressed":
4641        // quota, a dropped stream, or an exhausted continuation are gaps in
4642        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4643        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4644        for f in &mut self.state.operator_fixes[request_index].findings {
4645            f.outcome = if fix.failed.is_some() {
4646                OperatorFixOutcome::Unreported
4647            } else if fix.addressed.contains(&f.id) {
4648                OperatorFixOutcome::Addressed
4649            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4650                OperatorFixOutcome::Rejected { why: r.why.clone() }
4651            } else {
4652                OperatorFixOutcome::Unreported
4653            };
4654        }
4655
4656        let committed = fix.committed;
4657        if committed {
4658            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4659        }
4660        self.state.operator_fixes[request_index].fix = Some(fix);
4661        // Saved again now that the fixer's own outcome is final, on top of
4662        // the save right after the request was first pushed above.
4663        self.state.save()?;
4664
4665        if committed {
4666            self.state.event(
4667                "fix",
4668                format!(
4669                    "operator fix committed {}; opening a follow-up review-only run",
4670                    short(&after)
4671                ),
4672            );
4673            // The operator asked for the fix, and the follow-up serves whatever
4674            // task the run it follows served.
4675            let origin =
4676                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4677            match Self::review(
4678                &self.state.repo,
4679                &winner.branch,
4680                self.state.config.clone(),
4681                origin,
4682            )
4683            .await
4684            {
4685                Ok(mut follow_up) => {
4686                    follow_up.state.event(
4687                        "start",
4688                        format!(
4689                            "requested by an operator fix on run {} for finding(s) {}",
4690                            self.state.id,
4691                            self.state.operator_fixes[request_index]
4692                                .findings
4693                                .iter()
4694                                .map(|f| f.id.as_str())
4695                                .collect::<Vec<_>>()
4696                                .join(", "),
4697                        ),
4698                    );
4699                    follow_up.state.save()?;
4700                    let follow_up_id = follow_up.state.id.clone();
4701                    // The follow-up is a run like any other: it belongs to the
4702                    // task of the run it follows, or to one filed for it.
4703                    let adopted = match crate::direct::adopt(
4704                        &follow_up.state,
4705                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4706                    ) {
4707                        Ok(a) => a,
4708                        Err(e) => {
4709                            // An ownerless run must not spend agent calls; it
4710                            // stays saved, and `magi run --resume` adopts it.
4711                            self.state.event(
4712                                "fix",
4713                                format!(
4714                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4715                                ),
4716                            );
4717                            self.state.save()?;
4718                            return Ok(());
4719                        }
4720                    };
4721                    let executed = follow_up.execute().await;
4722                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4723                    if let Some(a) = adopted {
4724                        a.finish(&follow_up.state, executed);
4725                    }
4726                    if let Some(e) = failure {
4727                        self.state.event(
4728                            "fix",
4729                            format!(
4730                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4731                            ),
4732                        );
4733                    }
4734                    self.state.operator_fixes[request_index].follow_up_review_run =
4735                        Some(follow_up_id);
4736                }
4737                Err(e) => {
4738                    self.state.event(
4739                        "fix",
4740                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4741                    );
4742                }
4743            }
4744            self.state.save()?;
4745        }
4746
4747        Ok(())
4748    }
4749
4750    // --------------------------------------------------------------- review
4751
4752    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
4753    /// names, in order, each at most once, moving on only when the call
4754    /// advances (an error, a quota hit or nothing usable - the same decision
4755    /// point `agent::chain_advances` is for the other chained roles).
4756    ///
4757    /// Each agent gets its own seat from `Runner::seat`: the same agent
4758    /// continues its conversation (the winner's own implementer seat when it
4759    /// is the winner's author, now that the competition is over), another
4760    /// takes a fresh one so the full prompt is sent again. A handover is
4761    /// recorded under `node`, which is what makes the fallback stick for the
4762    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
4763    /// is returned, so an exhausted chain reads exactly like a single failed
4764    /// fixer: one quota loss, the same wording, the same refund. An earlier
4765    /// attempt's edits are left in the tree and judged, with the final
4766    /// attempt's, by what git says afterwards.
4767    ///
4768    /// Returns the job that produced the outcome, for `continue_fix_report`.
4769    async fn ask_fixer(
4770        &mut self,
4771        winner: &Candidate,
4772        node: &'static str,
4773        round: Option<usize>,
4774        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4775    ) -> (SeatJob, SeatState, AgentOutcome) {
4776        let attempts = fixer::attempts(&self.state, &self.roles, winner);
4777        self.ask_fixer_among(attempts, node, round, None, build)
4778            .await
4779    }
4780
4781    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
4782    /// worktree and a commit: before the next agent is asked after one that
4783    /// did not satisfy the call, the worktree is put back on that commit, so
4784    /// a failed agent's partial edits are not carried into the next one.
4785    async fn ask_fixer_among(
4786        &mut self,
4787        attempts: Vec<(AgentSpec, String)>,
4788        node: &'static str,
4789        round: Option<usize>,
4790        restore: Option<(&Path, &str)>,
4791        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4792    ) -> (SeatJob, SeatState, AgentOutcome) {
4793        let run_id = self.state.id.clone();
4794        let prompts = self.state.config.prompts.clone();
4795        let cache = self.state.config.cache_dir();
4796        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4797        let mut last = None;
4798        for (i, (spec, key)) in attempts.into_iter().enumerate() {
4799            let seat = self.seat(&key, &spec.id);
4800            let mut job = build(spec.clone(), seat);
4801            if i > 0 {
4802                job.stem = format!("{}-{}", job.stem, spec.id);
4803            }
4804            let ctx = WaveCtx {
4805                carry_seats: false,
4806                run: &run_id,
4807                node,
4808                prompts: &prompts,
4809                cache: cache.as_deref(),
4810                round,
4811            };
4812            let (seat, out) =
4813                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4814            let advances = match &out {
4815                AgentOutcome::Ok(o) => agent::output_advances(o),
4816                _ => true,
4817            };
4818            if let Some(next) = ids.get(i + 1)
4819                && advances
4820            {
4821                self.state.seats.insert(seat.key.clone(), seat.clone());
4822                let class =
4823                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4824                let reason = match &out {
4825                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4826                    other => fail_reason(other),
4827                };
4828                record_handover(
4829                    &mut self.state,
4830                    node,
4831                    &seat.key,
4832                    &spec.id,
4833                    next,
4834                    &class,
4835                    &reason,
4836                );
4837                // A failed agent's edits must not reach the next one, and a
4838                // restore that did not work ends the chain: the call fails
4839                // and the caller's own (confirmed) restore decides.
4840                if let Some((wt, rev)) = restore
4841                    && let Err(e) = git::restore_to(wt, rev).await
4842                {
4843                    let why =
4844                        format!("could not restore the worktree before the next fixer: {e:#}");
4845                    tracing::warn!("{why}");
4846                    last = Some((job, seat, AgentOutcome::Failed(why)));
4847                    break;
4848                }
4849                continue;
4850            }
4851            last = Some((job, seat, out));
4852            break;
4853        }
4854        last.expect("the fixer chain always has an entry")
4855    }
4856
4857    async fn review_loop(&mut self) -> Result<()> {
4858        // A base that would not rebase is a person's decision, not a review
4859        // round: nothing here would change the answer, and reviewers and a
4860        // fixer would be spending real budget on a tree that cannot land
4861        // regardless of what they find.
4862        if self
4863            .state
4864            .base_sync
4865            .as_ref()
4866            .is_some_and(|s| s.conflict.is_some())
4867        {
4868            return Ok(());
4869        }
4870        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4871        // agent files with `magi task add` name the run that paid for it. The
4872        // prompt overlay is cloned alongside it because the waves borrow it
4873        // while `self` is mutably borrowed by the node's own bookkeeping.
4874        let run_id = self.state.id.clone();
4875        let prompts = self.state.config.prompts.clone();
4876        let Some(winner) = self.state.winner().cloned() else {
4877            return Ok(());
4878        };
4879        let max_rounds = self.state.config.graph.review_rounds;
4880        // A clean round, an exhausted round budget, or a stalled tree (see
4881        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4882        // they are recorded — recomputed here, not read off `status`, so a
4883        // reentry into a run that already stopped restates the identical
4884        // verdict instead of silently handing back whatever an earlier node
4885        // in this same walk clobbered `status` to (a solo-candidate
4886        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4887        // below runs an empty range once the budget is spent, and would
4888        // otherwise fall through without touching `status` at all.
4889        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4890            // A reentry after a crash between the last round's save and
4891            // `stop_reviewing` reaches the hand-off here, not there.
4892            if status == RunStatus::Gating {
4893                self.record_contested_handoff();
4894            }
4895            self.state.status = status;
4896            self.state.save()?;
4897            return Ok(());
4898        }
4899        self.state.status = RunStatus::Reviewing;
4900        // A last recorded round whose own verification never resolved
4901        // (`ResourceBlocked` — the shared build cache, not the patch) is
4902        // never a concluded round, whatever the round budget says: starting
4903        // a fresh round on top of it would spend a whole new reviewer wave
4904        // re-reading an unchanged patch instead of just retrying the one
4905        // check that actually needs it, and once the budget is spent the
4906        // loop below has nothing left to do at all (its range is empty).
4907        // Retry that check directly instead, exactly the same retry
4908        // `stop_reviewing` already does for its own catch-up case.
4909        if self
4910            .state
4911            .reviews
4912            .last()
4913            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4914        {
4915            let shell = self.state.config.shell();
4916            return self
4917                .stop_reviewing(
4918                    "the last round's own verification never resolved",
4919                    &shell,
4920                    &winner.worktree,
4921                )
4922                .await;
4923        }
4924
4925        let repo = self.state.repo.clone();
4926        let root = self.state.worktree_root();
4927        let language = self.state.config.graph.language.clone();
4928        let sessions = self.state.config.graph.sessions;
4929        let artifacts = agent::artifacts_dir(&self.state.dir());
4930        let base = self.landing_base();
4931        let base_short = short(&base);
4932        let reviewers = self.roles.reviewers.clone();
4933        let shell = self.state.config.shell();
4934
4935        for round in (self.state.reviews.len() + 1)..=max_rounds {
4936            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4937            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4938            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4939            // The prior round's own record, already persisted — never a
4940            // hand-carried variable of just its failing output: that is
4941            // exactly what let a round's e2e result drift out of sync with
4942            // which commit it was actually about (see `SCHEMA`'s doc for
4943            // schema 8). Judged against `head`, the commit reviewers are
4944            // about to look at now, so the summary always reads as "an
4945            // earlier head" here — this round's own patch has not been
4946            // checked yet.
4947            let prev_verification = self
4948                .state
4949                .reviews
4950                .last()
4951                .and_then(|r| r.verification_summary(&head));
4952
4953            // Each reviewer gets its own detached checkout of exactly this
4954            // commit: nobody can perturb the winner's tree, and the fixer can
4955            // keep working without racing a reviewer.
4956            let mut jobs = Vec::new();
4957            for (r, spec) in reviewers.iter().cloned().enumerate() {
4958                let wt = root.join(format!("review-{}", r + 1));
4959                // `clean -fdx` in a reset wipes setup's products, so every
4960                // round sets the seat up again.
4961                if wt.exists() {
4962                    git::reset_detached(&wt, &head).await?;
4963                } else {
4964                    git::worktree_add_detached(&repo, &wt, &head).await?;
4965                }
4966                self.setup_seat_worktree(&repo, &wt, false).await?;
4967                let seat_key = format!("review-{}", r + 1);
4968                // The seat starts the round on whoever answered it last, not
4969                // on the agent the spec names, so a failure is not re-paid.
4970                let spec = pick_start_spec(
4971                    &self.roles.reviewer_roster,
4972                    spec,
4973                    self.state.seat_history.get(&seat_key),
4974                );
4975                let seat = self.seat(&seat_key, &spec.id);
4976                jobs.push(SeatJob {
4977                    prompt: prompt::review(&prompt::ReviewCtx {
4978                        instruction: &self.state.instruction,
4979                        branch: &winner.branch,
4980                        base_short: &base_short,
4981                        stat: &stat,
4982                        patch: &patch,
4983                        verification: prev_verification.as_ref(),
4984                        reviewers: reviewers.len(),
4985                        round,
4986                        rounds: max_rounds,
4987                        // A review-only run has no rankings, so nothing
4988                        // competed for this patch and the reviewer is told so.
4989                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4990                        lens: Lens::for_seat(r),
4991                        language: &language,
4992                    }),
4993                    spec,
4994                    seat,
4995                    cwd: wt,
4996                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4997                    allow_write: false,
4998                    sessions,
4999                    artifacts: artifacts.clone(),
5000                    stem: format!("review-{round}-{}", r + 1),
5001                    handover: None,
5002                });
5003            }
5004
5005            self.state.event(
5006                "review",
5007                format!(
5008                    "round {round}: {} reviewers on {}",
5009                    jobs.len(),
5010                    short(&head)
5011                ),
5012            );
5013            let mut quota_losses = Vec::new();
5014            let review_retries = self.state.config.graph.retries;
5015            let review_cache = self.state.config.cache_dir();
5016            let ctx = WaveCtx {
5017                carry_seats: true,
5018                run: &run_id,
5019                node: "review",
5020                prompts: &prompts,
5021                cache: review_cache.as_deref(),
5022                round: Some(round),
5023            };
5024            let results = ask_json_wave::<Review>(
5025                jobs,
5026                Arc::clone(&self.sem),
5027                review_retries,
5028                &self.roles.reviewer_roster,
5029                &ctx,
5030                &mut quota_losses,
5031                &mut self.state,
5032                &|_: &Review| Ok(()),
5033            )
5034            .await;
5035            // Counted before the move below: how many of *this* round's
5036            // reviewer seats were lost to their own rate limit, as opposed to
5037            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5038            let round_quota_missing = quota_losses.len();
5039            self.state.quota.extend(quota_losses);
5040
5041            let mut records = Vec::new();
5042            let mut all_findings = Vec::new();
5043            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5044                let agent_id = seat.agent.clone();
5045                self.state.seats.insert(seat.key.clone(), seat);
5046                let mut record = ReviewRecord {
5047                    reviewer: r + 1,
5048                    agent: agent_id,
5049                    summary: String::new(),
5050                    findings: Vec::new(),
5051                    vote: None,
5052                    failed: None,
5053                    duration_ms: 0,
5054                    // Set for both outcomes: `failed: Some(_)` with
5055                    // `attempts > 0` is a seat every retry still lost, not a
5056                    // recovered one — only `failed: None` with `attempts > 0`
5057                    // reads as "answered after a nudge" (see this field's own
5058                    // doc).
5059                    attempts,
5060                };
5061                match res {
5062                    Ok((review, out)) => {
5063                        // Sanitized here, at the point every other piece of
5064                        // agent prose in this file is (candidate summaries,
5065                        // deliberation turns, vote reasons): a reviewer's own
5066                        // words are the one thing about it that could name
5067                        // it, and reconsideration below broadcasts this same
5068                        // summary and these same findings to every other
5069                        // seat on the panel.
5070                        record.summary =
5071                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5072                        record.vote = Some(review.vote);
5073                        record.duration_ms = out.duration_ms;
5074                        for (n, mut f) in review.findings.into_iter().enumerate() {
5075                            // ids are magi's, never the agent's: the fixer's
5076                            // adoption report is keyed by them.
5077                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5078                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5079                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5080                            // `file` is agent-supplied prose too, never
5081                            // checked against the real tree — the same
5082                            // exposure `title`/`detail` above have, just in
5083                            // a field easy to forget because it looks like a
5084                            // path rather than free text.
5085                            f.file = f
5086                                .file
5087                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5088                            all_findings.push(f.clone());
5089                            record.findings.push(f);
5090                        }
5091                        self.state.event(
5092                            "review",
5093                            format!(
5094                                "round {round}: reviewer {} voted {} with {} finding(s)",
5095                                r + 1,
5096                                review.vote.label(),
5097                                record.findings.len()
5098                            ),
5099                        );
5100                    }
5101                    Err(e) => {
5102                        record.failed = Some(e.to_string());
5103                        self.state.event(
5104                            "review",
5105                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5106                        );
5107                    }
5108                }
5109                records.push(record);
5110            }
5111
5112            // Tally the round's votes and, if they split, spend the one
5113            // round of reconsideration the split -> deliberate -> revote
5114            // shape `judge`/`vote` use for the panel, sized down to what a
5115            // read-only review round can afford: one round, and a revote
5116            // rather than an argument, because the panel already wrote its
5117            // reasoning down as findings the first time around.
5118            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5119            let vote_split =
5120                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5121            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5122            if vote_split {
5123                self.state.event(
5124                    "review",
5125                    format!(
5126                        "round {round}: votes split ({}) — one round of reconsideration",
5127                        initial_votes
5128                            .iter()
5129                            .map(|v| v.label())
5130                            .collect::<Vec<_>>()
5131                            .join(", ")
5132                    ),
5133                );
5134                // Seats read every seat's findings and votes, still numbered
5135                // and never named — the same anonymity `review` itself keeps.
5136                let panel: Vec<ReviewSeatReport<'_>> = records
5137                    .iter()
5138                    .filter_map(|r| {
5139                        r.vote.map(|vote| ReviewSeatReport {
5140                            reviewer: r.reviewer,
5141                            vote,
5142                            summary: &r.summary,
5143                            findings: &r.findings,
5144                        })
5145                    })
5146                    .collect();
5147
5148                let mut jobs = Vec::new();
5149                let mut seats_at = Vec::new();
5150                for (r, spec) in reviewers.iter().cloned().enumerate() {
5151                    // A seat with no initial vote has nothing to reconsider
5152                    // from and stays absent, the same as it stayed absent
5153                    // from `panel` above.
5154                    if records[r].vote.is_none() {
5155                        continue;
5156                    }
5157                    let wt = root.join(format!("review-{}", r + 1));
5158                    let seat_key = format!("review-{}", r + 1);
5159                    let spec = self.occupant(&seat_key, spec);
5160                    let seat = self.seat(&seat_key, &spec.id);
5161                    // A seat with no live session has already forgotten the
5162                    // initial review's prompt — restate the patch it is
5163                    // voting on, the same as `deliberate`/`vote` do for a
5164                    // judge in the same position.
5165                    // The panel already carries this seat's own review and
5166                    // vote, so restating the patch makes the prompt whole for
5167                    // a seat handed to another agent.
5168                    let build = |with_patch: bool| {
5169                        prompt::review_reconsider(&ReviewReconsiderCtx {
5170                            instruction: &self.state.instruction,
5171                            reviewer: r + 1,
5172                            lens: Lens::for_seat(r),
5173                            panel: &panel,
5174                            patch: with_patch.then_some(ReviewPatch {
5175                                branch: &winner.branch,
5176                                base_short: &base_short,
5177                                stat: &stat,
5178                                patch: &patch,
5179                            }),
5180                            round,
5181                            rounds: max_rounds,
5182                            language: &language,
5183                        })
5184                    };
5185                    let full = build(true);
5186                    let prompt = if has_context(&spec, &seat, sessions) {
5187                        build(false)
5188                    } else {
5189                        full.clone()
5190                    };
5191                    jobs.push(SeatJob {
5192                        prompt,
5193                        spec,
5194                        seat,
5195                        cwd: wt,
5196                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5197                        allow_write: false,
5198                        sessions,
5199                        artifacts: artifacts.clone(),
5200                        stem: format!("review-{round}-reconsider-{}", r + 1),
5201                        handover: Some(full),
5202                    });
5203                    seats_at.push(r);
5204                }
5205
5206                let mut recon_quota_losses = Vec::new();
5207                let recon_cache = self.state.config.cache_dir();
5208                let recon_ctx = WaveCtx {
5209                    carry_seats: true,
5210                    run: &run_id,
5211                    node: "review",
5212                    prompts: &prompts,
5213                    cache: recon_cache.as_deref(),
5214                    round: Some(round),
5215                };
5216                let recon_results = ask_json_wave::<ReviewRevote>(
5217                    jobs,
5218                    Arc::clone(&self.sem),
5219                    review_retries,
5220                    &self.roles.reviewer_roster,
5221                    &recon_ctx,
5222                    &mut recon_quota_losses,
5223                    &mut self.state,
5224                    &|_: &ReviewRevote| Ok(()),
5225                )
5226                .await;
5227                self.state.quota.extend(recon_quota_losses);
5228
5229                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5230                    let agent_id = seat.agent.clone();
5231                    self.state.seats.insert(seat.key.clone(), seat);
5232                    let mut rec = ReviewRevoteRecord {
5233                        reviewer: r + 1,
5234                        agent: agent_id,
5235                        vote: None,
5236                        reason: String::new(),
5237                        failed: None,
5238                    };
5239                    match res {
5240                        Ok((rv, _)) => {
5241                            rec.vote = Some(rv.vote);
5242                            rec.reason =
5243                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5244                            self.state.event(
5245                                "review",
5246                                format!(
5247                                    "round {round}: reviewer {} revoted {}",
5248                                    r + 1,
5249                                    rv.vote.label()
5250                                ),
5251                            );
5252                        }
5253                        Err(e) => {
5254                            rec.failed = Some(e.to_string());
5255                            self.state.event(
5256                                "review",
5257                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5258                            );
5259                        }
5260                    }
5261                    reconsideration.push(rec);
5262                }
5263            } else if initial_votes.len() > 1 {
5264                self.state.event(
5265                    "review",
5266                    format!(
5267                        "round {round}: votes agreed ({}) — no reconsideration",
5268                        initial_votes[0].label()
5269                    ),
5270                );
5271            }
5272
5273            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5274            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5275            // A round that already has a blocking finding and a round left to
5276            // try is going back to the fixer no matter what `verify.e2e`
5277            // says, so running it first only spends the loop's slowest step
5278            // (minutes, for a Rust repo's full test suite) on a head about
5279            // to be rewritten. Deferred, never skipped: `verify.e2e` still
5280            // runs once a round has no blocking findings left (see
5281            // `round_is_clean`, which a deferred — empty — `e2e` can never
5282            // satisfy since `blocking` is nonzero whenever this branch is
5283            // taken), and `stop_reviewing` forces a real run before it will
5284            // ever read a deferred round as green.
5285            let defer_e2e =
5286                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5287            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5288                let reason =
5289                    format!("{blocking} blocking finding(s) already required a fix this round");
5290                self.state.event(
5291                    "verify",
5292                    format!(
5293                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5294                         {}); it will run once a round has none left",
5295                        short(&head)
5296                    ),
5297                );
5298                (Vec::new(), false, true, Some(reason))
5299            } else {
5300                let e2e_commands = self.state.config.verify.e2e.clone();
5301                let cache_dir = self.state.config.cache_dir();
5302                let context = format!("round {round}");
5303                let (e2e, verify_retried) = with_cache_lease(
5304                    &mut self.state,
5305                    cache_dir.as_deref(),
5306                    "e2e",
5307                    "e2e",
5308                    &winner.worktree,
5309                    &head,
5310                    verify_timeout,
5311                    &context,
5312                    |state, budget| {
5313                        let shell = shell.clone();
5314                        let e2e_commands = e2e_commands.clone();
5315                        let worktree = winner.worktree.clone();
5316                        let context = context.clone();
5317                        async move {
5318                            run_e2e_with_retry(
5319                                state,
5320                                &shell,
5321                                &e2e_commands,
5322                                &worktree,
5323                                budget,
5324                                &context,
5325                            )
5326                            .await
5327                        }
5328                    },
5329                )
5330                .await;
5331                (e2e, verify_retried, false, None)
5332            };
5333
5334            let expected = records.len();
5335            let answered = records.iter().filter(|r| r.failed.is_none()).count();
5336            let incomplete = answered < expected;
5337            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5338            let policy = self.state.config.graph.incomplete_review;
5339            let clean = round_is_clean(
5340                blocking,
5341                e2e_ok,
5342                answered,
5343                expected,
5344                round_quota_missing,
5345                policy,
5346            );
5347
5348            let mut round_record = ReviewRound {
5349                round,
5350                head: head.clone(),
5351                verified_head: None,
5352                verified_at: None,
5353                reviews: records,
5354                e2e,
5355                verify_retried,
5356                e2e_deferred,
5357                e2e_defer_reason,
5358                fix: None,
5359                blocking,
5360                answered,
5361                expected,
5362                clean,
5363                progressed: false,
5364                vote_split,
5365                reconsideration,
5366                verdict: None,
5367            };
5368            // The final vote per seat is its revote where reconsideration
5369            // ran and answered, its initial vote otherwise — the same
5370            // fallback `tally` uses for a judge whose private vote failed.
5371            round_record.verdict = ReviewVote::worst(
5372                round_record
5373                    .final_votes()
5374                    .into_iter()
5375                    .map(|(_, _, vote)| vote),
5376            );
5377            // Which commit and when magi actually attempted to check —
5378            // known the moment a command was dispatched against `head`,
5379            // whether or not it finished: a resource-blocked attempt still
5380            // targeted a specific commit at a specific time, and leaving
5381            // that unrecorded is exactly what made `verification_summary`
5382            // report a fresh attempt as "commit unknown ... recorded before
5383            // this was tracked", indistinguishable from a genuinely old,
5384            // untracked record. Only a deferred or unconfigured round never
5385            // ran at all and has nothing to record — see
5386            // `ReviewRound::verified_head`'s own doc.
5387            if !matches!(
5388                round_record.e2e_status(),
5389                E2eStatus::Deferred | E2eStatus::NotConfigured
5390            ) {
5391                round_record.verified_head = Some(head.clone());
5392                round_record.verified_at = Some(Timestamp::now());
5393            }
5394            let this_round_verification = round_record.verification_summary(&head);
5395
5396            if incomplete {
5397                let missing: Vec<String> = round_record
5398                    .reviews
5399                    .iter()
5400                    .filter(|r| r.failed.is_some())
5401                    .map(|r| format!("review-{}", r.reviewer))
5402                    .collect();
5403                self.state.event(
5404                    "review",
5405                    format!(
5406                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5407                        missing.join(", ")
5408                    ),
5409                );
5410            }
5411
5412            if clean {
5413                self.state.event(
5414                    "review",
5415                    if incomplete && policy == IncompleteReviewPolicy::Warn {
5416                        format!(
5417                            "round {round}: clean (warn policy, incomplete panel) — no \
5418                             blocking findings from the seats that answered, verification green"
5419                        )
5420                    } else if incomplete {
5421                        format!(
5422                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5423                             quorum) — no blocking findings from the seats that answered, \
5424                             verification green",
5425                            expected - answered
5426                        )
5427                    } else {
5428                        format!("round {round}: clean — no blocking findings, verification green")
5429                    },
5430                );
5431                self.state.reviews.push(round_record);
5432                self.state.status = RunStatus::Gating;
5433                self.state.save()?;
5434                return Ok(());
5435            }
5436
5437            // Nothing was raised and verification passed, but not every seat
5438            // answered and `round_is_clean` still refused to call it clean —
5439            // either a seat is missing for a reason other than its own quota
5440            // (a crash, a timeout, unparsable output — worth another try), or
5441            // every seat that could have answered lost its quota and nobody
5442            // is left to decide on: re-review rather than send the fixer
5443            // after a round with nothing to fix.
5444            if incomplete && blocking == 0 && e2e_ok {
5445                self.state.reviews.push(round_record);
5446                self.state.save()?;
5447                if round == max_rounds {
5448                    self.state.status = RunStatus::Blocked;
5449                    self.state.event(
5450                        "review",
5451                        format!(
5452                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5453                             refusing to call it clean",
5454                            expected - answered
5455                        ),
5456                    );
5457                    return Ok(());
5458                }
5459                continue;
5460            }
5461
5462            // Nothing for the fixer to act on (`blocking == 0`) and the only
5463            // reason this round is not clean is that magi itself never got
5464            // a command to run — the shared build cache, not the patch (see
5465            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5466            // the fixer would invite a change to appease contention that has
5467            // nothing to do with the diff, and would leave this attempt
5468            // sitting in the next round's prompt as if it were about an
5469            // earlier, superseded commit rather than what it actually is:
5470            // the same head, still waiting to be checked. Wait for it the
5471            // same way the final round's own contention is already handled,
5472            // whatever round this happens to be.
5473            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5474                self.state.reviews.push(round_record);
5475                return self
5476                    .stop_reviewing(
5477                        "the round's own verification could not run",
5478                        &shell,
5479                        &winner.worktree,
5480                    )
5481                    .await;
5482            }
5483
5484            if round == max_rounds {
5485                self.state.reviews.push(round_record);
5486                return self
5487                    .stop_reviewing(
5488                        &format!(
5489                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5490                        ),
5491                        &shell,
5492                        &winner.worktree,
5493                    )
5494                    .await;
5495            }
5496
5497            // Fix. The winner's own implementer seat continues its conversation:
5498            // the competition is over, so context is pure benefit now.
5499            let blocking_findings: Vec<_> = all_findings
5500                .iter()
5501                .filter(|f| f.severity.blocks())
5502                .cloned()
5503                .collect();
5504            let fix_prompt = prompt::fix(
5505                &self.state.instruction,
5506                &blocking_findings,
5507                this_round_verification.as_ref(),
5508                round,
5509                max_rounds,
5510                &language,
5511            );
5512            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5513            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5514            let (job, seat, out) = self
5515                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5516                    prompt: fix_prompt.clone(),
5517                    spec,
5518                    seat,
5519                    cwd: winner.worktree.clone(),
5520                    timeout,
5521                    allow_write: true,
5522                    sessions,
5523                    artifacts: artifacts.clone(),
5524                    stem: format!("fix-{round}"),
5525                    handover: None,
5526                })
5527                .await;
5528            let agent_id = seat.agent.clone();
5529
5530            let mut fix = FixRecord {
5531                agent: agent_id,
5532                addressed: Vec::new(),
5533                rejected: Vec::new(),
5534                notes: String::new(),
5535                committed: false,
5536                failed: None,
5537                duration_ms: 0,
5538                continuation: None,
5539            };
5540            let mut continuation = ContinuationRecord::not_needed();
5541            let mut final_seat = seat.clone();
5542            match out {
5543                AgentOutcome::Ok(o) => {
5544                    fix.duration_ms = o.duration_ms;
5545                    let parsed = verdict::extract_json::<FixReport>(&o.text);
5546                    // A parsed report standing next to a command this same
5547                    // reply's own CLI never confirmed the exit status of is
5548                    // not a resolved answer — the identical `CommandEvidence`
5549                    // `state.jobs` renders, read here instead of only on
5550                    // display, per the completion judgment and the shown
5551                    // record needing to agree.
5552                    let incomplete_reason = match &parsed {
5553                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5554                            "the reply parsed, but it reported a command whose own CLI never \
5555                             confirmed an exit status"
5556                                .to_owned(),
5557                        ),
5558                        Ok(_) => None,
5559                        Err(e) => Some(e.to_string()),
5560                    };
5561                    match incomplete_reason {
5562                        None => {
5563                            let report = parsed.expect("checked Ok above");
5564                            fix.addressed = report.addressed;
5565                            fix.rejected = report.rejected;
5566                            fix.notes =
5567                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
5568                        }
5569                        Some(reason) => {
5570                            let (resumed_seat, resolved, failure, cont) = self
5571                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5572                                .await;
5573                            fix.duration_ms += cont.cumulative_wait_ms;
5574                            continuation = cont;
5575                            final_seat = resumed_seat;
5576                            match resolved {
5577                                Some(report) => {
5578                                    fix.addressed = report.addressed;
5579                                    fix.rejected = report.rejected;
5580                                    fix.notes = blind::sanitize_prose(
5581                                        &report.notes,
5582                                        &self.state.config.blind,
5583                                    );
5584                                }
5585                                None => fix.failed = failure,
5586                            }
5587                        }
5588                    }
5589                }
5590                // The CLI's raw error JSON is not a fix report to parse.
5591                AgentOutcome::Dropped(o) => {
5592                    fix.duration_ms = o.duration_ms;
5593                    let why = o
5594                        .dropped
5595                        .as_ref()
5596                        .map(|d| d.why.as_str())
5597                        .unwrap_or("the CLI ended the stream without delivering its answer");
5598                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5599                }
5600                AgentOutcome::Quota(o) => {
5601                    self.state.quota.push(QuotaLoss {
5602                        seat: final_seat.key.clone(),
5603                        node: "fix".to_owned(),
5604                        at: Timestamp::now(),
5605                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5606                    });
5607                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5608                }
5609                AgentOutcome::Failed(e) => fix.failed = Some(e),
5610            }
5611            fix.continuation = Some(continuation);
5612            self.state.seats.insert(final_seat.key.clone(), final_seat);
5613            if let Ok(r) = git::rescue_commit(
5614                &winner.worktree,
5615                &format!("magi: review round {round} fixes (uncommitted work)"),
5616            )
5617            .await
5618            {
5619                self.state.note_withheld("fix", &r.withheld);
5620            }
5621            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5622            fix.committed = after != before;
5623            // Judged by what `git` says moved against base, never by the
5624            // fixer's own `addressed`/`rejected` count — see
5625            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5626            // `patch` snapshot above: swallowing this error would default
5627            // `diff_after` to empty, which almost always differs from a
5628            // non-empty `patch` and reads as "progressed" — exactly backwards
5629            // for a `git` failure the stagnation check cannot see through.
5630            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5631            let progressed = diff_after != patch;
5632            let commit_note = if fix.committed {
5633                "committed"
5634            } else {
5635                "NO new commit"
5636            };
5637            let tree_note = if progressed {
5638                "changed vs base"
5639            } else {
5640                "unchanged vs base"
5641            };
5642            self.state.event(
5643                "fix",
5644                match &fix.failed {
5645                    // Distinct on purpose from "0 addressed, 0 rejected": the
5646                    // fixer's own diff still landed (blocking counts do keep
5647                    // falling round over round), only its adoption report did
5648                    // not come back, so this must never read like every
5649                    // finding was reviewed and declined.
5650                    Some(reason) => {
5651                        format!(
5652                            "round {round}: fixer's adoption report was lost ({reason}); \
5653                             {commit_note}, tree {tree_note}"
5654                        )
5655                    }
5656                    None => format!(
5657                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5658                         {tree_note}{}",
5659                        fix.addressed.len(),
5660                        fix.rejected.len(),
5661                        if continuation.outcome == ContinuationOutcome::Resumed {
5662                            format!(
5663                                " (adoption report recovered after {} continuation(s))",
5664                                continuation.attempts
5665                            )
5666                        } else {
5667                            String::new()
5668                        },
5669                    ),
5670                },
5671            );
5672            round_record.fix = Some(fix);
5673            round_record.progressed = progressed;
5674            self.state.reviews.push(round_record);
5675            self.state.save()?;
5676
5677            // The fixer's own report never came back this round, even after
5678            // `continue_fix_report`'s own budget was spent on it — not an
5679            // ordinary "no report" (dropped stream, quota, plain failure),
5680            // which already reads that way and is left to the existing round
5681            // budget. Stopping here, rather than opening another round, is
5682            // what keeps a next reviewer/fixer wave from ever being
5683            // dispatched onto `winner.worktree` while whatever the seat's
5684            // last call may still have running there is unaccounted for: no
5685            // process liveness check exists (and none is being added — see
5686            // AGENTS.md/this task's own scope), so the only way to honour
5687            // "nothing starts before a valid report returns" is to not start
5688            // anything further on this worktree from this run at all.
5689            if matches!(
5690                continuation.outcome,
5691                ContinuationOutcome::Exhausted
5692                    | ContinuationOutcome::QuotaLost
5693                    | ContinuationOutcome::NoSession
5694            ) {
5695                return self
5696                    .stop_reviewing(
5697                        "the fixer's adoption report never came back, even after resuming its \
5698                         own seat; refusing to start another round against the same worktree \
5699                         while that is unresolved",
5700                        &shell,
5701                        &winner.worktree,
5702                    )
5703                    .await;
5704            }
5705
5706            let streak = self
5707                .state
5708                .reviews
5709                .iter()
5710                .rev()
5711                .take_while(|r| !r.progressed)
5712                .count();
5713            if streak >= STAGNANT_LIMIT {
5714                return self
5715                    .stop_reviewing(
5716                        &format!(
5717                            "the tree has not moved against base for {streak} round(s) in a row"
5718                        ),
5719                        &shell,
5720                        &winner.worktree,
5721                    )
5722                    .await;
5723            }
5724        }
5725        Ok(())
5726    }
5727
5728    /// Decide, from the last recorded round's own verification, whether
5729    /// stopping the review loop is a hand-off or a genuine block.
5730    ///
5731    /// Called once the loop has given up trying — the round budget is spent,
5732    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5733    /// findings still open, never while a round is still clean or the
5734    /// incomplete-panel case handled inline above. Gate and e2e are facts
5735    /// about the tree; a lingering review finding is an opinion, and this
5736    /// workload's own `magi stats` puts reviewer precision low enough
5737    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5738    /// must not by itself stand between a green, verified change and the
5739    /// human who decides what to do with it. A red e2e is not an opinion, so
5740    /// that case still blocks, with the failing command and a tail of its
5741    /// output recorded here rather than left in `run.json` for someone to go
5742    /// find.
5743    ///
5744    /// A round that deferred its own e2e (see [`Config::graph`]'s
5745    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5746    /// only because nothing ran, and treating an empty list as a passing one
5747    /// here is exactly the "deferred painted green" bug this function exists
5748    /// to not have. When the last round's own verification never resolved —
5749    /// deferred on purpose, or a real attempt the shared build cache blocked
5750    /// — this makes (or retries) the real run, on the actual worktree this
5751    /// loop is about to stop touching, before deciding anything. A
5752    /// resource-blocked attempt is likewise never read as either green or
5753    /// red: it is evidence about the machine, not the patch (see
5754    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5755    /// blocked cache leaves this call without deciding rather than guessing
5756    /// — the caller retries on a later reentry.
5757    /// Record, once, that the review loop handed off over a blocking finding
5758    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5759    /// `land` asks the owner even with `land_approval` off. Called from every
5760    /// path that concludes `Gating`; a reentry keeps the first record.
5761    fn record_contested_handoff(&mut self) {
5762        if self.state.contested_handoff.is_some() {
5763            return;
5764        }
5765        let Some(contested) = self
5766            .state
5767            .reviews
5768            .last()
5769            .and_then(ReviewRound::contested_handoff)
5770        else {
5771            return;
5772        };
5773        self.state.event(
5774            "review",
5775            format!(
5776                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5777                 wait for the owner's approval",
5778                contested.findings.len(),
5779                contested.rejecters.len()
5780            ),
5781        );
5782        self.state.contested_handoff = Some(contested);
5783    }
5784
5785    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5786        let round_idx = self.state.reviews.len() - 1;
5787        // A deferred round and a resource-blocked one are the same shape
5788        // here: neither has a real result yet, and both get one more
5789        // attempt. Read off `e2e_status` — the single source for this —
5790        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5791        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5792        // still retries instead of being read as a settled result the
5793        // instant it stops being empty.
5794        let needs_catchup_run = matches!(
5795            self.state.reviews[round_idx].e2e_status(),
5796            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5797        );
5798        if needs_catchup_run {
5799            let round = self.state.reviews[round_idx].round;
5800            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5801            let commands = self.state.config.verify.e2e.clone();
5802            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5803            let cache_dir = self.state.config.cache_dir();
5804            let context = format!(
5805                "round {round}: verification unresolved, catching up before the final decision"
5806            );
5807            let (outcomes, verify_retried) = with_cache_lease(
5808                &mut self.state,
5809                cache_dir.as_deref(),
5810                "e2e",
5811                "e2e",
5812                worktree,
5813                &attempted_head,
5814                timeout,
5815                &context,
5816                |state, budget| {
5817                    let shell = shell.to_vec();
5818                    let commands = commands.clone();
5819                    let context = context.clone();
5820                    async move {
5821                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5822                            .await
5823                    }
5824                },
5825            )
5826            .await;
5827            let last = &mut self.state.reviews[round_idx];
5828            last.e2e = outcomes;
5829            last.verify_retried = verify_retried;
5830            // Always the commit and time this attempt actually targeted,
5831            // whether or not it happens to equal the reviewed `head` and
5832            // whether or not a command finished — see
5833            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5834            // attempt is recorded too, so a later reader sees "attempted
5835            // again at T2" rather than silence.
5836            last.verified_head = Some(attempted_head);
5837            last.verified_at = Some(Timestamp::now());
5838            if verify_inconclusive(&last.e2e) {
5839                // Still not a real result: `e2e_deferred` is left exactly
5840                // as it was, so `needs_catchup_run` above reads
5841                // `ResourceBlocked` (via `e2e_status`, which checks
5842                // `resource_blocked` before `e2e_deferred`) and retries
5843                // again on the next reentry, rather than recording
5844                // contention as a red e2e and blocking the run on it.
5845                self.state.save()?;
5846                return Ok(());
5847            }
5848            last.e2e_deferred = false;
5849        }
5850        let last = &self.state.reviews[round_idx];
5851        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5852
5853        match last.e2e_status() {
5854            E2eStatus::Failed => {
5855                let red: Vec<String> = last
5856                    .e2e
5857                    .iter()
5858                    .filter(|o| !o.ok())
5859                    .map(|o| {
5860                        format!(
5861                            "`{}` -> {:?}\n{}",
5862                            o.command,
5863                            o.code,
5864                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5865                        )
5866                    })
5867                    .collect();
5868                self.state
5869                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5870                self.state.status = RunStatus::Blocked;
5871            }
5872            // `needs_catchup_run` above already retried once this call; if
5873            // it is still blocked, this is magi's own admission it could
5874            // not get a command to run, never a verdict on the patch — the
5875            // run is left exactly where a later reentry can retry again.
5876            E2eStatus::ResourceBlocked => {
5877                self.state.event(
5878                    "review",
5879                    format!(
5880                        "{why}; e2e could not run (shared build cache unavailable); not \
5881                         deciding yet"
5882                    ),
5883                );
5884            }
5885            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5886                self.state.event(
5887                    "review",
5888                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5889                );
5890                self.record_contested_handoff();
5891                self.state.status = RunStatus::Gating;
5892            }
5893        }
5894        self.state.save()?;
5895        Ok(())
5896    }
5897
5898    // ----------------------------------------------------------------- gate
5899
5900    async fn gate(&mut self) -> Result<()> {
5901        // Judged by the review record itself, not by `status`: a solo
5902        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5903        // reentry (see `judge`), and trusting it here is exactly how a run
5904        // that exhausted its review budget got gated and merged a second
5905        // time around. `review_conclusion` recomputes the review loop's own
5906        // verdict from the round records themselves — `Gating` for a clean
5907        // round or a hand-off (see `stop_reviewing`), anything else means the
5908        // loop is still going or genuinely blocked.
5909        // A base the winner could not be replayed onto is a decision, not a
5910        // round: there is no landing tree to gate. Read as its own record for
5911        // the same reason the review verdict is.
5912        if self.state.status == RunStatus::Failed
5913            || self
5914                .state
5915                .base_sync
5916                .as_ref()
5917                .is_some_and(|s| s.conflict.is_some())
5918            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5919                != Some(RunStatus::Gating)
5920        {
5921            return Ok(());
5922        }
5923        if self.state.gate_ran {
5924            // `review_loop` derives its conclusion from the clean review
5925            // record on every reentry and therefore puts a completed run back
5926            // in `Gating`. A recorded gate is a stronger, terminal fact:
5927            // retain its original command output (or lack of any, for a repo
5928            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5929            // doc) and restore `Blocked` on a real failure rather than
5930            // pretending the command is still running or running it a second
5931            // time. `gate_ran == false` remains the only shape — unattempted,
5932            // or a resource-blocked retry — that may still need to execute a
5933            // command.
5934            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5935                self.state.status = RunStatus::Blocked;
5936                self.state.save()?;
5937            }
5938            return Ok(());
5939        }
5940        let Some(winner) = self.state.winner().cloned() else {
5941            return Ok(());
5942        };
5943        self.state.status = RunStatus::Gating;
5944        let mut outcomes = self.run_gate(&winner).await?;
5945        loop {
5946            // A resource-blocked outcome means the gate command never actually
5947            // ran - the shared build cache could not be acquired or confirmed
5948            // fresh in time - which is evidence about the machine, not about
5949            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5950            // Recording it as a red gate would mark a run `Blocked` on nothing
5951            // but contention magi has already logged; leaving `self.state.gate`
5952            // empty and `self.state.gate_ran` false instead keeps the shape
5953            // this function already treats as "still needs to run" (see the
5954            // early-return above), so the next call retries the command
5955            // rather than concluding anything.
5956            if verify_inconclusive(&outcomes) {
5957                self.state.save()?;
5958                return Ok(());
5959            }
5960            if outcomes.iter().all(CommandOutcome::ok) {
5961                break;
5962            }
5963            match self.gate_fix_round(&winner, &outcomes).await? {
5964                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5965                GateFix::Stop => break,
5966                GateFix::Defer => {
5967                    self.state.save()?;
5968                    return Ok(());
5969                }
5970            }
5971        }
5972        let passed = outcomes.iter().all(CommandOutcome::ok);
5973        self.state.gate = outcomes;
5974        self.state.gate_ran = true;
5975        if !passed {
5976            self.state.status = RunStatus::Blocked;
5977            let spent = self.state.gate_fixes.len();
5978            self.state.event(
5979                "gate",
5980                if spent == 0 {
5981                    "gate failed; not merging".to_owned()
5982                } else {
5983                    format!("gate failed after {spent} gate-fix round(s); not merging")
5984                },
5985            );
5986        }
5987        self.state.save()?;
5988        Ok(())
5989    }
5990
5991    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
5992    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
5993    /// after review is clean and never on a candidate awaiting judging.
5994    ///
5995    /// Never fails the run: a non-zero exit or timeout is a warning and a
5996    /// recorded outcome, and the gate remains the single arbiter. Nothing
5997    /// configured means nothing happens - no event, no commit. `commit_all`
5998    /// commits any leftover change under the neutral identity and returns
5999    /// `false` when the tree is clean, so no empty commit is ever made.
6000    async fn run_pre_gate(&mut self, winner: &Candidate) {
6001        let commands = self.state.config.verify.pre_gate.clone();
6002        if commands.is_empty() {
6003            return;
6004        }
6005        let shell = self.state.config.shell();
6006        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6007        let (outcomes, _) = run_commands(
6008            &mut self.state,
6009            "pre_gate",
6010            "pre_gate",
6011            0,
6012            &shell,
6013            &commands,
6014            &winner.worktree,
6015            timeout,
6016        )
6017        .await;
6018        for o in &outcomes {
6019            if !o.ok() {
6020                tracing::warn!(
6021                    "pre_gate `{}` failed ({:?}); the gate decides",
6022                    o.command,
6023                    o.code
6024                );
6025            }
6026            self.state.event(
6027                "pre_gate",
6028                format!(
6029                    "`{}` -> {}",
6030                    o.command,
6031                    if o.ok() {
6032                        "pass".to_owned()
6033                    } else {
6034                        format!(
6035                            "FAIL ({:?})\n{}",
6036                            o.code,
6037                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6038                        )
6039                    }
6040                ),
6041            );
6042        }
6043        self.state.pre_gate = outcomes;
6044        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6045            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6046                Ok(head) => {
6047                    self.state
6048                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
6049                    self.state.pre_gate_commit = Some(head);
6050                }
6051                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6052            },
6053            Ok(false) => {}
6054            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6055        }
6056        if let Err(e) = self.state.save() {
6057            tracing::warn!("could not persist the pre_gate record: {e:#}");
6058        }
6059    }
6060
6061    /// Run `verify.gate` once against the winner's current tree, logging one
6062    /// event per command. Empty when nothing is configured.
6063    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6064        self.run_pre_gate(winner).await;
6065        let shell = self.state.config.shell();
6066        let gate_commands = self.state.config.verify.gate.clone();
6067        // Zero commands has nothing to run and nothing that could touch the
6068        // shared build cache, so it never needs a lease: `Config::cache_dir`
6069        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
6070        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
6071        // otherwise queue behind an unrelated run's lease and come back
6072        // resource-blocked - `gate_ran` would stay false on nothing but
6073        // cache contention, for a step that had nothing to check in the
6074        // first place.
6075        let outcomes = if gate_commands.is_empty() {
6076            Vec::new()
6077        } else {
6078            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6079            let cache_dir = self.state.config.cache_dir();
6080            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6081            let (outcomes, _) = with_cache_lease(
6082                &mut self.state,
6083                cache_dir.as_deref(),
6084                "gate",
6085                "gate",
6086                &winner.worktree,
6087                &head,
6088                timeout,
6089                "final gate",
6090                |state, budget| {
6091                    let shell = shell.clone();
6092                    let gate_commands = gate_commands.clone();
6093                    let worktree = winner.worktree.clone();
6094                    async move {
6095                        let (outcomes, timed_out_pids) = run_commands(
6096                            state,
6097                            "gate",
6098                            "gate",
6099                            0,
6100                            &shell,
6101                            &gate_commands,
6102                            &worktree,
6103                            budget,
6104                        )
6105                        .await;
6106                        (outcomes, false, timed_out_pids)
6107                    }
6108                },
6109            )
6110            .await;
6111            outcomes
6112        };
6113        if outcomes.is_empty() {
6114            // Nothing configured to check — distinct from every other
6115            // silence in this run's event log, since an empty `gate` alone
6116            // no longer says whether the gate ran at all (see
6117            // `RunState::gate_ran`'s own doc).
6118            self.state.event(
6119                "gate",
6120                "no gate commands configured; nothing to check, passing",
6121            );
6122        }
6123        for o in &outcomes {
6124            self.state.event(
6125                "gate",
6126                format!(
6127                    "`{}` -> {}",
6128                    o.command,
6129                    if o.ok() {
6130                        "pass".to_owned()
6131                    } else {
6132                        format!(
6133                            "FAIL ({:?})\n{}",
6134                            o.code,
6135                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6136                        )
6137                    }
6138                ),
6139            );
6140        }
6141        Ok(outcomes)
6142    }
6143
6144    /// One bounded fix round for a failing gate.
6145    ///
6146    /// The fixer is told the failure came from the gate itself, not from a
6147    /// reviewer, and is shown the failed commands, their exit codes and a tail
6148    /// of their output - whatever `[verify].gate` holds, nothing here knows
6149    /// what those commands run. Only a normal non-zero exit that printed
6150    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6151    /// command or a full disk says nothing about the code, and a fixer sent
6152    /// after it can only appease the machine. The round is judged by what git
6153    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6154    /// again before the gate does, so a fix cannot trade a green gate for a
6155    /// red e2e unnoticed.
6156    async fn gate_fix_round(
6157        &mut self,
6158        winner: &Candidate,
6159        outcomes: &[CommandOutcome],
6160    ) -> Result<GateFix> {
6161        let cap = self.state.config.graph.gate_fix_rounds;
6162        let spent = self.state.gate_fixes.len();
6163        if spent >= cap {
6164            if cap > 0 {
6165                self.state.event(
6166                    "gate",
6167                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6168                );
6169            }
6170            return Ok(GateFix::Stop);
6171        }
6172        if !gate_fixable(outcomes) {
6173            self.state.event(
6174                "gate",
6175                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6176                 command or similar); not spending a fix round on it",
6177            );
6178            return Ok(GateFix::Stop);
6179        }
6180        let min_free = self.state.config.disk.min_free_bytes;
6181        if min_free > 0 {
6182            match crate::disk::free_bytes(&winner.worktree) {
6183                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6184                Ok(free) => {
6185                    self.state.event(
6186                        "gate",
6187                        format!(
6188                            "only {free} bytes free ({min_free} required by `[disk] \
6189                             min_free_bytes`); not spending a fix round on a failure the disk \
6190                             may explain"
6191                        ),
6192                    );
6193                    return Ok(GateFix::Stop);
6194                }
6195                Err(e) => {
6196                    self.state.event(
6197                        "gate",
6198                        format!("free disk space could not be measured ({e:#}); no fix round"),
6199                    );
6200                    return Ok(GateFix::Stop);
6201                }
6202            }
6203        }
6204
6205        let attempt = spent + 1;
6206        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6207        let base = self.landing_base();
6208        let fix_prompt = prompt::gate_fix(
6209            &self.state.instruction,
6210            &failed,
6211            attempt,
6212            cap,
6213            &self.state.config.graph.language,
6214        );
6215        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6216        let sessions = self.state.config.graph.sessions;
6217        let artifacts = agent::artifacts_dir(&self.state.dir());
6218        self.state.event(
6219            "gate",
6220            format!("gate failed; gate-fix round {attempt} of {cap}"),
6221        );
6222        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6223        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6224        let (_, seat, out) = self
6225            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6226                prompt: fix_prompt.clone(),
6227                spec,
6228                seat,
6229                cwd: winner.worktree.clone(),
6230                timeout,
6231                allow_write: true,
6232                sessions,
6233                artifacts: artifacts.clone(),
6234                stem: format!("gate-fix-{attempt}"),
6235                handover: None,
6236            })
6237            .await;
6238        let mut record = GateFixRecord {
6239            agent: seat.agent.clone(),
6240            failed,
6241            notes: String::new(),
6242            committed: false,
6243            error: None,
6244        };
6245        match out {
6246            AgentOutcome::Ok(o) => {
6247                // A missing report is not a failed fix: the round is judged
6248                // by the tree below, and the report only carries prose.
6249                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6250                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6251                }
6252            }
6253            AgentOutcome::Dropped(_) => {
6254                record.error = Some("the CLI dropped the stream".to_owned());
6255            }
6256            AgentOutcome::Quota(o) => {
6257                self.state.quota.push(QuotaLoss {
6258                    seat: seat.key.clone(),
6259                    node: "gate-fix".to_owned(),
6260                    at: Timestamp::now(),
6261                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6262                });
6263                record.error = Some("rate limited (quota); fixer could not run".to_owned());
6264            }
6265            AgentOutcome::Failed(e) => record.error = Some(e),
6266        }
6267        self.state.seats.insert(seat.key.clone(), seat);
6268        if let Ok(r) = git::rescue_commit(
6269            &winner.worktree,
6270            &format!("magi: gate fix {attempt} (uncommitted work)"),
6271        )
6272        .await
6273        {
6274            self.state.note_withheld("gate-fix", &r.withheld);
6275        }
6276        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6277        record.committed = after != before;
6278        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6279        let note = record.error.clone();
6280        self.state.gate_fixes.push(record);
6281        self.state.save()?;
6282        if !changed {
6283            self.state.event(
6284                "gate",
6285                match note {
6286                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6287                    None => format!("gate-fix round {attempt}: the tree did not change"),
6288                },
6289            );
6290            return Ok(GateFix::Stop);
6291        }
6292        self.state.event(
6293            "gate",
6294            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6295        );
6296
6297        let commands = self.state.config.verify.e2e.clone();
6298        if !commands.is_empty() {
6299            let shell = self.state.config.shell();
6300            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6301            let cache_dir = self.state.config.cache_dir();
6302            let context = format!("gate-fix round {attempt}");
6303            let (e2e, _) = with_cache_lease(
6304                &mut self.state,
6305                cache_dir.as_deref(),
6306                "e2e",
6307                "e2e",
6308                &winner.worktree,
6309                &after,
6310                timeout,
6311                &context,
6312                |state, budget| {
6313                    let shell = shell.clone();
6314                    let commands = commands.clone();
6315                    let context = context.clone();
6316                    let worktree = winner.worktree.clone();
6317                    async move {
6318                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6319                            .await
6320                    }
6321                },
6322            )
6323            .await;
6324            if verify_inconclusive(&e2e) {
6325                return Ok(GateFix::Defer);
6326            }
6327            if e2e.iter().any(|o| !o.ok()) {
6328                self.state.event(
6329                    "gate",
6330                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6331                );
6332                return Ok(GateFix::Stop);
6333            }
6334        }
6335        Ok(GateFix::Retry)
6336    }
6337
6338    // ---------------------------------------------------------------- merge
6339
6340    /// One read-only rewrite by the summary's author; a text the gate still
6341    /// withholds goes to the owner (see `github_text`) and the fixed fallback
6342    /// applies on their word or on silence. `None` means the run is parked on
6343    /// that question and nothing may be pushed or opened yet.
6344    async fn guarded_pr_message(
6345        &mut self,
6346        winner: &Candidate,
6347        facts: Option<&BranchFacts>,
6348        posting: bool,
6349    ) -> Result<Option<PrMessage>> {
6350        let mut pr = pr_message_raw(&self.state, winner.label, facts);
6351        if !posting {
6352            let identity = crate::scrub::Identity::current();
6353            return Ok(Some(PrMessage {
6354                title: crate::scrub::scrub(&pr.title, &identity),
6355                body: crate::scrub::scrub(&pr.body, &identity),
6356            }));
6357        }
6358        // This very text was already rejected and asked about: no second
6359        // judge call, rewrite or question, just carry the record on.
6360        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6361        if let Some(known) = self
6362            .state
6363            .github_text
6364            .clone()
6365            .filter(|g| g.fingerprint == raw_fp)
6366        {
6367            return self.settle_withheld(winner, &pr, known).await;
6368        }
6369        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6370        let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6371        let identity = crate::scrub::Identity::current();
6372        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6373            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6374            && !violations.contains(&crate::github_text::Violation::SensitiveData)
6375        {
6376            violations.push(crate::github_text::Violation::SensitiveData);
6377        }
6378        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
6379        // only a language violation starts one; sensitive data alone goes
6380        // straight to the owner, with the redacted text as the fallback.
6381        let has_language = violations
6382            .iter()
6383            .any(|v| *v != crate::github_text::Violation::SensitiveData);
6384        if self.state.config.graph.github_text_guard && !violations.is_empty() {
6385            self.state.event(
6386                "github-text",
6387                if has_language {
6388                    format!("description rejected: {violations:?}; requesting one rewrite")
6389                } else {
6390                    format!("description rejected: {violations:?}; asking the owner")
6391                },
6392            );
6393            let mut rewritten = false;
6394            if has_language
6395                && let Some(spec) = self
6396                    .state
6397                    .config
6398                    .agents
6399                    .iter()
6400                    .find(|a| a.id == winner.agent)
6401                    .cloned()
6402            {
6403                let key = format!("impl-{}", winner.label);
6404                let seat = self.seat(&key, &spec.id);
6405                let prompt = format!(
6406                    "Rewrite only the following pull request description. The posting gate reported {violations:?}. Write English prose and remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
6407                    pr.title, pr.body
6408                );
6409                let job = SeatJob {
6410                    spec,
6411                    seat,
6412                    cwd: winner.worktree.clone(),
6413                    prompt,
6414                    timeout: retry_budget(
6415                        Duration::from_secs(self.state.config.graph.timeout_implement),
6416                        true,
6417                    ),
6418                    allow_write: false,
6419                    sessions: self.state.config.graph.sessions,
6420                    artifacts: agent::artifacts_dir(&self.state.dir()),
6421                    stem: "github-text-rewrite".to_owned(),
6422                    handover: None,
6423                };
6424                let prompts = self.state.config.prompts.clone();
6425                let cache = self.state.config.cache_dir();
6426                let run = self.state.id.clone();
6427                let ctx = WaveCtx {
6428                    run: &run,
6429                    node: "github-text",
6430                    prompts: &prompts,
6431                    cache: cache.as_deref(),
6432                    round: None,
6433                    carry_seats: false,
6434                };
6435                let (seat, outcome) =
6436                    run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6437                self.state.seats.insert(seat.key.clone(), seat);
6438                if let AgentOutcome::Ok(output) = outcome
6439                    && let Some(title) = summary_title(&output.text)
6440                {
6441                    let mut body = summary_without_title(&output.text);
6442                    let rewrite = PrMessage {
6443                        title: title.clone(),
6444                        body: body.clone(),
6445                    };
6446                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6447                    if !body.trim().is_empty()
6448                        && crate::github_text::check_with(&title, &body, redecision).is_empty()
6449                        && crate::github_text::shareable(&title)
6450                        && crate::github_text::shareable(&body)
6451                    {
6452                        decision = redecision;
6453                        let marker = format!("magi:run/{}", self.state.id);
6454                        if !body.contains(&marker) {
6455                            body.push_str(&format!("\n\n{marker}"));
6456                        }
6457                        pr = PrMessage { title, body };
6458                        rewritten = true;
6459                        self.state
6460                            .event("github-text", "description rewrite passed");
6461                    }
6462                }
6463            }
6464            if !rewritten {
6465                self.state.event(
6466                    "github-text",
6467                    "description rewrite unavailable or rejected; asking the owner",
6468                );
6469                if let Some(w) =
6470                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6471                {
6472                    // A different text than the one asked about before: that
6473                    // question no longer describes anything.
6474                    self.retire_github_text_question("the withheld text changed");
6475                    let record = crate::run::GithubTextAsk {
6476                        fingerprint: raw_fp,
6477                        title: w.title,
6478                        body: w.body,
6479                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6480                        question: None,
6481                        asks: 0,
6482                        resolved: false,
6483                        chosen_title: None,
6484                        choice: None,
6485                    };
6486                    return self.settle_withheld(winner, &pr, record).await;
6487                }
6488            }
6489        }
6490        let (title, body) =
6491            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6492        Ok(Some(PrMessage { title, body }))
6493    }
6494
6495    /// Abandon the standing posting-gate question, if any.
6496    fn retire_github_text_question(&mut self, why: &str) {
6497        let Some(id) = self
6498            .state
6499            .github_text
6500            .as_ref()
6501            .and_then(|g| g.question.clone())
6502        else {
6503            return;
6504        };
6505        let _ = ask::Questions::open().update(&id, |q| {
6506            q.abandon(why);
6507            Ok(())
6508        });
6509    }
6510
6511    /// Carry a withheld text to its end: file the question, park while it is
6512    /// open, and on an answer, silence or timeout pick the text to post. The
6513    /// decision is saved before it is used, so it applies once.
6514    async fn settle_withheld(
6515        &mut self,
6516        winner: &Candidate,
6517        pr: &PrMessage,
6518        mut g: crate::run::GithubTextAsk,
6519    ) -> Result<Option<PrMessage>> {
6520        use crate::github_text as gt;
6521        let store = ask::Questions::open();
6522        let timeout = self.state.config.graph.answer_timeout;
6523        if !g.resolved {
6524            let mut standing = g
6525                .question
6526                .as_ref()
6527                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6528            if let Some(q) = standing.as_ref()
6529                && q.status.open()
6530                && gt::expired(q, timeout)
6531            {
6532                let why = format!("no answer within {}s of asking", timeout.max(1));
6533                standing = store
6534                    .update(&q.id, |q| {
6535                        q.abandon(&why);
6536                        Ok(())
6537                    })
6538                    .ok()
6539                    .map(|(q, ())| q);
6540            }
6541            let mut ask_again: Option<Option<Vec<String>>> = None;
6542            match standing {
6543                // Recorded but never written (a stop between the two saves),
6544                // or removed: ask again without spending the retry.
6545                None if g.asks <= 1 => {
6546                    g.asks = 0;
6547                    ask_again = Some(None);
6548                }
6549                None => {
6550                    g.resolved = true;
6551                    g.chosen_title = None;
6552                    g.choice = Some(GithubTextChoice::Fallback);
6553                }
6554                Some(q) if q.status.open() => {
6555                    self.park_on_github_text(&g)?;
6556                    return Ok(None);
6557                }
6558                Some(q) => match gt::read_reply(&q) {
6559                    gt::Reply::Fallback => {
6560                        g.resolved = true;
6561                        g.chosen_title = None;
6562                        g.choice = Some(GithubTextChoice::Fallback);
6563                    }
6564                    gt::Reply::Original => {
6565                        g.resolved = true;
6566                        g.chosen_title = None;
6567                        g.choice = Some(GithubTextChoice::Original);
6568                    }
6569                    gt::Reply::Title(text) => {
6570                        let first = text
6571                            .lines()
6572                            .map(str::trim)
6573                            .find(|l| !l.is_empty())
6574                            .unwrap_or("");
6575                        let decision = if first.is_empty() {
6576                            None
6577                        } else {
6578                            let candidate = PrMessage {
6579                                title: first.to_owned(),
6580                                body: String::new(),
6581                            };
6582                            self.judge_pr_language(&winner.worktree, &candidate).await
6583                        };
6584                        match gt::vet_title(&text, decision) {
6585                            Ok(title) => {
6586                                g.resolved = true;
6587                                g.chosen_title = Some(title);
6588                                g.choice = Some(GithubTextChoice::MyTitle);
6589                            }
6590                            Err(cats) if g.asks < 2 => {
6591                                ask_again = Some(Some(
6592                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
6593                                ))
6594                            }
6595                            Err(_) => {
6596                                g.resolved = true;
6597                                g.chosen_title = None;
6598                                g.choice = Some(GithubTextChoice::Fallback);
6599                            }
6600                        }
6601                    }
6602                },
6603            }
6604            if let Some(rejected) = ask_again {
6605                // A retry (after a rejected replacement title) is still about
6606                // the original text: the same ORIGINAL / CHANGED comparison.
6607                let w = gt::Withheld {
6608                    title: g.title,
6609                    body: g.body,
6610                    categories: g.categories.clone(),
6611                };
6612                // Named by the original text's fingerprint alone, so a resume
6613                // rewrites the same file and the detail stays identical.
6614                let artifact = match crate::run::write_artifact(
6615                    &self.state,
6616                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
6617                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id),
6618                ) {
6619                    Ok(p) => Some(p),
6620                    Err(e) => {
6621                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
6622                        None
6623                    }
6624                };
6625                let mut q = ask::Question::new(
6626                    self.state.id.clone(),
6627                    gt::ASK_NODE.to_owned(),
6628                    gt::ASK_SEAT.to_owned(),
6629                    gt::question_summary(&self.state.config.graph.language, &w),
6630                    gt::question_detail(
6631                        &w,
6632                        &pr.title,
6633                        &pr.body,
6634                        &self.state.id,
6635                        rejected.as_deref(),
6636                        artifact.as_deref(),
6637                    ),
6638                    gt::question_choices(&w),
6639                );
6640                q.answer_timeout = timeout;
6641                // Recorded before it is written: a stop in between leaves a
6642                // record naming a question the resume then files again.
6643                g.question = Some(q.id.clone());
6644                g.asks += 1;
6645                self.state.github_text = Some(g.clone());
6646                self.state.event(
6647                    "github-text",
6648                    format!("asking the owner about the withheld text ({})", q.short()),
6649                );
6650                self.state.save()?;
6651                store
6652                    .put(&mut q)
6653                    .context("file the posting-gate question")?;
6654                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6655                    tracing::warn!(
6656                        "could not notify about posting-gate question {}: {e:#}",
6657                        q.short()
6658                    );
6659                }
6660                self.park_on_github_text(&g)?;
6661                return Ok(None);
6662            }
6663        }
6664        let resolved = g.chosen_title.clone();
6665        let original = g.choice == Some(GithubTextChoice::Original);
6666        // Field names only: the event log never records the text.
6667        self.state.event(
6668            "github-text",
6669            if original {
6670                format!(
6671                    "posting the original text for: {}",
6672                    match (g.title, g.body) {
6673                        (true, true) => "title, description",
6674                        (true, false) => "title",
6675                        _ => "description",
6676                    }
6677                )
6678            } else if resolved.is_some() {
6679                "posting the owner's replacement title".to_owned()
6680            } else {
6681                "posting the neutral text for the withheld fields".to_owned()
6682            },
6683        );
6684        self.state.github_text = Some(g.clone());
6685        self.state.save()?;
6686        let w = gt::Withheld {
6687            title: g.title,
6688            body: g.body,
6689            categories: g.categories.clone(),
6690        };
6691        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
6692        if let Some(t) = resolved {
6693            title = t;
6694        }
6695        // Every withheld field is now neutral or vetted English.
6696        let english = gt::LanguageDecision {
6697            title_english: true,
6698            body_english: true,
6699            body_complete: true,
6700        };
6701        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6702        if original {
6703            // The owner's explicit choice overrides the gate for the fields it
6704            // was made for; the others keep what the gate produced.
6705            if g.title {
6706                title = pr.title.clone();
6707            }
6708            if g.body {
6709                body = pr.body.clone();
6710            }
6711        }
6712        Ok(Some(PrMessage { title, body }))
6713    }
6714
6715    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6716        self.state.github_text = Some(g.clone());
6717        self.state.parked = true;
6718        self.state.event(
6719            "github-text",
6720            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6721        );
6722        self.state.save()
6723    }
6724
6725    /// Ask `[roles] language_judge` about this text and record which source
6726    /// decided; the text itself is never recorded.
6727    async fn judge_pr_language(
6728        &mut self,
6729        cwd: &Path,
6730        pr: &PrMessage,
6731    ) -> Option<crate::github_text::LanguageDecision> {
6732        self.state.config.roles.language_judge.as_ref()?;
6733        let decision =
6734            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6735        self.state.event(
6736            "github-text",
6737            if decision.is_some() {
6738                "language decided by the language judge"
6739            } else {
6740                "language judge unavailable; using built-in heuristics"
6741            },
6742        );
6743        decision
6744    }
6745
6746    async fn merge(&mut self) -> Result<()> {
6747        // Same reasoning as `gate`: ask the review and gate records directly
6748        // rather than `status`, which a solo-candidate `judge`/`deliberate`
6749        // skip can rewrite on reentry to something that no longer says
6750        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
6751        // so a hand-off (open findings, green verification) reaches merge
6752        // exactly like a genuinely clean round does.
6753        //
6754        // A run resumed mid-`land` never reaches here at all: `execute`
6755        // recognises `RunStatus::Landing` before it even calls `prep`, and
6756        // routes straight to `run_land` instead. That has to happen a level
6757        // up from this function, not with a check in here, because
6758        // `review_loop`'s own status recomputation (see its doc) runs
6759        // *before* `merge` on every reentry and would otherwise overwrite
6760        // the `Landing` marker with `Gating` before this node ever saw it.
6761        if self
6762            .state
6763            .base_sync
6764            .as_ref()
6765            .is_some_and(|s| s.conflict.is_some())
6766            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6767                != Some(RunStatus::Gating)
6768            // `gate_ran == false` is not "passed" - `gate` leaves it false
6769            // both before it has ever run and when its last attempt was
6770            // resource-blocked (see `Runner::gate`'s own doc), and neither is
6771            // permission to merge on nothing but the review record. Only a
6772            // gate that actually ran - zero commands configured and
6773            // vacuously passed, or one or more that all exited 0 - may
6774            // proceed; `RunState::gate_status` is the single place that
6775            // reading is computed.
6776            || !self.state.gate_status().ok()
6777        {
6778            return Ok(());
6779        }
6780        // This node's own record, not `status`: `status == Ready` is not
6781        // unique to the harmless `MergeMode::None` path this line was
6782        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
6783        // run's PR was closed without merging — and on that run `mode` is
6784        // still `Pr`, so a reentry that fell through here would push and
6785        // open a second pull request. `self.state.merge` is set exactly once
6786        // this node (or `land`) has already produced a verdict, under every
6787        // mode, which is what "already done" actually means here.
6788        if self.state.merge.is_some() {
6789            return Ok(());
6790        }
6791        let Some(winner) = self.state.winner().cloned() else {
6792            return Ok(());
6793        };
6794        let repo = self.state.repo.clone();
6795        let base = self.state.base_branch.clone();
6796        let mode = self.state.config.merge.mode;
6797        let style = self.state.config.merge.style;
6798        let facts = if is_review_run(&self.state) {
6799            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6800            let start = review_base(
6801                &repo,
6802                &self.state.config.merge.remote,
6803                &base,
6804                &self.state.base_commit,
6805                &winner.branch,
6806            )
6807            .await;
6808            branch_facts(&repo, &start, &winner.branch).await
6809        } else {
6810            None
6811        };
6812        // `None` when the base could not be freshly read: then an adopted
6813        // pull request's title is left alone.
6814        let leaked = if is_review_run(&self.state) {
6815            leaked_subjects(&self.state, &winner.branch).await
6816        } else {
6817            Some(Vec::new())
6818        };
6819        // `None`: parked on the owner's word about a withheld title or body.
6820        // Nothing has been pushed or opened, and `merge` stays unrecorded so
6821        // the resume arrives here again.
6822        let Some(pr) = self
6823            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6824            .await?
6825        else {
6826            return Ok(());
6827        };
6828        let message = pr.commit_message();
6829
6830        let outcome = match mode {
6831            MergeMode::None => MergeOutcome {
6832                mode,
6833                ok: true,
6834                detail: manual_merge_command(style, &repo, &winner.branch, &message),
6835                empty: false,
6836            },
6837            MergeMode::Pr | MergeMode::Local
6838                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6839            {
6840                MergeOutcome {
6841                    mode,
6842                    ok: false,
6843                    detail: empty_candidate_detail(&self.state, &base),
6844                    empty: true,
6845                }
6846            }
6847            MergeMode::Local => {
6848                let on = git::current_branch(&repo).await?;
6849                if on.as_deref() != Some(base.as_str()) {
6850                    MergeOutcome {
6851                        mode,
6852                        ok: false,
6853                        detail: format!(
6854                            "{} has {} checked out, not the base branch {base}",
6855                            repo.display(),
6856                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
6857                        ),
6858                        empty: false,
6859                    }
6860                } else if !git::is_clean(&repo).await? {
6861                    MergeOutcome {
6862                        mode,
6863                        ok: false,
6864                        detail: format!("{} is dirty; refusing to merge", repo.display()),
6865                        empty: false,
6866                    }
6867                } else {
6868                    let out = match style {
6869                        MergeStyle::Merge => {
6870                            git::merge_no_ff(&repo, &winner.branch, &message).await?
6871                        }
6872                        MergeStyle::Squash => {
6873                            git::merge_squash(&repo, &winner.branch, &message).await?
6874                        }
6875                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6876                    };
6877                    MergeOutcome {
6878                        mode,
6879                        ok: out.ok(),
6880                        detail: if out.ok() { out.stdout } else { out.stderr },
6881                        empty: false,
6882                    }
6883                }
6884            }
6885            MergeMode::Pr => {
6886                let remote = self.state.config.merge.remote.clone();
6887                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6888                if !pushed.ok() {
6889                    MergeOutcome {
6890                        mode,
6891                        ok: false,
6892                        detail: pushed.stderr,
6893                        empty: false,
6894                    }
6895                } else {
6896                    // A retry or resume of a run whose branch already has an
6897                    // open pull request adopts it rather than failing on a
6898                    // duplicate. Only this winner branch into this base:
6899                    // `branch_for` derives the name from the run id, so a
6900                    // different run's pull request never matches.
6901                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6902                    let out = match pr_merge_plan(found) {
6903                        PrPlan::Create => {
6904                            gh_pr_create(
6905                                &winner.worktree,
6906                                &base,
6907                                &winner.branch,
6908                                &pr.title,
6909                                &pr.body,
6910                            )
6911                            .await
6912                        }
6913                        PrPlan::Adopt { url, title } => {
6914                            self.state
6915                                .event("merge", format!("Pr: adopted open pull request {url}"));
6916                            if title != pr.title
6917                                && (!is_review_run(&self.state)
6918                                    || leaked
6919                                        .as_deref()
6920                                        .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6921                                && let Err(e) =
6922                                    if self.state.github_text.as_ref().is_some_and(|g| {
6923                                        g.resolved
6924                                            && g.title
6925                                            && g.choice == Some(GithubTextChoice::Original)
6926                                    }) {
6927                                        // The owner chose to post the original title.
6928                                        land::set_pr_title_verbatim(
6929                                            &winner.worktree,
6930                                            &url,
6931                                            &pr.title,
6932                                        )
6933                                        .await
6934                                    } else {
6935                                        land::set_pr_title(
6936                                            &mut self.state,
6937                                            &winner.worktree,
6938                                            &url,
6939                                            &pr.title,
6940                                        )
6941                                        .await
6942                                    }
6943                            {
6944                                tracing::warn!("could not refresh title of {url}: {e:#}");
6945                                self.state
6946                                    .event("merge", format!("Pr: title refresh failed: {e:#}"));
6947                            }
6948                            Ok(url)
6949                        }
6950                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6951                    };
6952                    match out {
6953                        Ok(url) => MergeOutcome {
6954                            mode,
6955                            ok: true,
6956                            detail: url,
6957                            empty: false,
6958                        },
6959                        Err(e) => MergeOutcome {
6960                            mode,
6961                            ok: false,
6962                            detail: e.to_string(),
6963                            empty: false,
6964                        },
6965                    }
6966                }
6967            }
6968        };
6969
6970        self.state.status = match (mode, outcome.ok) {
6971            (MergeMode::None, _) => RunStatus::Ready,
6972            (_, true) => RunStatus::Merged,
6973            (_, false) => RunStatus::Blocked,
6974        };
6975        self.state.event(
6976            "merge",
6977            format!(
6978                "{:?}: {}",
6979                mode,
6980                outcome.detail.lines().next().unwrap_or("")
6981            ),
6982        );
6983        self.state.merge = Some(outcome);
6984        self.state.save()?;
6985
6986        // The PR is open and the run would historically stop here, leaving the
6987        // operator to watch checks, feed review comments back to a fixer, and
6988        // merge. That was done by hand six times in one session before this
6989        // existed. Opt-in, because merging is the one irreversible thing magi
6990        // can do to a repository.
6991        if self.state.config.graph.land
6992            && mode == MergeMode::Pr
6993            && self.state.status == RunStatus::Merged
6994        {
6995            self.run_land().await?;
6996        }
6997        // `run_land` may have left `status` at `Landing` - still waiting on
6998        // CI or the owner's approval, not actually settled - so this has to
6999        // read whatever `status` ended up as here, not the `Merged` this
7000        // function set a few lines up.
7001        self.settle_questions();
7002        Ok(())
7003    }
7004
7005    /// Enter `land`.
7006    ///
7007    /// Shared between a fresh run's first pass through [`Runner::merge`] and
7008    /// a resumed run's re-entry. `land::land` itself is what serialises the
7009    /// two git-mutating moments inside the loop — the rebase push and
7010    /// `gh pr merge` — per repository (see its own doc); nothing here needs
7011    /// to hold a lock across the whole call, and doing so would serialise
7012    /// this run's CI wait against a *different* run's land-approval resume
7013    /// in the same repository, which is exactly the "must not wait on
7014    /// another task" property the daemon's slot-freeing exists to give.
7015    async fn run_land(&mut self) -> Result<()> {
7016        let url = self
7017            .state
7018            .merge
7019            .as_ref()
7020            .map(|m| m.detail.clone())
7021            .unwrap_or_default();
7022        let url = url.lines().next().unwrap_or("").trim().to_owned();
7023        if !url.starts_with("http") {
7024            return Ok(());
7025        }
7026        // A land failure is not a lost run: the work is on a branch and the
7027        // pull request is open, which is exactly where a human takes over.
7028        match land::land(&mut self.state, &url).await {
7029            Ok(pr) if self.state.parked => {
7030                // `land` already saved the parked marker; nothing here
7031                // overrides `status` back to a terminal value while an
7032                // approval is still outstanding.
7033                let _ = pr;
7034            }
7035            Ok(pr) => {
7036                self.state.status = match pr.state {
7037                    land::PrLifecycle::Merged => RunStatus::Merged,
7038                    _ => RunStatus::Blocked,
7039                };
7040                // Downstream of a confirmed merge only - see
7041                // `bump::should_release_bump`'s own doc for why this one
7042                // check covers all three of `land`'s success paths.
7043                // Best-effort: the run already landed, so a failure here
7044                // (the decision call, `gh`, `cargo`) is recorded and never
7045                // turns a landed run into a failed one.
7046                if bump::should_release_bump(self.state.status)
7047                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7048                {
7049                    // The event is the run's own record. Not-eligible cases
7050                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
7051                    // `Err` is a bump that was tried and failed:
7052                    // `after_merge` itself raises the operator notice for
7053                    // that, whether or not a release PR exists yet.
7054                    self.state
7055                        .event("bump", format!("release bump skipped: {e:#}"));
7056                }
7057                // Independent of the bump, and best-effort in the same way:
7058                // findings the merge left open become follow-up tasks.
7059                if self.state.status == RunStatus::Merged {
7060                    crate::followup::after_merge(&mut self.state, &pr.url).await;
7061                }
7062                self.state.save()?;
7063            }
7064            Err(e) => {
7065                self.state.status = RunStatus::Blocked;
7066                self.state.event("land", format!("gave up: {e}"));
7067                self.state.save()?;
7068            }
7069        }
7070        Ok(())
7071    }
7072
7073    // -------------------------------------------------------------- helpers
7074
7075    /// Fetch or create a seat, keeping its conversation across nodes.
7076    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7077        if let Some(existing) = self.state.seats.get(key)
7078            && existing.agent == agent
7079        {
7080            return existing.clone();
7081        }
7082        // A seat that changes agent mints its session id from the agent too,
7083        // like a handover: the old agent's uuid is already taken by the CLI.
7084        let fresh = if self.state.seats.contains_key(key) {
7085            handover_seat(key, agent, self.state.next_seat_seed())
7086        } else {
7087            SeatState::new(key, agent, self.state.seed)
7088        };
7089        self.state.seats.insert(key.to_owned(), fresh.clone());
7090        fresh
7091    }
7092
7093    /// The agent now holding seat `key`: `spec`, unless a handover moved the
7094    /// seat to another roster agent, in which case that agent. Nodes that
7095    /// continue a seat's conversation (deliberation, the votes, a reviewer's
7096    /// reconsideration) must keep talking to whoever answered it, not slip
7097    /// back to the agent that failed it.
7098    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7099        match self.state.seats.get(key) {
7100            Some(s) if s.agent != spec.id => {
7101                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7102            }
7103            _ => spec,
7104        }
7105    }
7106
7107    /// A candidate rendered for judging, with the leak policy applied.
7108    fn view(&self, c: &Candidate) -> CandidateView {
7109        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7110            .unwrap_or_default();
7111        let (patch, _) = blind::sanitize_patch(
7112            &format!("candidate {} patch", c.label),
7113            &raw,
7114            &self.state.config.blind,
7115        );
7116        CandidateView {
7117            label: c.label,
7118            branch: c.branch.clone(),
7119            summary: c.summary.clone(),
7120            stat: c.stat.clone(),
7121            patch,
7122        }
7123    }
7124
7125    /// The full candidate set as prompt text, for seats with no live session.
7126    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7127        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7128        prompt::judge(
7129            "(see above)",
7130            &views,
7131            self.roles.judges.len(),
7132            base_short,
7133            "en",
7134        )
7135    }
7136
7137    /// The final-vote prompt with everything a seat that has no session of its
7138    /// own needs: the candidates, the seat's own ranking and reasons, and the
7139    /// anonymised deliberation it took part in (only when there was one, so a
7140    /// handed-over seat never sees more than the seat it replaces did). The
7141    /// `Final vote` heading stays first.
7142    fn vote_prompt_full(
7143        &self,
7144        j: usize,
7145        labels: &[char],
7146        language: &str,
7147        candidates: &[Candidate],
7148        base_short: &str,
7149    ) -> String {
7150        let mut text = format!(
7151            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7152            prompt::final_vote(labels, language),
7153            self.state.instruction,
7154            self.candidate_block(candidates, base_short)
7155        );
7156        if let Some(own) = self
7157            .state
7158            .judgements
7159            .get(j)
7160            .filter(|r| !r.ranking.is_empty())
7161        {
7162            let reasons = own
7163                .reasons
7164                .iter()
7165                .map(|(k, v)| format!("- {k}: {v}"))
7166                .collect::<Vec<_>>()
7167                .join("\n");
7168            text.push_str(&format!(
7169                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
7170                own.ranking.iter().collect::<String>(),
7171                if reasons.is_empty() {
7172                    ""
7173                } else {
7174                    ", because:\n"
7175                }
7176            ));
7177        }
7178        if !self.state.deliberation.is_empty() {
7179            text.push_str("\n# What was argued before this vote\n");
7180            for t in self.transcript(&[], j) {
7181                text.push_str(&format!(
7182                    "\n## {}{}\n\n{}\n",
7183                    t.who,
7184                    if t.is_self { " (you)" } else { "" },
7185                    t.body.trim()
7186                ));
7187            }
7188        }
7189        text
7190    }
7191
7192    /// Anonymised transcript for judge `self_idx`.
7193    ///
7194    /// The initial rankings are always the opening statements. Seeding them
7195    /// only when no turn had been taken yet meant every judge after the first
7196    /// argued against a single voice instead of against the actual split — the
7197    /// disagreement is the information, so it is always on the table.
7198    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
7199        let mut turns = Vec::new();
7200        for j in &self.state.judgements {
7201            if j.ranking.is_empty() {
7202                continue;
7203            }
7204            let reasons = j
7205                .reasons
7206                .iter()
7207                .map(|(k, v)| format!("- {k}: {v}"))
7208                .collect::<Vec<_>>()
7209                .join("\n");
7210            turns.push(Turn {
7211                who: format!("Judge {} (opening ranking)", j.judge),
7212                is_self: j.judge == self_idx + 1,
7213                body: format!(
7214                    "Ranked {}{}{reasons}",
7215                    j.ranking.iter().collect::<String>(),
7216                    if reasons.is_empty() {
7217                        ""
7218                    } else {
7219                        ", because:\n"
7220                    }
7221                ),
7222            });
7223        }
7224        for t in self
7225            .state
7226            .deliberation
7227            .iter()
7228            .flat_map(|r| r.turns.iter())
7229            .chain(current)
7230        {
7231            turns.push(Turn {
7232                who: format!("Judge {}", t.judge),
7233                is_self: t.judge == self_idx + 1,
7234                body: t.body.clone(),
7235            });
7236        }
7237        turns
7238    }
7239}
7240
7241/// Does this seat still hold the context a follow-up prompt would rely on?
7242fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
7243    agent::has_session(spec.kind, seat, sessions)
7244}
7245
7246/// The next entry in `roster` after `start`, never wrapping back to the
7247/// front, whose id is not in `tried` yet.
7248///
7249/// Starts one past `start` rather than at the front of `roster`: `start` is
7250/// the seat's own original position, and a seat whose candidate slot already
7251/// sits on the roster's second entry must fall through to the third next, not
7252/// restart at the first — which is very likely a different candidate's own
7253/// agent already. Never wraps back past `start`, for the same reason: an
7254/// entry earlier in the roster than the seat's own position is almost
7255/// certainly some *other* candidate slot's own agent, and once the tail of
7256/// the roster is exhausted there are no more untried agents for *this* seat
7257/// to fall through to — the caller's fallback chain ends there, exactly as
7258/// "no further untried agents remain in the list for that seat" asks for.
7259///
7260/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
7261/// the same id twice (an operator's `roles.implementers` typo, or a
7262/// `[[agents]]` list reused across roles) must not let
7263/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
7264/// over `roster` either finds an untried id or runs out, so this always
7265/// terminates regardless of duplicates.
7266fn next_untried_in_roster<'a>(
7267    roster: &'a [AgentSpec],
7268    start: usize,
7269    tried: &BTreeSet<String>,
7270) -> Option<&'a AgentSpec> {
7271    roster
7272        .get(start + 1..)?
7273        .iter()
7274        .find(|s| !tried.contains(&s.id))
7275}
7276
7277/// The successor for a seat, shared by all four seat kinds (implement, judge,
7278/// review, advise). `others` holds the ids that *currently* occupy the other
7279/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
7280/// sees them), so roster entries beyond the seat count act as spares: a failed
7281/// seat goes to an agent no other seat holds whenever the roster permits.
7282///
7283/// `carried` is `Some` only for the review loop's carried failure history.
7284/// Order: (1) forward from `start`, never wrapping, untried, not a carried
7285/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
7286/// whole roster: untried and not another seat's occupant; (3) the plain walk
7287/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
7288/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
7289/// a better outcome than an empty seat, and it keeps the answer to "is there
7290/// a successor at all" exactly what it was before occupants were considered,
7291/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
7292/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
7293/// carried failure is a cheaper bet than doubling an agent on the panel, and
7294/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
7295/// the same round are handled one at a time, so a seat later in the batch
7296/// sees an earlier one's new occupant but not yet its own freed agent.
7297fn pick_successor<'a>(
7298    roster: &'a [AgentSpec],
7299    start: usize,
7300    tried: &BTreeSet<String>,
7301    carried: Option<&BTreeSet<String>>,
7302    others: &BTreeSet<String>,
7303) -> Option<&'a AgentSpec> {
7304    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
7305    roster
7306        .get(start + 1..)
7307        .and_then(|tail| {
7308            tail.iter()
7309                .filter(free)
7310                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
7311        })
7312        .or_else(|| {
7313            carried
7314                .is_some()
7315                .then(|| roster.iter().find(free))
7316                .flatten()
7317        })
7318        .or_else(|| match carried {
7319            Some(c) => next_for_seat(roster, start, tried, c),
7320            None => next_untried_in_roster(roster, start, tried),
7321        })
7322}
7323
7324/// The next agent for a seat that carries its failure history across rounds
7325/// (the review loop). `round_tried` is this round's own bound and starts
7326/// empty every round; `carried_failed` only decides priority.
7327///
7328/// First: an id walking forward from `start`, never wrapping, that is neither
7329/// tried this round nor failed in an earlier one. Only when that is exhausted
7330/// does it rescue: the first roster id (in roster order, so this one *does*
7331/// look before `start`) not yet tried this round, which is by then a carried
7332/// failure. Each id is rescued at most once per round, so it cannot loop.
7333fn next_for_seat<'a>(
7334    roster: &'a [AgentSpec],
7335    start: usize,
7336    round_tried: &BTreeSet<String>,
7337    carried_failed: &BTreeSet<String>,
7338) -> Option<&'a AgentSpec> {
7339    roster
7340        .get(start + 1..)?
7341        .iter()
7342        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
7343        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
7344}
7345
7346/// Where a reviewer seat starts a round: the agent that last answered it when
7347/// it is still on the roster and not marked failed, else the spec's own agent
7348/// unless it failed, else the next roster agent that has not failed, else the
7349/// spec's own agent again (the whole roster failed). Ids no longer on the
7350/// roster are ignored. An empty roster has no handover, so the spec stands.
7351fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
7352    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
7353        return spec;
7354    };
7355    let ok = |id: &str| !h.failed.contains(id);
7356    if let Some(last) = h.last_ok.as_deref()
7357        && ok(last)
7358        && let Some(s) = roster.iter().find(|s| s.id == last)
7359    {
7360        return s.clone();
7361    }
7362    if ok(&spec.id) {
7363        return spec;
7364    }
7365    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
7366    roster
7367        .get(from + 1..)
7368        .into_iter()
7369        .flatten()
7370        .chain(roster.iter())
7371        .find(|s| ok(&s.id))
7372        .cloned()
7373        .unwrap_or(spec)
7374}
7375
7376/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
7377/// seed so a CLI that mints its session id up front (`--session-id`) never
7378/// reuses the uuid the previous agent already opened under the same seat key.
7379pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7380    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7381}
7382
7383/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
7384/// so the classifier below and the code that builds the message cannot drift.
7385const TIMED_OUT: &str = "timed out";
7386
7387impl FailClass {
7388    /// `None` for an answer; otherwise how the turn failed.
7389    fn of(out: &AgentOutcome) -> Option<Self> {
7390        match out {
7391            AgentOutcome::Ok(_) => None,
7392            AgentOutcome::Quota(_) => Some(Self::Quota),
7393            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7394            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7395            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7396        }
7397    }
7398
7399    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
7400    fn stem_word(&self) -> &'static str {
7401        match self {
7402            Self::Quota => "quota",
7403            _ => "handover",
7404        }
7405    }
7406}
7407
7408/// The message's first line with its variable parts removed — digit runs and
7409/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
7410/// as one kind of failure.
7411fn failure_signature(msg: &str) -> String {
7412    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7413    let mut out = Vec::new();
7414    for word in line.split_whitespace() {
7415        if word.contains('/') || word.contains('\\') {
7416            out.push("<path>".to_owned());
7417            continue;
7418        }
7419        let mut w = String::new();
7420        let mut in_digits = false;
7421        for c in word.chars() {
7422            if c.is_ascii_digit() {
7423                if !in_digits {
7424                    w.push('#');
7425                }
7426                in_digits = true;
7427            } else {
7428                in_digits = false;
7429                w.push(c);
7430            }
7431        }
7432        out.push(w);
7433    }
7434    out.join(" ").chars().take(120).collect()
7435}
7436
7437/// Whether a seat that just failed with `cur` may go to the next roster agent.
7438/// A quota or a timeout always may. Any other failure may not when the agent
7439/// before it failed the same way: an error the prompt causes would otherwise
7440/// walk the whole roster. `prev` is the class of the immediately preceding
7441/// agent's failure, so a quota or timeout in between breaks the run of
7442/// identical failures by itself.
7443fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7444    match cur {
7445        FailClass::Quota | FailClass::Timeout => true,
7446        FailClass::Other(_) => prev != Some(cur),
7447    }
7448}
7449
7450/// A short human reason for a failed outcome, for the handover record.
7451fn fail_reason(out: &AgentOutcome) -> String {
7452    match out {
7453        AgentOutcome::Ok(_) => String::new(),
7454        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7455        AgentOutcome::Dropped(o) => format!(
7456            "the CLI dropped the stream ({})",
7457            o.dropped
7458                .as_ref()
7459                .map(|d| d.why.as_str())
7460                .unwrap_or("it ended without delivering its answer")
7461        ),
7462        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7463    }
7464}
7465
7466/// Note one handover in the run: the structured record and, in the timeline,
7467/// the sentence a person reads. A quota keeps the wording it always had.
7468pub(crate) fn record_handover(
7469    state: &mut RunState,
7470    node: &str,
7471    seat: &str,
7472    from: &str,
7473    to: &str,
7474    class: &FailClass,
7475    reason: &str,
7476) {
7477    let message = if *class == FailClass::Quota {
7478        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7479    } else {
7480        format!("{seat}: handed over {from} -> {to} ({reason})")
7481    };
7482    state.event(node, message);
7483    state.handovers.push(Handover {
7484        at: Timestamp::now(),
7485        node: node.to_owned(),
7486        seat: seat.to_owned(),
7487        from: from.to_owned(),
7488        to: to.to_owned(),
7489        reason: reason.to_owned(),
7490    });
7491}
7492
7493/// Did this reply report running a command whose own CLI never confirmed an
7494/// exit status?
7495///
7496/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
7497/// command *finished* (see that type's own doc), so this can only be `true`
7498/// for a command whose completion event carried no readable exit code — not
7499/// for one that simply is not mentioned at all. That is the one signal this
7500/// crate can read, from the same record `state.jobs` renders, about a reply
7501/// standing next to work its own CLI cannot vouch for finishing; it is
7502/// deliberately not a check on the exit code's *value* (a fixer legitimately
7503/// runs a command that fails mid-iteration before it succeeds) and not a
7504/// guess at a command still running in the background (which emits no event
7505/// at all, and so leaves no evidence here to find).
7506fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7507    commands.iter().any(|c| c.exit_code.is_none())
7508}
7509
7510/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
7511/// trusted as a verified no-op — the adoption guard's own text-level half.
7512///
7513/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
7514/// timed out): a marker only earns the benefit of the doubt from a turn the
7515/// CLI itself vouches for finishing properly, the same house style
7516/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
7517/// *fix* report to for `commands`. A candidate that timed out, exited
7518/// non-zero, or left a command unconfirmed is read as the ordinary loss it
7519/// is, whatever prose it wrote — this returns `None` before it ever looks at
7520/// `text`. The remaining guards (the tree really is empty, the evidence is
7521/// non-empty) are the caller's: this only reads what the reply *claimed*.
7522fn verified_noop_claim(
7523    usable: bool,
7524    commands: &[agent::CommandEvidence],
7525    text: &str,
7526) -> Option<String> {
7527    (usable && !has_unconfirmed_command(commands))
7528        .then(|| verdict::verified_noop(text))
7529        .flatten()
7530}
7531
7532fn short(commit: &str) -> String {
7533    commit.chars().take(7).collect()
7534}
7535
7536fn make_executable(path: &Path) -> Result<()> {
7537    #[cfg(unix)]
7538    {
7539        use std::os::unix::fs::PermissionsExt as _;
7540        let mut perms = std::fs::metadata(path)?.permissions();
7541        perms.set_mode(0o755);
7542        std::fs::set_permissions(path, perms)?;
7543    }
7544    #[cfg(not(unix))]
7545    {
7546        let _ = path;
7547    }
7548    Ok(())
7549}
7550
7551/// What every seat in one batch shares: where the answers are attributed, the
7552/// prompt overlay they inherit, and the build cache they are told to use.
7553///
7554/// A struct rather than four more parameters: `wave` also needs the run's
7555/// state (to record who is answering right now) and the attempt number, and
7556/// eight positional arguments is both unreadable and a clippy error.
7557struct WaveCtx<'a> {
7558    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
7559    /// paid for it.
7560    run: &'a str,
7561    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
7562    node: &'a str,
7563    prompts: &'a Prompts,
7564    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
7565    cache: Option<&'a Path>,
7566    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
7567    /// `JobRecord::round`. `None` for every other node.
7568    round: Option<usize>,
7569    /// Carry each seat's failed-agent history across waves (the review loop
7570    /// only): start-of-round priority and handover choice read
7571    /// [`RunState::seat_history`], and every answer or failure writes it.
7572    carry_seats: bool,
7573}
7574
7575/// Run one job, honouring the parallelism budget.
7576async fn run_one(
7577    job: SeatJob,
7578    sem: Arc<Semaphore>,
7579    ctx: &WaveCtx<'_>,
7580    state: &mut RunState,
7581    attempt: usize,
7582) -> (SeatState, AgentOutcome) {
7583    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7584        .await
7585        .pop()
7586        .expect("one job in, one result out");
7587    (seat, out)
7588}
7589
7590/// Run every job concurrently, capped by the semaphore, preserving order.
7591///
7592/// Every seat in the batch is recorded into [`RunState::active`] before the
7593/// wave starts and cleared as each answer lands, so the run's own record says
7594/// who is still being waited on rather than only who finished.
7595async fn wave(
7596    jobs: Vec<SeatJob>,
7597    sem: Arc<Semaphore>,
7598    ctx: &WaveCtx<'_>,
7599    state: &mut RunState,
7600    attempt: usize,
7601) -> Vec<(usize, SeatState, AgentOutcome)> {
7602    let WaveCtx {
7603        run,
7604        node,
7605        prompts,
7606        cache,
7607        round,
7608        carry_seats: _,
7609    } = *ctx;
7610    for job in &jobs {
7611        state.seat_started(node, &job.seat.key, job.timeout, attempt);
7612    }
7613    if let Err(e) = state.save() {
7614        // A failed persist of "who is answering right now" must not abort the
7615        // wave: the seats are already being asked, and the alternative is
7616        // losing the answers to save a status line nobody may even be
7617        // watching.
7618        tracing::warn!("could not persist in-progress seats: {e:#}");
7619    }
7620    // Hold the shared build cache's lease for the whole batch, not per job:
7621    // several candidates (an implement wave) or a fixer legitimately share
7622    // one cache concurrently within this run, and that stays untouched — a
7623    // single lease taken once for the whole wave and released once it is
7624    // done is what stops a *different* borrower (another run's own wave, its
7625    // e2e/gate, a human's `magi review`) from interleaving a build into the
7626    // same directory while this one is in flight. Best-effort, not
7627    // all-or-nothing: a wave that cannot get the lease within its own
7628    // longest job's budget still runs — an hour of paid implementer calls is
7629    // not thrown away over cache contention — but every write-allowed seat
7630    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
7631    // below), the same fallback a read-only seat always gets, rather than
7632    // building into a directory this run was never granted. The identity
7633    // record is still invalidated below either way, so the next tracked
7634    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
7635    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7636    let wait_started = Instant::now();
7637    let cache_guard = if let Some(cache_dir) = cache {
7638        if jobs_had_a_writer {
7639            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7640            let budget = jobs
7641                .iter()
7642                .map(|j| j.timeout)
7643                .max()
7644                .unwrap_or(Duration::from_secs(60));
7645            acquire_cache_lease(state, cache_dir, &owner, budget, node)
7646                .await
7647                .ok()
7648        } else {
7649            None
7650        }
7651    } else {
7652        None
7653    };
7654    // Carved out of each job's own budget, not added on top of it: a seat
7655    // that waited behind the lease must not also get its full timeout
7656    // afterward, or a run contended on the cache could double the time it
7657    // spends per wave. `saturating_sub` floors at zero rather than
7658    // wrapping - a job whose whole budget was spent waiting starts with
7659    // none left, which is the honest number, not a free minimum.
7660    let waited_for_lease = wait_started.elapsed();
7661    let mut set = tokio::task::JoinSet::new();
7662    let overlay = prompts.overlay(node);
7663    for (i, mut job) in jobs.into_iter().enumerate() {
7664        job.timeout = job.timeout.saturating_sub(waited_for_lease);
7665        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7666        if cache.is_some() {
7667            job.prompt.push('\n');
7668            job.prompt
7669                .push_str(&prompt::build_cache_note(node, job.allow_write));
7670        }
7671        let sem = Arc::clone(&sem);
7672        let run = run.to_owned();
7673        let node = node.to_owned();
7674        // Only implementers were told about the task's attachments, so only
7675        // their seats get the directory widened for reading.
7676        let attachments = if node == "implement" {
7677            state.attachments.clone()
7678        } else {
7679            Vec::new()
7680        };
7681        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
7682        // `prompt::build_cache_note`'s doc for why setting it anyway is
7683        // exactly how a sandboxed reviewer's write refusal got reported as a
7684        // defect in the patch, not a property of its own seat. And a
7685        // write-allowed one is handed it only when the lease above was
7686        // actually acquired: a wave that could not get it (`cache_guard` is
7687        // `None`, see its own comment) must not send seats to build into a
7688        // directory this run does not hold - that is the exact concurrent,
7689        // unmanaged-write race this module exists to prevent, not something
7690        // "proceeding anyway" is allowed to reintroduce.
7691        let cache = cache
7692            .filter(|_| job.allow_write && cache_guard.is_some())
7693            .map(Path::to_path_buf);
7694        set.spawn(async move {
7695            let _permit = sem.acquire().await;
7696            let mut seat = job.seat;
7697            let out = agent::invoke(
7698                &job.spec,
7699                &mut seat,
7700                &Invocation {
7701                    cwd: &job.cwd,
7702                    prompt: &job.prompt,
7703                    timeout: job.timeout,
7704                    allow_write: job.allow_write,
7705                    unsandboxed: false,
7706                    sessions: job.sessions,
7707                    artifacts: &job.artifacts,
7708                    stem: &job.stem,
7709                    run: &run,
7710                    node: &node,
7711                    cache_dir: cache.as_deref(),
7712                    attachments: &attachments,
7713                    writable: &[],
7714                },
7715            )
7716            .await;
7717            let out = match out {
7718                Ok(o) if o.usable() => AgentOutcome::Ok(o),
7719                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7720                // Billed work the CLI failed to hand over is not an ordinary
7721                // failure, but its text is the CLI's raw error JSON, not an
7722                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
7723                // read it as one by forgetting to check. `usable()` is always
7724                // false here (dropped implies an empty response), so this has
7725                // to be checked before the catch-all `Failed` below or the
7726                // one shape this exists for is lost with the rest.
7727                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7728                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7729                Ok(o) => AgentOutcome::Failed(format!(
7730                    "exited with {:?} and no usable output",
7731                    o.exit_code
7732                )),
7733                Err(e) => AgentOutcome::Failed(e.to_string()),
7734            };
7735            (i, seat, out)
7736        });
7737    }
7738    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7739    while let Some(joined) = set.join_next().await {
7740        let (i, seat, out) = match joined {
7741            Ok(v) => v,
7742            // No seat to clear: a panicked task never reported which one it
7743            // was. The defensive sweep below this loop is what stops that
7744            // seat's `active` entry from surviving forever.
7745            Err(e) => {
7746                tracing::error!("agent task panicked: {e}");
7747                continue;
7748            }
7749        };
7750        state.seat_finished(&seat.key);
7751        record_jobs(state, node, round, &seat.key, &out);
7752        if let Err(e) = state.save() {
7753            tracing::warn!("could not persist a seat's completion: {e:#}");
7754        }
7755        if collected.len() <= i {
7756            collected.resize_with(i + 1, || None);
7757        }
7758        collected[i] = Some((i, seat, out));
7759    }
7760    // Belt-and-braces for the panic branch above: every seat this exact batch
7761    // started shares this `(node, attempt)` pair, and every seat that finished
7762    // normally already cleared itself, so anything left tagged with it here
7763    // can only be a panicked task's leftover. Cleared unconditionally rather
7764    // than left to read as still answering forever.
7765    if state
7766        .active
7767        .values()
7768        .any(|a| a.node == node && a.attempt == attempt)
7769    {
7770        state
7771            .active
7772            .retain(|_, a| !(a.node == node && a.attempt == attempt));
7773        if let Err(e) = state.save() {
7774            tracing::warn!("could not persist the end of a wave: {e:#}");
7775        }
7776    }
7777    // Whether or not the lease above was actually held, several worktrees
7778    // may just have built into the cache with nothing here able to name one
7779    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
7780    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
7781    // clean it might not have strictly needed; trusting a stale match would
7782    // cost it a wrong answer.
7783    if let Some(cache_dir) = cache
7784        && jobs_had_a_writer
7785    {
7786        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7787    }
7788    if let Some(guard) = cache_guard {
7789        guard.release();
7790    }
7791    collected.into_iter().flatten().collect()
7792}
7793
7794/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
7795/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
7796/// this is data collection, not the fix-specific completion contract in
7797/// [`Runner::continue_fix_report`], and applies regardless of which node
7798/// asked.
7799///
7800/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
7801/// evidence from; `Failed` does not, and correctly contributes nothing — a
7802/// timeout or crash is not itself evidence about a command the seat may have
7803/// started.
7804fn record_jobs(
7805    state: &mut RunState,
7806    node: &str,
7807    round: Option<usize>,
7808    seat: &str,
7809    out: &AgentOutcome,
7810) {
7811    let commands: &[agent::CommandEvidence] = match out {
7812        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7813        AgentOutcome::Failed(_) => &[],
7814    };
7815    let checked_at = Timestamp::now();
7816    for c in commands {
7817        state.jobs.push(JobRecord {
7818            node: node.to_owned(),
7819            round,
7820            seat: seat.to_owned(),
7821            id: c.id.clone(),
7822            description: c.description.clone(),
7823            checked_at,
7824            status: match c.exit_code {
7825                Some(0) => JobStatus::Completed,
7826                Some(_) => JobStatus::Failed,
7827                None => JobStatus::Unknown,
7828            },
7829            exit_code: c.exit_code,
7830            result_summary: c.result_summary.clone(),
7831            source: c.source.clone(),
7832        });
7833    }
7834}
7835
7836/// Is a review round clean, given how many reviewer seats answered against
7837/// how many the round expected?
7838///
7839/// A seat that never answered (timeout, crash, unparsable output) is not a
7840/// seat that read the patch and found nothing — treating it as such is
7841/// exactly the bug this function exists to close. Under the default `block`
7842/// policy a missing seat can never be clean; `warn` still requires the seats
7843/// that *did* answer to have found nothing blocking and verification to be
7844/// green.
7845///
7846/// `quota_missing` narrows that `block` default for exactly one cause of
7847/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
7848/// a session limit lifts by the very next round buys nothing — the seat is
7849/// asked again with the same quota — so once every missing seat is accounted
7850/// for by a quota loss (and at least one seat *did* answer, so a decision has
7851/// something to rest on) the round is decided on the panel that could answer,
7852/// same as `warn` would. A panel that lost every seat to quota is not
7853/// decided here: `answered == 0` falls through to the existing `block`
7854/// fallback so a fully collapsed panel still waits rather than landing on no
7855/// review at all.
7856fn round_is_clean(
7857    blocking: usize,
7858    e2e_ok: bool,
7859    answered: usize,
7860    expected: usize,
7861    quota_missing: usize,
7862    policy: IncompleteReviewPolicy,
7863) -> bool {
7864    if blocking != 0 || !e2e_ok {
7865        return false;
7866    }
7867    if answered == expected || policy == IncompleteReviewPolicy::Warn {
7868        return true;
7869    }
7870    answered > 0 && expected - answered <= quota_missing
7871}
7872
7873/// The review loop's own conclusion, derived entirely from its persisted
7874/// round records and the round budget that produced them — never from
7875/// `status`, so a reentry (or `gate`/`merge` reading it independently)
7876/// recomputes the identical answer regardless of what an earlier node in the
7877/// same walk, or a previous walk, did to `status`.
7878///
7879/// `None` while more rounds remain to try, including when review never ran
7880/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
7881/// gone clean, or the budget is spent, or the tree has stopped moving (see
7882/// [`STAGNANT_LIMIT`]), the answer is one of two things:
7883///
7884/// - An incomplete panel that raised nothing is missing input, not a
7885///   verified tree — never a hand-off candidate, whatever verification said
7886///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
7887/// - Otherwise, green e2e on the last round hands off (see
7888///   [`Runner::stop_reviewing`]); red e2e blocks.
7889///
7890/// A last round whose own verification is still `ResourceBlocked` — magi
7891/// itself never got a command to run, not evidence the patch is broken —
7892/// is neither: this returns `None` for it too, the same as "more rounds
7893/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
7894/// handling of that shape) instead of this cheap recomputation guessing a
7895/// verdict a real attempt never produced.
7896fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7897    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7898        return Some(RunStatus::Gating);
7899    }
7900    let last = reviews.last()?;
7901    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7902    if reviews.len() < max_rounds && !stagnant {
7903        return None;
7904    }
7905    if last.incomplete() && last.blocking == 0 {
7906        return Some(RunStatus::Blocked);
7907    }
7908    if last.e2e_status() == E2eStatus::ResourceBlocked {
7909        return None;
7910    }
7911    Some(if last.e2e.iter().all(CommandOutcome::ok) {
7912        RunStatus::Gating
7913    } else {
7914        RunStatus::Blocked
7915    })
7916}
7917
7918/// How long a re-ask may take, given the budget the first attempt had.
7919///
7920/// A `nudged` retry is a request to restate an answer the seat has already
7921/// worked out: it carries no new work, so it does not deserve the original
7922/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
7923/// 133 seconds while a third sat for over ten minutes on a resumed session
7924/// holding 410 KB of prior output - and because the retry had inherited the
7925/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
7926/// judging round whose other seats were long finished.
7927///
7928/// A quarter of the budget, with a floor so that a deliberately short timeout
7929/// does not collapse to nothing. A retry that re-sends the whole prompt
7930/// (because the seat kept no context) is the original job again, and keeps the
7931/// original budget.
7932pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7933    if nudged {
7934        (full / 4).max(Duration::from_secs(120)).min(full)
7935    } else {
7936        full
7937    }
7938}
7939
7940/// Run a wave and parse each reply, re-asking the seats whose reply was
7941/// unusable.
7942///
7943/// The re-ask is a nudge rather than the whole prompt again when the seat still
7944/// holds its conversation, which is the difference between a cheap retry and
7945/// paying for the entire candidate set twice.
7946///
7947/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
7948/// successor in `roster` is handed to it instead of being re-asked: the
7949/// handover is the retry. A seat with no successor left (a single-agent
7950/// roster, the roster's tail) is nudged as before, up to `retries` times. So
7951/// the asks to one seat in one node number at most
7952/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
7953/// is asked once (a dropped stream is nudged first), and only the last agent
7954/// of the chain gets the `retries` same-agent nudges. Each roster agent is
7955/// tried at most once per seat, walking forward from the seat's own position and never wrapping
7956/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
7957/// other failure stops the chain when the previous agent failed the same way
7958/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
7959/// [`has_context`] is false and the job's own full prompt and full budget are
7960/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
7961/// intermediate ones are not losses) and is returned as a failure like any
7962/// other absent seat — the caller decides whether the panel still has a
7963/// quorum. An empty `roster` disables handover: failures are nudged as they
7964/// always were, and a quota is simply lost. A reply that fails to parse or
7965/// validate is the prompt's doing and is only ever nudged, never handed over.
7966///
7967/// The returned [`SeatState`] names the agent that answered (or tried last).
7968#[allow(clippy::too_many_arguments)]
7969async fn ask_json_wave<T>(
7970    jobs: Vec<SeatJob>,
7971    sem: Arc<Semaphore>,
7972    retries: usize,
7973    roster: &[AgentSpec],
7974    ctx: &WaveCtx<'_>,
7975    losses: &mut Vec<QuotaLoss>,
7976    state: &mut RunState,
7977    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7978) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7979where
7980    T: serde::de::DeserializeOwned + Send + 'static,
7981{
7982    ask_wave_with(
7983        jobs,
7984        sem,
7985        retries,
7986        roster,
7987        ctx,
7988        losses,
7989        state,
7990        &|text: &str| {
7991            let v = verdict::extract_json::<T>(text)?;
7992            validate(&v)?;
7993            Ok(v)
7994        },
7995    )
7996    .await
7997}
7998
7999/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
8000/// a node whose answer is prose (deliberation) shares the same handover,
8001/// failure classification, quota bookkeeping and bounds instead of a copy.
8002///
8003/// A seat handed to another roster agent is sent the job's `handover` prompt
8004/// (when it has one) rather than `prompt`: the new agent has no session, so a
8005/// resume-style prompt would be incomplete. That holds for the handover ask
8006/// and for every nudge to that agent whose `has_context` is false.
8007#[allow(clippy::too_many_arguments)]
8008async fn ask_wave_with<T>(
8009    jobs: Vec<SeatJob>,
8010    sem: Arc<Semaphore>,
8011    retries: usize,
8012    roster: &[AgentSpec],
8013    ctx: &WaveCtx<'_>,
8014    losses: &mut Vec<QuotaLoss>,
8015    state: &mut RunState,
8016    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8017) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8018where
8019    T: Send + 'static,
8020{
8021    let n = jobs.len();
8022    let originals: Vec<SeatJob> = jobs;
8023    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8024    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8025    // Nudges each seat's *current* agent has taken — 0 for a first-ask
8026    // answer, N once it has gone through N nudges. Read back once this
8027    // returns, so a caller building a history record (`ReviewRecord`) can
8028    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
8029    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
8030    // field's own doc.
8031    let mut nudges: Vec<usize> = vec![0; n];
8032    // The agent now occupying each seat, the ids it has already been through,
8033    // where in the roster the walk began, the class of the last failure, and
8034    // the stem word of a handover not yet asked (full prompt, full budget).
8035    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8036    let mut tried: Vec<BTreeSet<String>> = specs
8037        .iter()
8038        .map(|s| BTreeSet::from([s.id.clone()]))
8039        .collect();
8040    let starts: Vec<usize> = specs
8041        .iter()
8042        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8043        .collect();
8044    let carry = ctx.carry_seats && !roster.is_empty();
8045    // Carried across rounds: ids that failed the seat earlier, and how the
8046    // last failure went (so a repeat of it is not handed over again).
8047    let carried: Vec<BTreeSet<String>> = originals
8048        .iter()
8049        .map(|j| {
8050            state
8051                .seat_history
8052                .get(&j.seat.key)
8053                .filter(|_| carry)
8054                .map(|h| h.failed.clone())
8055                .unwrap_or_default()
8056        })
8057        .collect();
8058    let mut prev: Vec<Option<FailClass>> = originals
8059        .iter()
8060        .map(|j| {
8061            state
8062                .seat_history
8063                .get(&j.seat.key)
8064                .filter(|_| carry)
8065                .and_then(|h| h.last_fail.clone())
8066        })
8067        .collect();
8068    let next_agent =
8069        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
8070            let others: BTreeSet<String> = specs
8071                .iter()
8072                .enumerate()
8073                .filter(|(j, _)| *j != i)
8074                .map(|(_, s)| s.id.clone())
8075                .collect();
8076            pick_successor(
8077                roster,
8078                starts[i],
8079                tried,
8080                carry.then(|| &carried[i]),
8081                &others,
8082            )
8083            .cloned()
8084        };
8085    let mut fresh: Vec<Option<String>> = vec![None; n];
8086    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
8087    let mut pending: Vec<usize> = (0..n).collect();
8088
8089    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
8090    // asks: an agent with a successor is asked once and handed over, and only
8091    // a seat with no successor spends `retries` nudges on the same agent. This
8092    // only guarantees the loop's own termination whatever those say.
8093    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
8094    for round in 0..max_rounds {
8095        if pending.is_empty() {
8096            break;
8097        }
8098        let mut batch = Vec::with_capacity(pending.len());
8099        let mut renudged: Vec<&str> = Vec::new();
8100        for &i in &pending {
8101            let src = &originals[i];
8102            // A seat now held by another agent than the job named has no
8103            // session of its own: it gets the full-context prompt whenever
8104            // it is asked in full (the handover ask, a nudge it cannot
8105            // resume).
8106            let full: &str = match &src.handover {
8107                Some(h) if specs[i].id != src.spec.id => h,
8108                _ => &src.prompt,
8109            };
8110            // The prompt and the budget are one decision: a nudge restates
8111            // finished work, a re-sent prompt redoes it.
8112            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
8113                (
8114                    full.to_owned(),
8115                    src.timeout,
8116                    format!("{}-{word}-{}", src.stem, specs[i].id),
8117                )
8118            } else if nudges[i] == 0 {
8119                (full.to_owned(), src.timeout, src.stem.clone())
8120            } else {
8121                renudged.push(src.seat.key.as_str());
8122                let why = done[i]
8123                    .as_ref()
8124                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
8125                    .unwrap_or_else(|| "no parsable answer".to_owned());
8126                let nudge = prompt::nudge(&why);
8127                let nudged = has_context(&specs[i], &seats[i], src.sessions);
8128                let prompt = if nudged {
8129                    nudge
8130                } else {
8131                    format!("{full}\n\n---\n\n{nudge}")
8132                };
8133                (
8134                    prompt,
8135                    retry_budget(src.timeout, nudged),
8136                    format!("{}-retry{}", src.stem, nudges[i]),
8137                )
8138            };
8139            batch.push(SeatJob {
8140                spec: specs[i].clone(),
8141                seat: seats[i].clone(),
8142                cwd: src.cwd.clone(),
8143                prompt,
8144                timeout,
8145                allow_write: src.allow_write,
8146                sessions: src.sessions,
8147                artifacts: src.artifacts.clone(),
8148                stem,
8149                handover: None,
8150            });
8151        }
8152
8153        if !renudged.is_empty() {
8154            state.event(
8155                ctx.node,
8156                format!("retry {round}: re-asking {}", renudged.join(", ")),
8157            );
8158        }
8159        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
8160        let mut still = Vec::new();
8161        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
8162            seats[i] = seat;
8163            let class = FailClass::of(&out);
8164            // A dropped stream is nudged first (the conversation is still
8165            // there to pick up); only a seat whose nudges are spent hands over.
8166            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
8167                && nudges[i] < retries
8168                && !roster.is_empty();
8169            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
8170                let next = should_hand_over(prev[i].as_ref(), &cur)
8171                    .then(|| next_agent(i, &tried[i], &specs))
8172                    .flatten();
8173                if carry {
8174                    let h = state
8175                        .seat_history
8176                        .entry(originals[i].seat.key.clone())
8177                        .or_default();
8178                    h.failed.insert(specs[i].id.clone());
8179                    h.last_fail = Some(cur.clone());
8180                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
8181                        h.last_ok = None;
8182                    }
8183                    // Saved before the next agent is asked, so a restart in
8184                    // between does not forget who failed.
8185                    if let Err(e) = state.save() {
8186                        tracing::warn!("could not persist a seat's failure history: {e:#}");
8187                    }
8188                }
8189                if let Some(next) = next {
8190                    record_handover(
8191                        state,
8192                        ctx.node,
8193                        &originals[i].seat.key,
8194                        &specs[i].id,
8195                        &next.id,
8196                        &cur,
8197                        &fail_reason(&out),
8198                    );
8199                    tried[i].insert(next.id.clone());
8200                    prev[i] = Some(cur.clone());
8201                    seats[i] =
8202                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
8203                    specs[i] = next;
8204                    fresh[i] = Some(cur.stem_word().to_owned());
8205                    nudges[i] = 0;
8206                    done[i] = Some(Err(anyhow::anyhow!(
8207                        "handed over after: {}",
8208                        fail_reason(&out)
8209                    )));
8210                    still.push(i);
8211                    continue;
8212                }
8213            }
8214            if carry
8215                && !nudge_first
8216                && let Some(cur) = class.clone()
8217            {
8218                // The chain ended here (no successor, or a repeated failure).
8219                let h = state
8220                    .seat_history
8221                    .entry(originals[i].seat.key.clone())
8222                    .or_default();
8223                h.failed.insert(specs[i].id.clone());
8224                h.last_fail = Some(cur);
8225            }
8226            let parsed = match out {
8227                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
8228                AgentOutcome::Quota(o) => {
8229                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
8230                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
8231                }
8232                // Not a parseable answer: the nudge loop re-asks it, which is
8233                // exactly what a dropped stream needs. Just don't hand its raw
8234                // error JSON to `extract_json`.
8235                AgentOutcome::Dropped(o) => {
8236                    let why = o
8237                        .dropped
8238                        .as_ref()
8239                        .map(|d| d.why.as_str())
8240                        .unwrap_or("the CLI ended the stream without delivering its answer");
8241                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
8242                }
8243                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
8244            };
8245            let quota = class == Some(FailClass::Quota);
8246            let failed = parsed.is_err();
8247            done[i] = Some(parsed);
8248            if carry && !failed {
8249                let h = state
8250                    .seat_history
8251                    .entry(originals[i].seat.key.clone())
8252                    .or_default();
8253                h.failed.remove(&specs[i].id);
8254                h.last_ok = Some(specs[i].id.clone());
8255                h.last_fail = None;
8256            }
8257            // Do not re-ask a rate-limited seat (quota) — a retry is known to
8258            // fail the same way; and never re-ask a seat that already parsed.
8259            // A failed agent that still has a successor is not re-asked
8260            // either: the handover was its remedy and has just been refused
8261            // (the chain stops on a repeated failure class). A seat with no
8262            // successor left (a single-agent roster, the roster's tail, or an
8263            // empty roster) keeps the same-agent nudge, bounded by `retries`.
8264            let agent_failure = class.is_some()
8265                && !nudge_first
8266                && !roster.is_empty()
8267                && next_agent(i, &tried[i], &specs).is_some();
8268            if failed && !quota && !agent_failure && nudges[i] < retries {
8269                nudges[i] += 1;
8270                still.push(i);
8271            }
8272        }
8273        pending = still;
8274    }
8275
8276    // One loss per seat whose chain ended on a quota: the intermediate ones
8277    // were absorbed by a handover and are not losses.
8278    for (i, q) in last_quota.into_iter().enumerate() {
8279        if let Some(reset) = q {
8280            losses.push(QuotaLoss {
8281                seat: originals[i].seat.key.clone(),
8282                node: ctx.node.to_owned(),
8283                at: Timestamp::now(),
8284                reset,
8285            });
8286        }
8287    }
8288
8289    seats
8290        .into_iter()
8291        .zip(done)
8292        .zip(nudges)
8293        .map(|((seat, res), attempts)| {
8294            (
8295                seat,
8296                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
8297                attempts,
8298            )
8299        })
8300        .collect()
8301}
8302
8303/// Acquire the shared build cache's lease, waiting out contention within
8304/// `budget` (never past it — see AGENTS.md's build-cache section on why an
8305/// unbounded wait is never acceptable).
8306///
8307/// A first, non-blocking check happens before ever waiting; if it finds the
8308/// lease busy, that fact is logged as a `verify` event *and* flushed with
8309/// [`RunState::save`] immediately — not only once the wait finally succeeds
8310/// or gives up — so a `magi show` run by a different process while this one
8311/// is still waiting reads a `run.json` that says so, rather than whatever it
8312/// looked like before the wait started. The same applies to the terminal
8313/// failure: logged and saved before this returns `Err`, so a caller that
8314/// could not get the lease at all still leaves a legible record of why.
8315async fn acquire_cache_lease(
8316    state: &mut RunState,
8317    cache_dir: &Path,
8318    owner: &crate::cache::Owner,
8319    budget: Duration,
8320    context: &str,
8321) -> Result<crate::cache::Guard> {
8322    let home = crate::run::home();
8323    let started = Instant::now();
8324    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
8325        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
8326        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
8327        Err(e) => {
8328            state.event(
8329                "verify",
8330                format!("{context}: could not check the shared build cache: {e:#}"),
8331            );
8332            if let Err(e2) = state.save() {
8333                tracing::warn!("could not persist a cache-check failure: {e2:#}");
8334            }
8335            return Err(e);
8336        }
8337    };
8338    state.event(
8339        "verify",
8340        format!(
8341            "{context}: waiting for the shared build cache at {} ({})",
8342            cache_dir.display(),
8343            busy.describe()
8344        ),
8345    );
8346    if let Err(e) = state.save() {
8347        tracing::warn!("could not persist a cache wait: {e:#}");
8348    }
8349    let remaining = budget.saturating_sub(started.elapsed());
8350    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
8351        Ok(g) => Ok(g),
8352        Err(e) => {
8353            state.event("verify", format!("{context}: {e:#}"));
8354            if let Err(e2) = state.save() {
8355                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
8356            }
8357            Err(e)
8358        }
8359    }
8360}
8361
8362/// Run `body` — a verify command batch — while holding the shared build
8363/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
8364/// never interleave with another borrower's build against the same
8365/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
8366/// timeout, or a human's own `magi review`. See the `cache` module doc for
8367/// why this matters more than Cargo's own per-target locking covers — two
8368/// *different* worktrees building the same package name/version into one
8369/// cache directory is a staleness bug, not a lock contention one.
8370///
8371/// The wait for the lease is carved out of `budget`, never on top of it —
8372/// `body` is handed whatever is left, so a caller's own node timeout is the
8373/// only clock involved, exactly what AGENTS.md's build-cache section asks
8374/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
8375/// cache configured at all — this is a pass-through: `body` runs with the
8376/// full budget and nothing is leased.
8377///
8378/// A lease that cannot be acquired within `budget` is reported as a single
8379/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
8380/// verification — the same shape a spawn failure already takes in
8381/// [`run_commands`], so a caller need not special-case it.
8382#[allow(clippy::too_many_arguments)]
8383async fn with_cache_lease<'s, F, Fut>(
8384    state: &'s mut RunState,
8385    cache_dir: Option<&Path>,
8386    node: &str,
8387    seat: &str,
8388    worktree: &Path,
8389    head: &str,
8390    budget: Duration,
8391    context: &str,
8392    body: F,
8393) -> (Vec<CommandOutcome>, bool)
8394where
8395    F: FnOnce(&'s mut RunState, Duration) -> Fut,
8396    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8397{
8398    let Some(cache_dir) = cache_dir else {
8399        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8400        return (outcomes, retried);
8401    };
8402    let home = crate::run::home();
8403    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8404    let started = Instant::now();
8405    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8406        Ok(g) => g,
8407        Err(e) => {
8408            return (
8409                vec![CommandOutcome {
8410                    command: "(waiting for the shared build cache)".to_owned(),
8411                    code: None,
8412                    output_tail: e.to_string(),
8413                    duration_ms: started.elapsed().as_millis() as u64,
8414                    resource_blocked: true,
8415                }],
8416                false,
8417            );
8418        }
8419    };
8420    let identity = crate::cache::Identity::new(worktree, head);
8421    if let Err(e) = crate::cache::ensure_fresh(
8422        &home,
8423        cache_dir,
8424        &identity,
8425        &state.config.verify.cargo_manifests(),
8426    ) {
8427        // A failed freshness check means this process cannot vouch for what
8428        // is sitting in the cache right now - on Windows this is exactly the
8429        // "a stale test executable is still locked, `cargo clean -p` cannot
8430        // remove it" case the evidence log records. Running verify anyway
8431        // and reporting whatever it says would let a result nobody can trust
8432        // stand for the tree it claims to have checked; fail the step
8433        // instead of the patch.
8434        state.event(
8435            "verify",
8436            format!(
8437                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8438                worktree.display(),
8439                short(head)
8440            ),
8441        );
8442        guard.release();
8443        return (
8444            vec![CommandOutcome {
8445                command: "(confirming the shared build cache is fresh)".to_owned(),
8446                code: None,
8447                output_tail: e.to_string(),
8448                duration_ms: started.elapsed().as_millis() as u64,
8449                resource_blocked: true,
8450            }],
8451            false,
8452        );
8453    }
8454    let remaining = budget.saturating_sub(started.elapsed());
8455    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8456    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
8457    // `start_kill`); confirm it actually has before handing the directory to
8458    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
8459    // for what this can and cannot see.
8460    if !timed_out_pids.is_empty() {
8461        wait_for_timed_out_children_to_die(&timed_out_pids).await;
8462    }
8463    guard.release();
8464    (outcomes, retried)
8465}
8466
8467/// Poll `pids` — commands [`run_commands`] reports as still running when its
8468/// own timeout elapsed — until every one is confirmed gone, or
8469/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
8470///
8471/// Real confirmation where confirmation is possible, not a substitute for
8472/// full process-tree observation: a grandchild the timed-out process spawned
8473/// and that survives independently of it is invisible to a pid check the
8474/// same way it always was, and continuing to observe and collect *that*
8475/// stays a different piece of work with its own owner. This only narrows a
8476/// fixed blind wait into an actual check of the pids this process does know
8477/// about.
8478async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8479    wait_for_pids_with(
8480        pids,
8481        crate::proc::pid_alive,
8482        LEASE_RELEASE_POLL,
8483        LEASE_RELEASE_MAX_WAIT,
8484    )
8485    .await;
8486}
8487
8488/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
8489/// interval and ceiling supplied by the caller, so the polling *logic* -
8490/// returns as soon as every pid reports dead, gives up at the ceiling
8491/// otherwise - is testable on millisecond durations without asking the real
8492/// OS about a pid at all.
8493async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8494    pids: &[u32],
8495    alive: F,
8496    poll: Duration,
8497    max_wait: Duration,
8498) {
8499    let deadline = Instant::now() + max_wait;
8500    loop {
8501        if pids.iter().all(|&pid| !alive(pid)) {
8502            return;
8503        }
8504        if Instant::now() >= deadline {
8505            return;
8506        }
8507        tokio::time::sleep(poll).await;
8508    }
8509}
8510
8511/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
8512/// admission that it could not even get a verify command to run, as opposed
8513/// to evidence the command actually produced? A caller that would otherwise
8514/// read a resource-blocked outcome as a red command must check this first:
8515/// see [`Runner::gate`], which retries rather than records `Blocked` when
8516/// this is true.
8517fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8518    outcomes.iter().any(|o| o.resource_blocked)
8519}
8520
8521/// What [`Runner::gate_fix_round`] decided.
8522enum GateFix {
8523    /// The tree changed and `verify.e2e` is still green: run the gate again.
8524    Retry,
8525    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
8526    /// last failure stands and the run ends blocked.
8527    Stop,
8528    /// `verify.e2e` could not run after the fix (magi's own contention):
8529    /// decide nothing now, a later reentry retries.
8530    Defer,
8531}
8532
8533/// Is every red command in `outcomes` an ordinary failure the code could
8534/// explain: it ran, exited non-zero, and said something?
8535///
8536/// A timeout, a spawn failure and a killed process all leave `code` `None`;
8537/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
8538/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
8539/// what the command is stays the gate's business.
8540fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8541    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8542    red.peek().is_some()
8543        && red.all(|o| {
8544            !o.resource_blocked
8545                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8546                && !o.output_tail.trim().is_empty()
8547        })
8548}
8549
8550/// Describe one verify command's outcome for the event log, distinguishing a
8551/// build/link failure — the toolchain never produced a binary to run — from
8552/// an actual test failure, since only the latter is a verdict on the patch.
8553fn e2e_outcome_label(o: &CommandOutcome) -> String {
8554    if o.ok() {
8555        return "pass".to_owned();
8556    }
8557    let reason = if o.build_failed() {
8558        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8559    } else {
8560        format!("FAIL ({:?})", o.code)
8561    };
8562    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8563}
8564
8565/// Run `verify.e2e`, retrying once if the first attempt could not build or
8566/// link — a build/link failure is frequently a race against a shared
8567/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
8568/// `verify` event per command, tagged with `context` (normally `"round N"`)
8569/// so the two call sites that need this — the ordinary per-round leg in
8570/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
8571/// it will ever call a round green — read identically in the event log.
8572async fn run_e2e_with_retry(
8573    state: &mut RunState,
8574    shell: &[String],
8575    commands: &[String],
8576    worktree: &Path,
8577    timeout: Duration,
8578    context: &str,
8579) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8580    let (mut e2e, mut timed_out_pids) = run_commands(
8581        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8582    )
8583    .await;
8584    for o in &e2e {
8585        state.event(
8586            "verify",
8587            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8588        );
8589    }
8590    // A build/link failure is not a verdict on the patch — it is frequently a
8591    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
8592    // one retry before letting a red like that decide the round.
8593    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8594    if verify_retried {
8595        state.event(
8596            "verify",
8597            format!(
8598                "{context}: verify could not build/link, not a test result — retrying once \
8599                 before concluding"
8600            ),
8601        );
8602        let retried = run_commands(
8603            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8604        )
8605        .await;
8606        e2e = retried.0;
8607        // Both attempts' timeouts matter, not just the last one: the first
8608        // attempt's descendants may still be alive alongside the retry's.
8609        timed_out_pids.extend(retried.1);
8610        for o in &e2e {
8611            state.event(
8612                "verify",
8613                format!(
8614                    "{context}: retry `{}` -> {}",
8615                    o.command,
8616                    e2e_outcome_label(o)
8617                ),
8618            );
8619        }
8620    }
8621    (e2e, verify_retried, timed_out_pids)
8622}
8623
8624/// Run configured shell commands in `cwd`, in order. The second element is
8625/// the pid of every command that hit `timeout` and was still running when
8626/// this stopped waiting on it (best-effort: `None` when the platform did not
8627/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
8628/// that releases a shared resource afterward needs to know.
8629///
8630/// Records `task` into [`RunState::active`] at every command boundary
8631/// (`RunState::task_command`) and clears it once the whole list has run
8632/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
8633/// for minutes with no seat and no output of its own to show for it (see
8634/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
8635/// apart from "not yet run" without this), and this is the only place that
8636/// knows which command is running right now and how many are left. Three
8637/// saves per command — start, not per second — matching the same "only at a
8638/// boundary" rule [`wave`] already follows for seats.
8639#[allow(clippy::too_many_arguments)]
8640async fn run_commands(
8641    state: &mut RunState,
8642    node: &str,
8643    task: &str,
8644    attempt: usize,
8645    shell: &[String],
8646    commands: &[String],
8647    cwd: &Path,
8648    timeout: Duration,
8649) -> (Vec<CommandOutcome>, Vec<u32>) {
8650    if commands.is_empty() {
8651        // Nothing to mark as running and nothing to clear — an empty list
8652        // means "not configured", and touching `active` (or the disk) over
8653        // that would be a write for every round of a repo with no
8654        // `verify.e2e` / `verify.gate` commands at all.
8655        return (Vec::new(), Vec::new());
8656    }
8657    let mut out = Vec::new();
8658    let mut timed_out_pids = Vec::new();
8659    let total = commands.len();
8660    for (idx, command) in commands.iter().enumerate() {
8661        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8662        if let Err(e) = state.save() {
8663            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8664        }
8665        let started = Instant::now();
8666        let mut cmd = tokio::process::Command::new(&shell[0]);
8667        cmd.quiet();
8668        cmd.args(&shell[1..])
8669            .arg(command)
8670            .current_dir(cwd)
8671            .stdin(std::process::Stdio::null())
8672            .stdout(std::process::Stdio::piped())
8673            .stderr(std::process::Stdio::piped())
8674            .kill_on_drop(true);
8675        let spawned = cmd.spawn();
8676        let (code, body) = match spawned {
8677            Ok(child) => {
8678                // Captured before the child is consumed below: `kill_on_drop`
8679                // only *asks* the process to die when the timeout branch
8680                // drops it, and the pid is the only way anyone downstream can
8681                // later check whether that request actually took.
8682                let pid = child.id();
8683                match tokio::time::timeout(timeout, child.wait_with_output()).await {
8684                    Ok(Ok(o)) => {
8685                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8686                        body.push_str(&String::from_utf8_lossy(&o.stderr));
8687                        (o.status.code(), body)
8688                    }
8689                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
8690                    Err(_) => {
8691                        if let Some(pid) = pid {
8692                            timed_out_pids.push(pid);
8693                        }
8694                        (None, format!("timed out after {}s", timeout.as_secs()))
8695                    }
8696                }
8697            }
8698            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8699        };
8700        out.push(CommandOutcome {
8701            command: command.clone(),
8702            code,
8703            output_tail: tail(&body, OUTPUT_TAIL),
8704            duration_ms: started.elapsed().as_millis() as u64,
8705            resource_blocked: false,
8706        });
8707    }
8708    state.task_finished(task);
8709    if let Err(e) = state.save() {
8710        tracing::warn!("could not persist the end of {task}: {e:#}");
8711    }
8712    (out, timed_out_pids)
8713}
8714
8715/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
8716/// section (`report::run`) and in the `merge` event this node records — for
8717/// the operator to run by hand.
8718///
8719/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
8720/// branch whose ruleset forbids merge commits (GitHub's "must not contain
8721/// merge commits", or "require linear history") rejects the push a `--no-ff`
8722/// merge would produce, which is exactly the guidance this function replaces.
8723/// `message`'s first line becomes the squash commit's subject, matching the
8724/// note `report::run` prints alongside this command — see that function for
8725/// why an explicit subject is not optional there.
8726fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8727    let repo = repo.display();
8728    match style {
8729        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8730        MergeStyle::Squash => {
8731            // The subject sits inside double quotes, and a title an agent
8732            // wrote may carry the characters that break out of them.
8733            let subject = message
8734                .lines()
8735                .next()
8736                .unwrap_or(branch)
8737                .replace(['\\', '"', '$', '`'], "");
8738            format!(
8739                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8740            )
8741        }
8742        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8743    }
8744}
8745
8746/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
8747/// under the hood, rejects a `title` over 256 characters and the whole
8748/// command fails — no PR at all, for a run whose body was otherwise fine
8749/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
8750/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
8751/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
8752/// this bound. It is a margin, not a guarantee — a title packed
8753/// with multi-unit characters could still in principle land close to the
8754/// edge, but a real task title's occasional emoji or accented letter fits
8755/// comfortably inside it.
8756const PR_TITLE_MAX: usize = 240;
8757
8758/// A pull request title from the opening line of `text`, or `None` when that
8759/// line is not English (GitHub text is) or has no letters.
8760///
8761/// A line within `max` is kept as is. A longer one is cut at the end of its
8762/// first sentence when that falls inside `max`, else at a word boundary with a
8763/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
8764/// make every merely-truncated title look non-English). The language check
8765/// runs on the kept text before any mark is added, so only what GitHub will
8766/// show is judged: an English opening followed by non-ASCII far past the cut
8767/// still passes.
8768fn english_title(text: &str, max: usize) -> Option<String> {
8769    let line = queue::first_line(text)?;
8770    let chars: Vec<char> = line.chars().collect();
8771    let (kept, mark) = if chars.len() <= max {
8772        (line.to_owned(), "")
8773    } else if let Some(end) = sentence_end(&chars, max) {
8774        (chars[..end].iter().collect::<String>(), "")
8775    } else {
8776        let room = max.saturating_sub(3);
8777        // Cut at the last space inside the room; when the char just past the
8778        // room is a space the room already ends on a word.
8779        let cut = if chars[room].is_whitespace() {
8780            room
8781        } else {
8782            chars[..room]
8783                .iter()
8784                .rposition(|c| c.is_whitespace())
8785                .unwrap_or(room)
8786        };
8787        let head: String = chars[..cut].iter().collect();
8788        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8789        (head.to_owned(), "...")
8790    };
8791    if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8792        return None;
8793    }
8794    Some(format!("{kept}{mark}"))
8795}
8796
8797/// The char length of the first sentence of `chars` when it ends within `max`
8798/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
8799/// abbreviations so `e.g. foo` does not end a title early.
8800fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8801    const MIN: usize = 20;
8802    for i in MIN..max.min(chars.len()) {
8803        if !matches!(chars[i], '.' | '!' | '?') {
8804            continue;
8805        }
8806        let Some(&next) = chars.get(i + 1) else {
8807            continue;
8808        };
8809        if !next.is_whitespace() {
8810            continue;
8811        }
8812        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8813        if after.is_some_and(|c| c.is_ascii_lowercase()) {
8814            continue;
8815        }
8816        let word: String = chars[..i]
8817            .iter()
8818            .rev()
8819            .take_while(|c| !c.is_whitespace())
8820            .collect::<Vec<_>>()
8821            .into_iter()
8822            .rev()
8823            .collect();
8824        let word = word.to_ascii_lowercase();
8825        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8826            continue;
8827        }
8828        let end = chars[..i]
8829            .iter()
8830            .rposition(|c| !c.is_whitespace())
8831            .map_or(i, |p| p + 1);
8832        return Some(end);
8833    }
8834    None
8835}
8836
8837/// What `merge = "pr"` (and the merge commit of the other modes) says about a
8838/// change: a title and a body describing what was *implemented*, not the task
8839/// that asked for it. A task reads as a request; a reader of the merged
8840/// history wants the change.
8841struct PrMessage {
8842    title: String,
8843    body: String,
8844}
8845
8846impl PrMessage {
8847    /// Title, blank line, body. The first line is the squash/merge commit
8848    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
8849    /// has to stay one sensible line.
8850    fn commit_message(&self) -> String {
8851        format!("{}\n\n{}", self.title, self.body)
8852    }
8853}
8854
8855/// The text after a leading `TITLE:` (any case) on `line`.
8856fn title_marker(line: &str) -> Option<&str> {
8857    let line = line.trim();
8858    let head = line.get(..6)?;
8859    head.eq_ignore_ascii_case("title:")
8860        .then(|| line[6..].trim())
8861}
8862
8863/// The implementer's own one-line title: the `TITLE:` line the implement
8864/// prompt asks for at the top of its SUMMARY. Candidate commits are all
8865/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
8866/// source, and a title that says as much is refused here too.
8867fn summary_title(summary: &str) -> Option<String> {
8868    let first = summary.lines().find(|l| !l.trim().is_empty())?;
8869    let raw = title_marker(first)?;
8870    if raw.is_empty() {
8871        return None;
8872    }
8873    let title = queue::title_from(raw, PR_TITLE_MAX);
8874    let lower = title.to_ascii_lowercase();
8875    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8876        return None;
8877    }
8878    Some(title)
8879}
8880
8881/// How `open_review`'s instruction begins; see [`landing_title`].
8882const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8883
8884/// Marker `open_review` gives a candidate that nothing in the roster wrote.
8885const EXISTING_BRANCH: &str = "(existing branch)";
8886
8887/// Does this run review work that already existed, rather than implement a
8888/// task? Runs recorded before `reviewed_commits` existed carry only the
8889/// candidate marker.
8890fn is_review_run(state: &RunState) -> bool {
8891    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8892}
8893
8894/// The title of a review-only run: the subject of the oldest commit under
8895/// review. Later commits are usually fixups, and `instruction` is the review
8896/// prompt, which says nothing about the change. GitHub text is English, so a
8897/// non-ASCII or blank subject yields `None` and the caller's neutral title.
8898fn review_title(state: &RunState) -> Option<String> {
8899    english_subject(state.reviewed_commits.as_ref()?.first()?)
8900}
8901
8902/// `raw` as a pull request title, or `None` when it is blank, not English
8903/// (GitHub text is), or one of magi's own candidate commit subjects.
8904fn english_subject(raw: &str) -> Option<String> {
8905    let raw = raw.trim();
8906    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8907        return None;
8908    }
8909    let title = queue::title_from(raw, PR_TITLE_MAX);
8910    let lower = title.to_ascii_lowercase();
8911    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8912        return None;
8913    }
8914    Some(title)
8915}
8916
8917/// What a review-only run's branch says about itself, read at the moment the
8918/// pull request is opened.
8919#[derive(Debug, Clone, PartialEq, Eq)]
8920struct BranchFacts {
8921    /// `(subject, body)` of each commit, oldest first.
8922    commits: Vec<(String, String)>,
8923    /// Trimmed `git diff --stat`.
8924    stat: String,
8925}
8926
8927/// Longest diff stat shown: this many file lines plus the summary line.
8928const STAT_FILE_LINES: usize = 25;
8929/// Cap on the commit list, well inside GitHub's 65536-character body limit.
8930const COMMITS_MAX_CHARS: usize = 20_000;
8931
8932/// Read the commits and diff stat of `base..branch`. `None` when git cannot
8933/// say or finds nothing, so the caller falls back to what the run recorded.
8934async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8935    let commits = git::commit_log(repo, base, branch).await.ok()?;
8936    if commits.is_empty() {
8937        return None;
8938    }
8939    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8940    let lines: Vec<&str> = stat.lines().collect();
8941    let stat = if lines.len() > STAT_FILE_LINES + 1 {
8942        let omitted = lines.len() - 1 - STAT_FILE_LINES;
8943        let more = format!(" ... {omitted} more file(s)");
8944        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8945        kept.push(&more);
8946        kept.push(lines[lines.len() - 1]);
8947        kept.join("\n")
8948    } else {
8949        lines.join("\n")
8950    };
8951    Some(BranchFacts { commits, stat })
8952}
8953
8954/// Defang what would break the surrounding markdown: a closing `</details>`
8955/// and a code fence.
8956fn markdown_safe(text: &str) -> String {
8957    text.replace("</details>", "&lt;/details&gt;")
8958        .replace("\x60\x60\x60", "~~~")
8959}
8960
8961fn neutral_title(state: &RunState, winner: char) -> String {
8962    format!(
8963        "chore: land candidate {} of run {}",
8964        winner.to_ascii_uppercase(),
8965        state.id
8966    )
8967}
8968
8969/// The pull request title to hand to `land::merge_subject`. A review-only run
8970/// opened by an earlier build titled its pull request with the review prompt;
8971/// that title is dropped (empty, so the fallback applies) rather than landed.
8972/// Any other title, including an operator's rename, passes through untouched,
8973/// and so does every title of a run that implements a task.
8974pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8975    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8976        ""
8977    } else {
8978        pr_title
8979    }
8980}
8981
8982/// What the squash subject falls back to when the pull request title is empty
8983/// or candidate-shaped: for a review-only run the derived title, never the
8984/// review prompt held in `instruction`.
8985pub fn landing_subject_source(state: &RunState) -> String {
8986    if is_review_run(state) {
8987        let winner = state.candidates.first().map_or('A', |c| c.label);
8988        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8989    }
8990    state.instruction.clone()
8991}
8992
8993/// `summary` without its `TITLE:` line, which the pull request title already
8994/// carries.
8995fn summary_without_title(summary: &str) -> String {
8996    let mut lines = summary.trim().lines().peekable();
8997    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8998        lines.next();
8999    }
9000    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
9001}
9002
9003/// The pull request title and body for the winning candidate.
9004///
9005/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
9006/// the task's own opening line via [`queue::title_from`] when there is none.
9007/// `state.instruction` can open with blank lines (`task_text` only rejects a
9008/// body that is blank *entirely*), which `title_from` skips.
9009///
9010/// Body: the implementer's summary and the fixer's notes, then — when the
9011/// winning review round was not clean — the findings still open and whatever
9012/// the fixer declined, so `merge = "pr"` hands the reader the same material
9013/// `magi show` does. The task follows inside a collapsed block, and the
9014/// footer repeats the run and candidate as plain tags for a reader holding
9015/// only the merged commit or the PR body.
9016#[cfg(test)]
9017fn pr_message(state: &RunState, winner: char) -> PrMessage {
9018    pr_message_with(state, winner, None)
9019}
9020
9021/// [`pr_message`] with what the branch of a review-only run says about itself.
9022/// `facts` is ignored for a run that implements a task.
9023#[cfg(test)]
9024fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9025    let raw = pr_message_raw(state, winner, facts);
9026    let id = crate::scrub::Identity::current();
9027    PrMessage {
9028        title: crate::scrub::scrub(&raw.title, &id),
9029        body: crate::scrub::scrub(&raw.body, &id),
9030    }
9031}
9032
9033fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9034    let summary = state
9035        .candidates
9036        .iter()
9037        .find(|c| c.label == winner)
9038        .map(|c| c.summary.as_str())
9039        .unwrap_or_default();
9040    // The fallback is the operator's own words and may not be English; GitHub
9041    // text always is, so a non-English task gets a neutral title instead.
9042    let review = is_review_run(state);
9043    let title = if review {
9044        facts
9045            .and_then(|f| english_subject(&f.commits.first()?.0))
9046            .or_else(|| review_title(state))
9047            .or_else(|| {
9048                state
9049                    .candidates
9050                    .iter()
9051                    .find(|c| c.label == winner)
9052                    .filter(|c| !c.branch.starts_with("magi/"))
9053                    .and_then(|c| english_subject(&c.branch))
9054            })
9055            .unwrap_or_else(|| neutral_title(state, winner))
9056    } else {
9057        summary_title(summary).unwrap_or_else(|| {
9058            english_title(&state.instruction, PR_TITLE_MAX)
9059                .unwrap_or_else(|| neutral_title(state, winner))
9060        })
9061    };
9062
9063    let mut body = String::new();
9064    let what = summary_without_title(summary);
9065    if !what.is_empty() {
9066        body.push_str("## Summary\n\n");
9067        body.push_str(&what);
9068        body.push_str("\n\n");
9069    }
9070
9071    // The last round is usually a clean verification pass with no fix of its
9072    // own, so every round's notes are read, not just the final one's.
9073    let notes: Vec<(usize, &str)> = state
9074        .reviews
9075        .iter()
9076        .filter_map(|r| {
9077            let n = r.fix.as_ref()?.notes.trim();
9078            (!n.is_empty()).then_some((r.round, n))
9079        })
9080        .collect();
9081    if !notes.is_empty() {
9082        body.push_str("## Review fixes\n\n");
9083        if let [(_, only)] = notes.as_slice() {
9084            body.push_str(only);
9085            body.push_str("\n\n");
9086        } else {
9087            for (round, n) in &notes {
9088                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
9089            }
9090        }
9091    }
9092    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
9093
9094    let open = state.open_findings();
9095    if !open.is_empty() {
9096        body.push_str("## Open review findings\n\n");
9097        for f in &open {
9098            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
9099        }
9100        body.push('\n');
9101    }
9102
9103    if let Some(fix) = fix
9104        && !fix.rejected.is_empty()
9105    {
9106        body.push_str("## Declined by the fixer\n\n");
9107        for r in &fix.rejected {
9108            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
9109        }
9110        body.push('\n');
9111    }
9112
9113    if review {
9114        // The review prompt is not the task; list what the branch carries.
9115        body.push_str("## Commits under review\n\n");
9116        if let Some(facts) = facts {
9117            let mut left = COMMITS_MAX_CHARS;
9118            for (i, (subject, text)) in facts.commits.iter().enumerate() {
9119                let mut entry = format!("- {}\n", markdown_safe(subject));
9120                for l in markdown_safe(text).lines() {
9121                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
9122                }
9123                if left == 0 {
9124                    body.push_str(&format!(
9125                        "- ... {} more commit(s)\n",
9126                        facts.commits.len() - i
9127                    ));
9128                    break;
9129                }
9130                if entry.len() > left {
9131                    // Even the first commit is cut: one huge body must not
9132                    // push the whole description past GitHub's limit.
9133                    let mut end = left;
9134                    while !entry.is_char_boundary(end) {
9135                        end -= 1;
9136                    }
9137                    entry.truncate(end);
9138                    entry.push_str("\n  ... (truncated)\n");
9139                    left = 0;
9140                } else {
9141                    left -= entry.len();
9142                }
9143                body.push_str(&entry);
9144            }
9145            if !facts.stat.trim().is_empty() {
9146                body.push_str(&format!(
9147                    "\n## Diff stat\n\n```\n{}\n```\n",
9148                    markdown_safe(facts.stat.trim())
9149                ));
9150            }
9151        } else {
9152            match &state.reviewed_commits {
9153                Some(subjects) => {
9154                    for s in subjects {
9155                        body.push_str(&format!("- {}\n", s.trim()));
9156                    }
9157                }
9158                None => {
9159                    // An older run kept only the prompt, with the commit list
9160                    // after its first paragraph.
9161                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
9162                    body.push_str(rest.trim());
9163                    body.push('\n');
9164                }
9165            }
9166        }
9167    } else {
9168        let task = state.instruction.trim();
9169        let task = if task.is_empty() {
9170            "(empty task)"
9171        } else {
9172            task
9173        };
9174        body.push_str(&format!(
9175            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
9176            task.replace("</details>", "&lt;/details&gt;")
9177        ));
9178    }
9179
9180    body.push_str(&format!(
9181        "\n---\nmagi:run/{} magi:candidate-{}\n",
9182        state.id,
9183        winner.to_ascii_lowercase()
9184    ));
9185
9186    PrMessage { title, body }
9187}
9188
9189/// The task with what the repository says about the existing work it names
9190/// appended, so an implementer knows what it started from and what it must
9191/// not redo. Unchanged when the task names nothing.
9192fn seeded_instruction(state: &RunState) -> String {
9193    match refs::describe(&state.seeds) {
9194        Some(facts) => format!(
9195            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
9196             Candidates start from the unmerged branch named above, when there \
9197             is one, and carry any unmerged commit named by sha as a \
9198             cherry-pick. Check that this is what the task meant before \
9199             building on it.",
9200            state.instruction
9201        ),
9202        None => state.instruction.clone(),
9203    }
9204}
9205
9206/// Does the winner have no commits ahead of the base it would land on?
9207/// Any failure to find out reads as "not empty": the merge then behaves as it
9208/// always did rather than refusing on a guess.
9209async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
9210    let base = &state.base_branch;
9211    let mut against = base.clone();
9212    if mode == MergeMode::Pr {
9213        // A pull request lands on the remote's base, never the local branch
9214        // of the same name: if that cannot be read, "not empty" is the safe
9215        // answer.
9216        let remote = &state.config.merge.remote;
9217        let tracking = format!("{remote}/{base}");
9218        let fetched = git::fetch(repo, remote, base).await;
9219        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
9220            against = tracking;
9221        } else {
9222            return false;
9223        }
9224    }
9225    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
9226}
9227
9228/// Why nothing was opened for an empty winner, with what the task's own
9229/// references resolved to.
9230fn empty_candidate_detail(state: &RunState, base: &str) -> String {
9231    let mut detail = format!(
9232        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
9233         nothing to open a pull request for"
9234    );
9235    match refs::describe(&state.seeds) {
9236        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
9237        None => detail.push_str(
9238            "\nThe task names no existing branch or commit; if it means to land work \
9239             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
9240        ),
9241    }
9242    detail
9243}
9244
9245/// What the `Pr` merge does once it knows whether the branch already has an
9246/// open pull request.
9247#[derive(Debug, PartialEq, Eq)]
9248enum PrPlan {
9249    Create,
9250    Adopt { url: String, title: String },
9251    Stop(String),
9252}
9253
9254/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
9255/// a failed lookup -> stop with the real reason. Never guesses.
9256fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
9257    match found {
9258        Ok(land::OpenPr::None) => PrPlan::Create,
9259        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
9260        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
9261            "several open pull requests exist for this branch, not picking one: {}",
9262            urls.join(" ")
9263        )),
9264        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
9265    }
9266}
9267
9268/// `gh pr create`, returning the PR url.
9269async fn gh_pr_create(
9270    cwd: &Path,
9271    base: &str,
9272    head: &str,
9273    title: &str,
9274    body: &str,
9275) -> Result<String> {
9276    let out = tokio::process::Command::new("gh")
9277        .args([
9278            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
9279        ])
9280        .current_dir(cwd)
9281        .quiet()
9282        .stdin(std::process::Stdio::null())
9283        .output()
9284        .await
9285        .context("spawn gh")?;
9286    if out.status.success() {
9287        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
9288    } else {
9289        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
9290    }
9291}
9292
9293/// Tear a run's worktrees and branches down.
9294///
9295/// `home` is where the updated `run.json` is saved (via
9296/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
9297/// a housekeeping pass already has its own honest `home` handed to it, and
9298/// falling through to the global here would write back through whichever
9299/// directory some other process or test pinned into that `OnceLock` first,
9300/// not the one the caller actually resolved its `runs` and `state` from.
9301pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
9302    let repo = state.repo.clone();
9303    let root = state.worktree_root();
9304    let winner = state.tally.as_ref().map(|t| t.winner);
9305    let mut removed = Vec::new();
9306
9307    for i in 0..state.candidates.len() {
9308        let c = state.candidates[i].clone();
9309        let is_winner = Some(c.label) == winner;
9310        if is_winner && !drop_winner {
9311            continue;
9312        }
9313        if c.worktree.exists() {
9314            git::worktree_remove(&repo, &c.worktree).await.ok();
9315            removed.push(c.worktree.to_string_lossy().into_owned());
9316        }
9317        // A branch handed to a later run (and its pull request) is not this
9318        // run's to delete.
9319        let handed_over = state.released_branches.contains(&c.branch);
9320        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
9321            git::branch_delete(&repo, &c.branch).await.ok();
9322            removed.push(c.branch.clone());
9323        }
9324        state.candidates[i].folded = true;
9325    }
9326
9327    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
9328        let path = name.path();
9329        let keep = !drop_winner
9330            && winner.is_some_and(|w| {
9331                path.file_name()
9332                    .is_some_and(|n| n == format!("cand-{w}").as_str())
9333            });
9334        if keep {
9335            continue;
9336        }
9337        git::worktree_remove(&repo, &path).await.ok();
9338        removed.push(path.to_string_lossy().into_owned());
9339    }
9340
9341    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
9342    // judge worktrees, so once the loop above has cleared all of them out,
9343    // the parent is a bare directory nobody else was ever going to remove -
9344    // git only ever managed what was inside it. Left alone, one of these
9345    // accumulates per fully-folded run; the operator's own machine had 74.
9346    // `remove_if_empty` re-checks rather than assuming: a run whose winner
9347    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
9348    // so does anything a run never claimed that happens to share the bay.
9349    remove_if_empty(&root);
9350
9351    if state.enabled_worktree_config && drop_winner {
9352        // A release, not a raw disable: some sibling run in this repository
9353        // may still hold its own reference (see `git::acquire_worktree_config`),
9354        // and only the last release actually turns the setting back off.
9355        git::release_worktree_config(&repo).await.ok();
9356        state.enabled_worktree_config = false;
9357    }
9358    state.save_under(home)?;
9359    Ok(removed)
9360}
9361
9362/// Remove `dir` if it exists and has nothing in it.
9363///
9364/// Best-effort and silent by design: a directory that is not empty (a run
9365/// whose winner is still parked there, a stray file some other process left)
9366/// is exactly the case this must refuse, and a directory that is already gone
9367/// is not a failure worth reporting either. `std::fs::remove_dir` itself
9368/// already refuses a non-empty directory, so the emptiness check below is
9369/// belt, not suspenders - it is what keeps this from ever attempting the
9370/// removal in the case that matters, rather than trusting `remove_dir`'s
9371/// error path to have no side effects if it ever changed.
9372fn remove_if_empty(dir: &Path) {
9373    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9374        std::fs::remove_dir(dir).ok();
9375    }
9376}
9377
9378/// Severity of the worst open finding in the last review round, for reporting.
9379pub fn worst_open(state: &RunState) -> Option<Severity> {
9380    state
9381        .reviews
9382        .last()?
9383        .reviews
9384        .iter()
9385        .flat_map(|r| r.findings.iter())
9386        .map(|f| f.severity)
9387        .max()
9388}
9389
9390/// `[worktree] setup` hides its products per worktree, which needs
9391/// `extensions.worktreeConfig`. Taken once per run and released with the run's
9392/// worktrees, like the hook's; every place that runs setup calls this first.
9393async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9394    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9395        git::acquire_worktree_config(repo).await?;
9396        state.enabled_worktree_config = true;
9397    }
9398    Ok(())
9399}
9400
9401#[cfg(test)]
9402mod tests {
9403    #[test]
9404    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9405        let leaked = vec!["chore(deps): update a crate".to_owned()];
9406        let own = "fix(daemon): apply a chosen action";
9407        assert!(should_retitle("", own, &leaked));
9408        assert!(should_retitle(
9409            &format!("{REVIEW_PROMPT_OPENING} `x`"),
9410            own,
9411            &leaked
9412        ));
9413        assert!(should_retitle(
9414            "chore: land candidate A of run 1",
9415            own,
9416            &leaked
9417        ));
9418        assert!(should_retitle(
9419            "magi: candidate A (uncommitted work)",
9420            own,
9421            &leaked
9422        ));
9423        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9424        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9425        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9426    }
9427
9428    #[test]
9429    fn pr_merge_plan_creates_adopts_or_stops() {
9430        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9431        assert_eq!(
9432            pr_merge_plan(Ok(land::OpenPr::One {
9433                url: "u".into(),
9434                title: "t".into()
9435            })),
9436            PrPlan::Adopt {
9437                url: "u".into(),
9438                title: "t".into()
9439            }
9440        );
9441        let PrPlan::Stop(many) =
9442            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9443        else {
9444            panic!("many must stop");
9445        };
9446        assert!(many.contains('a') && many.contains('b'));
9447        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9448            panic!("a failed lookup must stop");
9449        };
9450        assert!(err.contains("bad token"));
9451    }
9452
9453    use super::*;
9454    use crate::run::GateStatus;
9455    use std::collections::BTreeMap;
9456    use std::time::Duration;
9457
9458    fn conductor() -> AgentSpec {
9459        AgentSpec {
9460            id: "conductor".to_owned(),
9461            kind: crate::config::AgentKind::Command,
9462            model: None,
9463            command: vec!["true".to_owned()],
9464            extra_args: Vec::new(),
9465            env: BTreeMap::new(),
9466            prompt_delivery: None,
9467        }
9468    }
9469
9470    fn spec(id: &str) -> AgentSpec {
9471        AgentSpec {
9472            id: id.to_owned(),
9473            kind: crate::config::AgentKind::Command,
9474            model: None,
9475            command: vec!["true".to_owned()],
9476            extra_args: Vec::new(),
9477            env: BTreeMap::new(),
9478            prompt_delivery: None,
9479        }
9480    }
9481
9482    fn ids(xs: &[&str]) -> BTreeSet<String> {
9483        xs.iter().map(|s| (*s).to_owned()).collect()
9484    }
9485
9486    #[test]
9487    fn pick_successor_skips_an_agent_another_seat_holds() {
9488        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
9489        let roster = [spec("a"), spec("b"), spec("c")];
9490        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9491        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9492    }
9493
9494    #[test]
9495    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9496        // The other seat started on c but was handed to d; c is free again.
9497        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9498        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9499        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9500        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9501        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9502    }
9503
9504    #[test]
9505    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9506        let roster = [spec("a"), spec("b")];
9507        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9508        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9509        let carried = ids(&["b"]);
9510        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9511        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9512    }
9513
9514    #[test]
9515    fn pick_successor_returns_none_without_any_untried_successor() {
9516        let roster = [spec("a"), spec("b")];
9517        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9518        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9519        let carried = ids(&["a"]);
9520        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9521    }
9522
9523    #[test]
9524    fn pick_successor_rescue_avoids_another_seats_occupant() {
9525        // b is a carried failure and free; a is held by the other seat.
9526        let roster = [spec("a"), spec("b"), spec("c")];
9527        let carried = ids(&["b", "c"]);
9528        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9529        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9530    }
9531
9532    #[test]
9533    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9534        let roster = [spec("a"), spec("b"), spec("c")];
9535        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9536        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9537    }
9538
9539    #[test]
9540    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9541        let roster = [spec("a"), spec("b"), spec("c")];
9542        let failed = ids(&["a", "b", "c"]);
9543        // Rescue looks at the whole roster, once per id, then runs out.
9544        let mut tried = ids(&["b"]);
9545        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9546        assert_eq!(first.id, "a");
9547        tried.insert(first.id.clone());
9548        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9549        assert_eq!(second.id, "c");
9550        tried.insert(second.id.clone());
9551        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9552    }
9553
9554    #[test]
9555    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9556        let roster = [spec("a"), spec("b")];
9557        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9558        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9559    }
9560
9561    #[test]
9562    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9563        let roster = [spec("a"), spec("b"), spec("c")];
9564        let h = SeatHistory {
9565            failed: ids(&["a"]),
9566            last_ok: Some("b".to_owned()),
9567            last_fail: None,
9568        };
9569        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9570        // A last answerer that left the roster, or later failed, is ignored.
9571        let gone = SeatHistory {
9572            last_ok: Some("zzz".to_owned()),
9573            ..h.clone()
9574        };
9575        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9576        let failed = SeatHistory {
9577            failed: ids(&["a", "b"]),
9578            last_ok: Some("b".to_owned()),
9579            last_fail: None,
9580        };
9581        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9582    }
9583
9584    #[test]
9585    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9586        let first = SeatState::new("review-1", "alpha", 7);
9587        let next = handover_seat("review-1", "gamma", 7);
9588        assert_ne!(first.claude_session, next.claude_session);
9589    }
9590
9591    #[test]
9592    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9593        let mut state = state_with_summary("x", "y");
9594        let a = handover_seat("review-1", "beta", state.next_seat_seed());
9595        let b = handover_seat("review-1", "beta", state.next_seat_seed());
9596        assert_ne!(a.claude_session, b.claude_session);
9597    }
9598
9599    #[test]
9600    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9601        let roster = [spec("a"), spec("b")];
9602        let h = SeatHistory {
9603            failed: ids(&["a", "b"]),
9604            ..SeatHistory::default()
9605        };
9606        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9607        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9608        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9609    }
9610
9611    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
9612    // fallback loop depends on to terminate: it must walk forward from the
9613    // seat's own position, never restart at the front of the roster, and it
9614    // must never hand back an id already tried, however many times that id
9615    // happens to appear.
9616
9617    #[test]
9618    fn failure_signature_ignores_numbers_and_paths() {
9619        assert_eq!(
9620            failure_signature("exited with Some(2) and no usable output"),
9621            failure_signature("exited with Some(137) and no usable output")
9622        );
9623        assert_eq!(
9624            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9625            failure_signature("cannot open /var/x.txt: denied")
9626        );
9627        assert_ne!(failure_signature("boom"), failure_signature("bang"));
9628    }
9629
9630    #[test]
9631    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9632        let other = FailClass::Other("x".into());
9633        assert!(should_hand_over(None, &FailClass::Quota));
9634        assert!(should_hand_over(Some(&other), &FailClass::Quota));
9635        assert!(should_hand_over(
9636            Some(&FailClass::Timeout),
9637            &FailClass::Timeout
9638        ));
9639        assert!(should_hand_over(None, &other));
9640        assert!(!should_hand_over(Some(&other), &other));
9641        assert!(should_hand_over(
9642            Some(&other),
9643            &FailClass::Other("y".into())
9644        ));
9645        // A quota or timeout in between ends the run of identical failures.
9646        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9647        assert!(should_hand_over(Some(&FailClass::Quota), &other));
9648    }
9649
9650    #[test]
9651    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9652        let a = SeatState::new("judge-1", "alpha", 7);
9653        let b = handover_seat("judge-1", "beta", 7);
9654        assert_ne!(a.claude_session, b.claude_session);
9655        assert_eq!(b.turns, 0);
9656    }
9657
9658    #[test]
9659    fn a_timeout_is_classified_apart_from_other_failures() {
9660        assert_eq!(
9661            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9662            Some(FailClass::Timeout)
9663        );
9664        assert!(matches!(
9665            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9666            Some(FailClass::Other(_))
9667        ));
9668    }
9669
9670    #[test]
9671    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9672        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9673        let tried = BTreeSet::from(["beta".to_owned()]);
9674        // beta sits at index 1; the next candidate is gamma, never alpha —
9675        // which is very likely a different candidate slot's own agent.
9676        let next = next_untried_in_roster(&roster, 1, &tried);
9677        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9678    }
9679
9680    #[test]
9681    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9682        let roster = vec![spec("alpha"), spec("beta")];
9683        let tried = BTreeSet::from(["beta".to_owned()]);
9684        // beta is the roster's last entry: nothing follows it, and alpha —
9685        // earlier in the roster, almost certainly a different candidate
9686        // slot's own agent — must not be reached by wrapping back to it.
9687        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9688    }
9689
9690    #[test]
9691    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9692        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9693        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9694        // beta (index 1) and gamma (index 2, the only entry after it) have
9695        // both been tried; alpha (index 0) never has, but it comes before
9696        // beta's own position, so there is nothing further for this seat.
9697        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9698    }
9699
9700    #[test]
9701    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9702        let roster = vec![spec("a"), spec("a"), spec("b")];
9703        let tried = BTreeSet::from(["a".to_owned()]);
9704        let next = next_untried_in_roster(&roster, 0, &tried);
9705        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9706    }
9707
9708    #[test]
9709    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9710        let roster = vec![spec("a"), spec("b")];
9711        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9712        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9713    }
9714
9715    #[test]
9716    fn remove_if_empty_only_ever_takes_a_bare_directory() {
9717        let dir = tempfile::tempdir().unwrap();
9718        let bay = dir.path().join("ffff");
9719
9720        // Not there yet: nothing to do, nothing to panic on.
9721        remove_if_empty(&bay);
9722        assert!(!bay.exists());
9723
9724        // Something still inside - the winner's worktree, or a stray file -
9725        // keeps the directory standing.
9726        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9727        remove_if_empty(&bay);
9728        assert!(bay.exists(), "non-empty directory must survive");
9729
9730        // Once the last entry is gone, so is the directory itself.
9731        std::fs::remove_dir(bay.join("cand-A")).unwrap();
9732        remove_if_empty(&bay);
9733        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9734    }
9735
9736    // `round_is_clean` is the exact decision this task fixed: a round with a
9737    // seat that never answered must not read the same as a round every seat
9738    // actually reviewed. These are deterministic and process-free by design —
9739    // the equivalent end-to-end check (a real reviewer timing out under a
9740    // live graph run) is a genuine race against wall-clock contention, and a
9741    // spawn slow enough to blow even a generous budget under a loaded test
9742    // run must not turn this specific regression check flaky.
9743
9744    #[test]
9745    fn a_full_panel_that_found_nothing_is_clean() {
9746        assert!(round_is_clean(
9747            0,
9748            true,
9749            2,
9750            2,
9751            0,
9752            IncompleteReviewPolicy::Block
9753        ));
9754    }
9755
9756    #[test]
9757    fn a_missing_seat_is_never_clean_under_the_default_policy() {
9758        assert!(!round_is_clean(
9759            0,
9760            true,
9761            1,
9762            2,
9763            0,
9764            IncompleteReviewPolicy::Block
9765        ));
9766    }
9767
9768    #[test]
9769    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9770        assert!(!round_is_clean(
9771            1,
9772            true,
9773            1,
9774            2,
9775            0,
9776            IncompleteReviewPolicy::Warn
9777        ));
9778    }
9779
9780    #[test]
9781    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9782        assert!(round_is_clean(
9783            0,
9784            true,
9785            1,
9786            2,
9787            0,
9788            IncompleteReviewPolicy::Warn
9789        ));
9790    }
9791
9792    #[test]
9793    fn a_full_panel_with_an_open_finding_is_not_clean() {
9794        assert!(!round_is_clean(
9795            1,
9796            true,
9797            2,
9798            2,
9799            0,
9800            IncompleteReviewPolicy::Block
9801        ));
9802    }
9803
9804    #[test]
9805    fn a_full_panel_with_a_red_e2e_is_not_clean() {
9806        assert!(!round_is_clean(
9807            0,
9808            false,
9809            2,
9810            2,
9811            0,
9812            IncompleteReviewPolicy::Block
9813        ));
9814    }
9815
9816    // The stall this task closes: under the default `block` policy, a seat
9817    // missing only because it was rate limited must not force a wait for a
9818    // session limit that will not lift by the next round. `round_is_clean`
9819    // is where that quorum carve-out lives; the review loop around it never
9820    // changes what a reviewer's vote or a finding's severity means.
9821
9822    #[test]
9823    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9824        // 1 of 2 answered, and the one missing was quota'd — the exact
9825        // "review-2 rate limited (quota)" shape from the field report.
9826        assert!(round_is_clean(
9827            0,
9828            true,
9829            1,
9830            2,
9831            1,
9832            IncompleteReviewPolicy::Block
9833        ));
9834    }
9835
9836    #[test]
9837    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9838        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
9839        // not a quota loss (`quota_missing` stays 0) — worth another try.
9840        assert!(!round_is_clean(
9841            0,
9842            true,
9843            1,
9844            2,
9845            0,
9846            IncompleteReviewPolicy::Block
9847        ));
9848    }
9849
9850    #[test]
9851    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9852        assert!(!round_is_clean(
9853            1,
9854            true,
9855            1,
9856            2,
9857            1,
9858            IncompleteReviewPolicy::Block
9859        ));
9860        assert!(!round_is_clean(
9861            0,
9862            false,
9863            1,
9864            2,
9865            1,
9866            IncompleteReviewPolicy::Block
9867        ));
9868    }
9869
9870    #[test]
9871    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9872        // Every seat quota'd, nobody answered: there is no panel to decide
9873        // on, so this must fall through to the existing block-and-retry
9874        // fallback rather than call an unreviewed patch clean.
9875        assert!(!round_is_clean(
9876            0,
9877            true,
9878            0,
9879            2,
9880            2,
9881            IncompleteReviewPolicy::Block
9882        ));
9883    }
9884
9885    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9886        CommandOutcome {
9887            command: "test".to_owned(),
9888            code,
9889            output_tail: String::new(),
9890            duration_ms: 0,
9891            resource_blocked,
9892        }
9893    }
9894
9895    #[test]
9896    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9897        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9898        assert!(
9899            !verify_inconclusive(&[outcome(Some(1), false)]),
9900            "an ordinary failure is still evidence about the patch"
9901        );
9902        assert!(verify_inconclusive(&[outcome(None, true)]));
9903        assert!(
9904            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9905            "one inconclusive outcome taints the whole batch"
9906        );
9907        assert!(!verify_inconclusive(&[]));
9908    }
9909
9910    #[tokio::test]
9911    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9912        // Alive for the first two checks, then dead - confirms the loop
9913        // actually re-polls rather than deciding once and sleeping out the
9914        // ceiling regardless.
9915        let calls = std::sync::atomic::AtomicUsize::new(0);
9916        let started = Instant::now();
9917        wait_for_pids_with(
9918            &[123],
9919            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9920            Duration::from_millis(5),
9921            Duration::from_secs(5),
9922        )
9923        .await;
9924        assert!(
9925            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9926            "must keep checking rather than deciding on the first answer"
9927        );
9928        assert!(
9929            started.elapsed() < Duration::from_secs(1),
9930            "must return the moment it is confirmed dead, not wait out the ceiling"
9931        );
9932    }
9933
9934    #[tokio::test]
9935    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9936        let started = Instant::now();
9937        wait_for_pids_with(
9938            &[123],
9939            |_| true, // never reports dead
9940            Duration::from_millis(5),
9941            Duration::from_millis(30),
9942        )
9943        .await;
9944        let elapsed = started.elapsed();
9945        assert!(
9946            elapsed >= Duration::from_millis(30),
9947            "must not give up before its own ceiling: {elapsed:?}"
9948        );
9949        assert!(
9950            elapsed < Duration::from_secs(1),
9951            "must not wait past its own ceiling either: {elapsed:?}"
9952        );
9953    }
9954
9955    #[tokio::test]
9956    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9957        let started = Instant::now();
9958        wait_for_pids_with(
9959            &[],
9960            |_| true,
9961            Duration::from_secs(5),
9962            Duration::from_secs(5),
9963        )
9964        .await;
9965        assert!(
9966            started.elapsed() < Duration::from_millis(200),
9967            "an empty pid list has nothing to confirm"
9968        );
9969    }
9970
9971    // `review_conclusion` is the exact decision the review hand-off task
9972    // fixed: a round budget spent (or a tree that stopped moving) must not
9973    // collapse into `Blocked` regardless of what verification actually
9974    // said. Deterministic and process-free for the same reason the
9975    // `round_is_clean` family above is.
9976    fn review_round(
9977        clean: bool,
9978        blocking: usize,
9979        answered: usize,
9980        expected: usize,
9981        progressed: bool,
9982        e2e_ok: bool,
9983    ) -> ReviewRound {
9984        ReviewRound {
9985            round: 1,
9986            head: "h".to_owned(),
9987            verified_head: None,
9988            verified_at: None,
9989            reviews: Vec::new(),
9990            e2e: vec![CommandOutcome {
9991                command: "test".to_owned(),
9992                code: Some(if e2e_ok { 0 } else { 1 }),
9993                output_tail: String::new(),
9994                duration_ms: 0,
9995                resource_blocked: false,
9996            }],
9997            verify_retried: false,
9998            e2e_deferred: false,
9999            e2e_defer_reason: None,
10000            fix: None,
10001            blocking,
10002            answered,
10003            expected,
10004            clean,
10005            progressed,
10006            vote_split: false,
10007            reconsideration: Vec::new(),
10008            verdict: None,
10009        }
10010    }
10011
10012    #[test]
10013    fn review_conclusion_is_none_when_nothing_has_run() {
10014        assert_eq!(review_conclusion(&[], 3), None);
10015    }
10016
10017    #[test]
10018    fn review_conclusion_is_none_while_rounds_remain() {
10019        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
10020        assert_eq!(review_conclusion(&rounds, 3), None);
10021    }
10022
10023    #[test]
10024    fn review_conclusion_is_gating_once_a_round_is_clean() {
10025        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
10026        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
10027    }
10028
10029    #[test]
10030    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
10031        let rounds = vec![
10032            review_round(false, 1, 2, 2, true, true),
10033            review_round(false, 1, 2, 2, true, true),
10034        ];
10035        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
10036    }
10037
10038    #[test]
10039    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
10040        let rounds = vec![
10041            review_round(false, 1, 2, 2, true, true),
10042            review_round(false, 1, 2, 2, true, false),
10043        ];
10044        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
10045    }
10046
10047    #[test]
10048    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
10049        // Magi never got a command to run against this round's own head — a
10050        // resource-blocked attempt, not a red one — so this must never
10051        // settle on `Blocked` the way a genuine e2e failure would. `None`
10052        // here is what tells `Runner::review_loop` to retry the check
10053        // itself rather than trust this cheap recomputation with a verdict
10054        // it cannot actually produce.
10055        let mut blocked = review_round(false, 1, 2, 2, true, false);
10056        blocked.e2e[0].resource_blocked = true;
10057        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
10058        assert_eq!(review_conclusion(&rounds, 2), None);
10059    }
10060
10061    #[test]
10062    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
10063        // Missing input, not a verified tree — never a hand-off candidate.
10064        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
10065        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
10066    }
10067
10068    #[test]
10069    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
10070        let rounds = vec![
10071            review_round(false, 1, 2, 2, false, true),
10072            review_round(false, 1, 2, 2, false, true),
10073        ];
10074        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
10075    }
10076
10077    fn secs(n: u64) -> Duration {
10078        Duration::from_secs(n)
10079    }
10080
10081    /// A throwaway repo with one commit on `main`, for tests that need `merge`
10082    /// to make real (and, if it runs at all, real*ly fail*) git calls.
10083    fn init_repo(dir: &Path) {
10084        let run = |args: &[&str]| {
10085            let out = std::process::Command::new("git")
10086                .args(args)
10087                .current_dir(dir)
10088                .quiet()
10089                .output()
10090                .expect("spawn git");
10091            assert!(
10092                out.status.success(),
10093                "git {args:?} failed: {}",
10094                String::from_utf8_lossy(&out.stderr)
10095            );
10096        };
10097        run(&["init", "-b", "main"]);
10098        run(&["config", "user.name", "magi test"]);
10099        run(&["config", "user.email", "magi@example.com"]);
10100        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
10101        run(&["add", "-A"]);
10102        run(&["commit", "-m", "init"]);
10103    }
10104
10105    // `settle_questions` is what closes the ghost the phone showed: a run's
10106    // seat asked something, the run then ended, and nothing was left to
10107    // abandon the question it left `open`. `HOME` is a process-wide
10108    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
10109    // first time it runs in the binary — every test below still reaches the
10110    // same directory whichever call won, and each gets its own run id from
10111    // `RunState::new`, so they never collide there.
10112    fn ask_test_home() {
10113        crate::run::pin_test_home();
10114    }
10115
10116    /// A minimal, git-free `Runner` at a given status — `settle_questions`
10117    /// reads nothing else off it.
10118    fn runner_at(status: RunStatus) -> Runner {
10119        let mut state = RunState::new(
10120            PathBuf::from("/nonexistent/repo"),
10121            "main".to_owned(),
10122            "deadbeef".to_owned(),
10123            "task".to_owned(),
10124            Config::default(),
10125        );
10126        state.status = status;
10127        Runner {
10128            state,
10129            roles: ResolvedRoles {
10130                implementers: Vec::new(),
10131                judges: Vec::new(),
10132                reviewers: Vec::new(),
10133                fixer: None,
10134                conductor: conductor(),
10135                implementer_roster: Vec::new(),
10136                judge_roster: Vec::new(),
10137                reviewer_roster: Vec::new(),
10138            },
10139            sem: Arc::new(Semaphore::new(1)),
10140            pause: Pause::new(),
10141            interrupt: Pause::new(),
10142        }
10143    }
10144
10145    /// `park_here` folding in the reason `Pause::park_because` recorded -
10146    /// this is what lets an operator reading a run's events tell an
10147    /// interrupt-driven park from an ordinary shutdown park.
10148    #[test]
10149    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
10150        crate::run::pin_test_home();
10151        let mut runner = runner_at(RunStatus::Implementing);
10152        let interrupt = Pause::new();
10153        runner.watch_interrupt(interrupt.clone());
10154
10155        interrupt.park_because("task a1b2 asked to run first");
10156
10157        assert!(runner.park_here().expect("park_here"));
10158        assert!(runner.state.parked);
10159        let last = runner.state.events.last().expect("a park event");
10160        assert_eq!(last.node, "park");
10161        assert!(
10162            last.message.contains("task a1b2 asked to run first"),
10163            "expected the interrupt reason in {:?}",
10164            last.message
10165        );
10166    }
10167
10168    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
10169    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
10170    /// cleared) must not make a *different* run - one only watching its own,
10171    /// unshared interrupt `Pause` - see itself as parked. If a future change
10172    /// ever collapsed these back into one handle, the interrupt scheduler
10173    /// would park every run for the rest of the daemon's life, not just the
10174    /// one it meant to interrupt.
10175    #[test]
10176    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
10177        crate::run::pin_test_home();
10178        let mut runner = runner_at(RunStatus::Implementing);
10179        let shutdown = Pause::new();
10180        runner.on_pause(shutdown.clone());
10181        let interrupt = Pause::new();
10182        runner.watch_interrupt(interrupt.clone());
10183
10184        // Nobody has asked for anything yet.
10185        assert!(!runner.park_here().expect("park_here"));
10186        assert!(!runner.state.parked);
10187
10188        // Only the interrupt handle fires; the shutdown handle stays clear.
10189        interrupt.park_because("test");
10190        assert!(!shutdown.parked());
10191        assert!(runner.park_here().expect("park_here"));
10192    }
10193
10194    /// The property every prior attempt at this feature failed to pin down:
10195    /// asking a run to park while one of its nodes has a real, in-flight
10196    /// async operation running (an agent call, in production) must not cut
10197    /// that operation short. `park_here` is only ever consulted *between*
10198    /// `execute`'s node calls - see its own doc - so nothing inside a node
10199    /// can observe a park request until the node itself returns. This proves
10200    /// that structurally, with real `tokio` concurrency and a channel
10201    /// handshake (never a sleep, which would only prove "usually", not
10202    /// "cannot"): the "node" below reports that it has genuinely started,
10203    /// and only then is the park requested; the node still has to be told to
10204    /// finish before `park_here` is ever called, exactly mirroring every
10205    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
10206    /// in `execute`.
10207    #[tokio::test]
10208    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
10209        crate::run::pin_test_home();
10210        let mut runner = runner_at(RunStatus::Implementing);
10211        let interrupt = Pause::new();
10212        runner.watch_interrupt(interrupt.clone());
10213
10214        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
10215        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
10216
10217        // Stands in for one node's in-flight agent call: it proves it has
10218        // genuinely started, then blocks - exactly as a spawned CLI process
10219        // does - until told to finish.
10220        let node = async move {
10221            started_tx.send(()).expect("send started");
10222            finish_rx.await.expect("recv finish");
10223            "node finished"
10224        };
10225
10226        let interrupter = async move {
10227            started_rx.await.expect("recv started");
10228            // The call is now genuinely in flight. Ask it to park.
10229            interrupt.park_because("higher-priority task waiting");
10230            // Nothing the node does can observe this yet - there is no
10231            // check inside it, by construction - so let the executor run
10232            // anything pending and then let the node finish on its own.
10233            tokio::task::yield_now().await;
10234            finish_tx.send(()).expect("send finish");
10235        };
10236
10237        let (node_result, ()) = tokio::join!(node, interrupter);
10238        assert_eq!(
10239            node_result, "node finished",
10240            "the in-flight call ran to completion"
10241        );
10242
10243        // Only now, at the boundary the real `execute` would check right
10244        // after this node, does the park take effect.
10245        assert!(runner.park_here().expect("park_here"));
10246        assert!(runner.state.parked);
10247    }
10248
10249    /// A run parked mid-competition carries every field it had accumulated
10250    /// through the exact same disk round-trip an ordinary resume uses -
10251    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
10252    /// Nothing about parking for an interrupt is a special case of that path;
10253    /// this is what proves it rather than assuming it.
10254    #[test]
10255    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
10256        crate::run::pin_test_home();
10257        let mut runner = runner_at(RunStatus::Judging);
10258        // `Runner::resume` re-resolves roles from the saved config, which
10259        // refuses an empty roster - give it the same minimal one `conductor`
10260        // itself uses.
10261        runner.state.config.agents = vec![conductor()];
10262        runner.state.candidates = vec![Candidate {
10263            index: 0,
10264            label: 'A',
10265            agent: "alpha".to_owned(),
10266            branch: "magi/x/A".to_owned(),
10267            worktree: PathBuf::from("/nonexistent/worktree"),
10268            summary: "did the thing".to_owned(),
10269            stat: "1 file changed".to_owned(),
10270            files: 1,
10271            commits: 1,
10272            empty: false,
10273            failed: None,
10274            verified_noop: None,
10275            duration_ms: 1234,
10276            folded: false,
10277        }];
10278        let run_id = runner.state.id.clone();
10279
10280        let interrupt = Pause::new();
10281        runner.watch_interrupt(interrupt.clone());
10282        interrupt.park_because("task c3d4 asked to run first");
10283        assert!(runner.park_here().expect("park_here"));
10284
10285        let resumed = Runner::resume(&run_id).expect("resume");
10286        assert_eq!(resumed.state.candidates.len(), 1);
10287        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
10288        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
10289        assert_eq!(resumed.state.status, runner.state.status);
10290        assert!(
10291            resumed.state.parked,
10292            "still parked until `execute` actually walks the graph again"
10293        );
10294        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
10295    }
10296
10297    /// A fresh open question on `run`, stored and handed back for assertions.
10298    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
10299        let mut q = ask::Question::new(
10300            run.to_owned(),
10301            "implement".to_owned(),
10302            "impl-A".to_owned(),
10303            "Which storage backend should the cache use?".to_owned(),
10304            String::new(),
10305            vec!["SQLite".to_owned(), "Redis".to_owned()],
10306        );
10307        store.put(&mut q).unwrap();
10308        q
10309    }
10310
10311    #[test]
10312    fn a_failed_runs_open_question_is_abandoned() {
10313        ask_test_home();
10314        let store = ask::Questions::open();
10315        let mut runner = runner_at(RunStatus::Failed);
10316        let run = runner.state.id.clone();
10317        let q = ask_open_question(&store, &run);
10318
10319        runner.settle_questions();
10320
10321        let back = store.get(&q.id).unwrap();
10322        assert!(
10323            !back.status.open(),
10324            "the seat that asked died with the run; nobody is left to read an answer"
10325        );
10326        assert!(
10327            back.detail.contains(&run) && back.detail.contains("failed"),
10328            "the reason names what the run became, not just that it is gone: {}",
10329            back.detail
10330        );
10331    }
10332
10333    #[test]
10334    fn a_merged_runs_open_question_is_abandoned_too() {
10335        ask_test_home();
10336        let store = ask::Questions::open();
10337        // A run that finishes cleanly still leaves nobody to read an answer -
10338        // this is not only a failure-path cleanup.
10339        for status in [RunStatus::Merged, RunStatus::Ready] {
10340            let mut runner = runner_at(status);
10341            let run = runner.state.id.clone();
10342            let q = ask_open_question(&store, &run);
10343
10344            runner.settle_questions();
10345
10346            let back = store.get(&q.id).unwrap();
10347            assert!(
10348                !back.status.open(),
10349                "{status:?} run's question must not outlive the run"
10350            );
10351        }
10352    }
10353
10354    #[test]
10355    fn a_still_resumable_runs_open_question_is_left_alone() {
10356        ask_test_home();
10357        let store = ask::Questions::open();
10358        // `Blocked` and `Stalled` can still be resumed — the candidates, the
10359        // review round and the seat sessions are all still on disk — so a
10360        // question asked mid-round may yet get a real answer from a real
10361        // resume. Sweeping it here would be exactly the failure mode this
10362        // whole feature exists to avoid on the other side.
10363        for status in [RunStatus::Blocked, RunStatus::Stalled] {
10364            let mut runner = runner_at(status);
10365            let run = runner.state.id.clone();
10366            let q = ask_open_question(&store, &run);
10367
10368            runner.settle_questions();
10369
10370            let back = store.get(&q.id).unwrap();
10371            assert!(
10372                back.status.open(),
10373                "{status:?} is still alive; the question must still be waiting"
10374            );
10375        }
10376    }
10377
10378    #[test]
10379    fn settle_questions_never_touches_an_already_answered_question() {
10380        ask_test_home();
10381        let store = ask::Questions::open();
10382        let mut runner = runner_at(RunStatus::Failed);
10383        let run = runner.state.id.clone();
10384        let mut q = ask_open_question(&store, &run);
10385        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10386            .unwrap();
10387        store.put(&mut q).unwrap();
10388
10389        // Called twice, the way a crash-recovered daemon reclaim and the
10390        // graph's own cleanup both can for the same run — `abandon_for_run`
10391        // only ever touches what is still open, so this must be inert both
10392        // times, not merely the second.
10393        runner.settle_questions();
10394        runner.settle_questions();
10395
10396        let back = store.get(&q.id).unwrap();
10397        assert_eq!(
10398            back.status,
10399            ask::QuestionStatus::Answered,
10400            "a real answer is a decision on record, never overwritten by a sweep"
10401        );
10402    }
10403
10404    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
10405    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
10406    /// without merging, whose winner is still the operator's answer to read.
10407    /// Nothing previously called `fold_run` itself with a real `tally`, so
10408    /// this is the first test to pin down the one distinction the whole
10409    /// automatic-fold feature depends on: the winner's worktree and branch
10410    /// must survive, everything else sharing the run's worktree bay - a
10411    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
10412    /// second sweep treats every non-winner directory under the bay alike -
10413    /// must not.
10414    #[tokio::test]
10415    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10416        crate::run::pin_test_home();
10417        let tmp = tempfile::tempdir().expect("tempdir");
10418        let repo = tmp.path().join("repo");
10419        std::fs::create_dir_all(&repo).unwrap();
10420        init_repo(&repo);
10421
10422        let mut config = Config::default();
10423        config.graph.worktree_root = Some(tmp.path().join("wt"));
10424
10425        let mut state = RunState::new(
10426            repo.clone(),
10427            "main".to_owned(),
10428            "deadbeef".to_owned(),
10429            "task".to_owned(),
10430            config,
10431        );
10432        let root = state.worktree_root();
10433        let wt_a = root.join("cand-A");
10434        let wt_b = root.join("cand-B");
10435        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10436            .await
10437            .expect("worktree A");
10438        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10439            .await
10440            .expect("worktree B");
10441
10442        state.candidates = vec![
10443            Candidate {
10444                index: 0,
10445                label: 'A',
10446                agent: "alpha".to_owned(),
10447                branch: "magi/x/A".to_owned(),
10448                worktree: wt_a.clone(),
10449                summary: String::new(),
10450                stat: String::new(),
10451                files: 0,
10452                commits: 0,
10453                empty: false,
10454                failed: None,
10455                verified_noop: None,
10456                duration_ms: 0,
10457                folded: false,
10458            },
10459            Candidate {
10460                index: 1,
10461                label: 'B',
10462                agent: "beta".to_owned(),
10463                branch: "magi/x/B".to_owned(),
10464                worktree: wt_b.clone(),
10465                summary: String::new(),
10466                stat: String::new(),
10467                files: 0,
10468                commits: 0,
10469                empty: false,
10470                failed: None,
10471                verified_noop: None,
10472                duration_ms: 0,
10473                folded: false,
10474            },
10475        ];
10476        state.tally = Some(Tally {
10477            first_choice: BTreeMap::from([('A', 1)]),
10478            borda: BTreeMap::new(),
10479            winner: 'A',
10480            rankings: 1,
10481            unanimous_initial: true,
10482            deliberated: false,
10483            changed_votes: 0,
10484            unanimous_final: true,
10485            tie_break: None,
10486            judges: 1,
10487            present: 1,
10488            quorum: 1,
10489            met_quorum: true,
10490            uncontested: None,
10491        });
10492        state.status = RunStatus::Ready;
10493
10494        fold_run(&mut state, false, &crate::run::home())
10495            .await
10496            .expect("fold_run");
10497
10498        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10499        assert!(
10500            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10501            "the unmerged winner's branch survives"
10502        );
10503        assert!(
10504            !state.candidates[0].folded,
10505            "the winner is not marked folded"
10506        );
10507
10508        assert!(!wt_b.exists(), "the loser's worktree is removed");
10509        assert!(
10510            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10511            "the loser's branch is removed"
10512        );
10513        assert!(state.candidates[1].folded, "the loser is marked folded");
10514    }
10515
10516    /// A branch handed to a later run is that run's (and its pull request's):
10517    /// folding the run that released it must not delete it.
10518    #[tokio::test]
10519    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10520        let tmp = tempfile::tempdir().expect("tempdir");
10521        let repo = tmp.path().join("repo");
10522        std::fs::create_dir_all(&repo).unwrap();
10523        init_repo(&repo);
10524        let home = tmp.path().join("home");
10525
10526        let mut config = Config::default();
10527        config.graph.worktree_root = Some(tmp.path().join("wt"));
10528        let mut state = RunState::new(
10529            repo.clone(),
10530            "main".to_owned(),
10531            "deadbeef".to_owned(),
10532            "task".to_owned(),
10533            config,
10534        );
10535        // The worktree is already gone (released); the branch survives.
10536        git::git(&repo, &["branch", "magi/x/A", "main"])
10537            .await
10538            .expect("branch");
10539        state.candidates = vec![Candidate {
10540            index: 0,
10541            label: 'A',
10542            agent: "alpha".to_owned(),
10543            branch: "magi/x/A".to_owned(),
10544            worktree: state.worktree_root().join("cand-A"),
10545            summary: String::new(),
10546            stat: String::new(),
10547            files: 0,
10548            commits: 0,
10549            empty: false,
10550            failed: None,
10551            verified_noop: None,
10552            duration_ms: 0,
10553            folded: true,
10554        }];
10555        state.released_to = Some("20260901-000000-new1".to_owned());
10556        state.released_branches = vec!["magi/x/A".to_owned()];
10557
10558        fold_run(&mut state, true, &home).await.expect("fold_run");
10559
10560        assert!(
10561            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10562            "the handed-over branch survives a fold"
10563        );
10564    }
10565
10566    /// A winner with nothing ahead of the base is caught before `gh` is ever
10567    /// asked for a pull request, and the message carries what the task's
10568    /// references resolved to.
10569    #[tokio::test]
10570    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10571        let tmp = tempfile::tempdir().expect("tempdir");
10572        let repo = tmp.path().join("repo");
10573        std::fs::create_dir_all(&repo).unwrap();
10574        init_repo(&repo);
10575        let run = |args: &[&str]| {
10576            let out = std::process::Command::new("git")
10577                .quiet()
10578                .args(args)
10579                .current_dir(&repo)
10580                .output()
10581                .expect("spawn git");
10582            assert!(out.status.success(), "git {args:?}");
10583        };
10584        run(&["branch", "magi/x/A"]);
10585        run(&["checkout", "-q", "-b", "magi/x/B"]);
10586        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10587        run(&["add", "-A"]);
10588        run(&["commit", "-q", "-m", "work"]);
10589        run(&["checkout", "-q", "main"]);
10590
10591        // A pull request is compared against the remote's base, so the
10592        // fixture needs one. Before it exists nothing can be read, and the
10593        // answer must be "not empty".
10594        let probe = RunState::new(
10595            repo.clone(),
10596            "main".to_owned(),
10597            "deadbeef".to_owned(),
10598            "task".to_owned(),
10599            Config::default(),
10600        );
10601        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10602        let bare = tmp.path().join("origin.git");
10603        let out = std::process::Command::new("git")
10604            .quiet()
10605            .args(["init", "-q", "--bare"])
10606            .arg(&bare)
10607            .output()
10608            .expect("spawn git");
10609        assert!(out.status.success(), "git init --bare");
10610        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10611        run(&["push", "-q", "origin", "main"]);
10612
10613        let mut state = RunState::new(
10614            repo.clone(),
10615            "main".to_owned(),
10616            "deadbeef".to_owned(),
10617            "task".to_owned(),
10618            Config::default(),
10619        );
10620        state.seeds = vec![refs::Seed {
10621            token: "magi/27b2/A".to_owned(),
10622            kind: refs::SeedKind::Unresolved,
10623            sha: String::new(),
10624            branch: true,
10625            detail: "no branch or commit named magi/27b2/A".to_owned(),
10626        }];
10627
10628        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10629        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10630        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10631        let detail = empty_candidate_detail(&state, "main");
10632        assert!(detail.starts_with("empty candidate"), "{detail}");
10633        assert!(detail.contains("magi/27b2/A"), "{detail}");
10634    }
10635
10636    /// `status == Ready` used to be read as "this is the harmless
10637    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
10638    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
10639    /// run's PR was closed without merging — and reentering `merge` with
10640    /// `mode` still `Pr` does not know the difference, so it pushed and
10641    /// opened a second pull request. `mode == Local` reproduces the same
10642    /// blind spot without a network call: reentry must not attempt another
10643    /// git merge once this node has already recorded an outcome.
10644    #[tokio::test]
10645    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10646        let tmp = tempfile::tempdir().expect("tempdir");
10647        let repo = tmp.path().join("repo");
10648        std::fs::create_dir_all(&repo).unwrap();
10649        init_repo(&repo);
10650
10651        let mut config = Config::default();
10652        config.merge.mode = MergeMode::Local;
10653
10654        let mut state = RunState::new(
10655            repo.clone(),
10656            "main".to_owned(),
10657            "deadbeef".to_owned(),
10658            "task".to_owned(),
10659            config,
10660        );
10661        state.candidates = vec![Candidate {
10662            index: 0,
10663            label: 'A',
10664            agent: "alpha".to_owned(),
10665            branch: "does-not-exist".to_owned(),
10666            worktree: repo.clone(),
10667            summary: String::new(),
10668            stat: String::new(),
10669            files: 0,
10670            commits: 0,
10671            empty: false,
10672            failed: None,
10673            verified_noop: None,
10674            duration_ms: 0,
10675            folded: false,
10676        }];
10677        state.tally = Some(Tally {
10678            first_choice: BTreeMap::from([('A', 1)]),
10679            borda: BTreeMap::new(),
10680            winner: 'A',
10681            rankings: 1,
10682            unanimous_initial: true,
10683            deliberated: false,
10684            changed_votes: 0,
10685            unanimous_final: true,
10686            tie_break: None,
10687            judges: 0,
10688            present: 0,
10689            quorum: 0,
10690            met_quorum: true,
10691            uncontested: Some("only candidate A produced a change".to_owned()),
10692        });
10693        state.reviews = vec![ReviewRound {
10694            round: 1,
10695            head: "deadbeef".to_owned(),
10696            verified_head: None,
10697            verified_at: None,
10698            reviews: Vec::new(),
10699            e2e: Vec::new(),
10700            fix: None,
10701            blocking: 0,
10702            answered: 0,
10703            expected: 0,
10704            clean: true,
10705            verify_retried: false,
10706            e2e_deferred: false,
10707            e2e_defer_reason: None,
10708            progressed: false,
10709            vote_split: false,
10710            reconsideration: Vec::new(),
10711            verdict: None,
10712        }];
10713        state.gate = vec![CommandOutcome {
10714            command: "test".to_owned(),
10715            code: Some(0),
10716            output_tail: String::new(),
10717            duration_ms: 0,
10718            resource_blocked: false,
10719        }];
10720        state.gate_ran = true;
10721        // Reached its conclusion already — e.g. `land` closing the PR without
10722        // merging it, which (like the honest `MergeMode::None` path) leaves
10723        // `status` at `Ready`. The recorded outcome is what actually marks
10724        // this node done.
10725        state.status = RunStatus::Ready;
10726        state.merge = Some(MergeOutcome {
10727            mode: MergeMode::Local,
10728            ok: false,
10729            detail: "already concluded".to_owned(),
10730            empty: false,
10731        });
10732
10733        let mut runner = Runner {
10734            state,
10735            roles: ResolvedRoles {
10736                implementers: Vec::new(),
10737                judges: Vec::new(),
10738                reviewers: Vec::new(),
10739                fixer: None,
10740                conductor: conductor(),
10741                implementer_roster: Vec::new(),
10742                judge_roster: Vec::new(),
10743                reviewer_roster: Vec::new(),
10744            },
10745            sem: Arc::new(Semaphore::new(1)),
10746            pause: Pause::new(),
10747            interrupt: Pause::new(),
10748        };
10749
10750        runner.merge().await.expect("merge");
10751
10752        assert_eq!(
10753            runner.state.status,
10754            RunStatus::Ready,
10755            "a concluded run's status must not change on reentry"
10756        );
10757        assert_eq!(
10758            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10759            Some("already concluded"),
10760            "merge must not run again once the node already recorded an outcome"
10761        );
10762    }
10763
10764    /// `gate` leaves `state.gate_ran` false both before it has ever run and
10765    /// when its last attempt was resource-blocked (the shared build cache
10766    /// could not be acquired or confirmed fresh in time - see
10767    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
10768    /// `Vec` this also leaves behind used to read as "nothing failed" and let
10769    /// a run merge a tree the gate never actually checked - exactly the case
10770    /// a contended cache produces on every retry until it clears. `merge`
10771    /// must refuse until `gate` has actually recorded an attempt.
10772    #[tokio::test]
10773    async fn merge_refuses_a_gate_that_has_not_actually_run() {
10774        let tmp = tempfile::tempdir().expect("tempdir");
10775        let repo = tmp.path().join("repo");
10776        std::fs::create_dir_all(&repo).unwrap();
10777        init_repo(&repo);
10778
10779        let mut config = Config::default();
10780        config.merge.mode = MergeMode::Local;
10781
10782        let mut state = RunState::new(
10783            repo.clone(),
10784            "main".to_owned(),
10785            "deadbeef".to_owned(),
10786            "task".to_owned(),
10787            config,
10788        );
10789        state.candidates = vec![Candidate {
10790            index: 0,
10791            label: 'A',
10792            agent: "alpha".to_owned(),
10793            branch: "does-not-exist".to_owned(),
10794            worktree: repo.clone(),
10795            summary: String::new(),
10796            stat: String::new(),
10797            files: 0,
10798            commits: 0,
10799            empty: false,
10800            failed: None,
10801            verified_noop: None,
10802            duration_ms: 0,
10803            folded: false,
10804        }];
10805        state.tally = Some(Tally {
10806            first_choice: BTreeMap::from([('A', 1)]),
10807            borda: BTreeMap::new(),
10808            winner: 'A',
10809            rankings: 1,
10810            unanimous_initial: true,
10811            deliberated: false,
10812            changed_votes: 0,
10813            unanimous_final: true,
10814            tie_break: None,
10815            judges: 0,
10816            present: 0,
10817            quorum: 0,
10818            met_quorum: true,
10819            uncontested: Some("only candidate A produced a change".to_owned()),
10820        });
10821        state.reviews = vec![ReviewRound {
10822            round: 1,
10823            head: "deadbeef".to_owned(),
10824            verified_head: None,
10825            verified_at: None,
10826            reviews: Vec::new(),
10827            e2e: Vec::new(),
10828            fix: None,
10829            blocking: 0,
10830            answered: 0,
10831            expected: 0,
10832            clean: true,
10833            verify_retried: false,
10834            e2e_deferred: false,
10835            e2e_defer_reason: None,
10836            progressed: false,
10837            vote_split: false,
10838            reconsideration: Vec::new(),
10839            verdict: None,
10840        }];
10841        // The point: `gate` has not recorded anything yet.
10842        state.gate = Vec::new();
10843        state.gate_ran = false;
10844        state.status = RunStatus::Gating;
10845
10846        let mut runner = Runner {
10847            state,
10848            roles: ResolvedRoles {
10849                implementers: Vec::new(),
10850                judges: Vec::new(),
10851                reviewers: Vec::new(),
10852                fixer: None,
10853                conductor: conductor(),
10854                implementer_roster: Vec::new(),
10855                judge_roster: Vec::new(),
10856                reviewer_roster: Vec::new(),
10857            },
10858            sem: Arc::new(Semaphore::new(1)),
10859            pause: Pause::new(),
10860            interrupt: Pause::new(),
10861        };
10862
10863        runner.merge().await.expect("merge");
10864
10865        assert!(
10866            runner.state.merge.is_none(),
10867            "an empty gate must never be read as a passing one: {:?}",
10868            runner.state.merge
10869        );
10870    }
10871
10872    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
10873    /// commands configured and `merge.mode` is `none` (a review-only run).
10874    /// `gate` must still record a real attempt — zero commands, vacuously
10875    /// passed — rather than leaving `state.gate` empty in a way `merge`
10876    /// cannot tell apart from "never ran"; otherwise the run reaches
10877    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
10878    #[tokio::test]
10879    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10880        let tmp = tempfile::tempdir().expect("tempdir");
10881        let repo = tmp.path().join("repo");
10882        std::fs::create_dir_all(&repo).unwrap();
10883        init_repo(&repo);
10884
10885        // Default config: `verify.gate` empty, `merge.mode` is `none`.
10886        let config = Config::default();
10887
10888        let mut state = RunState::new(
10889            repo.clone(),
10890            "main".to_owned(),
10891            "deadbeef".to_owned(),
10892            "task".to_owned(),
10893            config,
10894        );
10895        state.candidates = vec![Candidate {
10896            index: 0,
10897            label: 'A',
10898            agent: "alpha".to_owned(),
10899            branch: "does-not-exist".to_owned(),
10900            worktree: repo.clone(),
10901            summary: String::new(),
10902            stat: String::new(),
10903            files: 0,
10904            commits: 0,
10905            empty: false,
10906            failed: None,
10907            verified_noop: None,
10908            duration_ms: 0,
10909            folded: false,
10910        }];
10911        state.tally = Some(Tally {
10912            first_choice: BTreeMap::from([('A', 1)]),
10913            borda: BTreeMap::new(),
10914            winner: 'A',
10915            rankings: 1,
10916            unanimous_initial: true,
10917            deliberated: false,
10918            changed_votes: 0,
10919            unanimous_final: true,
10920            tie_break: None,
10921            judges: 0,
10922            present: 0,
10923            quorum: 0,
10924            met_quorum: true,
10925            uncontested: Some("only candidate A produced a change".to_owned()),
10926        });
10927        state.reviews = vec![ReviewRound {
10928            round: 1,
10929            head: "deadbeef".to_owned(),
10930            verified_head: None,
10931            verified_at: None,
10932            reviews: Vec::new(),
10933            e2e: Vec::new(),
10934            fix: None,
10935            blocking: 0,
10936            answered: 0,
10937            expected: 0,
10938            clean: true,
10939            verify_retried: false,
10940            e2e_deferred: false,
10941            e2e_defer_reason: None,
10942            progressed: false,
10943            vote_split: false,
10944            reconsideration: Vec::new(),
10945            verdict: None,
10946        }];
10947
10948        let mut runner = Runner {
10949            state,
10950            roles: ResolvedRoles {
10951                implementers: Vec::new(),
10952                judges: Vec::new(),
10953                reviewers: Vec::new(),
10954                fixer: None,
10955                conductor: conductor(),
10956                implementer_roster: Vec::new(),
10957                judge_roster: Vec::new(),
10958                reviewer_roster: Vec::new(),
10959            },
10960            sem: Arc::new(Semaphore::new(1)),
10961            pause: Pause::new(),
10962            interrupt: Pause::new(),
10963        };
10964
10965        runner.gate().await.expect("gate");
10966        assert!(
10967            runner.state.gate_ran,
10968            "zero configured commands is still a real attempt, not an unrun gate"
10969        );
10970        assert!(runner.state.gate.is_empty());
10971        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10972        assert_ne!(
10973            runner.state.status,
10974            RunStatus::Blocked,
10975            "a gate with nothing to check must not read as failed"
10976        );
10977
10978        runner.merge().await.expect("merge");
10979        assert_eq!(
10980            runner.state.status,
10981            RunStatus::Ready,
10982            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10983        );
10984    }
10985
10986    /// `Config::cache_dir` is derived from `verify.e2e` as well as
10987    /// `verify.gate` (so the e2e leg and the final gate never build against
10988    /// different directories). With zero `verify.gate` commands but a
10989    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
10990    /// that lease before discovering it had nothing to run - so a repo with
10991    /// no gate commands could come back `resource_blocked` (and therefore
10992    /// still `gate_ran == false`) on nothing but an unrelated run holding the
10993    /// cache, exactly the contention this run's own zero commands could
10994    /// never have touched. `gate` must recognise there is nothing to check
10995    /// before it ever asks for the lease.
10996    #[tokio::test]
10997    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10998        crate::run::pin_test_home();
10999        let home = crate::run::home();
11000
11001        let tmp = tempfile::tempdir().expect("tempdir");
11002        let repo = tmp.path().join("repo");
11003        std::fs::create_dir_all(&repo).unwrap();
11004        init_repo(&repo);
11005        // Unique to this test, so holding its lease cannot collide with
11006        // another test sharing the same process-wide `home`.
11007        let cache_dir = tmp.path().join("target");
11008
11009        let mut config = Config::default();
11010        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
11011        // `verify.gate` stays empty (the default). Bounded so a regression
11012        // that does start waiting fails the test in seconds, not hangs it.
11013        config.graph.timeout_verify = Some(2);
11014
11015        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11016        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11017            .expect("no io error acquiring directly")
11018        {
11019            crate::cache::AcquireOutcome::Acquired(g) => g,
11020            crate::cache::AcquireOutcome::Busy(b) => {
11021                panic!("expected the direct acquire to win the lease first: {b:?}")
11022            }
11023        };
11024
11025        let mut state = RunState::new(
11026            repo.clone(),
11027            "main".to_owned(),
11028            "deadbeef".to_owned(),
11029            "task".to_owned(),
11030            config,
11031        );
11032        state.candidates = vec![Candidate {
11033            index: 0,
11034            label: 'A',
11035            agent: "alpha".to_owned(),
11036            branch: "does-not-exist".to_owned(),
11037            worktree: repo.clone(),
11038            summary: String::new(),
11039            stat: String::new(),
11040            files: 0,
11041            commits: 0,
11042            empty: false,
11043            failed: None,
11044            verified_noop: None,
11045            duration_ms: 0,
11046            folded: false,
11047        }];
11048        state.tally = Some(Tally {
11049            first_choice: BTreeMap::from([('A', 1)]),
11050            borda: BTreeMap::new(),
11051            winner: 'A',
11052            rankings: 1,
11053            unanimous_initial: true,
11054            deliberated: false,
11055            changed_votes: 0,
11056            unanimous_final: true,
11057            tie_break: None,
11058            judges: 0,
11059            present: 0,
11060            quorum: 0,
11061            met_quorum: true,
11062            uncontested: Some("only candidate A produced a change".to_owned()),
11063        });
11064        state.reviews = vec![ReviewRound {
11065            round: 1,
11066            head: "deadbeef".to_owned(),
11067            verified_head: None,
11068            verified_at: None,
11069            reviews: Vec::new(),
11070            e2e: Vec::new(),
11071            fix: None,
11072            blocking: 0,
11073            answered: 0,
11074            expected: 0,
11075            clean: true,
11076            verify_retried: false,
11077            e2e_deferred: false,
11078            e2e_defer_reason: None,
11079            progressed: false,
11080            vote_split: false,
11081            reconsideration: Vec::new(),
11082            verdict: None,
11083        }];
11084
11085        let mut runner = Runner {
11086            state,
11087            roles: ResolvedRoles {
11088                implementers: Vec::new(),
11089                judges: Vec::new(),
11090                reviewers: Vec::new(),
11091                fixer: None,
11092                conductor: conductor(),
11093                implementer_roster: Vec::new(),
11094                judge_roster: Vec::new(),
11095                reviewer_roster: Vec::new(),
11096            },
11097            sem: Arc::new(Semaphore::new(1)),
11098            pause: Pause::new(),
11099            interrupt: Pause::new(),
11100        };
11101
11102        let started = std::time::Instant::now();
11103        runner.gate().await.expect("gate");
11104        assert!(
11105            started.elapsed() < Duration::from_secs(1),
11106            "a gate with nothing to run must never wait on a lease it never needed"
11107        );
11108        assert!(
11109            runner.state.gate_ran,
11110            "zero commands is still a real, immediate attempt"
11111        );
11112        assert!(runner.state.gate.is_empty());
11113        assert_ne!(
11114            runner.state.status,
11115            RunStatus::Blocked,
11116            "must not read as resource-blocked on a lease it never asked for"
11117        );
11118    }
11119
11120    /// The addendum's second gap: a `verify.gate` command running for real
11121    /// wall-clock time had nothing at all to show for it in `active` before
11122    /// `run_commands` learned to record it — a run could sit in `Gating` for
11123    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
11124    /// what was actually happening. Proven with a genuinely still-running
11125    /// command, not just a before/after check on the final state: a poller
11126    /// task reads the same `run.json` `gate()` is writing, the same way the
11127    /// phone or `magi show` would, while the shell command is still blocked
11128    /// on its own release marker.
11129    #[tokio::test]
11130    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
11131        crate::run::pin_test_home();
11132
11133        let tmp = tempfile::tempdir().expect("tempdir");
11134        let repo = tmp.path().join("repo");
11135        std::fs::create_dir_all(&repo).unwrap();
11136        init_repo(&repo);
11137
11138        let mut config = Config::default();
11139        config.verify.gate = vec![
11140            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
11141             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
11142                .to_owned(),
11143        ];
11144
11145        let mut state = RunState::new(
11146            repo.clone(),
11147            "main".to_owned(),
11148            "deadbeef".to_owned(),
11149            "task".to_owned(),
11150            config,
11151        );
11152        let run_id = state.id.clone();
11153        state.candidates = vec![Candidate {
11154            index: 0,
11155            label: 'A',
11156            agent: "alpha".to_owned(),
11157            branch: "does-not-exist".to_owned(),
11158            worktree: repo.clone(),
11159            summary: String::new(),
11160            stat: String::new(),
11161            files: 0,
11162            commits: 0,
11163            empty: false,
11164            failed: None,
11165            verified_noop: None,
11166            duration_ms: 0,
11167            folded: false,
11168        }];
11169        state.tally = Some(Tally {
11170            first_choice: BTreeMap::from([('A', 1)]),
11171            borda: BTreeMap::new(),
11172            winner: 'A',
11173            rankings: 1,
11174            unanimous_initial: true,
11175            deliberated: false,
11176            changed_votes: 0,
11177            unanimous_final: true,
11178            tie_break: None,
11179            judges: 0,
11180            present: 0,
11181            quorum: 0,
11182            met_quorum: true,
11183            uncontested: Some("only candidate A produced a change".to_owned()),
11184        });
11185        state.reviews = vec![ReviewRound {
11186            round: 1,
11187            head: "deadbeef".to_owned(),
11188            verified_head: None,
11189            verified_at: None,
11190            reviews: Vec::new(),
11191            e2e: Vec::new(),
11192            fix: None,
11193            blocking: 0,
11194            answered: 0,
11195            expected: 0,
11196            clean: true,
11197            verify_retried: false,
11198            e2e_deferred: false,
11199            e2e_defer_reason: None,
11200            progressed: false,
11201            vote_split: false,
11202            reconsideration: Vec::new(),
11203            verdict: None,
11204        }];
11205
11206        let mut runner = Runner {
11207            state,
11208            roles: ResolvedRoles {
11209                implementers: Vec::new(),
11210                judges: Vec::new(),
11211                reviewers: Vec::new(),
11212                fixer: None,
11213                conductor: conductor(),
11214                implementer_roster: Vec::new(),
11215                judge_roster: Vec::new(),
11216                reviewer_roster: Vec::new(),
11217            },
11218            sem: Arc::new(Semaphore::new(1)),
11219            pause: Pause::new(),
11220            interrupt: Pause::new(),
11221        };
11222
11223        let started_marker = repo.join("started.marker");
11224        let release_marker = repo.join("release.marker");
11225        let poller = tokio::spawn(async move {
11226            // Bounded so a regression that never records the task entry
11227            // fails this test in seconds instead of hanging the suite —
11228            // the same shape `a_park_requested_while_a_seat_is_mid_call_
11229            // does_not_cut_it_short` uses for the same reason.
11230            for _ in 0..100 {
11231                if started_marker.exists()
11232                    && let Ok(s) = crate::run::RunState::load(&run_id)
11233                    && let Some(a) = s.active.get("gate")
11234                {
11235                    std::fs::write(&release_marker, b"go").expect("release marker");
11236                    return Some(a.clone());
11237                }
11238                tokio::time::sleep(Duration::from_millis(50)).await;
11239            }
11240            None
11241        });
11242
11243        runner.gate().await.expect("gate");
11244        let captured = poller.await.expect("poller task");
11245        let captured = captured.expect(
11246            "the poller never saw a `gate` task entry in run.json while the command was \
11247             still blocked on its own release marker",
11248        );
11249
11250        assert_eq!(captured.task.as_deref(), Some("gate"));
11251        assert_eq!(captured.node, "gate");
11252        assert_eq!(captured.index, Some(1));
11253        assert_eq!(captured.total, Some(1));
11254        assert!(
11255            captured
11256                .command
11257                .as_deref()
11258                .is_some_and(|c| c.contains("started.marker")),
11259            "{captured:?}"
11260        );
11261
11262        assert!(
11263            runner.state.active.is_empty(),
11264            "the entry must be cleared once the command actually finished: {:?}",
11265            runner.state.active
11266        );
11267        assert!(runner.state.gate_ran);
11268        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
11269    }
11270
11271    /// The hand-off over a blocking finding a reviewer rejected on leaves a
11272    /// record for `land`; one with only a Minor, or no reject, leaves none.
11273    #[tokio::test]
11274    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
11275        use crate::verdict::{Finding, ReviewVote, Severity};
11276        crate::run::pin_test_home();
11277        let tmp = tempfile::tempdir().expect("tempdir");
11278        let repo = tmp.path().join("repo");
11279        std::fs::create_dir_all(&repo).unwrap();
11280        init_repo(&repo);
11281
11282        for (severity, vote, expect) in [
11283            (Severity::Major, ReviewVote::Reject, true),
11284            (Severity::Minor, ReviewVote::Reject, false),
11285            (Severity::Major, ReviewVote::Approve, false),
11286        ] {
11287            let mut round = review_round(false, 1, 1, 1, false, true);
11288            round.reviews = vec![ReviewRecord {
11289                reviewer: 1,
11290                agent: "alpha".to_owned(),
11291                summary: String::new(),
11292                findings: vec![Finding {
11293                    id: "R1-1-1".to_owned(),
11294                    severity,
11295                    file: None,
11296                    line: None,
11297                    title: "t".to_owned(),
11298                    detail: String::new(),
11299                }],
11300                vote: Some(vote),
11301                failed: None,
11302                duration_ms: 0,
11303                attempts: 0,
11304            }];
11305            let mut state = RunState::new(
11306                repo.clone(),
11307                "main".to_owned(),
11308                "deadbeef".to_owned(),
11309                "task".to_owned(),
11310                Config::default(),
11311            );
11312            state.reviews = vec![round];
11313            let mut runner = Runner {
11314                state,
11315                roles: ResolvedRoles {
11316                    implementers: Vec::new(),
11317                    judges: Vec::new(),
11318                    reviewers: Vec::new(),
11319                    fixer: None,
11320                    conductor: conductor(),
11321                    implementer_roster: Vec::new(),
11322                    judge_roster: Vec::new(),
11323                    reviewer_roster: Vec::new(),
11324                },
11325                sem: Arc::new(Semaphore::new(1)),
11326                pause: Pause::new(),
11327                interrupt: Pause::new(),
11328            };
11329            let shell = runner.state.config.shell();
11330            runner
11331                .stop_reviewing("round budget spent", &shell, &repo)
11332                .await
11333                .expect("stop_reviewing");
11334            assert_eq!(runner.state.status, RunStatus::Gating);
11335            assert_eq!(
11336                runner.state.contested_handoff.is_some(),
11337                expect,
11338                "{severity:?} + {vote:?}"
11339            );
11340        }
11341    }
11342
11343    /// The shape the incident this whole fix responds to actually had: the
11344    /// round budget spent, the last round's own e2e blocked on the shared
11345    /// build cache (held here by a live pid — this test process — exactly
11346    /// `cache`'s own unit tests' pattern for "another owner, still alive"
11347    /// without forking a process). `stop_reviewing` must retry it — not
11348    /// silently leave the round looking untouched (the catch-up-only half of
11349    /// the bug), and not read the contention as a red `e2e` and block the
11350    /// run on it (the other half). Called directly, the same way
11351    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
11352    /// above exercises `gate`, so this never needs a real cargo build to
11353    /// reach: the lease is never released, so `with_cache_lease` never gets
11354    /// past acquiring it into anything that would need a real workspace.
11355    #[tokio::test]
11356    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
11357        crate::run::pin_test_home();
11358        let home = crate::run::home();
11359
11360        let tmp = tempfile::tempdir().expect("tempdir");
11361        let repo = tmp.path().join("repo");
11362        std::fs::create_dir_all(&repo).unwrap();
11363        init_repo(&repo);
11364        let head = crate::git::rev_parse(&repo, "HEAD")
11365            .await
11366            .expect("rev-parse");
11367        // Unique to this test, so holding its lease cannot collide with
11368        // another test sharing the same process-wide `home`.
11369        let cache_dir = tmp.path().join("target");
11370
11371        let mut config = Config::default();
11372        config.verify.e2e = vec![format!(
11373            "CARGO_TARGET_DIR='{}' test -f README.md",
11374            cache_dir.display()
11375        )];
11376        config.graph.review_rounds = 1;
11377        // Bounded so a regression that does start waiting fails the test in
11378        // seconds, not hangs it.
11379        config.graph.timeout_verify = Some(2);
11380
11381        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11382        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11383            .expect("no io error acquiring directly")
11384        {
11385            crate::cache::AcquireOutcome::Acquired(g) => g,
11386            crate::cache::AcquireOutcome::Busy(b) => {
11387                panic!("expected the direct acquire to win the lease first: {b:?}")
11388            }
11389        };
11390
11391        let mut state = RunState::new(
11392            repo.clone(),
11393            "main".to_owned(),
11394            head.clone(),
11395            "task".to_owned(),
11396            config,
11397        );
11398        state.candidates = vec![Candidate {
11399            index: 0,
11400            label: 'A',
11401            agent: "alpha".to_owned(),
11402            branch: "does-not-exist".to_owned(),
11403            worktree: repo.clone(),
11404            summary: String::new(),
11405            stat: String::new(),
11406            files: 0,
11407            commits: 0,
11408            empty: false,
11409            failed: None,
11410            verified_noop: None,
11411            duration_ms: 0,
11412            folded: false,
11413        }];
11414        state.tally = Some(Tally {
11415            first_choice: BTreeMap::from([('A', 1)]),
11416            borda: BTreeMap::new(),
11417            winner: 'A',
11418            rankings: 1,
11419            unanimous_initial: true,
11420            deliberated: false,
11421            changed_votes: 0,
11422            unanimous_final: true,
11423            tie_break: None,
11424            judges: 0,
11425            present: 0,
11426            quorum: 0,
11427            met_quorum: true,
11428            uncontested: Some("only candidate A produced a change".to_owned()),
11429        });
11430        // The round budget's last round, deferred: `needs_catchup_run`'s
11431        // other trigger. `stop_reviewing`'s retry machinery must treat this
11432        // exactly like a resource-blocked attempt once it actually runs.
11433        state.reviews = vec![ReviewRound {
11434            round: 1,
11435            head: head.clone(),
11436            verified_head: None,
11437            verified_at: None,
11438            reviews: Vec::new(),
11439            e2e: Vec::new(),
11440            fix: None,
11441            blocking: 1,
11442            answered: 1,
11443            expected: 1,
11444            clean: false,
11445            verify_retried: false,
11446            e2e_deferred: true,
11447            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11448            progressed: false,
11449            vote_split: false,
11450            reconsideration: Vec::new(),
11451            verdict: None,
11452        }];
11453
11454        let mut runner = Runner {
11455            state,
11456            roles: ResolvedRoles {
11457                implementers: Vec::new(),
11458                judges: Vec::new(),
11459                reviewers: Vec::new(),
11460                fixer: None,
11461                conductor: conductor(),
11462                implementer_roster: Vec::new(),
11463                judge_roster: Vec::new(),
11464                reviewer_roster: Vec::new(),
11465            },
11466            sem: Arc::new(Semaphore::new(1)),
11467            pause: Pause::new(),
11468            interrupt: Pause::new(),
11469        };
11470
11471        let shell = runner.state.config.shell();
11472        runner
11473            .stop_reviewing("round budget spent", &shell, &repo)
11474            .await
11475            .expect("stop_reviewing");
11476
11477        let last = runner.state.reviews.last().expect("round record");
11478        assert_eq!(
11479            last.e2e_status(),
11480            E2eStatus::ResourceBlocked,
11481            "the shared cache is still held; the attempt must read as blocked, not deferred or \
11482             failed: {last:?}"
11483        );
11484        assert_eq!(
11485            last.verified_head.as_deref(),
11486            Some(head.as_str()),
11487            "which commit this attempt targeted is known even though nothing finished checking \
11488             it"
11489        );
11490        let first_attempt_at = last
11491            .verified_at
11492            .expect("when this attempt ran is known too");
11493        assert_ne!(
11494            runner.state.status,
11495            RunStatus::Blocked,
11496            "contention is evidence about the machine, not the patch — it must not settle the \
11497             run as blocked: {:?}",
11498            runner.state.status
11499        );
11500        assert!(
11501            !runner
11502                .state
11503                .events
11504                .iter()
11505                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11506            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11507            runner.state.events
11508        );
11509
11510        // The cache is still held: a later reentry must retry the same
11511        // round's verification again — not leave it looking exactly as
11512        // untouched as the first blocked attempt, which is indistinguishable
11513        // from never having tried again at all.
11514        runner
11515            .stop_reviewing("round budget spent", &shell, &repo)
11516            .await
11517            .expect("stop_reviewing retry");
11518        assert_eq!(
11519            runner.state.reviews.len(),
11520            1,
11521            "no new round was started: {:?}",
11522            runner.state.reviews
11523        );
11524        let last = runner.state.reviews.last().expect("round record");
11525        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11526        assert!(
11527            last.verified_at.expect("still known") > first_attempt_at,
11528            "a second reentry must be a fresh attempt, not a stale copy of the first"
11529        );
11530        assert_ne!(runner.state.status, RunStatus::Blocked);
11531
11532        held.release();
11533    }
11534
11535    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
11536    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
11537    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
11538    /// own top-of-function fast path (`review_conclusion`) correctly reads
11539    /// this shape as `None` rather than guessing `Blocked`, and the loop's
11540    /// own `for` range is empty once the round budget is spent, so
11541    /// `review_loop` must retry the check itself rather than silently doing
11542    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
11543    /// above exercises directly, but through `review_loop`'s own entry point
11544    /// this time, proving the wiring between the two rather than just the
11545    /// retry logic in isolation.
11546    #[tokio::test]
11547    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11548        crate::run::pin_test_home();
11549        let home = crate::run::home();
11550
11551        let tmp = tempfile::tempdir().expect("tempdir");
11552        let repo = tmp.path().join("repo");
11553        std::fs::create_dir_all(&repo).unwrap();
11554        init_repo(&repo);
11555        let head = crate::git::rev_parse(&repo, "HEAD")
11556            .await
11557            .expect("rev-parse");
11558        let cache_dir = tmp.path().join("target");
11559
11560        let mut config = Config::default();
11561        config.verify.e2e = vec![format!(
11562            "CARGO_TARGET_DIR='{}' test -f README.md",
11563            cache_dir.display()
11564        )];
11565        config.graph.review_rounds = 1;
11566        config.graph.timeout_verify = Some(2);
11567
11568        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11569        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11570            .expect("no io error acquiring directly")
11571        {
11572            crate::cache::AcquireOutcome::Acquired(g) => g,
11573            crate::cache::AcquireOutcome::Busy(b) => {
11574                panic!("expected the direct acquire to win the lease first: {b:?}")
11575            }
11576        };
11577
11578        let mut state = RunState::new(
11579            repo.clone(),
11580            "main".to_owned(),
11581            head.clone(),
11582            "task".to_owned(),
11583            config,
11584        );
11585        state.candidates = vec![Candidate {
11586            index: 0,
11587            label: 'A',
11588            agent: "alpha".to_owned(),
11589            branch: "does-not-exist".to_owned(),
11590            worktree: repo.clone(),
11591            summary: String::new(),
11592            stat: String::new(),
11593            files: 0,
11594            commits: 0,
11595            empty: false,
11596            failed: None,
11597            verified_noop: None,
11598            duration_ms: 0,
11599            folded: false,
11600        }];
11601        state.tally = Some(Tally {
11602            first_choice: BTreeMap::from([('A', 1)]),
11603            borda: BTreeMap::new(),
11604            winner: 'A',
11605            rankings: 1,
11606            unanimous_initial: true,
11607            deliberated: false,
11608            changed_votes: 0,
11609            unanimous_final: true,
11610            tie_break: None,
11611            judges: 0,
11612            present: 0,
11613            quorum: 0,
11614            met_quorum: true,
11615            uncontested: Some("only candidate A produced a change".to_owned()),
11616        });
11617        // The exact shape a prior process's `stop_reviewing` would have left
11618        // on disk: the round budget's last round, a real attempt already
11619        // made and already resource-blocked.
11620        state.reviews = vec![ReviewRound {
11621            round: 1,
11622            head: head.clone(),
11623            verified_head: Some(head.clone()),
11624            verified_at: Some(jiff::Timestamp::now()),
11625            reviews: Vec::new(),
11626            e2e: vec![CommandOutcome {
11627                command: format!(
11628                    "CARGO_TARGET_DIR='{}' test -f README.md",
11629                    cache_dir.display()
11630                ),
11631                code: None,
11632                output_tail: "waiting for the shared build cache".to_owned(),
11633                duration_ms: 0,
11634                resource_blocked: true,
11635            }],
11636            fix: None,
11637            blocking: 1,
11638            answered: 1,
11639            expected: 1,
11640            clean: false,
11641            verify_retried: false,
11642            e2e_deferred: false,
11643            e2e_defer_reason: None,
11644            progressed: false,
11645            vote_split: false,
11646            reconsideration: Vec::new(),
11647            verdict: None,
11648        }];
11649
11650        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11651        let mut runner = Runner {
11652            state,
11653            roles: ResolvedRoles {
11654                implementers: Vec::new(),
11655                judges: Vec::new(),
11656                reviewers: Vec::new(),
11657                fixer: None,
11658                conductor: conductor(),
11659                implementer_roster: Vec::new(),
11660                judge_roster: Vec::new(),
11661                reviewer_roster: Vec::new(),
11662            },
11663            sem: Arc::new(Semaphore::new(1)),
11664            pause: Pause::new(),
11665            interrupt: Pause::new(),
11666        };
11667
11668        // The lease is still held throughout, so this reentry's own retry is
11669        // also contended — proving `review_loop` actually tried again (not
11670        // that it happened to succeed) is what the timestamp comparison
11671        // below is for.
11672        runner.review_loop().await.expect("review_loop");
11673
11674        assert_eq!(
11675            runner.state.reviews.len(),
11676            1,
11677            "no new round was started on top of the unresolved one: {:?}",
11678            runner.state.reviews
11679        );
11680        let last = &runner.state.reviews[0];
11681        assert_eq!(
11682            last.e2e_status(),
11683            E2eStatus::ResourceBlocked,
11684            "still contended: {last:?}"
11685        );
11686        assert!(
11687            last.verified_at.expect("still known") > first_attempt_at,
11688            "review_loop must have actually retried the check, not left it exactly as found"
11689        );
11690        assert_ne!(
11691            runner.state.status,
11692            RunStatus::Blocked,
11693            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11694            runner.state.status
11695        );
11696
11697        held.release();
11698    }
11699
11700    #[tokio::test]
11701    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11702        crate::run::pin_test_home();
11703        let tmp = tempfile::tempdir().expect("tempdir");
11704        let repo = tmp.path().join("repo");
11705        std::fs::create_dir_all(&repo).unwrap();
11706        init_repo(&repo);
11707
11708        let mut config = Config::default();
11709        config.merge.mode = MergeMode::Pr;
11710        config.graph.land = true;
11711        config.graph.land_approval = false;
11712
11713        let mut state = RunState::new(
11714            repo.clone(),
11715            "main".to_owned(),
11716            "deadbeef".to_owned(),
11717            "task".to_owned(),
11718            config,
11719        );
11720        state.candidates = vec![Candidate {
11721            index: 0,
11722            label: 'A',
11723            agent: "alpha".to_owned(),
11724            branch: "does-not-exist".to_owned(),
11725            worktree: repo.clone(),
11726            summary: String::new(),
11727            stat: String::new(),
11728            files: 0,
11729            commits: 0,
11730            empty: false,
11731            failed: None,
11732            verified_noop: None,
11733            duration_ms: 0,
11734            folded: false,
11735        }];
11736        state.tally = Some(Tally {
11737            first_choice: BTreeMap::from([('A', 1)]),
11738            borda: BTreeMap::new(),
11739            winner: 'A',
11740            rankings: 1,
11741            unanimous_initial: true,
11742            deliberated: false,
11743            changed_votes: 0,
11744            unanimous_final: true,
11745            tie_break: None,
11746            judges: 0,
11747            present: 0,
11748            quorum: 0,
11749            met_quorum: true,
11750            uncontested: Some("only candidate A produced a change".to_owned()),
11751        });
11752        state.reviews = vec![ReviewRound {
11753            round: 1,
11754            head: "deadbeef".to_owned(),
11755            verified_head: None,
11756            verified_at: None,
11757            reviews: Vec::new(),
11758            e2e: Vec::new(),
11759            fix: None,
11760            blocking: 0,
11761            answered: 0,
11762            expected: 0,
11763            clean: true,
11764            verify_retried: false,
11765            e2e_deferred: false,
11766            e2e_defer_reason: None,
11767            progressed: false,
11768            vote_split: false,
11769            reconsideration: Vec::new(),
11770            verdict: None,
11771        }];
11772        state.gate = vec![CommandOutcome {
11773            command: "test".to_owned(),
11774            code: Some(0),
11775            output_tail: String::new(),
11776            duration_ms: 0,
11777            resource_blocked: false,
11778        }];
11779        state.gate_ran = true;
11780        // A first pass through `merge` already pushed and opened this pull
11781        // request; `status` is `Landing` because a previous call into `land`
11782        // parked or was interrupted before it reached a terminal outcome.
11783        state.status = RunStatus::Landing;
11784        state.merge = Some(MergeOutcome {
11785            mode: MergeMode::Pr,
11786            ok: true,
11787            detail: "https://example.invalid/x/y/pull/1".to_owned(),
11788            empty: false,
11789        });
11790
11791        // The Landing-resume shortcut calls `run_land` directly rather than
11792        // through `merge`, which is exactly the call site that used to skip
11793        // `settle_questions` - see the fixture below.
11794        ask_test_home();
11795        let store = ask::Questions::open();
11796        let q = ask_open_question(&store, &state.id);
11797
11798        let mut runner = Runner {
11799            state,
11800            roles: ResolvedRoles {
11801                implementers: Vec::new(),
11802                judges: Vec::new(),
11803                reviewers: Vec::new(),
11804                fixer: None,
11805                conductor: conductor(),
11806                implementer_roster: Vec::new(),
11807                judge_roster: Vec::new(),
11808                reviewer_roster: Vec::new(),
11809            },
11810            sem: Arc::new(Semaphore::new(1)),
11811            pause: Pause::new(),
11812            interrupt: Pause::new(),
11813        };
11814
11815        // `execute`, not `merge` directly: the Landing-resume shortcut lives
11816        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
11817        // exactly because `review_loop` would otherwise clobber the marker
11818        // first.
11819        runner.execute().await.expect("execute");
11820
11821        assert_eq!(
11822            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11823            Some("https://example.invalid/x/y/pull/1"),
11824            "reentry must not push again or open a second pull request over the \
11825             one `land` is already watching"
11826        );
11827        assert_ne!(
11828            runner.state.status,
11829            RunStatus::Landing,
11830            "land could not actually reach the fake pull request, so it must \
11831             have given up rather than left the run silently parked forever"
11832        );
11833        // `land` could not reach the fake pull request, so it gave up into
11834        // `Blocked` - still resumable, so the question must not have been
11835        // swept just because this branch now also calls `settle_questions`.
11836        assert_eq!(runner.state.status, RunStatus::Blocked);
11837        assert!(
11838            store.get(&q.id).unwrap().status.open(),
11839            "Blocked is still alive; settle_questions must have been a no-op here"
11840        );
11841    }
11842
11843    fn state_with_round(round: ReviewRound) -> RunState {
11844        let mut s = RunState::new(
11845            PathBuf::from("/repo"),
11846            "main".to_owned(),
11847            "abc1234".to_owned(),
11848            "add retries".to_owned(),
11849            Config::default(),
11850        );
11851        s.reviews = vec![round];
11852        s
11853    }
11854
11855    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11856        crate::verdict::Finding {
11857            id: id.to_owned(),
11858            severity,
11859            file: None,
11860            line: None,
11861            title: title.to_owned(),
11862            detail: String::new(),
11863        }
11864    }
11865
11866    #[test]
11867    fn pr_body_names_open_findings_and_declined_ones() {
11868        let round = ReviewRound {
11869            round: 2,
11870            head: "deadbee".to_owned(),
11871            verified_head: None,
11872            verified_at: None,
11873            reviews: vec![ReviewRecord {
11874                attempts: 0,
11875                reviewer: 1,
11876                agent: "alpha".to_owned(),
11877                summary: String::new(),
11878                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11879                vote: None,
11880                failed: None,
11881                duration_ms: 0,
11882            }],
11883            e2e: vec![CommandOutcome {
11884                command: "cargo test".to_owned(),
11885                code: Some(0),
11886                output_tail: String::new(),
11887                duration_ms: 0,
11888                resource_blocked: false,
11889            }],
11890            verify_retried: false,
11891            e2e_deferred: false,
11892            e2e_defer_reason: None,
11893            fix: Some(FixRecord {
11894                agent: "alpha".to_owned(),
11895                addressed: Vec::new(),
11896                rejected: vec![crate::verdict::Rejection {
11897                    id: "R1-1-1".to_owned(),
11898                    why: "not reachable from any caller".to_owned(),
11899                }],
11900                notes: String::new(),
11901                committed: true,
11902                failed: None,
11903                duration_ms: 0,
11904                continuation: None,
11905            }),
11906            blocking: 0,
11907            answered: 1,
11908            expected: 1,
11909            clean: false,
11910            progressed: true,
11911            vote_split: false,
11912            reconsideration: Vec::new(),
11913            verdict: None,
11914        };
11915        let state = state_with_round(round);
11916        let body = pr_message(&state, 'A').body;
11917
11918        assert!(body.contains("add retries"), "the task must still be there");
11919        assert!(body.contains("R2-1-1"), "{body}");
11920        assert!(body.contains("unused import"), "{body}");
11921        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11922        assert!(
11923            body.contains("not reachable from any caller"),
11924            "the reason it was declined: {body}"
11925        );
11926    }
11927
11928    #[test]
11929    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11930        let round = ReviewRound {
11931            round: 1,
11932            head: "deadbee".to_owned(),
11933            verified_head: None,
11934            verified_at: None,
11935            reviews: vec![ReviewRecord {
11936                attempts: 0,
11937                reviewer: 1,
11938                agent: "alpha".to_owned(),
11939                summary: String::new(),
11940                findings: Vec::new(),
11941                vote: None,
11942                failed: None,
11943                duration_ms: 0,
11944            }],
11945            e2e: Vec::new(),
11946            verify_retried: false,
11947            e2e_deferred: false,
11948            e2e_defer_reason: None,
11949            fix: None,
11950            blocking: 0,
11951            answered: 1,
11952            expected: 1,
11953            clean: true,
11954            progressed: false,
11955            vote_split: false,
11956            reconsideration: Vec::new(),
11957            verdict: None,
11958        };
11959        let state = state_with_round(round);
11960        let body = pr_message(&state, 'A').body;
11961        assert!(!body.contains("Open review findings"), "{body}");
11962        assert!(!body.contains("Declined"), "{body}");
11963    }
11964
11965    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11966        let mut state = RunState::new(
11967            PathBuf::from("/repo"),
11968            "main".to_owned(),
11969            "abc1234".to_owned(),
11970            instruction.to_owned(),
11971            Config::default(),
11972        );
11973        state.candidates.push(Candidate {
11974            index: 0,
11975            label: 'A',
11976            agent: "alpha".to_owned(),
11977            branch: "magi/x/A".to_owned(),
11978            worktree: PathBuf::from("/wt"),
11979            summary: summary.to_owned(),
11980            stat: String::new(),
11981            files: 1,
11982            commits: 1,
11983            empty: false,
11984            failed: None,
11985            verified_noop: None,
11986            folded: false,
11987            duration_ms: 0,
11988        });
11989        state
11990    }
11991
11992    fn review_state(subjects: &[&str]) -> RunState {
11993        let mut state = state_with_summary(
11994            "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",
11995            "",
11996        );
11997        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11998        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11999        state
12000    }
12001
12002    /// A branch rebased onto a main that moved past the recorded
12003    /// `base_commit` is titled from its own first commit, never main's.
12004    #[tokio::test]
12005    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
12006        ask_test_home();
12007        let tmp = tempfile::tempdir().unwrap();
12008        let repo = tmp.path().join("repo");
12009        std::fs::create_dir_all(&repo).unwrap();
12010        init_repo(&repo);
12011        let origin = tmp.path().join("origin.git");
12012        let g = |dir: &Path, args: &[&str]| {
12013            let out = std::process::Command::new("git")
12014                .args(args)
12015                .current_dir(dir)
12016                .quiet()
12017                .output()
12018                .expect("spawn git");
12019            assert!(
12020                out.status.success(),
12021                "git {args:?}: {}",
12022                String::from_utf8_lossy(&out.stderr)
12023            );
12024        };
12025        g(
12026            tmp.path(),
12027            &[
12028                "clone",
12029                "--bare",
12030                "-q",
12031                repo.to_str().unwrap(),
12032                origin.to_str().unwrap(),
12033            ],
12034        );
12035        g(
12036            &repo,
12037            &["remote", "add", "origin", origin.to_str().unwrap()],
12038        );
12039        let c1 = git::rev_parse(&repo, "main").await.unwrap();
12040
12041        // Main moves on; the branch is built on top of the new main.
12042        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
12043        g(&repo, &["add", "-A"]);
12044        g(
12045            &repo,
12046            &["commit", "-q", "-m", "chore(deps): update a crate"],
12047        );
12048        g(&repo, &["push", "-q", "origin", "main"]);
12049        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
12050        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
12051        g(&repo, &["add", "-A"]);
12052        g(
12053            &repo,
12054            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
12055        );
12056        g(&repo, &["checkout", "-q", "main"]);
12057
12058        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
12059        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
12060        // Without a readable tracking ref the recorded base's merge base is used.
12061        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
12062        assert_eq!(fallback, c1);
12063
12064        let mut state = review_state(&[
12065            "chore(deps): update a crate",
12066            "fix(daemon): apply a chosen action",
12067        ]);
12068        state.repo = repo.clone();
12069        state.base_branch = "main".to_owned();
12070        state.base_commit = c1;
12071        refresh_reviewed_commits(&mut state, "feat/own").await;
12072        assert_eq!(
12073            state.reviewed_commits,
12074            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
12075        );
12076        assert_eq!(
12077            review_title(&state).as_deref(),
12078            Some("fix(daemon): apply a chosen action")
12079        );
12080        assert_eq!(
12081            leaked_subjects(&state, "feat/own").await,
12082            Some(vec!["chore(deps): update a crate".to_owned()])
12083        );
12084        // A stale tracking ref is still used when the fetch fails, but the
12085        // leak list is withheld.
12086        state.config.merge.remote = "nowhere".to_owned();
12087        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
12088    }
12089
12090    #[test]
12091    fn pr_message_review_single_commit_uses_its_subject() {
12092        let state = review_state(&["feat(nats): per-role user"]);
12093        let m = pr_message(&state, 'A');
12094        assert_eq!(m.title, "feat(nats): per-role user");
12095        assert!(!m.body.contains("Review the work already"), "{}", m.body);
12096        assert!(m.body.contains("## Commits under review"), "{}", m.body);
12097    }
12098
12099    #[test]
12100    fn pr_message_review_multi_commit_takes_the_oldest() {
12101        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
12102        let m = pr_message(&state, 'A');
12103        assert_eq!(m.title, "feat: the change");
12104        for s in ["feat: the change", "fix: typo", "fix: again"] {
12105            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
12106        }
12107    }
12108
12109    #[test]
12110    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
12111        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
12112            let mut state = review_state(&[first, "fix: later fixup"]);
12113            state.candidates[0].branch = "機能/ログイン".to_owned();
12114            let m = pr_message(&state, 'A');
12115            assert!(
12116                m.title.starts_with("chore: land candidate A of run"),
12117                "{}",
12118                m.title
12119            );
12120        }
12121    }
12122
12123    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
12124        BranchFacts {
12125            commits: commits
12126                .iter()
12127                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
12128                .collect(),
12129            stat: stat.to_owned(),
12130        }
12131    }
12132
12133    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
12134        let mut r = review_round(true, 0, 1, 1, true, true);
12135        r.round = round;
12136        r.fix = notes.map(|n| FixRecord {
12137            agent: "fixer".to_owned(),
12138            addressed: Vec::new(),
12139            rejected: Vec::new(),
12140            notes: n.to_owned(),
12141            committed: true,
12142            failed: None,
12143            duration_ms: 0,
12144            continuation: None,
12145        });
12146        r
12147    }
12148
12149    #[test]
12150    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
12151        let state = review_state(&["ignored"]);
12152        let f = facts(
12153            &[
12154                (
12155                    "fix(login): resolve PATH on macOS",
12156                    "Login shells skip rc files.",
12157                ),
12158                ("fix: address review", ""),
12159            ],
12160            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
12161        );
12162        let m = pr_message_with(&state, 'A', Some(&f));
12163        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
12164        assert!(
12165            m.body
12166                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
12167            "{}",
12168            m.body
12169        );
12170        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
12171        assert!(m.body.contains("## Diff stat"), "{}", m.body);
12172        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
12173        for banned in [
12174            "Review the work already",
12175            "no task statement",
12176            "Original task",
12177        ] {
12178            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
12179        }
12180        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
12181    }
12182
12183    #[test]
12184    fn pr_message_review_truncates_a_huge_first_commit_body() {
12185        let state = review_state(&["ignored"]);
12186        let f = facts(
12187            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
12188            "s",
12189        );
12190        let m = pr_message_with(&state, 'A', Some(&f));
12191        assert!(m.body.len() < 30_000, "{}", m.body.len());
12192        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
12193        assert!(
12194            m.body.contains("- ... 1 more commit(s)"),
12195            "{}",
12196            m.body.len()
12197        );
12198        assert!(m.body.ends_with("magi:candidate-a\n"));
12199    }
12200
12201    #[test]
12202    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
12203        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
12204        assert_eq!(m.title, "feat: x");
12205        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
12206        assert!(!m.body.contains("Diff stat"), "{}", m.body);
12207        assert!(!m.body.contains("no task statement"), "{}", m.body);
12208    }
12209
12210    #[test]
12211    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
12212        let mut state = review_state(&["日本語の件名"]);
12213        state.candidates[0].branch = "fix/macos-login-path".to_owned();
12214        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
12215        state.candidates[0].branch = "機能/ログイン".to_owned();
12216        assert!(
12217            pr_message(&state, 'A')
12218                .title
12219                .starts_with("chore: land candidate A")
12220        );
12221    }
12222
12223    #[test]
12224    fn pr_message_review_fixes_survive_a_clean_final_round() {
12225        let mut state = review_state(&["feat: x"]);
12226        state.reviews = vec![
12227            round_with_notes(1, Some("handled the PATH case")),
12228            round_with_notes(2, None),
12229        ];
12230        let body = pr_message(&state, 'A').body;
12231        assert!(
12232            body.contains("## Review fixes\n\nhandled the PATH case\n"),
12233            "{body}"
12234        );
12235        assert!(!body.contains("### Round"), "{body}");
12236
12237        state.reviews = vec![
12238            round_with_notes(1, Some("first fix")),
12239            round_with_notes(2, Some("")),
12240            round_with_notes(3, Some("second fix")),
12241            round_with_notes(4, None),
12242        ];
12243        let body = pr_message(&state, 'A').body;
12244        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
12245        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
12246        assert!(!body.contains("### Round 2"), "{body}");
12247    }
12248
12249    #[test]
12250    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
12251        let state = state_with_summary(
12252            "add retries to the client",
12253            "TITLE: feat: retries\n\nDid it.",
12254        );
12255        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
12256        assert_eq!(m.title, "feat: retries");
12257        assert!(
12258            m.body.contains("<summary>Original task</summary>"),
12259            "{}",
12260            m.body
12261        );
12262        assert!(!m.body.contains("Commits under review"), "{}", m.body);
12263        assert!(
12264            m.body
12265                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
12266            "{}",
12267            m.body
12268        );
12269    }
12270
12271    #[test]
12272    fn pr_message_review_bounds_a_long_english_subject() {
12273        let long = format!("feat: {}", "word ".repeat(100));
12274        let m = pr_message(&review_state(&[&long]), 'A');
12275        assert!(m.title.starts_with("feat: word"), "{}", m.title);
12276        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12277    }
12278
12279    #[test]
12280    fn pr_message_implementation_run_is_unchanged_by_review_support() {
12281        let state = state_with_summary("add retries\n\ndetails", "- did some things");
12282        let m = pr_message(&state, 'A');
12283        assert_eq!(m.title, "add retries");
12284        assert!(m.body.contains("<summary>Original task</summary>"));
12285        assert!(!m.body.contains("Commits under review"));
12286        assert_eq!(landing_subject_source(&state), state.instruction);
12287    }
12288
12289    #[test]
12290    fn review_run_squash_subject_is_the_change_not_the_prompt() {
12291        let state = review_state(&["feat: the change", "fix: typo"]);
12292        let source = landing_subject_source(&state);
12293        assert_eq!(land::merge_subject("", &source), "feat: the change");
12294        assert_eq!(
12295            land::merge_subject("magi: candidate A (uncommitted work)", &source),
12296            "feat: the change"
12297        );
12298        // An operator's rename still wins.
12299        assert_eq!(
12300            land::merge_subject("feat: renamed by hand", &source),
12301            "feat: renamed by hand"
12302        );
12303        let blank = review_state(&["日本語"]);
12304        assert!(
12305            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
12306        );
12307    }
12308
12309    #[test]
12310    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
12311        let state = review_state(&["feat: the change"]);
12312        let source = landing_subject_source(&state);
12313        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
12314        assert_eq!(
12315            land::merge_subject(landing_title(&state, old), &source),
12316            "feat: the change"
12317        );
12318        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
12319        let task = state_with_summary("add retries", "");
12320        assert_eq!(landing_title(&task, old), old);
12321    }
12322
12323    #[test]
12324    fn pr_message_describes_the_change_not_the_task() {
12325        let state = state_with_summary(
12326            "今回やってほしいこと: results projector を直す",
12327            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
12328        );
12329        let m = pr_message(&state, 'A');
12330        assert_eq!(m.title, "fix(web): batch the runs list reads");
12331        assert!(
12332            m.body.starts_with("## Summary\n\n- reads run.json once"),
12333            "{}",
12334            m.body
12335        );
12336        assert!(!m.body.contains("TITLE:"), "{}", m.body);
12337        let task_at = m.body.find("今回やってほしいこと").unwrap();
12338        let details_at = m.body.find("<details>").unwrap();
12339        assert!(
12340            details_at < task_at,
12341            "the task lives inside <details>: {}",
12342            m.body
12343        );
12344        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
12345        assert!(m.body.contains("magi:candidate-a"));
12346    }
12347
12348    #[test]
12349    fn pr_message_falls_back_to_the_task_without_a_title_line() {
12350        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
12351        let m = pr_message(&state, 'A');
12352        assert_eq!(m.title, "add retries");
12353        assert!(
12354            m.body.contains("## Summary\n\n- did some things"),
12355            "{}",
12356            m.body
12357        );
12358
12359        let none = RunState::new(
12360            PathBuf::from("/repo"),
12361            "main".to_owned(),
12362            "abc1234".to_owned(),
12363            "add retries".to_owned(),
12364            Config::default(),
12365        );
12366        let m = pr_message(&none, 'A');
12367        assert_eq!(m.title, "add retries");
12368        assert!(!m.body.contains("## Summary"), "{}", m.body);
12369    }
12370
12371    #[test]
12372    fn pr_message_refuses_the_candidate_commit_subject() {
12373        for bad in [
12374            "TITLE: magi: candidate A (uncommitted work)",
12375            "TITLE: chore: stuff (uncommitted work)",
12376            "TITLE:   ",
12377        ] {
12378            let state = state_with_summary("add retries", bad);
12379            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12380        }
12381    }
12382
12383    #[test]
12384    fn pr_message_bounds_a_very_long_task_and_title() {
12385        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12386        let state = state_with_summary(&long, "- nothing");
12387        let m = pr_message(&state, 'A');
12388        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12389        assert!(!m.title.contains('\n'));
12390
12391        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12392        let m = pr_message(&state, 'A');
12393        assert!(m.title.starts_with("feat: "));
12394        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12395        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12396    }
12397
12398    fn long_title_of(instruction: &str) -> String {
12399        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12400    }
12401
12402    #[test]
12403    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12404        let first = "Make the landing path keep a readable title for long tasks";
12405        let line = format!(
12406            "{first}. {}",
12407            "And then keep going with more words ".repeat(20)
12408        );
12409        let t = long_title_of(&line);
12410        assert_eq!(t, first);
12411        assert!(!t.starts_with("chore: land"));
12412    }
12413
12414    #[test]
12415    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12416        let line = "word ".repeat(200);
12417        let t = long_title_of(&line);
12418        assert!(t.ends_with("word..."), "{t}");
12419        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12420    }
12421
12422    #[test]
12423    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12424        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12425            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12426        }
12427    }
12428
12429    #[test]
12430    fn pr_message_title_limit_is_exact() {
12431        let at = "a".repeat(PR_TITLE_MAX);
12432        assert_eq!(long_title_of(&at), at);
12433        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12434        assert!(over.ends_with("..."), "{over}");
12435        assert_eq!(over.chars().count(), PR_TITLE_MAX);
12436    }
12437
12438    #[test]
12439    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12440        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12441        let t = long_title_of(&line);
12442        assert!(t.ends_with("..."), "{t}");
12443        assert!(t.is_ascii(), "{t}");
12444    }
12445
12446    #[test]
12447    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12448        let line = format!(
12449            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12450            "plus more filler words ".repeat(20)
12451        );
12452        let t = long_title_of(&line);
12453        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12454    }
12455
12456    #[test]
12457    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12458        let line = format!("\n\n# {}", "title words ".repeat(40));
12459        let state = state_with_summary(&line, "- nothing");
12460        let m = pr_message(&state, 'A');
12461        assert!(m.title.starts_with("title words"), "{}", m.title);
12462        assert_eq!(
12463            land::merge_subject(&m.title, &landing_subject_source(&state)),
12464            m.title
12465        );
12466        // An operator's rename wins untouched.
12467        assert_eq!(
12468            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12469            "feat: renamed by hand"
12470        );
12471    }
12472
12473    #[tokio::test]
12474    async fn github_text_rewrite_is_bounded_and_falls_back() {
12475        crate::run::pin_test_home();
12476        for (reply, accepted) in [
12477            (
12478                "TITLE: fix: retries\nAdd retries for failed requests.",
12479                true,
12480            ),
12481            (
12482                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12483                false,
12484            ),
12485            ("TITLE: fix: retries\nUse token=secret", false),
12486        ] {
12487            let dir = tempfile::tempdir().unwrap();
12488            let mut runner = runner_at(RunStatus::Gating);
12489            runner.state = state_with_summary(
12490                "add retries",
12491                "TITLE: fix: retries\n日本語の説明を書きます。",
12492            );
12493            runner.state.repo = dir.path().to_owned();
12494            runner.state.candidates[0].worktree = dir.path().to_owned();
12495            let mut author = spec("alpha");
12496            author.command = vec![
12497                "sh".into(),
12498                "-c".into(),
12499                "printf '%s' \"$REWRITE_REPLY\"".into(),
12500            ];
12501            author.env.insert("REWRITE_REPLY".into(), reply.into());
12502            runner.state.config.agents = vec![author];
12503            let winner = runner.state.candidates[0].clone();
12504            let first = runner
12505                .guarded_pr_message(&winner, None, true)
12506                .await
12507                .unwrap();
12508            let message = if accepted {
12509                first.expect("a passing rewrite never asks")
12510            } else {
12511                // Withheld: the run parks on a question and posts nothing yet.
12512                assert!(first.is_none());
12513                assert!(runner.state.parked);
12514                let store = ask::Questions::open();
12515                let asked: Vec<_> = store
12516                    .list()
12517                    .into_iter()
12518                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12519                    .collect();
12520                assert_eq!(asked.len(), 1);
12521                assert!(!asked[0].detail.contains("secret"));
12522                // Silence: the question lapses and the neutral text is used,
12523                // without a second rewrite call or a second question.
12524                store
12525                    .update(&asked[0].id, |q| {
12526                        q.abandon("test");
12527                        Ok(())
12528                    })
12529                    .unwrap();
12530                let again = runner
12531                    .guarded_pr_message(&winner, None, true)
12532                    .await
12533                    .unwrap()
12534                    .expect("an abandoned question falls back");
12535                let n = store
12536                    .list()
12537                    .into_iter()
12538                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12539                    .count();
12540                assert_eq!(n, 1, "asked once per rejected text");
12541                again
12542            };
12543            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12544            assert_eq!(
12545                message.body.contains("Add retries for failed requests."),
12546                accepted
12547            );
12548            assert_eq!(
12549                runner.state.seats["impl-A"].turns, 1,
12550                "only one rewrite invocation"
12551            );
12552            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12553            if !accepted {
12554                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12555                assert!(
12556                    message
12557                        .body
12558                        .contains(&format!("magi:run/{}", runner.state.id))
12559                );
12560            }
12561        }
12562    }
12563
12564    #[tokio::test]
12565    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12566        crate::run::pin_test_home();
12567        let dir = tempfile::tempdir().unwrap();
12568        let mut runner = runner_at(RunStatus::Gating);
12569        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12570        runner.state.repo = dir.path().to_owned();
12571        runner.state.candidates[0].worktree = dir.path().to_owned();
12572        let winner = runner.state.candidates[0].clone();
12573        assert!(
12574            runner
12575                .guarded_pr_message(&winner, None, true)
12576                .await
12577                .unwrap()
12578                .is_none()
12579        );
12580        let store = ask::Questions::open();
12581        let find = |run: &str| {
12582            store
12583                .list()
12584                .into_iter()
12585                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12586                .unwrap()
12587        };
12588        let run = runner.state.id.clone();
12589        let q = find(&run);
12590        let fp = runner
12591            .state
12592            .github_text
12593            .as_ref()
12594            .unwrap()
12595            .fingerprint
12596            .clone();
12597        let name = crate::github_text::artifact_name(&fp);
12598        let path = crate::run::artifact_path(&runner.state, &name);
12599        assert!(q.detail.contains(&path.display().to_string()));
12600        assert!(q.detail.contains("title-language: title"));
12601        let saved = std::fs::read_to_string(&path).expect("artifact written");
12602        assert!(saved.contains("日本語のタイトル"));
12603        assert!(q.cwd.is_none());
12604        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
12605        // A bad replacement is refused and asked about once more.
12606        let say = |id: &str, text: &str, choice: &str| {
12607            store
12608                .update(id, |q| {
12609                    q.thread.push(ask::Turn {
12610                        who: ask::Who::Operator,
12611                        body: text.to_owned(),
12612                        at: jiff::Timestamp::now(),
12613                        note: None,
12614                    });
12615                    q.answer(ask::Answer::Choice(choice.to_owned()))
12616                })
12617                .unwrap();
12618        };
12619        say(&q.id, "まだ日本語", "use my text");
12620        assert!(
12621            runner
12622                .guarded_pr_message(&winner, None, true)
12623                .await
12624                .unwrap()
12625                .is_none()
12626        );
12627        let second = find(&run);
12628        assert_ne!(second.id, q.id);
12629        assert!(second.detail.contains("did not pass"));
12630        // The retry still compares the original text; the rejected
12631        // replacement is named by category only.
12632        assert!(second.detail.contains("日本語のタイトル"));
12633        assert!(!second.detail.contains("まだ日本語"));
12634        assert!(second.detail.contains(&path.display().to_string()));
12635        say(&second.id, "fix: retry failed requests", "use my text");
12636        let message = runner
12637            .guarded_pr_message(&winner, None, true)
12638            .await
12639            .unwrap()
12640            .expect("a vetted replacement is posted");
12641        assert_eq!(message.title, "fix: retry failed requests");
12642        assert_eq!(
12643            runner
12644                .state
12645                .github_text
12646                .as_ref()
12647                .unwrap()
12648                .chosen_title
12649                .as_deref(),
12650            Some("fix: retry failed requests")
12651        );
12652        assert!(runner.state.github_text.as_ref().unwrap().resolved);
12653        // Resumed again: the saved decision is applied, nothing is re-asked.
12654        let again = runner
12655            .guarded_pr_message(&winner, None, true)
12656            .await
12657            .unwrap()
12658            .unwrap();
12659        assert_eq!(again.title, message.title);
12660        assert_eq!(
12661            store
12662                .list()
12663                .into_iter()
12664                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12665                .count(),
12666            2
12667        );
12668    }
12669
12670    #[test]
12671    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12672        // What magi itself writes stays English under any configured language,
12673        // so a future localisation of these headings fails here. (The agents'
12674        // own text is also checked by the posting gate.)
12675        let mut state = state_with_summary(
12676            "add retries",
12677            "TITLE: fix(web): batch reads\n- reads run.json once",
12678        );
12679        state.config.graph.language = "ja".to_owned();
12680        let m = pr_message(&state, 'A');
12681        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12682        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12683
12684        // The task is the operator's own text: it goes in untouched, and the
12685        // fallback title (no summary) may be in its language too.
12686        let task = "今回やってほしいこと: results projector を直す";
12687        let mut state = state_with_summary(task, "- no title line");
12688        state.config.graph.language = "ja".to_owned();
12689        let m = pr_message(&state, 'A');
12690        assert_eq!(
12691            m.title,
12692            format!("chore: land candidate A of run {}", state.id)
12693        );
12694        assert!(
12695            m.body.contains(&format!(
12696                "<summary>Original task</summary>\n\n{task}\n\n</details>"
12697            )),
12698            "{}",
12699            m.body
12700        );
12701    }
12702
12703    #[test]
12704    fn pr_message_scrubs_home_paths_and_addresses() {
12705        let state = state_with_summary(
12706            "fix it in /Users/someone/src/x",
12707            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12708        );
12709        let m = pr_message(&state, 'A');
12710        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12711            assert!(!m.body.contains(leak), "{}", m.body);
12712        }
12713        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12714    }
12715
12716    #[test]
12717    fn pr_message_survives_a_task_that_closes_details() {
12718        let state = state_with_summary("a </details> b", "TITLE: fix: x");
12719        let m = pr_message(&state, 'A');
12720        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12721    }
12722
12723    #[test]
12724    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12725        let cmd = manual_merge_command(
12726            MergeStyle::Squash,
12727            Path::new("/repo"),
12728            "b",
12729            "fix: \"quoted\" $(x) `y`\n\nbody",
12730        );
12731        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12732    }
12733
12734    #[test]
12735    fn manual_merge_command_matches_the_configured_style() {
12736        let repo = Path::new("/repo");
12737        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12738
12739        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12740        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12741
12742        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12743        assert_eq!(
12744            squash,
12745            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12746             \"Merge magi run 0832 (candidate A)\""
12747        );
12748
12749        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12750        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12751    }
12752
12753    #[test]
12754    fn a_nudge_gets_a_quarter_of_the_budget() {
12755        // The judge and implement budgets magi ships with.
12756        assert_eq!(retry_budget(secs(1200), true), secs(300));
12757        assert_eq!(retry_budget(secs(3600), true), secs(900));
12758    }
12759
12760    #[test]
12761    fn a_resent_prompt_keeps_the_whole_budget() {
12762        // The seat kept no context, so the retry is the original job again and
12763        // shortening it would only guarantee a second failure.
12764        assert_eq!(retry_budget(secs(1200), false), secs(1200));
12765        assert_eq!(retry_budget(secs(60), false), secs(60));
12766    }
12767
12768    #[test]
12769    fn the_floor_never_exceeds_the_original_budget() {
12770        // A short configured timeout must not be *raised* by the floor: the
12771        // operator asked for a bound, and a retry may not outlast the attempt
12772        // it is retrying.
12773        assert_eq!(retry_budget(secs(60), true), secs(60));
12774        assert_eq!(retry_budget(secs(480), true), secs(120));
12775        assert_eq!(retry_budget(secs(0), true), secs(0));
12776    }
12777
12778    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12779        agent::CommandEvidence {
12780            id: "item1".to_owned(),
12781            description: "cargo test".to_owned(),
12782            exit_code,
12783            result_summary: String::new(),
12784            source: "codex".to_owned(),
12785        }
12786    }
12787
12788    #[test]
12789    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12790        // No evidence is not the same fact as unconfirmed evidence: a
12791        // backend with no adapter, or a reply that ran no commands at all,
12792        // must not be misread as carrying a dangling job.
12793        assert!(!has_unconfirmed_command(&[]));
12794    }
12795
12796    #[test]
12797    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12798        // Deliberately not a check on the exit code's *value*: a fixer
12799        // legitimately runs something that fails mid-iteration before it
12800        // succeeds, and that must never by itself reopen a valid report.
12801        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12802        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12803        assert!(!has_unconfirmed_command(&[
12804            evidence(Some(0)),
12805            evidence(Some(101))
12806        ]));
12807    }
12808
12809    #[test]
12810    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12811        assert!(has_unconfirmed_command(&[
12812            evidence(Some(0)),
12813            evidence(None)
12814        ]));
12815    }
12816
12817    #[test]
12818    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12819        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12820        assert_eq!(
12821            verified_noop_claim(true, &[], text).as_deref(),
12822            Some("already fixed by b32cfc4, on main.")
12823        );
12824    }
12825
12826    #[test]
12827    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12828        // A timeout or a bad exit code reads as the ordinary loss it is,
12829        // whatever the reply's own prose claims.
12830        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12831        assert!(verified_noop_claim(false, &[], text).is_none());
12832    }
12833
12834    #[test]
12835    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12836        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12837        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12838        // A confirmed command alongside the marker is fine.
12839        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12840    }
12841
12842    #[test]
12843    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12844        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12845    }
12846
12847    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
12848    /// pair, in order, labelled A, B, C, ...
12849    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12850        runner.state.candidates = shape
12851            .iter()
12852            .enumerate()
12853            .map(|(i, &(empty, verified))| Candidate {
12854                index: i,
12855                label: (b'A' + i as u8) as char,
12856                agent: "sonnet".to_owned(),
12857                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12858                worktree: PathBuf::from(format!("/wt/{i}")),
12859                summary: String::new(),
12860                stat: String::new(),
12861                files: 0,
12862                commits: 0,
12863                empty,
12864                failed: None,
12865                verified_noop: verified.map(str::to_owned),
12866                duration_ms: 0,
12867                folded: false,
12868            })
12869            .collect();
12870    }
12871
12872    #[test]
12873    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12874        ask_test_home();
12875        let mut runner = runner_at(RunStatus::Implementing);
12876        set_candidates(
12877            &mut runner,
12878            &[
12879                (true, Some("already on main at b32cfc4")),
12880                (true, Some("same fix, see the existing test")),
12881            ],
12882        );
12883
12884        runner
12885            .after_implement()
12886            .expect("a verified no-op is not an error");
12887
12888        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12889    }
12890
12891    #[test]
12892    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12893        ask_test_home();
12894        let mut runner = runner_at(RunStatus::Implementing);
12895        // Candidate A declares a verified no-op; candidate B simply wrote
12896        // nothing and said nothing about why. One candidate's claim is not
12897        // the whole run's agreement.
12898        set_candidates(
12899            &mut runner,
12900            &[(true, Some("already on main at b32cfc4")), (true, None)],
12901        );
12902
12903        let err = runner
12904            .after_implement()
12905            .expect_err("an unverified empty candidate must still fail the run");
12906
12907        assert!(
12908            err.to_string().contains("no candidate produced a change"),
12909            "{err}"
12910        );
12911        assert_eq!(runner.state.status, RunStatus::Failed);
12912    }
12913
12914    #[test]
12915    fn after_implement_still_fails_an_ordinary_all_empty_run() {
12916        ask_test_home();
12917        let mut runner = runner_at(RunStatus::Implementing);
12918        set_candidates(&mut runner, &[(true, None), (true, None)]);
12919
12920        let err = runner
12921            .after_implement()
12922            .expect_err("no candidate declared anything; this is an ordinary failure");
12923
12924        assert!(
12925            err.to_string().contains("no candidate produced a change"),
12926            "{err}"
12927        );
12928        assert_eq!(runner.state.status, RunStatus::Failed);
12929    }
12930}
12931
12932/// A graft rollback whose restore could not be confirmed (see
12933/// [`Runner::graft`]).
12934#[derive(Debug)]
12935struct GraftRestoreFailed(String);
12936
12937impl std::fmt::Display for GraftRestoreFailed {
12938    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12939        write!(
12940            f,
12941            "could not restore the winner to its pre-graft commit: {}",
12942            self.0
12943        )
12944    }
12945}
12946
12947impl std::error::Error for GraftRestoreFailed {}