Skip to main content

magi/
graph.rs

1//! The competition graph.
2//!
3//! ```text
4//! prep ──► implement ×N ──► judge ×M (blind) ──► split? ──► deliberate ──► vote (private)
5//!                                                   │                          │
6//!                                                   └──── unanimous ───────────┤
7//!                                                                              ▼
8//!   merge ◄── gate ◄── review ×R + E2E, fix, repeat ◄── fold losers ◄──────── tally
9//! ```
10//!
11//! Every node persists before the next one starts, so a run can be resumed
12//! after a crash, a rate limit, or a reboot without re-spending the work that
13//! already landed.
14//!
15//! The design decision that matters most is *where the facilitator lives*.
16//! There is no moderator agent: magi assigns the labels, decides the
17//! presentation order, relays the transcript, and collects the final votes
18//! one-to-one. A moderator that never learns an author cannot leak one.
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36    ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51    GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52    MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, ParkCause,
53    QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory,
54    Tally, VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58    ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62/// How much verification output is kept and fed back to the fixer.
63const OUTPUT_TAIL: usize = 8_000;
64
65/// Bytes of a failing command's output kept in an event, so the reason a run
66/// stopped is readable from the report without opening `run.json`.
67const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
70/// command's pid before releasing the build cache's lease.
71const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
74/// command's pid to actually exit before giving up and releasing anyway.
75///
76/// A timeout means the process was asked to die (`kill_on_drop`,
77/// `start_kill`), not that it already has — on Windows in particular that can
78/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
79/// the instant the command returns would let the very next acquirer (this
80/// run's own next round, another run's verification, the janitor's prune)
81/// start touching the same directory while it might still be writing to it,
82/// so this polls the actual pid — real confirmation, not a fixed guess —
83/// until it is gone or this ceiling is reached. It is still not full
84/// process-tree reaping: a grandchild the timed-out process spawned and that
85/// outlives it independently is invisible to a pid check, and continuing to
86/// observe and collect *that* stays a different piece of work with its own
87/// owner. Set generously because the common case returns early the moment
88/// the pid is confirmed gone, not because every timeout pays this in full.
89const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91/// Consecutive review rounds with no tree progress (see
92/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
93/// instead of spending the rest of the round budget.
94///
95/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
96/// legitimately finds nothing left to change (its previous round's fix already
97/// covered it, and this round's reviewers re-raised only nits) looks the same
98/// as one that is spinning, for exactly one round. Two in a row is where the
99/// two stop being distinguishable, and a review round on this workload has
100/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
101/// third attempt at a tree that has not moved twice running is pure cost.
102/// This does not touch `review_rounds` itself, which stays the operator's
103/// call.
104pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
107/// a base that moved before giving up and leaving the run `Blocked` for a
108/// person.
109///
110/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
111/// that keeps moving faster than a run can catch it is not something more
112/// rebasing fixes, it is a person's call. Not the same *number as*
113/// `land_rounds` - this budget is spent before a pull request exists, land's
114/// after - but bounded for the identical reason, so it uses the same
115/// default. Counted across both call sites in [`Runner::finish_after_tally`]
116/// (once before review, once before the gate), because either one finding
117/// the base still moving is the same signal.
118const BASE_SYNC_ROUNDS: usize = 4;
119
120/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
121/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
122/// reply held no [`FixReport`].
123///
124/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
125/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
126/// "I'll pause here until the `cargo make check` background run reports
127/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
128/// No `FixReport` was ever collected from that seat, and the run moved on to
129/// the next review round regardless.
130///
131/// Bounded independently of `review_rounds` and `graph.retries`: this
132/// recovers one seat's missing report mid-round, not a new round of review or
133/// an ordinary parse retry, and must not itself become the unbounded wait the
134/// rest of this module exists to avoid.
135const MAX_FIX_CONTINUATIONS: usize = 2;
136
137/// One queued agent invocation.
138///
139/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
140/// CLI hung up on its own stream is asked again from the same job rather than
141/// rebuilt from scratch. See [`Runner::resume_undelivered`].
142#[derive(Clone)]
143struct SeatJob {
144    spec: AgentSpec,
145    seat: SeatState,
146    cwd: PathBuf,
147    prompt: String,
148    timeout: Duration,
149    allow_write: bool,
150    sessions: bool,
151    artifacts: PathBuf,
152    stem: String,
153    /// The prompt for a seat that has been handed to another roster agent
154    /// (a fresh session): everything the original seat would have
155    /// remembered. `None` when `prompt` already carries it, as the first
156    /// ranking and the implement prompt do. Never a resume-style prompt.
157    handover: Option<String>,
158}
159
160/// How the graph reads one agent invocation.
161///
162/// Quota is split out from an ordinary failure on purpose: a rate-limited call
163/// is known to fail again if retried now, so the retry loop must not spend an
164/// attempt on it. `Dropped` is split out for the opposite reason: unlike
165/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
166/// error JSON, never the agent's answer — a caller that matched only
167/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
168/// left to read that JSON as if it were usable output. `resume_undelivered`
169/// is the only caller that acts on it; everywhere else it is reported like an
170/// ordinary failure.
171enum AgentOutcome {
172    /// A usable output.
173    Ok(AgentOutput),
174    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
175    Quota(AgentOutput),
176    /// The CLI hung up on its own stream after billed work. See
177    /// [`agent::AgentOutput::work_undelivered`].
178    Dropped(AgentOutput),
179    /// Any other failure: a timeout, a bad exit code, an empty reply.
180    Failed(String),
181    /// The agent was never spawned: a shared resource it needs (the build
182    /// cache's lease) stayed busy past `[graph] timeout_resource_wait`, or the
183    /// wait was abandoned because the run is parking. Not a failure of the
184    /// agent: it must not advance a fallback chain, enter seat history, cost
185    /// quota or spend an attempt. [`RunState::resource_block`] says why.
186    ResourceBlocked(String),
187}
188
189/// A request to park the run at its next node boundary.
190///
191/// Cloning is how the request travels: the loop keeps one handle and hands a
192/// clone to each [`Runner`], and every clone points at the same flag. There
193/// is no channel because there is nothing to send - the only message is
194/// "park", it is idempotent, and a flag cannot be missed by a receiver that
195/// was not listening yet.
196///
197/// The boundary is what makes this cheap. Every node writes the run's state
198/// before the next one starts, and every node skips what is already recorded:
199/// `prep` returns early once candidates exist, `implement` asks only the seats
200/// with nothing on disk, `judge` returns early once judgements exist. So a
201/// parked run resumes into exactly the node it stopped before, and no agent
202/// work is thrown away. Killing the process mid-node, by contrast, loses
203/// whatever the seats in flight had not yet written - which for an implement
204/// wave is an hour of paid work.
205///
206/// A [`Runner`] watches two independent handles of this type - see
207/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
208/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
209/// clone covering the whole daemon's lifetime and is never asked to un-park,
210/// which is correct exactly because nothing is dispatched after it fires.
211/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
212/// that parks for an interrupted task must go on to run other tasks
213/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
214/// reusing the daemon-wide one.
215#[derive(Debug, Clone, Default)]
216pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<ParkCause>>>);
217
218impl Pause {
219    /// A pause nobody has asked for yet.
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Ask the run to park at its next node boundary. Idempotent.
226    pub fn park(&self) {
227        self.0.store(true, Ordering::SeqCst);
228    }
229
230    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
231    /// fold into the run's own `park` event - so an operator reading the run
232    /// later knows this was a deliberate interrupt rather than a shutdown or
233    /// a binary swap. The first reason recorded wins; a park already in
234    /// flight is not relabelled by a second, unrelated request.
235    pub fn park_because(&self, reason: impl Into<String>) {
236        self.park_for(ParkCause::Interrupt {
237            reason: reason.into(),
238        });
239    }
240
241    /// [`Pause::park`], recording the structured cause. The first cause
242    /// recorded wins, like [`Pause::park_because`].
243    pub fn park_for(&self, cause: ParkCause) {
244        let mut reason_guard = self
245            .1
246            .lock()
247            .unwrap_or_else(std::sync::PoisonError::into_inner);
248        if reason_guard.is_none() {
249            *reason_guard = Some(cause);
250        }
251        drop(reason_guard);
252        self.park();
253    }
254
255    /// Has a park been asked for?
256    #[must_use]
257    pub fn parked(&self) -> bool {
258        self.0.load(Ordering::SeqCst)
259    }
260
261    /// Why the park was asked for, when the caller used [`Pause::park_because`].
262    #[must_use]
263    pub fn reason(&self) -> Option<String> {
264        self.cause().map(|c| c.label())
265    }
266
267    /// The structured cause, when one was recorded.
268    #[must_use]
269    pub fn cause(&self) -> Option<ParkCause> {
270        self.1
271            .lock()
272            .unwrap_or_else(std::sync::PoisonError::into_inner)
273            .clone()
274    }
275}
276
277/// Drives one run.
278pub struct Runner {
279    /// Run state; public so the CLI can report on it.
280    pub state: RunState,
281    roles: ResolvedRoles,
282    sem: Arc<Semaphore>,
283    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
284    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
285    /// this is never the same handle as `interrupt`.
286    pause: Pause,
287    /// Set when `magi serve`'s interrupt scheduler wants this specific run
288    /// parked at its next node boundary, to let a task marked
289    /// [`crate::queue::Task::interrupt`] run alone before this one carries
290    /// on. Unlike `pause`, a fresh, unshared handle per run - see
291    /// [`Runner::watch_interrupt`].
292    interrupt: Pause,
293}
294
295/// Where the branch's own commits start: its merge base with the base branch
296/// as the remote has it now, else with the recorded `base_commit`. A branch
297/// rebased onto a base that moved past `base_commit` would otherwise count
298/// the base's commits as its own under `base_commit..branch`.
299async fn review_base(
300    repo: &Path,
301    remote: &str,
302    base_branch: &str,
303    base_commit: &str,
304    branch: &str,
305) -> String {
306    review_base_checked(repo, remote, base_branch, base_commit, branch)
307        .await
308        .0
309}
310
311/// [`review_base`] plus whether the base was read from a freshly fetched
312/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
313/// never older than `base_commit`'s view of the base), but the answer is then
314/// not trusted to rewrite a pull request's title.
315async fn review_base_checked(
316    repo: &Path,
317    remote: &str,
318    base_branch: &str,
319    base_commit: &str,
320    branch: &str,
321) -> (String, bool) {
322    let tracking = format!("{remote}/{base_branch}");
323    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
324    if git::rev_exists(repo, &tracking).await
325        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
326        && !mb.is_empty()
327    {
328        return (mb, fresh);
329    }
330    let mb = git::merge_base(repo, base_commit, branch)
331        .await
332        .ok()
333        .filter(|mb| !mb.is_empty())
334        .unwrap_or_else(|| base_commit.to_owned());
335    (mb, false)
336}
337
338/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
339/// when git cannot say or finds nothing: a stale list is better than a wrong
340/// or empty one.
341pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
342    if !is_review_run(state) {
343        return;
344    }
345    let base = review_base(
346        &state.repo,
347        &state.config.merge.remote,
348        &state.base_branch,
349        &state.base_commit,
350        branch,
351    )
352    .await;
353    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
354        && !subjects.is_empty()
355        && state.reviewed_commits.as_ref() != Some(&subjects)
356    {
357        state.reviewed_commits = Some(subjects);
358        state.save().ok();
359    }
360}
361
362/// Subjects of the base's commits between the recorded start and the branch's
363/// merge base: what a stale `base_commit..branch` would have mistaken for the
364/// branch's own work.
365async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
366    let (base, trusted) = review_base_checked(
367        &state.repo,
368        &state.config.merge.remote,
369        &state.base_branch,
370        &state.base_commit,
371        branch,
372    )
373    .await;
374    if !trusted {
375        return None;
376    }
377    git::subjects(&state.repo, &state.base_commit, &base)
378        .await
379        .ok()
380}
381
382/// May an adopted pull request's title be replaced with `computed`? Only when
383/// it is empty, magi's own shape, or a base commit's subject that leaked in;
384/// a title a person wrote stays. Never when `computed` is itself a leak.
385pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
386    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
387    if is_leak(computed) {
388        return false;
389    }
390    let cur = current.trim();
391    cur.is_empty()
392        || cur.starts_with(REVIEW_PROMPT_OPENING)
393        || cur.starts_with("chore: land candidate")
394        || cur.starts_with("magi: candidate")
395        || is_leak(cur)
396}
397
398/// The commit a run branches from: the base branch as the remote has it.
399///
400/// Two failures this replaces. A run used to branch off `HEAD` and so refused
401/// to start on a dirty tree, which made `magi serve` decline every task for as
402/// long as the operator had work in progress - most of the time. Branching off
403/// the *local* base branch fixed that and introduced a worse one: `land` merges
404/// the winner on GitHub, nothing updates the local ref, and the next run
405/// branches off a base missing everything the previous runs landed. Two tasks
406/// in a row from a phone would have had the second silently re-implementing
407/// against stale code and opening a pull request that reverted the first.
408///
409/// Only refs move here - no checkout, no local branch, no merge - so it is safe
410/// with uncommitted work in the tree. A machine with no network still starts:
411/// the fetch may fail and the local tip is used with a warning, because
412/// refusing to run offline is a worse failure than running against a base the
413/// operator can see for themselves.
414///
415/// One function, called by both entry points. Two answers to "where does a run
416/// branch from" is the kind of drift nobody notices until a diff is wrong.
417/// Bring the local `branch` in line with `<remote>/<branch>` before a review
418/// checks it out.
419///
420/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
421/// anything other than plain `git push` from this checkout (a jj colocated
422/// workspace, another clone) moves only the remote-tracking ref - so the local
423/// one can be a stale placeholder. It moves only when local is behind the remote or is an
424/// empty placeholder that diverged from it; unpushed local work is kept, and a real
425/// divergence is refused rather than guessed at.
426async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
427    let tracking = format!("{remote}/{branch}");
428    let fetched = git::fetch(repo, remote, branch).await;
429    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
430    let local_exists = git::branch_exists(repo, branch).await?;
431    if !fresh {
432        if !local_exists {
433            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
434        }
435        tracing::warn!(
436            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
437        );
438        return Ok(());
439    }
440    let remote_sha = git::rev_parse(repo, &tracking).await?;
441    if !local_exists {
442        git::git(repo, &["branch", branch, &tracking]).await?;
443        return Ok(());
444    }
445    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
446    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
447        return Ok(());
448    }
449    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
450        // Diverged. `reconcile` settles it only when it can prove nothing is
451        // lost: a local tip that is the remote's change rebased is pushed over
452        // it (lease pinned to the tip read here), a tip whose every extra
453        // commit is empty is a placeholder the remote's work replaced, and
454        // anything else is two different changes - a question for a person.
455        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
456            .await?
457        {
458            crate::reconcile::Reconciliation::Pushed => {
459                tracing::warn!(
460                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
461                    short(&local_sha),
462                    short(&remote_sha)
463                );
464                return Ok(());
465            }
466            crate::reconcile::Reconciliation::Placeholder => {}
467            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
468        }
469    }
470    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
471    if !out.ok() {
472        bail!(
473            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
474            short(&local_sha),
475            short(&remote_sha),
476            out.stderr
477        );
478    }
479    tracing::warn!(
480        "local `{branch}` was stale: fast-forwarded {} -> {}",
481        short(&local_sha),
482        short(&remote_sha)
483    );
484    Ok(())
485}
486
487async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
488    let tracking = format!("{remote}/{base_branch}");
489    let fetched = git::fetch(repo, remote, base_branch).await;
490    if let Ok(out) = &fetched
491        && out.ok()
492        && git::rev_exists(repo, &tracking).await
493    {
494        return git::rev_parse(repo, &tracking).await;
495    }
496    let why = match &fetched {
497        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
498        Ok(_) => format!("{remote} has no {base_branch}"),
499        Err(e) => e.to_string(),
500    };
501    // No fallback to the local branch: it may be behind, and branching off an
502    // old commit is the stale-checkout bug this check exists to prevent.
503    bail!(
504        "cannot read {tracking} ({why}); refusing to branch off the local \
505         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
506         base / remote in magi.toml"
507    )
508}
509
510/// Exclusive claim on one run's repair or `magi fix`, released on drop — including
511/// on an early return or a panic.
512///
513/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
514/// manual `magi fix` invocations against the same run are otherwise
515/// invisible to each other and would race to remove and recreate the same
516/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
517/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
518/// which is only ever reclaimed later, out of band, by
519/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
520/// `magi fix` invocation is not necessarily running under either of those, so
521/// nothing would ever sweep a lock a killed or crashed process left behind.
522/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
523/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
524/// lease use: an unreadable or unparsable pid, or a liveness query the
525/// platform cannot answer, reads as alive and the lock is left in place.
526struct FixClaim {
527    path: PathBuf,
528}
529
530impl FixClaim {
531    fn acquire(dir: &Path) -> Result<Self> {
532        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
533        let path = dir.join("fix.lock");
534        match Self::create(&path) {
535            Ok(claim) => Ok(claim),
536            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
537                if Self::reclaim_if_dead(&path) {
538                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
539                } else {
540                    bail!(
541                        "another repair or `magi fix` is already running for this run ({} exists)",
542                        path.display()
543                    )
544                }
545            }
546            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
547        }
548    }
549
550    fn create(path: &Path) -> std::io::Result<Self> {
551        let mut f = std::fs::OpenOptions::new()
552            .write(true)
553            .create_new(true)
554            .open(path)?;
555        use std::io::Write as _;
556        // Read back by `reclaim_if_dead` on a later, stuck invocation.
557        writeln!(f, "{}", std::process::id())?;
558        Ok(Self {
559            path: path.to_owned(),
560        })
561    }
562
563    /// True if the lock named a process confirmed dead, in which case it was
564    /// also removed. Never true on an unreadable file, an unparsable pid, or
565    /// a liveness query the platform cannot answer — see this type's own doc.
566    fn reclaim_if_dead(path: &Path) -> bool {
567        let dead = std::fs::read_to_string(path)
568            .ok()
569            .and_then(|body| body.trim().parse::<u32>().ok())
570            .is_some_and(|pid| !crate::proc::pid_alive(pid));
571        dead && std::fs::remove_file(path).is_ok()
572    }
573}
574
575impl Drop for FixClaim {
576    fn drop(&mut self) {
577        let _ = std::fs::remove_file(&self.path);
578    }
579}
580
581impl Runner {
582    /// Start a fresh run against `repo`.
583    pub async fn start(
584        repo: &Path,
585        instruction: String,
586        config: Config,
587        origin: Origin,
588    ) -> Result<Self> {
589        Self::start_naming(repo, instruction, "", config, origin).await
590    }
591
592    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
593    /// searched for branch and commit references along with the instruction,
594    /// since a task may name the work it is about only in its title.
595    pub async fn start_naming(
596        repo: &Path,
597        instruction: String,
598        also_scan: &str,
599        config: Config,
600        origin: Origin,
601    ) -> Result<Self> {
602        let repo = git::toplevel(repo).await?;
603        let missing = agent::missing_programs(&config.agents);
604        if !missing.is_empty() {
605            bail!(
606                "these agent programs are not on PATH: {}. Fix the roster in \
607                 magi.toml or install them.",
608                missing.join(", ")
609            );
610        }
611        let base_branch =
612            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
613                .await?;
614        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
615        let roles = config.resolve_roles()?;
616        let max_parallel = config.graph.max_parallel.max(1);
617        // A task that points at work already in the repository starts from
618        // it; what the repository says about each reference is recorded.
619        let seeds = refs::resolve(
620            &repo,
621            &base_commit,
622            &config.merge.remote,
623            &format!("{also_scan}\n{instruction}"),
624        )
625        .await;
626        refs::plan(&repo, &seeds).await?;
627        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
628        // Recorded before the first save, so a crash right after minting
629        // cannot leave a run with no origin. Legibility only: nothing reads it
630        // to decide anything.
631        state.origin = Some(origin);
632        for seed in &seeds {
633            state.event(
634                "seed",
635                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
636            );
637        }
638        state.seeds = seeds;
639        state.event("start", format!("run {} created", state.id));
640        state.save()?;
641        Ok(Self {
642            state,
643            roles,
644            sem: Arc::new(Semaphore::new(max_parallel)),
645            pause: Pause::new(),
646            interrupt: Pause::new(),
647        })
648    }
649
650    /// Open a review-only run against work that already exists on `branch`.
651    ///
652    /// The expensive half of the graph is the implement wave — measured at
653    /// 111 and 134 internal tool-loop turns on this repository, against a
654    /// handful for a judge or a reviewer. The cheap half is worth running on
655    /// hand-written work too, and there was no way to reach it.
656    ///
657    /// No new state and no schema change are needed: a run with **one** viable
658    /// candidate and a tally already decided degrades `execute` to exactly
659    /// review → gate → merge, because `judge` skips a single-candidate field,
660    /// `deliberate` has fewer than two first choices to reconcile, `vote`
661    /// returns early, `tally` is already present and `fold_losers` has no
662    /// losers. Resuming such a run therefore does the right thing as well.
663    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
664        Self::review_taking_over(repo, branch, config, None, origin).await
665    }
666
667    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
668    /// at the same task still has `branch` checked out, its worktree is
669    /// released first if that is safe (see [`crate::handover`]), and the
670    /// review refuses with the reason if it is not. `None` is a hand-run
671    /// review: it has no earlier attempts, so only a worktree of a dead run
672    /// magi recorded itself can be released.
673    pub async fn review_taking_over(
674        repo: &Path,
675        branch: &str,
676        config: Config,
677        takeover: Option<crate::handover::Takeover>,
678        origin: Origin,
679    ) -> Result<Self> {
680        let repo = git::toplevel(repo).await?;
681        let missing = agent::missing_programs(&config.agents);
682        if !missing.is_empty() {
683            bail!(
684                "these agent programs are not on PATH: {}. Fix the roster in \
685                 magi.toml or install them.",
686                missing.join(", ")
687            );
688        }
689        let base_branch =
690            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
691                .await?;
692        if base_branch == branch {
693            bail!("`{branch}` is the base branch; there is nothing to review against");
694        }
695        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
696
697        let roles = config.resolve_roles()?;
698        let max_parallel = config.graph.max_parallel.max(1);
699        let mut state = RunState::new(
700            repo.clone(),
701            base_branch,
702            base_commit.clone(),
703            String::new(),
704            config,
705        );
706        state.origin = Some(origin);
707
708        // Released before anything else touches the branch: a stale local
709        // branch is moved with `git branch -f`, which git refuses while an
710        // earlier attempt's worktree still has it checked out. Everything
711        // after this point that can fail puts the old run back.
712        // A hand-run review has no task, hence no earlier attempts, but a
713        // worktree of a dead run magi made may still be released.
714        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
715            earlier: Vec::new(),
716            home: crate::run::home(),
717            choice: None,
718            task_instruction: None,
719        });
720        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
721        if let Some(released) = &released {
722            state.event(
723                "release",
724                format!(
725                    "took `{branch}` over from run {}: its worktree was released: {}",
726                    crate::run::short_of(&released.old_id),
727                    released.audit
728                ),
729            );
730        }
731        // The owner's answer to an earlier divergence question is applied
732        // here: after the release (git will not move a checked-out branch)
733        // and before the sync that would otherwise ask again.
734        if let Some(choice) = takeover.choice.as_ref()
735            && let Err(e) =
736                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
737                    .await
738        {
739            if let Some(released) = &released {
740                released.restore(&repo, branch).await;
741            }
742            return Err(e.context("applying the owner's answer about the diverged branch"));
743        }
744        // An earlier attempt of this task that published the task head, and
745        // whose work is the branch under review, hands that head over: the
746        // review run updates the same pull request. Without one (a legacy
747        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
748        // own head and its own open pull request is adopted as before.
749        for id in takeover.earlier.iter().rev() {
750            if let Ok(old) = RunState::load_under(id, &takeover.home)
751                && let Some(head) = old.pr_head.clone()
752                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
753            {
754                state.pr_head = Some(head);
755                break;
756            }
757        }
758        let opened = Self::open_review(
759            &repo,
760            branch,
761            state,
762            roles,
763            max_parallel,
764            base_commit,
765            &takeover,
766        )
767        .await;
768        if opened.is_err()
769            && let Some(released) = &released
770        {
771            released.restore(&repo, branch).await;
772        }
773        opened
774    }
775
776    /// Recover exactly the operator-selected public implementation, without touching old sources.
777    pub async fn recover_pr(
778        repo: &Path,
779        task: &str,
780        selection: crate::pr_recovery::Selection,
781        config: Config,
782    ) -> Result<Self> {
783        selection.check_task(task, &selection.instruction)?;
784        let base_branch =
785            git::merge_base_branch(repo, &config.merge.remote, config.merge.base.as_deref())
786                .await?;
787        let base_commit = resolve_base(repo, &base_branch, &config.merge.remote).await?;
788        selection
789            .verify(repo, &config.merge.remote, &base_branch, &selection.head)
790            .await?;
791        let roles = config.resolve_roles()?;
792        let max_parallel = config.graph.max_parallel.max(1);
793        let mut state = RunState::new(
794            repo.to_path_buf(),
795            base_branch,
796            base_commit.clone(),
797            selection.instruction.clone(),
798            config,
799        );
800        state.origin = Some(crate::run::Origin::queue(task));
801        state.pr_head = Some(selection.branch.clone());
802        state.pr_recovery = Some(selection.clone());
803        crate::pr_head::check_predecessors(&state, &selection.branch).await?;
804        Self::open_selected(repo, state, roles, max_parallel, base_commit, selection).await
805    }
806
807    async fn open_selected(
808        repo: &Path,
809        state: RunState,
810        roles: ResolvedRoles,
811        max_parallel: usize,
812        base_commit: String,
813        selection: crate::pr_recovery::Selection,
814    ) -> Result<Self> {
815        let mut state = state;
816        let branch = state.branch_for('A');
817        git::git(repo, &["branch", &branch, &selection.head]).await?;
818        state.event(
819            "recovery",
820            format!(
821                "selected public implementation {} from {}",
822                selection.head, selection.url
823            ),
824        );
825        let takeover = crate::handover::Takeover {
826            earlier: Vec::new(),
827            home: crate::run::home(),
828            choice: None,
829            task_instruction: Some(selection.instruction.clone()),
830        };
831        state.save()?;
832        let id = state.id.clone();
833        let opened = Self::open_review(
834            repo,
835            &branch,
836            state,
837            roles,
838            max_parallel,
839            base_commit,
840            &takeover,
841        )
842        .await;
843        match opened {
844            Ok(runner) => Ok(runner),
845            Err(e) => {
846                if let Ok(mut partial) = RunState::load(&id) {
847                    partial.status = RunStatus::Blocked;
848                    partial.driver_exited = true;
849                    partial.event(
850                        "recovery",
851                        format!("startup failed; retained source and candidate {branch}: {e:#}"),
852                    );
853                    partial.save()?;
854                }
855                Err(crate::pr_recovery::StartupFailed {
856                    run: id,
857                    cause: format!("{e:#}"),
858                }
859                .into())
860            }
861        }
862    }
863
864    /// What the newest earlier attempt that reviewed `branch` leaves for this
865    /// run (see [`crate::recovery`]). `None` for a hand-run review, which has
866    /// no earlier attempts. Reads the earlier record only: ownership of its
867    /// worktree and branch is [`crate::handover`]'s business.
868    async fn inherit(
869        repo: &Path,
870        branch: &str,
871        base_commit: &str,
872        takeover: &crate::handover::Takeover,
873    ) -> Option<crate::recovery::RecoveryRecord> {
874        use crate::recovery::{Relation, assess};
875        let newest = takeover.earlier.last()?;
876        let mut chosen = None;
877        for id in takeover.earlier.iter().rev() {
878            if let Ok(old) = RunState::load_under(id, &takeover.home)
879                && !old.reviews.is_empty()
880                && (old.winner().is_some_and(|w| w.branch == branch)
881                    || old.pr_head.as_deref() == Some(branch))
882            {
883                chosen = Some(old);
884                break;
885            }
886        }
887        let tip = git::rev_parse(repo, &format!("refs/heads/{branch}"))
888            .await
889            .unwrap_or_default();
890        let relation = match chosen.as_ref().and_then(|o| o.reviews.last()) {
891            Some(last) if last.head == tip => Relation::Same,
892            Some(last) if git::is_ancestor(repo, &last.head, &tip).await => Relation::Descendant,
893            _ => Relation::Diverged,
894        };
895        let from = chosen.as_ref().map_or(newest.as_str(), |o| o.id.as_str());
896        Some(assess(from, chosen.as_ref(), base_commit, relation))
897    }
898
899    /// The half of [`Runner::review_taking_over`] that can fail after an
900    /// earlier attempt's worktree was released.
901    async fn open_review(
902        repo: &Path,
903        branch: &str,
904        mut state: RunState,
905        roles: ResolvedRoles,
906        max_parallel: usize,
907        base_commit: String,
908        takeover: &crate::handover::Takeover,
909    ) -> Result<Self> {
910        if state.pr_recovery.is_none() {
911            sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
912        }
913        // The commit subjects are the closest thing to a task statement that
914        // existing work carries, and the reviewers are told as much.
915        let start = review_base(
916            repo,
917            &state.config.merge.remote,
918            &state.base_branch,
919            &base_commit,
920            branch,
921        )
922        .await;
923        let log = git::log_oneline(repo, &start, branch)
924            .await
925            .unwrap_or_default();
926        let log_text = if log.trim().is_empty() {
927            "(no commit messages)"
928        } else {
929            log.trim()
930        };
931        let mut instruction = format!(
932            "Review the work already on branch `{branch}`. There is no task \
933             statement: what the change claims to do is whatever its commits \
934             say.\n\n{log_text}"
935        );
936        // A retry of a task whose earlier attempt left reviews behind carries
937        // that attempt's evidence, and the task's own statement with it: the
938        // completion criteria are the task's, not whatever the commits claim.
939        if let Some(mut rec) = Self::inherit(repo, branch, &base_commit, takeover).await {
940            if let Some(task) = takeover
941                .task_instruction
942                .as_deref()
943                .filter(|t| !t.trim().is_empty())
944                && rec.reviewed_head.is_some()
945            {
946                instruction = format!(
947                    "{}\n\n# Work already on branch `{branch}` (commit subjects)\n\n{log_text}",
948                    task.trim()
949                );
950                rec.criteria_inherited = true;
951            }
952            state.event("recovery", rec.summary());
953            state.recovery = Some(rec);
954        }
955        if let Some(selected) = &state.pr_recovery {
956            instruction = selected.instruction.clone();
957        }
958        state.task_instruction = takeover.task_instruction.clone();
959        state.instruction = instruction;
960        state.reviewed_commits = Some(
961            git::subjects(repo, &start, branch)
962                .await
963                .unwrap_or_default(),
964        );
965
966        // An attached worktree, so the fixer's commits land on the branch under
967        // review rather than on a detached head nobody will look at again.
968        let worktree = state.worktree_root().join("under-review");
969        if let Some(parent) = worktree.parent() {
970            tokio::fs::create_dir_all(parent).await.ok();
971        }
972        let path = worktree.to_string_lossy().to_string();
973        git::git(repo, &["worktree", "add", &path, branch])
974            .await
975            .with_context(|| {
976                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
977            })?;
978
979        let setup = match ensure_setup_config(&mut state, repo).await {
980            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
981            Err(e) => Err(e),
982        };
983        if let Err(e) = setup {
984            git::worktree_remove(repo, &worktree).await.ok();
985            return Err(e);
986        }
987        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
988            .await
989            .unwrap_or(0);
990        if commits == 0 {
991            git::worktree_remove(repo, &worktree).await.ok();
992            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
993        }
994        let files = git::changed_files(&worktree, &base_commit, "HEAD")
995            .await
996            .map(|f| f.len())
997            .unwrap_or(0);
998        if files == 0
999            && let (Ok(head_tree), Ok(base_tree)) = (
1000                git::tree_of(&worktree, "HEAD").await,
1001                git::tree_of(&worktree, &base_commit).await,
1002            )
1003            && head_tree == base_tree
1004        {
1005            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
1006            git::worktree_remove(repo, &worktree).await.ok();
1007            bail!(
1008                "`{branch}` at {} has a tree identical to base {}; this usually means \
1009                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
1010                 against `{}/{branch}`) rather than an empty change",
1011                short(&head),
1012                short(&base_commit),
1013                state.config.merge.remote
1014            );
1015        }
1016        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
1017            .await
1018            .unwrap_or_default();
1019
1020        state.candidates.push(Candidate {
1021            index: 0,
1022            label: 'A',
1023            // Not an agent id on purpose: nothing in the roster wrote this, and
1024            // the stats tables must not credit anyone with a win for it.
1025            agent: EXISTING_BRANCH.to_owned(),
1026            branch: branch.to_owned(),
1027            worktree,
1028            summary: String::new(),
1029            stat,
1030            files,
1031            commits,
1032            empty: false,
1033            failed: None,
1034            verified_noop: None,
1035            duration_ms: 0,
1036            folded: false,
1037        });
1038        state.tally = Some(Tally {
1039            first_choice: BTreeMap::from([('A', 0)]),
1040            borda: BTreeMap::new(),
1041            winner: 'A',
1042            rankings: 0,
1043            unanimous_initial: false,
1044            deliberated: false,
1045            changed_votes: 0,
1046            unanimous_final: false,
1047            tie_break: None,
1048            // No panel sat, so no quorum applies. Zero judges is the correct
1049            // number for work that never competed, and must not be reported as
1050            // a collapsed panel.
1051            judges: 0,
1052            present: 0,
1053            quorum: 0,
1054            met_quorum: true,
1055            uncontested: Some("review-only run: nothing competed".to_owned()),
1056        });
1057        state.status = RunStatus::Reviewing;
1058        state.prep_complete = Some(true);
1059        state.event(
1060            "start",
1061            format!(
1062                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
1063                state.id
1064            ),
1065        );
1066        state.save()?;
1067        Ok(Self {
1068            state,
1069            roles,
1070            sem: Arc::new(Semaphore::new(max_parallel)),
1071            pause: Pause::new(),
1072            interrupt: Pause::new(),
1073        })
1074    }
1075
1076    /// Reopen an existing run.
1077    pub fn resume(id: &str) -> Result<Self> {
1078        let state = RunState::load(id)?;
1079        if state.pr_recovery.is_some() && state.reviewed_commits.is_none() {
1080            bail!(
1081                "selected public-head recovery did not finish setup; use `magi task recover-pr` after correcting the cause"
1082            );
1083        }
1084        if let Some(to) = &state.released_to {
1085            bail!(
1086                "run {} cannot be resumed: its worktree was released to run {}",
1087                state.short(),
1088                crate::run::short_of(to)
1089            );
1090        }
1091        let roles = state.config.resolve_roles()?;
1092        let max_parallel = state.config.graph.max_parallel.max(1);
1093        Ok(Self {
1094            state,
1095            roles,
1096            sem: Arc::new(Semaphore::new(max_parallel)),
1097            pause: Pause::new(),
1098            interrupt: Pause::new(),
1099        })
1100    }
1101
1102    /// Walk the graph to a terminal state, skipping nodes already recorded.
1103    ///
1104    /// Every way a run is driven - the queue loop, `magi run`, a resume from
1105    /// the phone - ends here, so this is the one place a run that ended
1106    /// Blocked / Stalled / Failed, or died with an error, is announced to the
1107    /// notification centre. Best-effort: see [`crate::notices::raise`].
1108    pub async fn execute(&mut self) -> Result<()> {
1109        // Queue claims cover daemon work; this also serializes manual drivers
1110        // consuming a repair answer, including the new review and decision.
1111        let _repair_claim = if !self.state.merge_repairs.is_empty()
1112            || (self.state.status == RunStatus::Landing && land::repair_available(&self.state))
1113        {
1114            let claim = FixClaim::acquire(&self.state.dir())?;
1115            let disk = RunState::load(&self.state.id)?;
1116            let checkpoint = |s: &RunState| {
1117                (
1118                    s.merge_repairs.len(),
1119                    s.merge_repairs
1120                        .last()
1121                        .map(|r| (r.question.clone(), r.phase)),
1122                    s.land_approval
1123                        .as_ref()
1124                        .map(|a| (a.question.clone(), a.head.clone())),
1125                )
1126            };
1127            if checkpoint(&disk) != checkpoint(&self.state) {
1128                bail!(
1129                    "repair decision changed since this run was loaded; reload and resume the existing run, never start a second repair"
1130                );
1131            }
1132            Some(claim)
1133        } else {
1134            None
1135        };
1136        let result = self.execute_graph().await;
1137        self.mark_driver_exited();
1138        let ended = if result.is_err() {
1139            Some(crate::notices::run_stopped(&self.state.id, &self.state))
1140        } else {
1141            crate::notices::run_ended(&self.state)
1142        };
1143        if let Some(notice) = ended {
1144            crate::notices::raise(notice);
1145        }
1146        result
1147    }
1148
1149    /// Record that this process no longer drives the run, so its pid (a
1150    /// daemon's outlives the run) is not read as a live driver.
1151    ///
1152    /// Written onto the record as it is on disk, never this copy: another
1153    /// process may have resumed the run (recording its own pid and clearing
1154    /// the flag) or released its worktree since this copy was read, and
1155    /// saving over that would mark a running driver dead. Only a record still
1156    /// naming this process as the driver is touched.
1157    fn mark_driver_exited(&mut self) {
1158        self.state.driver_exited = true;
1159        let pid = std::process::id();
1160        let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
1161            return;
1162        };
1163        let Ok(mut disk) = RunState::load(&self.state.id) else {
1164            return;
1165        };
1166        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
1167            return;
1168        }
1169        disk.driver_exited = true;
1170        if let Err(e) = disk.save() {
1171            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
1172        }
1173    }
1174
1175    /// Validate ownership and persist the driver claim in one critical section.
1176    fn claim_driver(&mut self) -> Result<()> {
1177        let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
1178        let disk = RunState::load(&self.state.id)?;
1179        crate::pr_head::ensure_owner(&disk)?;
1180        // Moving again, so it is no longer parked. Set before the walk rather
1181        // than in `resume`, so every way of re-entering the graph clears it
1182        // and a card cannot claim a run is waiting to be resumed while the
1183        // agents are already working.
1184        self.state.parked = false;
1185        self.state.resource_wait = None;
1186        self.state.resource_block = None;
1187        // Any seat this state still lists as answering belongs to whatever
1188        // process last drove this run — this one included, if it crashed
1189        // mid-wave. Cleared and flushed immediately, before anything else
1190        // runs, so a resume can never show a seat as live when nothing is
1191        // asking it anything yet; the node that actually dispatches the next
1192        // wave repopulates it.
1193        self.state.clear_active();
1194        // Recorded in the same spot, and flushed together with the clear
1195        // above: this is the pid a reader checks (`RunState::liveness`) when
1196        // no daemon claim exists to answer "is a process still driving this
1197        // run" — a plain `magi run` / `magi review` typed into a terminal
1198        // claims nothing there. Always overwritten, never only-if-absent, so
1199        // a resumed run's stale pid from a previous, possibly-dead process
1200        // can never survive into this one's own report. Unlike
1201        // `clear_active`, this changes on every single `execute()` call, so
1202        // the save below is now unconditional rather than only-if-cleared.
1203        //
1204        // `driver_started_at` is recorded in the same breath, from this same
1205        // pid, so `liveness` can tell a live pid that is genuinely still us
1206        // apart from one the OS has since handed to an unrelated process —
1207        // see that field's own doc for why the pid alone is not enough.
1208        // A resume that raced a takeover: the record on disk says the worktree
1209        // was handed to a later run after this copy was read. Saving over it
1210        // would erase that and drive a run with nothing to run in.
1211        if let Some(to) = &disk.released_to {
1212            bail!(
1213                "run {} cannot continue: its worktree was released to run {}",
1214                self.state.short(),
1215                crate::run::short_of(to)
1216            );
1217        }
1218        let pid = std::process::id();
1219        self.state.driver_pid = Some(pid);
1220        self.state.driver_started_at = crate::proc::process_started_at(pid);
1221        self.state.driver_exited = false;
1222        self.state.begin_pass();
1223        self.state.save()?;
1224        Ok(())
1225    }
1226
1227    async fn execute_graph(&mut self) -> Result<()> {
1228        self.claim_driver()?;
1229        if self.state.merge_repairs.last().is_some_and(|r| {
1230            matches!(
1231                r.phase,
1232                crate::run::MergeRepairPhase::Requested
1233                    | crate::run::MergeRepairPhase::Fixing
1234                    | crate::run::MergeRepairPhase::Reviewing
1235            )
1236        }) {
1237            if self.repair_merge_review().await? {
1238                self.run_land().await?;
1239            }
1240            self.settle_questions();
1241            return Ok(());
1242        }
1243        // Publication already passed review and gate. Resume only that node:
1244        // syncing to a newer base here could change the verified commit.
1245        if self.state.pr_publication_pending {
1246            self.merge().await?;
1247            if self.resource_halt()? {
1248                return Ok(());
1249            }
1250            self.state.save()?;
1251            self.settle_questions();
1252            return Ok(());
1253        }
1254        // A run that already lost its quorum never resumes into the verdict
1255        // machinery: `deliberate` and `vote` would otherwise clobber the
1256        // stalled marker back to Voting and the run would keep going past a
1257        // verdict that is no longer trustworthy. Everything already recorded is
1258        // kept, so the run stays resumable (or foldable) for a human to pick up.
1259        //
1260        // On --resume the run gets one chance to repair itself: the seats a
1261        // rate limit took out are re-asked. If their quota has since reset and
1262        // the quorum is restored, the run picks up and finishes; otherwise it
1263        // stays stale and still-resumable for a later retry. If it does not
1264        // recover, the returned status stays `Stalled` and nothing was
1265        // clobbered (the recovery only mutates entries for the lost seats).
1266        if self.state.status == RunStatus::Stalled {
1267            if self.recover_stall().await? {
1268                self.finish_after_tally().await?;
1269            } else {
1270                // Still below quorum: persist the marker and stay resumable.
1271                self.state.save()?;
1272            }
1273            return Ok(());
1274        }
1275        // A run parked inside `land` - watching CI, mid fix-round, or
1276        // waiting on the owner's merge approval - resumes directly into it,
1277        // never back through `prep`. Everything before `merge` already
1278        // concluded; that is the only way `status` reaches `Landing` in the
1279        // first place. Re-walking `review_loop` first would also be actively
1280        // wrong: its own status recomputation (see its doc) treats any
1281        // clean round as reason to set `status` to `Gating`, which would
1282        // clobber this marker before `merge` ever ran, and this run would
1283        // never find its way back into `land` at all.
1284        if self.state.status == RunStatus::Landing {
1285            self.run_land().await?;
1286            // `run_land` may have settled the run right here - CI came back
1287            // green and the PR merged, say - without ever passing back
1288            // through `merge`'s own trailing call. Whatever it left `status`
1289            // as is what this has to read.
1290            self.settle_questions();
1291            return Ok(());
1292        }
1293        // A run parked at `merge` on the owner's word about a withheld PR text
1294        // resumes straight into `merge`. Re-walking the tail would sync to the
1295        // base again (possibly rebasing, possibly spending the fixer) after the
1296        // gate had passed, and then push a tree nobody reviewed or gated.
1297        if self.state.github_text.is_some()
1298            && self.state.merge.is_none()
1299            && self.state.status == RunStatus::Gating
1300        {
1301            self.merge().await?;
1302            if self.resource_halt()? {
1303                return Ok(());
1304            }
1305            self.state.save()?;
1306            return Ok(());
1307        }
1308        if let Err(e) = self.prep().await {
1309            self.state.status = RunStatus::Blocked;
1310            self.state
1311                .prep_failure
1312                .get_or_insert_with(|| format!("{e:#}"));
1313            self.state
1314                .event("prep", format!("preparation stopped: {e:#}"));
1315            self.state.save()?;
1316            return Err(e);
1317        }
1318        if self.park_here()? {
1319            return Ok(());
1320        }
1321        self.advise().await?;
1322        if self.park_here()? {
1323            return Ok(());
1324        }
1325        self.implement().await?;
1326        if self.park_here()? {
1327            return Ok(());
1328        }
1329        // `after_implement` already saved the state and settled any open
1330        // questions when it set this; nothing later in the graph has
1331        // anything to judge.
1332        if self.state.status == RunStatus::VerifiedNoop {
1333            return Ok(());
1334        }
1335        self.judge().await?;
1336        if self.park_here()? {
1337            return Ok(());
1338        }
1339        self.deliberate().await?;
1340        if self.park_here()? {
1341            return Ok(());
1342        }
1343        self.vote().await?;
1344        if self.park_here()? {
1345            return Ok(());
1346        }
1347        self.tally()?;
1348        // A verdict that lost its quorum is not trustworthy: do not review,
1349        // gate, or merge on it. Everything already done is kept, so the run
1350        // stays resumable (or foldable); the human can replace the agent that
1351        // ran out of quota and pick it up.
1352        if self.state.status == RunStatus::Stalled {
1353            // Persist the stalled marker now — the normal end-of-execute save
1354            // below is below this early return, and without it a resumed run
1355            // would reload a pre-tally status and keep going.
1356            self.state.save()?;
1357            return Ok(());
1358        }
1359        self.finish_after_tally().await?;
1360        Ok(())
1361    }
1362
1363    /// A wave could not start its agents because a shared resource stayed busy
1364    /// (see [`RunState::resource_block`]): end the node without recording any
1365    /// result, leaving the run `Blocked` and resumable. No seat history,
1366    /// handover, quota loss or candidate state was written for it.
1367    fn stop_for_resource(&mut self) -> Result<()> {
1368        // A wait abandoned because the run is parking stays a park.
1369        if self.state.resource_block.is_some() && !(self.pause.parked() || self.interrupt.parked())
1370        {
1371            self.state.status = RunStatus::Blocked;
1372        }
1373        self.state.save()
1374    }
1375
1376    /// Stop walking the graph if a node ended on a resource block.
1377    fn resource_halt(&mut self) -> Result<bool> {
1378        if self.state.resource_block.is_none() {
1379            return Ok(false);
1380        }
1381        self.stop_for_resource()?;
1382        Ok(true)
1383    }
1384
1385    /// Park here if asked to, recording it in the run's own timeline.
1386    ///
1387    /// Returns whether the caller should stop walking the graph. The state is
1388    /// saved either way by the node that just finished; this adds the event so
1389    /// the operator's card says why a run that is neither finished nor moving
1390    /// is sitting where it is.
1391    fn park_here(&mut self) -> Result<bool> {
1392        let parking = self.pause.parked() || self.interrupt.parked();
1393        if !parking && self.state.resource_block.is_some() {
1394            self.stop_for_resource()?;
1395            return Ok(true);
1396        }
1397        // Either handle asking is enough - see `Pause`'s own doc for why
1398        // they are never the same one. `interrupt` is checked second so a
1399        // reason it carries is preferred in the message below over a plain
1400        // shutdown park racing it at the same boundary.
1401        if !self.pause.parked() && !self.interrupt.parked() {
1402            return Ok(false);
1403        }
1404        let cause = self.interrupt.cause().or_else(|| self.pause.cause());
1405        let why = match cause.as_ref().map(ParkCause::label) {
1406            Some(reason) => format!(
1407                "parked after `{}` ({reason}) — resume to carry on from here",
1408                self.state.status.as_str()
1409            ),
1410            None => format!(
1411                "parked after `{}` — resume to carry on from here",
1412                self.state.status.as_str()
1413            ),
1414        };
1415        self.state.event("park", why);
1416        self.state
1417            .end_pass_parked(cause.unwrap_or(ParkCause::Unspecified));
1418        self.state.parked = true;
1419        self.state.save()?;
1420        Ok(true)
1421    }
1422
1423    /// Only a new explicit operator approval may retry failed preparation.
1424    pub fn retry_preparation(&mut self, approval_at: jiff::Timestamp) -> Result<()> {
1425        if self.state.prep_failure.is_none() {
1426            return Ok(());
1427        }
1428        let failed_at = self
1429            .state
1430            .events
1431            .iter()
1432            .rev()
1433            .find(|event| event.node == "prep")
1434            .map_or(self.state.updated_at, |event| event.at);
1435        anyhow::ensure!(
1436            approval_at > failed_at,
1437            "preparation retry requires a new explicit release after the failure"
1438        );
1439        self.state.prep_failure = None;
1440        self.state.prep_complete = Some(false);
1441        self.state
1442            .event("prep-retry", "operator approved retry of preparation");
1443        Ok(())
1444    }
1445
1446    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1447    pub fn on_pause(&mut self, pause: Pause) {
1448        self.pause = pause;
1449    }
1450
1451    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1452    /// scheduler asking this one run - and no other - to park so a task
1453    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1454    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1455    /// handle.
1456    pub fn watch_interrupt(&mut self, pause: Pause) {
1457        self.interrupt = pause;
1458    }
1459
1460    /// Abandon this run's own open questions, once `status` has actually
1461    /// settled rather than merely paused.
1462    ///
1463    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1464    /// either back up with the candidates, the review round and the seat
1465    /// sessions already on disk, so a question an implementer asked mid-round
1466    /// may still get a real answer read by a real resume. Only the statuses
1467    /// `resumable` excludes are actually final: the run merged, it reached
1468    /// `Ready` with nothing left to do, it failed outright with no
1469    /// established point to continue from, or every candidate agreed, with
1470    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1471    /// every one of those the seat that asked is gone for good, exactly like
1472    /// the run being deleted under `magi run rm` - so the same cleanup
1473    /// applies, worded for what actually happened instead of "the run was
1474    /// deleted".
1475    ///
1476    /// Best-effort and silent on success: called from every place `status`
1477    /// can land on one of those three, including ones a resumed run revisits,
1478    /// so it must cost nothing when there was nothing open to begin with.
1479    fn settle_questions(&mut self) {
1480        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1481            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1482        }
1483    }
1484
1485    /// The tail of the graph after a trustworthy tally: fold losers, review,
1486    /// gate, merge, and persist.
1487    async fn finish_after_tally(&mut self) -> Result<()> {
1488        // Before the losers are folded: their branches are what the graft
1489        // reads from.
1490        self.graft().await?;
1491        if self.resource_halt()? {
1492            return Ok(());
1493        }
1494        self.fold_losers().await?;
1495        // Before review starts, and again right before the gate: a run's
1496        // review rounds can themselves take long enough for the base to move
1497        // a second time, and the gate is the one node whose "green" gets
1498        // acted on.
1499        self.sync_to_base().await?;
1500        if self.state.status == RunStatus::AlreadyInBase {
1501            return Ok(());
1502        }
1503        self.review_loop().await?;
1504        if self.resource_halt()? {
1505            return Ok(());
1506        }
1507        self.sync_to_base().await?;
1508        if self.state.status == RunStatus::AlreadyInBase {
1509            return Ok(());
1510        }
1511        self.gate().await?;
1512        self.merge().await?;
1513        if self.resource_halt()? {
1514            return Ok(());
1515        }
1516        self.state.save()?;
1517        Ok(())
1518    }
1519
1520    // ---------------------------------------------------------------- prep
1521
1522    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1523    /// run with the step and its output in the event; it is never swallowed.
1524    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1525    /// resume starts over instead of finding candidates and missing seats.
1526    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1527        let result = match ensure_setup_config(&mut self.state, repo).await {
1528            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1529            Err(e) => Err(e),
1530        };
1531        if let Err(e) = result {
1532            git::worktree_remove(repo, wt).await.ok();
1533            if in_prep {
1534                self.rollback_prep(repo).await;
1535            }
1536            self.state.status = RunStatus::Blocked;
1537            self.state.event(
1538                "setup",
1539                format!("worktree setup failed in {}: {e:#}", wt.display()),
1540            );
1541            self.state.save()?;
1542            return Err(e);
1543        }
1544        Ok(())
1545    }
1546
1547    /// Undo everything `prep` created, so a resume re-runs it whole.
1548    async fn rollback_prep(&mut self, repo: &Path) {
1549        let root = self.state.worktree_root();
1550        for c in std::mem::take(&mut self.state.candidates) {
1551            git::worktree_remove(repo, &c.worktree).await.ok();
1552            git::branch_delete(repo, &c.branch).await.ok();
1553        }
1554        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1555        for j in 1..=seats {
1556            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1557                .await
1558                .ok();
1559        }
1560        for k in 1..=self.state.config.graph.advisors {
1561            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1562                .await
1563                .ok();
1564        }
1565    }
1566
1567    async fn prep(&mut self) -> Result<()> {
1568        if let Some(reason) = &self.state.prep_failure {
1569            bail!(
1570                "candidate preparation previously failed: {reason}; correct the source before retrying"
1571            );
1572        }
1573        if self
1574            .state
1575            .prep_complete
1576            .unwrap_or(!self.state.candidates.is_empty())
1577        {
1578            return Ok(());
1579        }
1580        let repo = self.state.repo.clone();
1581        self.rollback_prep(&repo).await;
1582        self.state.prep_complete = Some(false);
1583        self.state.status = RunStatus::Prep;
1584        self.state.save()?;
1585        let repo = self.state.repo.clone();
1586        let base = self.state.base_commit.clone();
1587        let plan = refs::plan(&repo, &self.state.seeds).await?;
1588        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1589        let root = self.state.worktree_root();
1590        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1591
1592        // The hook is the write-time half of the blindness contract; the
1593        // presentation filter in `blind` is the half that cannot be bypassed.
1594        let hooks_dir = self.state.dir().join("hooks");
1595        if self.state.config.blind.commit_msg_hook {
1596            std::fs::create_dir_all(&hooks_dir)
1597                .with_context(|| format!("create {}", hooks_dir.display()))?;
1598            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1599            let path = hooks_dir.join("commit-msg");
1600            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1601            make_executable(&path)?;
1602            // Ref-counted rather than a plain idempotent set: with more than
1603            // one run able to be in flight in the same repository at once
1604            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1605            // true?" check cannot tell "another run of mine still needs
1606            // this" from "nobody does", and the run that happens to finish
1607            // first would disable the hook out from under a sibling still
1608            // relying on it.
1609            git::acquire_worktree_config(&repo).await?;
1610            self.state.enabled_worktree_config = true;
1611        }
1612
1613        // `[worktree] setup` hides its products per worktree, which needs the
1614        // same per-worktree config the hook does. Held until fold, like it.
1615        ensure_setup_config(&mut self.state, &repo).await?;
1616
1617        for (index, (spec, label)) in self
1618            .roles
1619            .implementers
1620            .clone()
1621            .into_iter()
1622            .zip(labels)
1623            .enumerate()
1624        {
1625            let branch = self.state.branch_for(label);
1626            let worktree = root.join(format!("cand-{label}"));
1627            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1628            if self.state.config.blind.commit_msg_hook {
1629                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1630            }
1631            git::local_exclude(&worktree, "/.magi/").await?;
1632            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1633                // Nothing of this prep may survive: a resume must start over,
1634                // not find half the candidates and skip the rest.
1635                git::worktree_remove(&repo, &worktree).await.ok();
1636                git::branch_delete(&repo, &branch).await.ok();
1637                self.rollback_prep(&repo).await;
1638                self.state.status = RunStatus::Blocked;
1639                self.state
1640                    .event("prep", format!("worktree setup failed: {e:#}"));
1641                self.state.prep_failure = Some(format!("worktree setup failed: {e:#}"));
1642                self.state.save()?;
1643                return Err(e);
1644            }
1645            self.state.candidates.push(Candidate {
1646                index,
1647                label,
1648                agent: spec.id.clone(),
1649                branch,
1650                worktree,
1651                summary: String::new(),
1652                stat: String::new(),
1653                files: 0,
1654                commits: 0,
1655                empty: false,
1656                failed: None,
1657                verified_noop: None,
1658                duration_ms: 0,
1659                folded: false,
1660            });
1661            self.state.save()?;
1662            for pick in &plan.picks {
1663                if let Err(e) = git::cherry_pick(&self.state.candidates[index].worktree, pick).await
1664                {
1665                    self.state.status = RunStatus::Blocked;
1666                    self.state.prep_failure = Some(format!("cannot apply referenced commit: {e}"));
1667                    self.state
1668                        .event("prep", format!("cannot apply referenced commit: {e}"));
1669                    self.state.save()?;
1670                    return Err(e);
1671                }
1672            }
1673        }
1674
1675        for j in 1..=self.roles.judges.len() {
1676            let wt = root.join(format!("judge-{j}"));
1677            if !wt.exists() {
1678                git::worktree_add_detached(&repo, &wt, &base).await?;
1679                self.setup_seat_worktree(&repo, &wt, true).await?;
1680            }
1681        }
1682
1683        // Disposable, detached checkouts for the design-deliberation stage's
1684        // advisor seats — the same shape as the judges' above, at the same
1685        // base commit, since advisors also only ever read. Sized off the
1686        // configured count directly rather than a resolved roster: unlike
1687        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1688        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1689        // `prep` has no `ResolvedRoles` field to read a count from here.
1690        if self.state.config.graph.advise {
1691            for k in 1..=self.state.config.graph.advisors {
1692                let wt = root.join(format!("advisor-{k}"));
1693                if !wt.exists() {
1694                    git::worktree_add_detached(&repo, &wt, &base).await?;
1695                    self.setup_seat_worktree(&repo, &wt, true).await?;
1696                }
1697            }
1698        }
1699
1700        // A judge cannot tell it is looking at its own patch — the seats keep
1701        // separate conversations — but a panel that shares agents with the
1702        // field is less independent than it looks, and that is worth saying out
1703        // loud once per run rather than leaving it in the config.
1704        let authors: Vec<&str> = self
1705            .roles
1706            .implementers
1707            .iter()
1708            .map(|a| a.id.as_str())
1709            .collect();
1710        let overlap: Vec<String> = self
1711            .roles
1712            .judges
1713            .iter()
1714            .enumerate()
1715            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1716            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1717            .collect();
1718        if !overlap.is_empty() {
1719            let note = format!(
1720                "{} also authored a candidate; blind, but the panel is less \
1721                 independent than {} distinct agents would be",
1722                overlap.join(", "),
1723                self.roles.judges.len()
1724            );
1725            self.state.event("prep", note);
1726        }
1727
1728        self.state.event(
1729            "prep",
1730            format!(
1731                "{} candidates, {} judges, base {} ({})",
1732                self.state.candidates.len(),
1733                self.roles.judges.len(),
1734                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1735                self.state.base_branch
1736            ),
1737        );
1738        self.state.prep_complete = Some(true);
1739        self.state.status = RunStatus::Implementing;
1740        self.state.save()?;
1741        Ok(())
1742    }
1743
1744    // -------------------------------------------------------------- advise
1745
1746    /// The design-deliberation stage: independent, read-only advisor seats
1747    /// each sketch a design before any implementer touches the repository,
1748    /// and (when at least one produced a usable proposal) a synthesis seat
1749    /// blends them into a brief `implement` carries in every candidate's
1750    /// prompt.
1751    ///
1752    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1753    /// advisors` is the proposal count. Everything here is best-effort and
1754    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1755    /// that cannot reach quota, or a synthesis seat that produced nothing
1756    /// usable all leave `implement` exactly as it was before this stage
1757    /// existed — the task instruction alone — rather than failing the whole
1758    /// competition over an enrichment stage. Every outcome is still recorded
1759    /// as an event, so a run that got nothing from this stage says why.
1760    ///
1761    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1762    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1763    /// resumed run whose stage failed would re-run it, and re-spend the
1764    /// agent calls, on every reentry before `implement`.
1765    ///
1766    /// Also skipped once any candidate shows implementation progress — the
1767    /// exact predicate `implement` itself uses to decide a candidate is no
1768    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1769    /// is not enough: a run created by an older binary that predates this
1770    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1771    /// an already-`Implementing`-or-later run under this build would
1772    /// otherwise walk straight back through `prep` (a no-op once candidates
1773    /// exist) into this node and spawn every advisor seat against worktrees
1774    /// `prep` never recreated — after implementation has already started,
1775    /// which is exactly the invariant this stage exists to guarantee.
1776    async fn advise(&mut self) -> Result<()> {
1777        let implement_untouched = self
1778            .state
1779            .candidates
1780            .iter()
1781            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1782        if !self.state.config.graph.advise || self.state.advise_attempted {
1783            return Ok(());
1784        }
1785        if !implement_untouched {
1786            self.state.event(
1787                "advise",
1788                "skipping the design-deliberation stage: at least one \
1789                 candidate already shows implementation progress, so this \
1790                 run is past the point the stage exists to run before"
1791                    .to_owned(),
1792            );
1793            self.state.advise_attempted = true;
1794            self.state.save()?;
1795            return Ok(());
1796        }
1797        let run_id = self.state.id.clone();
1798        let prompts = self.state.config.prompts.clone();
1799        let instruction = self.state.instruction.clone();
1800        let language = self.state.config.graph.language.clone();
1801        let root = self.state.worktree_root();
1802        let n = self.state.config.graph.advisors;
1803        let where_recorded = self.state.dir().join("run.json");
1804
1805        let seats = match self.state.config.advisors() {
1806            Ok(seats) if !seats.is_empty() => seats,
1807            Ok(_) => {
1808                self.state.event(
1809                    "advise",
1810                    format!(
1811                        "[graph] advisors is 0; skipping the design-deliberation \
1812                         stage and continuing without a synthesis brief (see {})",
1813                        where_recorded.display()
1814                    ),
1815                );
1816                self.state.advise_attempted = true;
1817                self.state.save()?;
1818                return Ok(());
1819            }
1820            Err(e) => {
1821                self.state.event(
1822                    "advise",
1823                    format!(
1824                        "could not resolve advisor seats ({e:#}); continuing \
1825                         without a design-deliberation brief (see {})",
1826                        where_recorded.display()
1827                    ),
1828                );
1829                self.state.advise_attempted = true;
1830                self.state.save()?;
1831                return Ok(());
1832            }
1833        };
1834
1835        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1836        let artifacts = agent::artifacts_dir(&self.state.dir());
1837        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1838
1839        let mut jobs = Vec::new();
1840        for (i, spec) in seats.iter().cloned().enumerate() {
1841            let seat_key = format!("advisor-{}", i + 1);
1842            let seat = self.seat(&seat_key, &spec.id);
1843            jobs.push(SeatJob {
1844                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1845                spec,
1846                seat,
1847                cwd: worktrees[i % worktrees.len()].clone(),
1848                timeout,
1849                allow_write: false,
1850                sessions: false,
1851                artifacts: artifacts.clone(),
1852                stem: seat_key,
1853                handover: None,
1854            });
1855        }
1856
1857        self.state.event(
1858            "advise",
1859            format!(
1860                "{} advisor seat(s) sketching a design in parallel",
1861                jobs.len()
1862            ),
1863        );
1864        let mut quota_losses = Vec::new();
1865        let cache = self.state.config.cache_dir();
1866        let ctx = WaveCtx {
1867            carry_seats: false,
1868            pauses: Some((&self.pause, &self.interrupt)),
1869            run: &run_id,
1870            node: "advise",
1871            prompts: &prompts,
1872            cache: cache.as_deref(),
1873            round: None,
1874        };
1875        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1876        let results = ask_json_wave::<Proposal>(
1877            jobs,
1878            Arc::clone(&self.sem),
1879            self.state.config.graph.retries,
1880            &advisor_roster,
1881            &ctx,
1882            &mut quota_losses,
1883            &mut self.state,
1884            &|p: &Proposal| p.validate(),
1885        )
1886        .await;
1887        if self.state.resource_block.is_some() {
1888            return self.stop_for_resource();
1889        }
1890        self.state.quota.extend(quota_losses);
1891
1892        let mut records = Vec::with_capacity(results.len());
1893        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1894            let agent_id = seat.agent.clone();
1895            self.state.seats.insert(seat.key.clone(), seat);
1896            match res {
1897                Ok((proposal, out)) => {
1898                    self.state
1899                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1900                    records.push(advise::AdvisorRecord::proposed(
1901                        i + 1,
1902                        agent_id,
1903                        proposal,
1904                        out.duration_ms,
1905                    ));
1906                }
1907                Err(e) => {
1908                    self.state.event(
1909                        "advise",
1910                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1911                    );
1912                    records.push(advise::AdvisorRecord::failed(
1913                        i + 1,
1914                        agent_id,
1915                        e.to_string(),
1916                    ));
1917                }
1918            }
1919        }
1920
1921        let mut advice = advise::Advice {
1922            records,
1923            synthesis: None,
1924        };
1925        if advice.proposals().is_empty() {
1926            self.state.event(
1927                "advise",
1928                "no advisor produced a usable proposal; continuing without a \
1929                 synthesis brief"
1930                    .to_owned(),
1931            );
1932        } else {
1933            match self
1934                .synthesize_brief(
1935                    &advice,
1936                    &instruction,
1937                    &language,
1938                    &worktrees[0],
1939                    &artifacts,
1940                    &run_id,
1941                    &prompts,
1942                    cache.as_deref(),
1943                )
1944                .await
1945            {
1946                Ok(Some(text)) => {
1947                    self.state.event(
1948                        "advise",
1949                        "synthesized a design brief for the implementer".to_owned(),
1950                    );
1951                    advice.synthesis = Some(text);
1952                }
1953                Ok(None) => {
1954                    self.state.event(
1955                        "advise",
1956                        "the synthesis seat produced nothing usable; continuing \
1957                         without a design brief"
1958                            .to_owned(),
1959                    );
1960                }
1961                Err(e) => {
1962                    self.state.event(
1963                        "advise",
1964                        format!("could not synthesize a design brief: {e:#}"),
1965                    );
1966                }
1967            }
1968        }
1969        advise::apply_reflection(&mut advice);
1970
1971        self.state.advice = Some(advice);
1972        self.state.advise_attempted = true;
1973        self.state.save()?;
1974        Ok(())
1975    }
1976
1977    /// The synthesis seat: reads every advisor's proposal and blends them
1978    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1979    /// out of [`Runner::advise`] only for readability — it is not called
1980    /// anywhere else.
1981    ///
1982    /// Picked the same way [`crate::talk`]'s standing conversation and
1983    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1984    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1985    /// order (a claude seat, else the first runnable agent in roster order)
1986    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1987    /// in [`crate::config`] for why a dedicated field exists here at all.
1988    #[allow(clippy::too_many_arguments)]
1989    async fn synthesize_brief(
1990        &mut self,
1991        advice: &advise::Advice,
1992        instruction: &str,
1993        language: &str,
1994        cwd: &Path,
1995        artifacts: &Path,
1996        run_id: &str,
1997        prompts: &Prompts,
1998        cache: Option<&Path>,
1999    ) -> Result<Option<String>> {
2000        let chain = agent::pick_chain(
2001            &self.state.config.agents,
2002            self.state.config.roles.synthesizer.as_ref(),
2003            &agent::installed,
2004            "synthesizer",
2005        )?;
2006        let proposals = advice.proposals();
2007        let mut prompt = prompt::with_overlay(
2008            prompt::synthesize_brief(instruction, &proposals, language),
2009            prompts.overlay("advise"),
2010        );
2011        if cache.is_some() {
2012            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
2013            // below — see `prompt::build_cache_note`'s doc for why telling a
2014            // read-only seat to build through the shared cache is exactly how
2015            // a sandbox's write refusal gets misread as a defect.
2016            prompt.push('\n');
2017            prompt.push_str(&prompt::build_cache_note("advise", false));
2018        }
2019        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
2020        // Each id is tried once, in order; a quota hit, error or unusable
2021        // answer moves to the next. The seat is single-turn (`sessions:
2022        // false`) and the prompt is the whole context, so a fallback agent
2023        // needs nothing carried over.
2024        let mut last = None;
2025        for (n, spec) in chain.iter().enumerate() {
2026            if n > 0 {
2027                self.state
2028                    .event("advise", format!("synthesis falling back to {}", spec.id));
2029            }
2030            let mut seat = self.seat("advise-synthesis", &spec.id);
2031            let outcome = agent::invoke(
2032                spec,
2033                &mut seat,
2034                &Invocation {
2035                    cwd,
2036                    prompt: &prompt,
2037                    timeout,
2038                    allow_write: false,
2039                    unsandboxed: false,
2040                    sessions: false,
2041                    artifacts,
2042                    stem: &if n == 0 {
2043                        "advise-synthesis".to_owned()
2044                    } else {
2045                        format!("advise-synthesis-{}", spec.id)
2046                    },
2047                    run: run_id,
2048                    node: "advise",
2049                    cache_dir: None,
2050                    attachments: &[],
2051                    writable: &[],
2052                },
2053            )
2054            .await;
2055            if outcome.is_ok() {
2056                self.state.seats.insert(seat.key.clone(), seat);
2057            }
2058            let advance = agent::chain_advances(&outcome);
2059            last = Some(outcome);
2060            if !advance {
2061                break;
2062            }
2063        }
2064        // Exhausted: the last attempt's result is what a single failed seat
2065        // would have produced.
2066        let out = last.expect("a chain holds at least one agent")?;
2067        if !out.usable() {
2068            return Ok(None);
2069        }
2070        let text =
2071            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
2072        Ok((!text.trim().is_empty()).then_some(text))
2073    }
2074
2075    // ----------------------------------------------------------- implement
2076
2077    async fn implement(&mut self) -> Result<()> {
2078        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2079        // agent files with `magi task add` name the run that paid for it. The
2080        // prompt overlay is cloned alongside it because the waves borrow it
2081        // while `self` is mutably borrowed by the node's own bookkeeping.
2082        let run_id = self.state.id.clone();
2083        let prompts = self.state.config.prompts.clone();
2084        let todo: Vec<usize> = self
2085            .state
2086            .candidates
2087            .iter()
2088            .enumerate()
2089            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
2090            .map(|(i, _)| i)
2091            .collect();
2092        if todo.is_empty() {
2093            return self.after_implement();
2094        }
2095        self.state.status = RunStatus::Implementing;
2096
2097        let language = self.state.config.graph.language.clone();
2098        let github_language = self.state.config.graph.github_language.clone();
2099        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
2100        let sessions = self.state.config.graph.sessions;
2101        let artifacts = agent::artifacts_dir(&self.state.dir());
2102        // The design-deliberation stage's blended brief, when `advise` found
2103        // one — carried into every implementer's prompt the same way
2104        // regardless of which candidate it is.
2105        let brief = self
2106            .state
2107            .advice
2108            .as_ref()
2109            .and_then(|a| a.synthesis.as_deref())
2110            .map(str::to_owned);
2111        let attachments = self.state.attachments.clone();
2112
2113        let mut jobs = Vec::new();
2114        for &i in &todo {
2115            let (index, label, worktree) = {
2116                let c = &self.state.candidates[i];
2117                (c.index, c.label, c.worktree.clone())
2118            };
2119            let spec = self.roles.implementers[index].clone();
2120            let seat_key = format!("impl-{label}");
2121            let seat = self.seat(&seat_key, &spec.id);
2122            let instruction = seeded_instruction(&self.state);
2123            jobs.push(SeatJob {
2124                spec,
2125                seat,
2126                prompt: prompt::implement_in(
2127                    &instruction,
2128                    &worktree.to_string_lossy(),
2129                    &language,
2130                    &github_language,
2131                    brief.as_deref(),
2132                    &attachments,
2133                ),
2134                cwd: worktree,
2135                timeout,
2136                allow_write: true,
2137                sessions,
2138                artifacts: artifacts.clone(),
2139                stem: format!("impl-{label}"),
2140                handover: None,
2141            });
2142        }
2143
2144        self.state.event(
2145            "implement",
2146            format!("{} candidates in parallel", jobs.len()),
2147        );
2148        // Kept so a seat whose CLI hung up can be asked again from the same
2149        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
2150        // can update a seat's own entry once a fallback agent takes it over —
2151        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
2152        // whichever agent actually answered, not the one that quota'd out.
2153        let mut sent = jobs.clone();
2154        let cache = self.state.config.cache_dir();
2155        let ctx = WaveCtx {
2156            carry_seats: false,
2157            pauses: Some((&self.pause, &self.interrupt)),
2158            run: &run_id,
2159            node: "implement",
2160            prompts: &prompts,
2161            cache: cache.as_deref(),
2162            round: None,
2163        };
2164        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2165        if wave_blocked(&results) {
2166            return self.stop_for_resource();
2167        }
2168        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
2169            .await;
2170        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
2171            .await;
2172        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
2173            .await;
2174        if self.state.resource_block.is_some() {
2175            return self.stop_for_resource();
2176        }
2177
2178        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
2179            let seat_key = seat.key.clone();
2180            // A quota fallback (`resume_seat_handovers`) may have handed this
2181            // seat to a different agent than the one `prep` recorded on the
2182            // candidate; the stats tables and any later fixer-defaults-to-
2183            // winner's-author lookup must credit whoever actually answered —
2184            // unless every fallback also quota'd out, in which case nobody
2185            // actually answered and crediting the last agent tried would
2186            // erase every earlier agent's own quota loss from the stats
2187            // tables instead of just this one seat's.
2188            let agent = seat.agent.clone();
2189            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
2190            self.state.seats.insert(seat.key.clone(), seat);
2191            let label = self.state.candidates[i].label;
2192            let worktree = self.state.candidates[i].worktree.clone();
2193            let base = self.state.base_commit.clone();
2194
2195            let (summary, duration, failed, verified_claim) = match out {
2196                AgentOutcome::Ok(o) => {
2197                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
2198                    let failed = (!o.usable()).then(|| {
2199                        if o.timed_out {
2200                            "agent timed out".to_owned()
2201                        } else {
2202                            format!("agent exited with {:?}", o.exit_code)
2203                        }
2204                    });
2205                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
2206                    (text, o.duration_ms, failed, verified_claim)
2207                }
2208                // Left un-resumed by `resume_undelivered` (a dirty tree
2209                // already rescues the work, or there was no session left to
2210                // resume into) — reported like the ordinary failure it is,
2211                // never as if `o.text` (the CLI's raw error JSON) were an
2212                // answer.
2213                AgentOutcome::Dropped(o) => {
2214                    let why = o
2215                        .dropped
2216                        .as_ref()
2217                        .map(|d| d.why.as_str())
2218                        .unwrap_or("the CLI ended the stream without delivering its answer");
2219                    (
2220                        String::new(),
2221                        o.duration_ms,
2222                        Some(format!("the CLI dropped the stream ({why})")),
2223                        None,
2224                    )
2225                }
2226                AgentOutcome::Quota(o) => {
2227                    self.state.quota.push(QuotaLoss {
2228                        seat: seat_key,
2229                        node: "implement".to_owned(),
2230                        at: Timestamp::now(),
2231                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2232                    });
2233                    (
2234                        String::new(),
2235                        o.duration_ms,
2236                        Some("rate limited (quota); produced no change".to_owned()),
2237                        None,
2238                    )
2239                }
2240                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
2241                    (String::new(), 0, Some(e), None)
2242                }
2243            };
2244
2245            // Rescue anything the agent edited but never committed: an
2246            // uncommitted candidate would silently be an empty one.
2247            let rescued = match git::rescue_commit(
2248                &worktree,
2249                &format!("magi: candidate {label} (uncommitted work)"),
2250            )
2251            .await
2252            {
2253                Ok(r) => {
2254                    self.state.note_withheld("implement", &r.withheld);
2255                    r.committed
2256                }
2257                Err(_) => false,
2258            };
2259            let commits = git::commits_ahead(&worktree, &base, "HEAD")
2260                .await
2261                .unwrap_or(0);
2262            let patch = git::diff(&worktree, &base, "HEAD")
2263                .await
2264                .unwrap_or_default();
2265            let stat = git::diff_stat(&worktree, &base, "HEAD")
2266                .await
2267                .unwrap_or_default();
2268            let files = git::changed_files(&worktree, &base, "HEAD")
2269                .await
2270                .map(|f| f.len())
2271                .unwrap_or(0);
2272            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
2273
2274            let c = &mut self.state.candidates[i];
2275            if !exhausted_the_fallback_chain {
2276                c.agent = agent;
2277            }
2278            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
2279            c.stat = stat;
2280            c.files = files;
2281            c.commits = commits;
2282            c.duration_ms = duration;
2283            c.empty = commits == 0 || patch.trim().is_empty();
2284            // An agent that failed but still produced a committed change stays
2285            // in the running: the patch is what gets judged, not the exit code.
2286            c.failed = match failed {
2287                Some(_) if c.empty => failed,
2288                _ => None,
2289            };
2290            // Only an empty candidate can be a verified no-op: a claim next
2291            // to a real patch is not what the marker is for, and `c.failed`
2292            // being `Some` here already implies `verified_claim` was never
2293            // set (see the guard above the match that produced it).
2294            c.verified_noop = if c.empty { verified_claim } else { None };
2295            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
2296                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
2297                (None, true, Some(_), _) => {
2298                    format!("candidate {label}: no change produced (agent-verified no-op)")
2299                }
2300                (None, true, None, _) => format!("candidate {label}: no change produced"),
2301                (None, false, _, true) => {
2302                    format!(
2303                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
2304                    )
2305                }
2306                (None, false, _, false) => {
2307                    format!("candidate {label}: {files} files, {commits} commits")
2308                }
2309            };
2310            self.state.event("implement", note);
2311            self.state.save()?;
2312        }
2313
2314        self.after_implement()
2315    }
2316
2317    /// Ask again, once, for work a CLI did and then failed to hand over.
2318    ///
2319    /// [`agent::dropped_stream`] recognises the one shape observed: an error
2320    /// status with an empty response and a usage report showing output tokens,
2321    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
2322    /// seven minutes and 14,267 output tokens that arrived as an empty
2323    /// candidate, because `agy`'s own subscriber fell behind and hung up.
2324    ///
2325    /// Two conditions, and both matter:
2326    ///
2327    /// - **Only when the tree is untouched.** Often the agent has already
2328    ///   written its files and only the closing message was lost; the rescue
2329    ///   commit below picks that up and there is nothing to ask for. Re-asking
2330    ///   then would pay for a second implementation of work already on disk.
2331    /// - **Once.** A CLI that drops one stream can drop the next, and this
2332    ///   node is the most expensive in the graph.
2333    ///
2334    /// The re-ask is a resume, not a re-run: `has_context` is true because the
2335    /// dropped reply still carried its `conversation_id`, so the seat is asked
2336    /// to finish what it was doing rather than sent the whole task again. It
2337    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2338    /// node's - for the same reason a re-ranked judge does: restating finished
2339    /// work is not the work.
2340    ///
2341    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2342    /// same way until it resets, while an abandoned conversation is still
2343    /// there to be picked up.
2344    async fn resume_undelivered(
2345        &mut self,
2346        results: &mut [(usize, SeatState, AgentOutcome)],
2347        sent: &[SeatJob],
2348        prompts: &Prompts,
2349        run_id: &str,
2350    ) {
2351        for (wi, seat, out) in results.iter_mut() {
2352            let Some(dropped) = (match &*out {
2353                AgentOutcome::Dropped(o) => o.dropped.clone(),
2354                _ => None,
2355            }) else {
2356                continue;
2357            };
2358            let Some(job) = sent.get(*wi) else { continue };
2359            // Already on disk? Then only the closing message was lost.
2360            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2361                self.state.event(
2362                    "implement",
2363                    format!(
2364                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2365                         work is in the tree",
2366                        seat.key, dropped.output_tokens, dropped.why
2367                    ),
2368                );
2369                continue;
2370            }
2371            // The re-ask only makes sense as a resume: `resume_after_drop`
2372            // says nothing about the task, trusting the seat to still hold it.
2373            // Without a session to resume — sessions disabled, or this CLI's
2374            // drop shape happened not to carry a session id — that prompt
2375            // would open a brand-new conversation with no context at all,
2376            // which is worse than leaving this as the ordinary failure it
2377            // already is.
2378            if !has_context(&job.spec, seat, job.sessions) {
2379                self.state.event(
2380                    "implement",
2381                    format!(
2382                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2383                         is no session left to resume",
2384                        seat.key, dropped.output_tokens, dropped.why
2385                    ),
2386                );
2387                continue;
2388            }
2389            self.state.event(
2390                "implement",
2391                format!(
2392                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2393                     conversation",
2394                    seat.key, dropped.output_tokens, dropped.why
2395                ),
2396            );
2397            let mut retry = job.clone();
2398            retry.seat = seat.clone();
2399            retry.prompt = prompt::resume_after_drop(&dropped.why);
2400            retry.timeout = retry_budget(job.timeout, true);
2401            retry.stem = format!("{}-resume", job.stem);
2402            let cache = self.state.config.cache_dir();
2403            let ctx = WaveCtx {
2404                carry_seats: false,
2405                pauses: Some((&self.pause, &self.interrupt)),
2406                run: run_id,
2407                node: "implement",
2408                prompts,
2409                cache: cache.as_deref(),
2410                round: None,
2411            };
2412            let (resumed_seat, resumed) =
2413                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2414            *seat = resumed_seat;
2415            *out = resumed;
2416        }
2417    }
2418
2419    /// Fall an implement seat through to the next untried agent in the
2420    /// implementer roster when it lost to quota — or, since the handover was
2421    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2422    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2423    /// quota path itself being unchanged — instead of leaving the
2424    /// seat's loss final the moment one agent's account runs dry.
2425    ///
2426    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2427    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2428    /// truncates to the single slot rotation picked, so a solo task whose one
2429    /// implementer hits quota mid-run used to have nothing else to try. This
2430    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2431    /// unrotated roster — which is the only place the *other* candidates in
2432    /// the machine's roster still exist once `implementers` has been cut down
2433    /// to size.
2434    ///
2435    /// Walks forward from just past the seat's own original position in the
2436    /// roster, never wrapping back to the front: a later candidate slot (say
2437    /// `beta`, the roster's second entry) must fall through to the *next*
2438    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2439    /// almost certainly a different candidate's own agent already — and once
2440    /// the roster's tail is exhausted there is nothing left to fall through
2441    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2442    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2443    /// let this retry that id forever. The loop keeps falling through until
2444    /// an attempt lands something other than `Quota` or the roster's tail
2445    /// runs out of untried ids, at which point the seat is left exactly as
2446    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2447    /// `QuotaLoss` recorded, the candidate failed/empty.
2448    ///
2449    /// `sent` is taken mutably and updated with the fallback agent's spec:
2450    /// `resume_unconfirmed_commands`, which runs after this and also reads
2451    /// `sent`, must see whichever agent actually ended up answering the seat
2452    /// — reading the stale, original spec there would check session
2453    /// eligibility against the wrong CLI and could hand a fallback agent's
2454    /// session id to the agent that just lost the seat to quota.
2455    ///
2456    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2457    /// — `self.seat` only reuses state when the agent id is unchanged, so
2458    /// handing it a different id already gets this for free. Reusing the old
2459    /// seat would resume a different CLI's session as if it were a
2460    /// continuation of this one.
2461    ///
2462    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2463    /// a quota loss cuts an agent off mid-turn, so anything already in the
2464    /// tree is unfinished work, not a completed candidate a re-ask would pay
2465    /// for twice. A dirty tree is rescued into a commit first (the same
2466    /// neutral-identity rescue `implement`'s own outcome loop gives every
2467    /// candidate) so the next agent starts clean.
2468    ///
2469    /// The new agent gets the implementer's full prompt and full
2470    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2471    /// it has no session and no context, and is implementing the task from
2472    /// nothing, unlike a resumed drop which is only restating work already
2473    /// done.
2474    ///
2475    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2476    /// `implement` event, never into `self.state.quota` — that is what
2477    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2478    /// go unspent, and a seat that ultimately recovered on its second or
2479    /// third agent is not the stalled panel that check exists to catch. Only
2480    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2481    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2482    /// outcome loop already has — this helper never pushes to it itself.
2483    async fn resume_seat_handovers(
2484        &mut self,
2485        results: &mut [(usize, SeatState, AgentOutcome)],
2486        sent: &mut [SeatJob],
2487        prompts: &Prompts,
2488        run_id: &str,
2489    ) {
2490        let instruction = seeded_instruction(&self.state);
2491        let language = self.state.config.graph.language.clone();
2492        let github_language = self.state.config.graph.github_language.clone();
2493        let brief = self
2494            .state
2495            .advice
2496            .as_ref()
2497            .and_then(|a| a.synthesis.as_deref())
2498            .map(str::to_owned);
2499        let attachments = self.state.attachments.clone();
2500        for (wi, seat, out) in results.iter_mut() {
2501            // Who holds the other candidate seats of this wave right now
2502            // (earlier handovers already written back to `sent`).
2503            let others: BTreeSet<String> = sent
2504                .iter()
2505                .enumerate()
2506                .filter(|(j, _)| j != wi)
2507                .map(|(_, j)| j.spec.id.clone())
2508                .collect();
2509            let Some(job) = sent.get_mut(*wi) else {
2510                continue;
2511            };
2512            // Where the seat's own original agent sits in the roster — the
2513            // fallback walk starts just past here, never at the front, so a
2514            // later candidate slot's quota loss does not fall back onto an
2515            // earlier slot's own agent.
2516            let start = self
2517                .roles
2518                .implementer_roster
2519                .iter()
2520                .position(|s| s.id == job.spec.id)
2521                .unwrap_or(0);
2522            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2523            let mut fallback_attempt = 0usize;
2524            let mut prev: Option<FailClass> = None;
2525            while let Some(cur) = FailClass::of(&*out) {
2526                if !should_hand_over(prev.as_ref(), &cur) {
2527                    break;
2528                }
2529                let Some(next) =
2530                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2531                        .cloned()
2532                else {
2533                    break;
2534                };
2535                tried.insert(next.id.clone());
2536                fallback_attempt += 1;
2537
2538                if let Ok(r) = git::rescue_commit(
2539                    &job.cwd,
2540                    &format!(
2541                        "magi: candidate {} (uncommitted work before {} fallback)",
2542                        seat.key,
2543                        if cur == FailClass::Quota {
2544                            "quota"
2545                        } else {
2546                            "handover"
2547                        }
2548                    ),
2549                )
2550                .await
2551                {
2552                    self.state.note_withheld("implement", &r.withheld);
2553                }
2554
2555                record_handover(
2556                    &mut self.state,
2557                    "implement",
2558                    &seat.key,
2559                    &seat.agent,
2560                    &next.id,
2561                    &cur,
2562                    &fail_reason(&*out),
2563                );
2564                prev = Some(cur.clone());
2565
2566                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2567                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2568                // Kept in sync on `sent` itself, not just the local retry: a
2569                // later helper (`resume_unconfirmed_commands`) reads `sent`
2570                // after this one returns and must see whichever agent is now
2571                // occupying the seat, not the one that just quota'd out —
2572                // otherwise it would judge session/continuation eligibility
2573                // by the wrong CLI and could resend a fallback's session id
2574                // to the agent that lost it the seat in the first place.
2575                job.spec = next.clone();
2576                let mut retry = job.clone();
2577                retry.seat = new_seat;
2578                retry.prompt = prompt::implement_in(
2579                    &instruction,
2580                    &job.cwd.to_string_lossy(),
2581                    &language,
2582                    &github_language,
2583                    brief.as_deref(),
2584                    &attachments,
2585                );
2586                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2587                let cache = self.state.config.cache_dir();
2588                let ctx = WaveCtx {
2589                    carry_seats: false,
2590                    pauses: Some((&self.pause, &self.interrupt)),
2591                    run: run_id,
2592                    node: "implement",
2593                    prompts,
2594                    cache: cache.as_deref(),
2595                    round: None,
2596                };
2597                let (fallback_seat, fallback_out) = run_one(
2598                    retry,
2599                    Arc::clone(&self.sem),
2600                    &ctx,
2601                    &mut self.state,
2602                    fallback_attempt,
2603                )
2604                .await;
2605                *seat = fallback_seat;
2606                *out = fallback_out;
2607            }
2608        }
2609    }
2610
2611    /// Ask an implement seat's own CLI to confirm what it started, once, when
2612    /// its reply reported a command whose completion status it never
2613    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2614    /// that does and does not mean.
2615    ///
2616    /// The completion contract this task asks for, extended to `implement`
2617    /// with the same signal `continue_fix_report` reads for the fixer,
2618    /// rather than a keyword search over the reply or a hard requirement on
2619    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2620    /// a candidate's CLI turn ended cleanly while a test run it had started
2621    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2622    /// named in it at all is untouched by this: `commands` is empty, so
2623    /// there is nothing to be unconfirmed.
2624    ///
2625    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2626    /// this is not about recovering edits that might already be on disk, it
2627    /// is about a result the seat itself never vouched for, which resuming
2628    /// asks for regardless of what the tree already holds. Bounded to one
2629    /// attempt for the same reason `resume_undelivered` is — this is the
2630    /// most expensive node in the graph — and a seat that still cannot
2631    /// confirm on that attempt is left as whatever its (possibly still
2632    /// unconfirmed) reply says; this does not invent a new "failed" reason
2633    /// for a candidate that otherwise produced a real, committed change.
2634    async fn resume_unconfirmed_commands(
2635        &mut self,
2636        results: &mut [(usize, SeatState, AgentOutcome)],
2637        sent: &[SeatJob],
2638        prompts: &Prompts,
2639        run_id: &str,
2640    ) {
2641        for (wi, seat, out) in results.iter_mut() {
2642            let AgentOutcome::Ok(o) = &*out else {
2643                continue;
2644            };
2645            if !has_unconfirmed_command(&o.commands) {
2646                continue;
2647            }
2648            let Some(job) = sent.get(*wi) else { continue };
2649            if !has_context(&job.spec, seat, job.sessions) {
2650                self.state.event(
2651                    "implement",
2652                    format!(
2653                        "{}: the reply named a command whose own CLI never confirmed the exit \
2654                         status of, but there is no session left to resume",
2655                        seat.key
2656                    ),
2657                );
2658                continue;
2659            }
2660            self.state.event(
2661                "implement",
2662                format!(
2663                    "{}: the reply named a command whose own CLI never confirmed the exit \
2664                     status of; resuming the conversation",
2665                    seat.key
2666                ),
2667            );
2668            let mut retry = job.clone();
2669            retry.seat = seat.clone();
2670            retry.prompt = prompt::resume_incomplete(
2671                "a command in your last reply had no confirmed exit status",
2672            );
2673            retry.timeout = retry_budget(job.timeout, true);
2674            retry.stem = format!("{}-confirm", job.stem);
2675            let cache = self.state.config.cache_dir();
2676            let ctx = WaveCtx {
2677                carry_seats: false,
2678                pauses: Some((&self.pause, &self.interrupt)),
2679                run: run_id,
2680                node: "implement",
2681                prompts,
2682                cache: cache.as_deref(),
2683                round: None,
2684            };
2685            let (resumed_seat, resumed) =
2686                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2687            *seat = resumed_seat;
2688            *out = resumed;
2689        }
2690    }
2691
2692    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2693    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2694    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2695    /// that motivated this.
2696    ///
2697    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2698    /// own nudge loop already covers for judge/review/vote seats, and not a
2699    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2700    /// implement seats: here the CLI turn genuinely finished while the node's
2701    /// own work — the fixer's account of what it did — had not. Gated purely
2702    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2703    /// reply, never on any wording in it, so a fixer whose valid, first-try
2704    /// `FixReport` happens to mention having waited on a background test is
2705    /// never resumed — the `Ok(report)` branch at the call site returns
2706    /// before this is ever invoked.
2707    ///
2708    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2709    /// attempt ([`retry_budget`]), nothing attempted once the session is
2710    /// gone, and a quota hit ends the loop immediately rather than retrying a
2711    /// rate limit that fails the same way again.
2712    async fn continue_fix_report(
2713        &mut self,
2714        mut seat: SeatState,
2715        parse_err: String,
2716        job: &SeatJob,
2717        prompts: &Prompts,
2718        run_id: &str,
2719        round: usize,
2720    ) -> (
2721        SeatState,
2722        Option<FixReport>,
2723        Option<String>,
2724        ContinuationRecord,
2725    ) {
2726        let mut last_err = parse_err;
2727        let mut cumulative_wait_ms = 0u64;
2728        let mut attempts = 0usize;
2729        loop {
2730            if !has_context(&job.spec, &seat, job.sessions) {
2731                self.state.event(
2732                    "fix",
2733                    format!(
2734                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2735                         session left to resume into"
2736                    ),
2737                );
2738                let outcome = if attempts == 0 {
2739                    ContinuationOutcome::NoSession
2740                } else {
2741                    ContinuationOutcome::Exhausted
2742                };
2743                return (
2744                    seat,
2745                    None,
2746                    Some(format!("unparsable fix report: {last_err}")),
2747                    ContinuationRecord {
2748                        attempts,
2749                        cumulative_wait_ms,
2750                        outcome,
2751                    },
2752                );
2753            }
2754            if attempts >= MAX_FIX_CONTINUATIONS {
2755                self.state.event(
2756                    "fix",
2757                    format!(
2758                        "round {round}: fixer's reply still had no adoption report after \
2759                         {attempts} continuation(s) ({last_err}); giving up"
2760                    ),
2761                );
2762                return (
2763                    seat,
2764                    None,
2765                    Some(format!(
2766                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2767                    )),
2768                    ContinuationRecord {
2769                        attempts,
2770                        cumulative_wait_ms,
2771                        outcome: ContinuationOutcome::Exhausted,
2772                    },
2773                );
2774            }
2775            attempts += 1;
2776            self.state.event(
2777                "fix",
2778                format!(
2779                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2780                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2781                ),
2782            );
2783            let mut retry = job.clone();
2784            retry.seat = seat.clone();
2785            retry.prompt = prompt::resume_incomplete(&last_err);
2786            retry.timeout = retry_budget(job.timeout, true);
2787            retry.stem = format!("{}-continue{attempts}", job.stem);
2788            let cache = self.state.config.cache_dir();
2789            let ctx = WaveCtx {
2790                carry_seats: false,
2791                pauses: Some((&self.pause, &self.interrupt)),
2792                run: run_id,
2793                node: "fix",
2794                prompts,
2795                cache: cache.as_deref(),
2796                round: Some(round),
2797            };
2798            let (resumed_seat, resumed_out) = run_one(
2799                retry,
2800                Arc::clone(&self.sem),
2801                &ctx,
2802                &mut self.state,
2803                attempts,
2804            )
2805            .await;
2806            seat = resumed_seat;
2807            match resumed_out {
2808                AgentOutcome::Ok(o) => {
2809                    cumulative_wait_ms += o.duration_ms;
2810                    match verdict::extract_json::<FixReport>(&o.text) {
2811                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2812                            self.state.event(
2813                                "fix",
2814                                format!(
2815                                    "round {round}: fixer's adoption report recovered after \
2816                                     {attempts} continuation(s)"
2817                                ),
2818                            );
2819                            return (
2820                                seat,
2821                                Some(report),
2822                                None,
2823                                ContinuationRecord {
2824                                    attempts,
2825                                    cumulative_wait_ms,
2826                                    outcome: ContinuationOutcome::Resumed,
2827                                },
2828                            );
2829                        }
2830                        // The report parsed, but this same reply's own
2831                        // CommandEvidence — the identical record `state.jobs`
2832                        // renders — names a command whose CLI never
2833                        // confirmed an exit status. Read together, that is
2834                        // not a resolved answer: keep nudging rather than
2835                        // accept a report standing next to a command the
2836                        // seat's own CLI cannot vouch for.
2837                        Ok(_) => {
2838                            last_err = "the reply parsed, but it reported a command whose own CLI \
2839                                 never confirmed an exit status"
2840                                .to_owned();
2841                        }
2842                        Err(e) => last_err = e.to_string(),
2843                    }
2844                }
2845                AgentOutcome::Quota(o) => {
2846                    cumulative_wait_ms += o.duration_ms;
2847                    self.state.quota.push(QuotaLoss {
2848                        seat: seat.key.clone(),
2849                        node: "fix".to_owned(),
2850                        at: Timestamp::now(),
2851                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2852                    });
2853                    self.state.event(
2854                        "fix",
2855                        format!(
2856                            "round {round}: continuation rate limited (quota); not retrying now"
2857                        ),
2858                    );
2859                    return (
2860                        seat,
2861                        None,
2862                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2863                        ContinuationRecord {
2864                            attempts,
2865                            cumulative_wait_ms,
2866                            outcome: ContinuationOutcome::QuotaLost,
2867                        },
2868                    );
2869                }
2870                AgentOutcome::Dropped(o) => {
2871                    cumulative_wait_ms += o.duration_ms;
2872                    let why = o
2873                        .dropped
2874                        .as_ref()
2875                        .map(|d| d.why.as_str())
2876                        .unwrap_or("the CLI ended the stream without delivering its answer");
2877                    last_err = format!("the CLI dropped the stream ({why})");
2878                }
2879                AgentOutcome::Failed(e) => last_err = e,
2880                // The continuation was never started; the report stays lost
2881                // and the caller records that, without any handover.
2882                AgentOutcome::ResourceBlocked(e) => {
2883                    return (
2884                        seat,
2885                        None,
2886                        Some(format!("the continuation was not started: {e}")),
2887                        ContinuationRecord {
2888                            attempts: attempts.saturating_sub(1),
2889                            cumulative_wait_ms,
2890                            outcome: ContinuationOutcome::NoSession,
2891                        },
2892                    );
2893                }
2894            }
2895        }
2896    }
2897
2898    fn after_implement(&mut self) -> Result<()> {
2899        // Scan every candidate patch once the set is complete.
2900        if self.state.leaks.is_empty() {
2901            let cfg = self.state.config.blind.clone();
2902            let mut leaks = Vec::new();
2903            for c in &self.state.candidates {
2904                let Some(patch) =
2905                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2906                else {
2907                    continue;
2908                };
2909                leaks.extend(blind::scan(
2910                    &format!("candidate {} patch", c.label),
2911                    &patch,
2912                    &cfg.vendor_tokens,
2913                ));
2914            }
2915            if !leaks.is_empty() {
2916                let summary = leaks
2917                    .iter()
2918                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2919                    .collect::<Vec<_>>()
2920                    .join(", ");
2921                match cfg.on_leak {
2922                    LeakPolicy::Fail => {
2923                        self.state.status = RunStatus::Failed;
2924                        self.state
2925                            .event("blind", format!("vendor text in a patch: {summary}"));
2926                        self.state.leaks = leaks;
2927                        self.state.save()?;
2928                        self.settle_questions();
2929                        bail!(
2930                            "blind.on_leak = \"fail\" and vendor text reached a \
2931                             judged patch: {summary}"
2932                        );
2933                    }
2934                    LeakPolicy::Redact => self.state.event(
2935                        "blind",
2936                        format!("redacting vendor text for judging: {summary}"),
2937                    ),
2938                    LeakPolicy::Warn => self.state.event(
2939                        "blind",
2940                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2941                    ),
2942                }
2943                self.state.leaks = leaks;
2944            }
2945        }
2946
2947        if self.state.viable().is_empty() {
2948            if self.state.all_candidates_verified_noop() {
2949                // Every candidate agreed, with evidence the adoption guard
2950                // accepted, that nothing belongs in this worktree. That is
2951                // not the same fact as a candidate that simply failed to
2952                // write anything, and settling it as an ordinary `Failed`
2953                // (see `SCHEMA`'s doc for schema 10) is what let two of
2954                // task 391f's attempts burn a retry each re-discovering the
2955                // same already-landed fix. Terminal either way, so `judge`
2956                // must never run over an empty candidate set — unlike the
2957                // `Failed` branch below this returns `Ok`, not an error:
2958                // nothing here failed.
2959                self.state.status = RunStatus::VerifiedNoop;
2960                self.state.save()?;
2961                self.settle_questions();
2962                return Ok(());
2963            }
2964            self.state.status = RunStatus::Failed;
2965            self.state.save()?;
2966            self.settle_questions();
2967            bail!("no candidate produced a change; nothing to judge");
2968        }
2969        self.state.status = RunStatus::Judging;
2970        self.state.save()?;
2971        Ok(())
2972    }
2973
2974    // --------------------------------------------------------------- judge
2975
2976    async fn judge(&mut self) -> Result<()> {
2977        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2978        // agent files with `magi task add` name the run that paid for it. The
2979        // prompt overlay is cloned alongside it because the waves borrow it
2980        // while `self` is mutably borrowed by the node's own bookkeeping.
2981        let run_id = self.state.id.clone();
2982        let prompts = self.state.config.prompts.clone();
2983        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2984            return Ok(());
2985        }
2986        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2987        if viable.len() == 1 {
2988            // Recorded so this is a one-time event: `judgements` stays empty
2989            // either way, which without this flag is indistinguishable from
2990            // "not yet judged" on the next reentry — and status is left
2991            // untouched, so a later node's conclusion (e.g. `Blocked` after
2992            // the review budget ran out) survives a resume instead of being
2993            // clobbered back to `Judging` by this node running again.
2994            self.state.judge_skipped = true;
2995            self.state.event(
2996                "judge",
2997                format!(
2998                    "only candidate {} produced a change; judging skipped",
2999                    viable[0].label
3000                ),
3001            );
3002            self.state.save()?;
3003            return Ok(());
3004        }
3005        self.state.status = RunStatus::Judging;
3006
3007        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3008        let language = self.state.config.graph.language.clone();
3009        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3010        let sessions = self.state.config.graph.sessions;
3011        let artifacts = agent::artifacts_dir(&self.state.dir());
3012        let root = self.state.worktree_root();
3013        let base_short = short(&self.state.base_commit);
3014
3015        let mut jobs = Vec::new();
3016        let mut orders = Vec::new();
3017        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3018            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3019            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3020            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
3021            let seat_key = format!("judge-{}", j + 1);
3022            let seat = self.seat(&seat_key, &spec.id);
3023            jobs.push(SeatJob {
3024                prompt: prompt::judge(
3025                    &self.state.instruction,
3026                    &views,
3027                    self.roles.judges.len(),
3028                    &base_short,
3029                    &language,
3030                ),
3031                spec,
3032                seat,
3033                cwd: root.join(format!("judge-{}", j + 1)),
3034                timeout,
3035                allow_write: false,
3036                sessions,
3037                artifacts: artifacts.clone(),
3038                stem: format!("judge-{}", j + 1),
3039                handover: None,
3040            });
3041        }
3042
3043        self.state.event(
3044            "judge",
3045            format!(
3046                "{} judges ranking {} candidates blind",
3047                jobs.len(),
3048                viable.len()
3049            ),
3050        );
3051        let labels_for_check = labels.clone();
3052        let mut quota_losses = Vec::new();
3053        let cache = self.state.config.cache_dir();
3054        let ctx = WaveCtx {
3055            carry_seats: false,
3056            pauses: Some((&self.pause, &self.interrupt)),
3057            run: &run_id,
3058            node: "judge",
3059            prompts: &prompts,
3060            cache: cache.as_deref(),
3061            round: None,
3062        };
3063        let results = ask_json_wave::<Ranking>(
3064            jobs,
3065            Arc::clone(&self.sem),
3066            self.state.config.graph.retries,
3067            &self.roles.judge_roster,
3068            &ctx,
3069            &mut quota_losses,
3070            &mut self.state,
3071            &move |r: &Ranking| r.validate(&labels_for_check),
3072        )
3073        .await;
3074        if self.state.resource_block.is_some() {
3075            return self.stop_for_resource();
3076        }
3077        self.state.quota.extend(quota_losses);
3078
3079        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
3080            let agent_id = seat.agent.clone();
3081            self.state.seats.insert(seat.key.clone(), seat);
3082            let mut record = Judgement {
3083                judge: j + 1,
3084                seat: format!("judge-{}", j + 1),
3085                agent: agent_id,
3086                ranking: Vec::new(),
3087                reasons: BTreeMap::new(),
3088                confidence: None,
3089                strengths: BTreeMap::new(),
3090                order: orders[j].clone(),
3091                failed: None,
3092                duration_ms: 0,
3093            };
3094            match res {
3095                Ok((ranking, out)) => {
3096                    record.ranking = ranking.normalized();
3097                    record.reasons = ranking.reasons;
3098                    record.strengths = ranking.strengths;
3099                    record.confidence = ranking.confidence;
3100                    record.duration_ms = out.duration_ms;
3101                    self.state.event(
3102                        "judge",
3103                        format!(
3104                            "judge {} ranked {}",
3105                            j + 1,
3106                            record.ranking.iter().collect::<String>()
3107                        ),
3108                    );
3109                }
3110                Err(e) => {
3111                    record.failed = Some(e.to_string());
3112                    self.state
3113                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
3114                }
3115            }
3116            self.state.judgements.push(record);
3117            self.state.save()?;
3118        }
3119        Ok(())
3120    }
3121
3122    // ---------------------------------------------------------- deliberate
3123
3124    async fn deliberate(&mut self) -> Result<()> {
3125        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3126        // agent files with `magi task add` name the run that paid for it. The
3127        // prompt overlay is cloned alongside it because the waves borrow it
3128        // while `self` is mutably borrowed by the node's own bookkeeping.
3129        let run_id = self.state.id.clone();
3130        let prompts = self.state.config.prompts.clone();
3131        if !self.state.deliberation.is_empty() {
3132            return Ok(());
3133        }
3134        let tops: Vec<char> = self
3135            .state
3136            .judgements
3137            .iter()
3138            .filter_map(|j| j.ranking.first().copied())
3139            .collect();
3140        let rounds = self.state.config.graph.deliberate_rounds;
3141        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
3142            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
3143                self.state.event(
3144                    "deliberate",
3145                    format!("judges agreed on {} outright; no deliberation", tops[0]),
3146                );
3147            }
3148            self.state.status = RunStatus::Voting;
3149            self.state.save()?;
3150            return Ok(());
3151        }
3152
3153        self.state.status = RunStatus::Deliberating;
3154        self.state.event(
3155            "deliberate",
3156            format!(
3157                "split: first choices were {} — opening {rounds} round(s)",
3158                tops.iter().collect::<String>()
3159            ),
3160        );
3161
3162        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3163        let language = self.state.config.graph.language.clone();
3164        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3165        let sessions = self.state.config.graph.sessions;
3166        let artifacts = agent::artifacts_dir(&self.state.dir());
3167        let root = self.state.worktree_root();
3168        let base_short = short(&self.state.base_commit);
3169
3170        // Judges argue in sequence so that a turn can answer the one before it;
3171        // that is the difference between deliberation and three parallel
3172        // monologues.
3173        for round in 1..=rounds {
3174            let mut turns: Vec<DeliberationTurn> = Vec::new();
3175            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3176                if self.state.judgements[j].failed.is_some() {
3177                    continue;
3178                }
3179                let seat_key = format!("judge-{}", j + 1);
3180                let spec = self.occupant(&seat_key, spec);
3181                let mut seat = self.seat(&seat_key, &spec.id);
3182                let transcript = self.transcript(&turns, j);
3183                let build = |context: Option<&str>| {
3184                    prompt::deliberate(
3185                        &self.state.instruction,
3186                        context,
3187                        &transcript,
3188                        round,
3189                        rounds,
3190                        &language,
3191                    )
3192                };
3193                let block = self.candidate_block(&viable, &base_short);
3194                let full = build(Some(&block));
3195                let text = if has_context(&spec, &seat, sessions) {
3196                    build(None)
3197                } else {
3198                    full.clone()
3199                };
3200                let job = SeatJob {
3201                    spec,
3202                    seat: seat.clone(),
3203                    prompt: text,
3204                    cwd: root.join(format!("judge-{}", j + 1)),
3205                    timeout,
3206                    allow_write: false,
3207                    sessions,
3208                    artifacts: artifacts.clone(),
3209                    stem: format!("delib-{round}-judge-{}", j + 1),
3210                    handover: Some(full),
3211                };
3212                let cache = self.state.config.cache_dir();
3213                let ctx = WaveCtx {
3214                    carry_seats: false,
3215                    pauses: Some((&self.pause, &self.interrupt)),
3216                    run: &run_id,
3217                    node: "deliberate",
3218                    prompts: &prompts,
3219                    cache: cache.as_deref(),
3220                    round: None,
3221                };
3222                // A turn is never nudged (`retries` 0): a failed seat is
3223                // handed to the next roster agent, which gets the full
3224                // context. An empty answer is a turn, not a failure.
3225                let mut losses = Vec::new();
3226                let mut results = ask_wave_with::<String>(
3227                    vec![job],
3228                    Arc::clone(&self.sem),
3229                    0,
3230                    &self.roles.judge_roster,
3231                    &ctx,
3232                    &mut losses,
3233                    &mut self.state,
3234                    &|text: &str| {
3235                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
3236                    },
3237                )
3238                .await;
3239                self.state.quota.extend(losses);
3240                let (updated, res, _) = results.pop().expect("one job in, one result out");
3241                seat = updated;
3242                let agent_id = seat.agent.clone();
3243                self.state.seats.insert(seat.key.clone(), seat);
3244                let body = match res {
3245                    Ok((body, _)) => body,
3246                    // Skip the seat; a CLI's raw error JSON is never read as
3247                    // this judge's position.
3248                    Err(e) => {
3249                        self.state
3250                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
3251                        continue;
3252                    }
3253                };
3254                let tentative = verdict::extract_json::<Position>(&body)
3255                    .ok()
3256                    .and_then(|p| p.tentative)
3257                    .and_then(|s| s.trim().chars().next())
3258                    .map(|c| c.to_ascii_uppercase());
3259                self.state.event(
3260                    "deliberate",
3261                    format!(
3262                        "round {round}: judge {} now favours {}",
3263                        j + 1,
3264                        tentative.map_or("—".to_owned(), |c| c.to_string())
3265                    ),
3266                );
3267                turns.push(DeliberationTurn {
3268                    judge: j + 1,
3269                    agent: agent_id,
3270                    body: blind::sanitize_prose(&body, &self.state.config.blind),
3271                    tentative,
3272                });
3273            }
3274            self.state
3275                .deliberation
3276                .push(DeliberationRound { round, turns });
3277            self.state.save()?;
3278        }
3279
3280        self.state.status = RunStatus::Voting;
3281        self.state.save()?;
3282        Ok(())
3283    }
3284
3285    // ---------------------------------------------------------------- vote
3286
3287    async fn vote(&mut self) -> Result<()> {
3288        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3289        // agent files with `magi task add` name the run that paid for it. The
3290        // prompt overlay is cloned alongside it because the waves borrow it
3291        // while `self` is mutably borrowed by the node's own bookkeeping.
3292        let run_id = self.state.id.clone();
3293        let prompts = self.state.config.prompts.clone();
3294        if !self.state.votes.is_empty() {
3295            return Ok(());
3296        }
3297        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3298        if viable.len() == 1 {
3299            return Ok(());
3300        }
3301        self.state.status = RunStatus::Voting;
3302
3303        let language = self.state.config.graph.language.clone();
3304        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3305        let sessions = self.state.config.graph.sessions;
3306        let artifacts = agent::artifacts_dir(&self.state.dir());
3307        let root = self.state.worktree_root();
3308        let base_short = short(&self.state.base_commit);
3309        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3310
3311        let mut jobs = Vec::new();
3312        let mut seats_at = Vec::new();
3313        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
3314            if self
3315                .state
3316                .judgements
3317                .get(j)
3318                .is_some_and(|r| r.failed.is_some())
3319            {
3320                continue;
3321            }
3322            let seat_key = format!("judge-{}", j + 1);
3323            let spec = self.occupant(&seat_key, spec);
3324            let seat = self.seat(&seat_key, &spec.id);
3325            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
3326            let text = if has_context(&spec, &seat, sessions) {
3327                prompt::final_vote(&viable, &language)
3328            } else {
3329                full.clone()
3330            };
3331            jobs.push(SeatJob {
3332                spec,
3333                seat,
3334                prompt: text,
3335                cwd: root.join(format!("judge-{}", j + 1)),
3336                timeout,
3337                allow_write: false,
3338                sessions,
3339                artifacts: artifacts.clone(),
3340                stem: format!("vote-judge-{}", j + 1),
3341                handover: Some(full),
3342            });
3343            seats_at.push(j);
3344        }
3345
3346        self.state.event(
3347            "vote",
3348            format!(
3349                "collecting {} final votes one by one, privately",
3350                jobs.len()
3351            ),
3352        );
3353        let allowed = viable.clone();
3354        let mut quota_losses = Vec::new();
3355        let cache = self.state.config.cache_dir();
3356        let ctx = WaveCtx {
3357            carry_seats: false,
3358            pauses: Some((&self.pause, &self.interrupt)),
3359            run: &run_id,
3360            node: "vote",
3361            prompts: &prompts,
3362            cache: cache.as_deref(),
3363            round: None,
3364        };
3365        let results = ask_json_wave::<FinalVote>(
3366            jobs,
3367            Arc::clone(&self.sem),
3368            self.state.config.graph.retries,
3369            &self.roles.judge_roster,
3370            &ctx,
3371            &mut quota_losses,
3372            &mut self.state,
3373            &move |v: &FinalVote| match v.label() {
3374                Some(c) if allowed.contains(&c) => Ok(()),
3375                other => bail!("vote {other:?} is not one of {allowed:?}"),
3376            },
3377        )
3378        .await;
3379        if self.state.resource_block.is_some() {
3380            return self.stop_for_resource();
3381        }
3382        self.state.quota.extend(quota_losses);
3383
3384        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3385            let agent_id = seat.agent.clone();
3386            self.state.seats.insert(seat.key.clone(), seat);
3387            let initial = self
3388                .state
3389                .judgements
3390                .get(j)
3391                .and_then(|r| r.ranking.first().copied());
3392            let mut record = VoteRecord {
3393                judge: j + 1,
3394                agent: agent_id,
3395                vote: None,
3396                reason: String::new(),
3397                changed: false,
3398            };
3399            match res {
3400                Ok((v, _)) => {
3401                    record.vote = v.label();
3402                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3403                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3404                    self.state.event(
3405                        "vote",
3406                        format!(
3407                            "judge {} voted {}{}",
3408                            j + 1,
3409                            record.vote.unwrap_or('?'),
3410                            if record.changed { " (changed)" } else { "" }
3411                        ),
3412                    );
3413                }
3414                Err(e) => {
3415                    self.state
3416                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3417                }
3418            }
3419            self.state.votes.push(record);
3420            self.state.save()?;
3421        }
3422        Ok(())
3423    }
3424
3425    // --------------------------------------------------------------- tally
3426
3427    fn tally(&mut self) -> Result<()> {
3428        if self.state.tally.is_some() {
3429            return Ok(());
3430        }
3431        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3432        let tops: Vec<char> = self
3433            .state
3434            .judgements
3435            .iter()
3436            .filter_map(|j| j.ranking.first().copied())
3437            .collect();
3438        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3439
3440        // A judge whose private vote failed still counted once, in the initial
3441        // ranking; using it beats discarding a whole seat.
3442        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3443        let mut cast: Vec<char> = Vec::new();
3444        for (i, j) in self.state.judgements.iter().enumerate() {
3445            let vote = self
3446                .state
3447                .votes
3448                .iter()
3449                .find(|v| v.judge == i + 1)
3450                .and_then(|v| v.vote)
3451                .or_else(|| j.ranking.first().copied());
3452            if let Some(v) = vote {
3453                *first_choice.entry(v).or_insert(0) += 1;
3454                cast.push(v);
3455            }
3456        }
3457
3458        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3459        for j in &self.state.judgements {
3460            let n = j.ranking.len();
3461            for (pos, label) in j.ranking.iter().enumerate() {
3462                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3463            }
3464        }
3465
3466        let best = first_choice.values().copied().max().unwrap_or(0);
3467        let mut leaders: Vec<char> = first_choice
3468            .iter()
3469            .filter(|(_, v)| **v == best)
3470            .map(|(k, _)| *k)
3471            .collect();
3472        let mut tie_break = None;
3473        if leaders.len() > 1 {
3474            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3475            let borda_leaders: Vec<char> = leaders
3476                .iter()
3477                .copied()
3478                .filter(|l| borda[l] == top_borda)
3479                .collect();
3480            tie_break = Some(if borda_leaders.len() == 1 {
3481                format!(
3482                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3483                    leaders.len()
3484                )
3485            } else {
3486                format!(
3487                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3488                    leaders.len()
3489                )
3490            });
3491            leaders = borda_leaders;
3492            leaders.sort_unstable();
3493        }
3494        let winner = *leaders
3495            .first()
3496            .or(viable.first())
3497            .context("no candidate to declare a winner from")?;
3498
3499        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3500        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3501        let deliberated = !self.state.deliberation.is_empty();
3502
3503        // Whose verdict is this? A rate-limited seat is absent even if it
3504        // ranked before the limit hit, so presence is measured against the
3505        // recorded losses, not just "did a ranking ever appear".
3506        let quota_seats: std::collections::BTreeSet<&str> =
3507            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3508        let mut present = 0usize;
3509        for (i, j) in self.state.judgements.iter().enumerate() {
3510            if quota_seats.contains(j.seat.as_str()) {
3511                continue;
3512            }
3513            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3514            let voted = self
3515                .state
3516                .votes
3517                .iter()
3518                .any(|v| v.judge == i + 1 && v.vote.is_some());
3519            if ranked || voted {
3520                present += 1;
3521            }
3522        }
3523        // Strict majority of the configured panel. A bare majority is real
3524        // signal we can act on, while a minority verdict must never stand in
3525        // for a healthy one. A one-candidate run needs no panel at all, and
3526        // `judges` stays `0` rather than the roster size a panel that never
3527        // sat would otherwise be credited with.
3528        let needs_quorum = viable.len() > 1;
3529        let judges_total = if needs_quorum {
3530            self.roles.judges.len()
3531        } else {
3532            0
3533        };
3534        let quorum = if needs_quorum {
3535            judges_total / 2 + 1
3536        } else {
3537            0
3538        };
3539        let met_quorum = !needs_quorum || present >= quorum;
3540        let uncontested = (!needs_quorum).then(|| {
3541            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3542        });
3543
3544        self.state.event(
3545            "tally",
3546            match &uncontested {
3547                Some(reason) => format!("winner {winner} — {reason}"),
3548                None => format!(
3549                    "winner {winner} — votes {} | initial {} | {} changed | \
3550                     {present}/{judges_total} judges{}",
3551                    first_choice
3552                        .iter()
3553                        .map(|(k, v)| format!("{k}:{v}"))
3554                        .collect::<Vec<_>>()
3555                        .join(" "),
3556                    if unanimous_initial {
3557                        "unanimous"
3558                    } else {
3559                        "split"
3560                    },
3561                    changed_votes,
3562                    if met_quorum {
3563                        String::new()
3564                    } else {
3565                        format!(" — below quorum ({quorum} required)")
3566                    },
3567                ),
3568            },
3569        );
3570        if !met_quorum {
3571            self.state.event(
3572                "stall",
3573                format!(
3574                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3575                     the run stops here, resumable"
3576                ),
3577            );
3578        }
3579        self.state.tally = Some(Tally {
3580            first_choice,
3581            borda,
3582            winner,
3583            rankings: tops.len(),
3584            unanimous_initial,
3585            deliberated,
3586            changed_votes,
3587            unanimous_final,
3588            tie_break,
3589            judges: judges_total,
3590            present,
3591            quorum,
3592            met_quorum,
3593            uncontested,
3594        });
3595        self.state.status = if met_quorum {
3596            RunStatus::Reviewing
3597        } else {
3598            RunStatus::Stalled
3599        };
3600        self.state.save()?;
3601        Ok(())
3602    }
3603
3604    // ------------------------------------------------------------- recover
3605
3606    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3607    /// resumed toward completion once the transient cause clears.
3608    ///
3609    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3610    /// it toward the quorum, which is exactly the set of seats whose absence
3611    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3612    /// not depend on which node happened to hit the limit), or an ordinary
3613    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3614    /// seat is never disturbed.
3615    ///
3616    /// A seat that now answers with a usable ranking is "recovered": its
3617    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3618    /// `tally` counts it present again), and its vote re-collected. A seat that
3619    /// still fails keeps its loss and stays absent.
3620    ///
3621    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3622    /// to review/gate/merge), `false` when it is still below quorum (the run
3623    /// stays `Stalled`, still resumable for a later retry).
3624    #[allow(clippy::too_many_lines)]
3625    async fn recover_stall(&mut self) -> Result<bool> {
3626        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3627        // agent files with `magi task add` name the run that paid for it. The
3628        // prompt overlay is cloned alongside it because the waves borrow it
3629        // while `self` is mutably borrowed by the node's own bookkeeping.
3630        let run_id = self.state.id.clone();
3631        let prompts = self.state.config.prompts.clone();
3632        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3633        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3634        // is what keeps a non-quota collapse — or a quota loss recorded at the
3635        // deliberate node — from being a permanent dead-end on `--resume`.
3636        let quota_seats: BTreeSet<&str> =
3637            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3638        let absent: Vec<String> = self
3639            .state
3640            .judgements
3641            .iter()
3642            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3643            .map(|j| j.seat.clone())
3644            .collect();
3645        if absent.is_empty() {
3646            return Ok(false);
3647        }
3648        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3649        if viable.len() <= 1 {
3650            return Ok(false);
3651        }
3652        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3653        let language = self.state.config.graph.language.clone();
3654        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3655        let sessions = self.state.config.graph.sessions;
3656        let artifacts = agent::artifacts_dir(&self.state.dir());
3657        let root = self.state.worktree_root();
3658        let base_short = short(&self.state.base_commit);
3659        let candidates: Vec<Candidate> = viable.clone();
3660
3661        // Map each absent seat key to its 0-based position in `roles.judges`.
3662        let mut positions: Vec<usize> = absent
3663            .iter()
3664            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3665            .collect();
3666        if positions.is_empty() {
3667            return Ok(false);
3668        }
3669        positions.sort_unstable();
3670        positions.dedup();
3671
3672        // Re-rank the lost seats, one blind prompt each.
3673        let mut judge_jobs = Vec::new();
3674        for &j in &positions {
3675            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3676            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3677            let seat_key = format!("judge-{}", j + 1);
3678            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3679            let seat = self.seat(&seat_key, &spec.id);
3680            judge_jobs.push(SeatJob {
3681                spec,
3682                seat,
3683                prompt: prompt::judge(
3684                    &self.state.instruction,
3685                    &views,
3686                    self.roles.judges.len(),
3687                    &base_short,
3688                    &language,
3689                ),
3690                cwd: root.join(seat_key),
3691                timeout,
3692                allow_write: false,
3693                sessions,
3694                artifacts: artifacts.clone(),
3695                stem: format!("judge-{}-recover", j + 1),
3696                handover: None,
3697            });
3698        }
3699
3700        let labels_for_check = labels.clone();
3701        let mut judge_losses = Vec::new();
3702        let retries = self.state.config.graph.retries;
3703        let cache = self.state.config.cache_dir();
3704        let ctx = WaveCtx {
3705            carry_seats: false,
3706            pauses: Some((&self.pause, &self.interrupt)),
3707            run: &run_id,
3708            node: "judge",
3709            prompts: &prompts,
3710            cache: cache.as_deref(),
3711            round: None,
3712        };
3713        let results = ask_json_wave::<Ranking>(
3714            judge_jobs,
3715            Arc::clone(&self.sem),
3716            retries,
3717            &self.roles.judge_roster,
3718            &ctx,
3719            &mut judge_losses,
3720            &mut self.state,
3721            &move |r: &Ranking| r.validate(&labels_for_check),
3722        )
3723        .await;
3724        if self.state.resource_block.is_some() {
3725            self.stop_for_resource()?;
3726            return Ok(false);
3727        }
3728
3729        // Refresh the judgement of every seat that ranked again.
3730        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3731        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3732            let agent_id = seat.agent.clone();
3733            self.state.seats.insert(seat.key.clone(), seat);
3734            let record = &mut self.state.judgements[j];
3735            match res {
3736                Ok((ranking, out)) => {
3737                    record.agent = agent_id;
3738                    record.ranking = ranking.normalized();
3739                    record.reasons = ranking.reasons;
3740                    record.strengths = ranking.strengths;
3741                    record.confidence = ranking.confidence;
3742                    record.failed = None;
3743                    record.duration_ms = out.duration_ms;
3744                    recovered.insert(j);
3745                    self.state.event(
3746                        "recover",
3747                        format!("judge {} ranked again after the limit", j + 1),
3748                    );
3749                }
3750                Err(e) => {
3751                    self.state
3752                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3753                }
3754            }
3755        }
3756
3757        // Re-ask the votes of the seats that recovered a ranking.
3758        let mut vote_jobs = Vec::new();
3759        let mut vote_pos: Vec<usize> = Vec::new();
3760        for &j in &recovered {
3761            let seat_key = format!("judge-{}", j + 1);
3762            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3763            let seat = self.seat(&seat_key, &spec.id);
3764            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3765            let text = if has_context(&spec, &seat, sessions) {
3766                prompt::final_vote(&labels, &language)
3767            } else {
3768                full.clone()
3769            };
3770            vote_jobs.push(SeatJob {
3771                spec,
3772                seat,
3773                prompt: text,
3774                cwd: root.join(seat_key),
3775                timeout,
3776                allow_write: false,
3777                sessions,
3778                artifacts: artifacts.clone(),
3779                stem: format!("vote-judge-{}-recover", j + 1),
3780                handover: Some(full),
3781            });
3782            vote_pos.push(j);
3783        }
3784        let allowed = labels.clone();
3785        let mut vote_losses = Vec::new();
3786        let vote_retries = self.state.config.graph.retries;
3787        let vote_cache = self.state.config.cache_dir();
3788        let ctx = WaveCtx {
3789            carry_seats: false,
3790            pauses: Some((&self.pause, &self.interrupt)),
3791            run: &run_id,
3792            node: "vote",
3793            prompts: &prompts,
3794            cache: vote_cache.as_deref(),
3795            round: None,
3796        };
3797        let votes = ask_json_wave::<FinalVote>(
3798            vote_jobs,
3799            Arc::clone(&self.sem),
3800            vote_retries,
3801            &self.roles.judge_roster,
3802            &ctx,
3803            &mut vote_losses,
3804            &mut self.state,
3805            &move |v: &FinalVote| match v.label() {
3806                Some(c) if allowed.contains(&c) => Ok(()),
3807                other => bail!("vote {other:?} is not one of {allowed:?}"),
3808            },
3809        )
3810        .await;
3811        if self.state.resource_block.is_some() {
3812            self.stop_for_resource()?;
3813            return Ok(false);
3814        }
3815        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3816            let agent_id = seat.agent.clone();
3817            self.state.seats.insert(seat.key.clone(), seat);
3818            match res {
3819                Ok((v, _)) => {
3820                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3821                        rec.vote = v.label();
3822                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3823                    } else {
3824                        self.state.votes.push(VoteRecord {
3825                            judge: j + 1,
3826                            agent: agent_id,
3827                            vote: v.label(),
3828                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3829                            changed: false,
3830                        });
3831                    }
3832                    self.state.event(
3833                        "recover",
3834                        format!("judge {} voted again after the limit", j + 1),
3835                    );
3836                }
3837                Err(e) => {
3838                    self.state
3839                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3840                }
3841            }
3842        }
3843
3844        // A seat that ranked again is present even if its re-vote failed —
3845        // `tally` falls back to the initial ranking's first choice — so clear
3846        // its quota loss. Seats that still fail keep theirs and stay absent.
3847        let recovered_keys: BTreeSet<String> = recovered
3848            .iter()
3849            .map(|&j| format!("judge-{}", j + 1))
3850            .collect();
3851        self.state
3852            .quota
3853            .retain(|q| !recovered_keys.contains(&q.seat));
3854        // A seat that hit the limit again is a fresh loss, not the old one:
3855        // replace the stale entry so the history stays one-per-seat and the
3856        // daemon can tell this attempt's loss from a previous session's.
3857        for loss in judge_losses.into_iter().chain(vote_losses) {
3858            if recovered_keys.contains(&loss.seat) {
3859                continue;
3860            }
3861            self.state.quota.retain(|q| q.seat != loss.seat);
3862            self.state.quota.push(loss);
3863        }
3864
3865        // Recompute the verdict from the refreshed panel.
3866        self.state.tally = None;
3867        self.tally()?;
3868        Ok(self
3869            .state
3870            .tally
3871            .as_ref()
3872            .map(|t| t.met_quorum)
3873            .unwrap_or(false))
3874    }
3875
3876    // ---------------------------------------------------------------- graft
3877
3878    /// Port the strengths the judges attributed to the losing candidates onto
3879    /// the winner, once, between the tally and the review loop.
3880    ///
3881    /// Best-effort by construction: it returns nothing and fails nothing. A
3882    /// skip, a fixer that failed or hit quota, a round that changed nothing
3883    /// and a result over the size limit ([`graft::limit`]) all leave the
3884    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
3885    /// run carries on as if the phase were off. See [`graft`] for the
3886    /// decisions and `AGENTS.md` for the invariants.
3887    ///
3888    /// The one exception is a rollback that could not be confirmed: the
3889    /// rejected commit may still be the winner's tip, so the run is blocked
3890    /// (resumable; the record stays `Running`, which makes a resume retry the
3891    /// restore) instead of reviewing, gating and merging it.
3892    async fn graft(&mut self) -> Result<()> {
3893        let Err(e) = self.graft_inner().await else {
3894            return Ok(());
3895        };
3896        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3897            self.state.status = RunStatus::Blocked;
3898            self.state.event("graft", format!("blocked: {e:#}"));
3899            self.state.save()?;
3900            return Err(e);
3901        }
3902        // An error after the `Running` record was saved may have left the
3903        // fixer's work in the tree: the record must not stay unsettled
3904        // (a later resume would reset past the review fixes), so confirm the
3905        // restore now, or block.
3906        if let Some(rec) = self.state.graft.clone()
3907            && !rec.outcome.settled()
3908        {
3909            if let Some(winner) = self.state.winner().cloned() {
3910                if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3911                    self.state.status = RunStatus::Blocked;
3912                    self.state.event("graft", format!("blocked: {re:#}"));
3913                    self.state.save()?;
3914                    return Err(re);
3915                }
3916            }
3917            if let Some(g) = self.state.graft.as_mut() {
3918                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3919            }
3920        }
3921        self.state.event("graft", format!("skipped: {e:#}"));
3922        if let Err(e) = self.state.save() {
3923            tracing::warn!("save after graft: {e:#}");
3924        }
3925        Ok(())
3926    }
3927
3928    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
3929    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
3930    async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3931        crate::recovery::restore_repair(wt, pre)
3932            .await
3933            .map_err(|e| GraftRestoreFailed(format!("{e:#}")).into())
3934    }
3935
3936    /// A skip is a run event, recorded once however often the node is re-entered.
3937    fn graft_skipped(&mut self, why: &str) {
3938        let message = format!("skipped: {why}");
3939        if !self
3940            .state
3941            .events
3942            .iter()
3943            .any(|e| e.node == "graft" && e.message == message)
3944        {
3945            self.state.event("graft", message);
3946        }
3947    }
3948
3949    async fn graft_inner(&mut self) -> Result<()> {
3950        // A round that was started and never finished (crash, kill): the
3951        // fixer may have left anything in the tree, so put the winner back.
3952        if let Some(rec) = self.state.graft.clone()
3953            && !rec.outcome.settled()
3954        {
3955            // Reviews exist only if the graft ended without its record being
3956            // settled (a swallowed error): the tree has moved on since, and
3957            // a reset to `pre` would discard the review fixes.
3958            if self.state.reviews.is_empty()
3959                && let Some(winner) = self.state.winner().cloned()
3960            {
3961                Self::graft_restore(&winner.worktree, &rec.pre).await?;
3962            }
3963            if let Some(g) = self.state.graft.as_mut() {
3964                g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3965            }
3966            self.state.event(
3967                "graft",
3968                "rolled back: the previous round was interrupted; the winner is untouched",
3969            );
3970            self.state.save()?;
3971            return Ok(());
3972        }
3973        let plan = match graft::plan(&self.state) {
3974            Ok(plan) => plan,
3975            Err(skip) => {
3976                if skip != graft::Skip::AlreadyDone {
3977                    self.graft_skipped(skip.reason());
3978                    self.state.save()?;
3979                }
3980                return Ok(());
3981            }
3982        };
3983        let Some(winner) = self.state.winner().cloned() else {
3984            return Ok(());
3985        };
3986        let wt = winner.worktree.clone();
3987        if !git::is_clean(&wt).await? {
3988            self.graft_skipped("the winner's worktree has uncommitted changes");
3989            self.state.save()?;
3990            return Ok(());
3991        }
3992        let pre = git::rev_parse(&wt, "HEAD").await?;
3993        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3994        else {
3995            self.graft_skipped("the winner's diff could not be measured");
3996            self.state.save()?;
3997            return Ok(());
3998        };
3999        let limit = graft::limit(winner_lines);
4000        // Saved before the fixer is called: a stop from here on is repaired
4001        // by the branch above on the next entry.
4002        self.state.graft = Some(GraftRecord {
4003            pre: pre.clone(),
4004            post: None,
4005            lines: None,
4006            limit: Some(limit),
4007            agent: None,
4008            outcome: GraftOutcome::Running,
4009            requested: graft::requested(&plan),
4010            adoption: None,
4011            adoption_error: None,
4012        });
4013        self.state.event(
4014            "graft",
4015            format!(
4016                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
4017                plan.strengths
4018                    .iter()
4019                    .map(|(l, _, _)| l.to_string())
4020                    .collect::<Vec<_>>()
4021                    .join(", "),
4022                plan.winner
4023            ),
4024        );
4025        self.state.save()?;
4026
4027        let fix_prompt = prompt::graft(
4028            &self.state.instruction,
4029            plan.winner,
4030            &plan.strengths,
4031            limit,
4032            &self.state.config.graph.language,
4033        );
4034        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4035        let sessions = self.state.config.graph.sessions;
4036        let artifacts = agent::artifacts_dir(&self.state.dir());
4037        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
4038        let (_, seat, out) = self
4039            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
4040                SeatJob {
4041                    prompt: fix_prompt.clone(),
4042                    spec,
4043                    seat,
4044                    cwd: wt.clone(),
4045                    timeout,
4046                    allow_write: true,
4047                    sessions,
4048                    artifacts: artifacts.clone(),
4049                    stem: "graft".to_owned(),
4050                    handover: None,
4051                }
4052            })
4053            .await;
4054        // Never spawned: nothing changed (`HEAD == pre` is confirmed), so the
4055        // record goes back to "not started" - not `RolledBack`, which would
4056        // settle it - and the run stops resumably. The seat, the chain and
4057        // the quota are untouched.
4058        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
4059            Self::graft_restore(&wt, &pre).await?;
4060            self.state.graft = None;
4061            self.state.save()?;
4062            return self.stop_for_resource();
4063        }
4064        let mut why: Option<String> = None;
4065        match &out {
4066            AgentOutcome::Ok(o) if agent::output_advances(o) => {
4067                why = Some("the fixer produced nothing usable".to_owned());
4068            }
4069            AgentOutcome::Ok(_) => {}
4070            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
4071            AgentOutcome::Quota(o) => {
4072                self.state.quota.push(QuotaLoss {
4073                    seat: seat.key.clone(),
4074                    node: "graft".to_owned(),
4075                    at: Timestamp::now(),
4076                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4077                });
4078                why = Some("rate limited (quota)".to_owned());
4079            }
4080            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
4081                why = Some(format!(
4082                    "the fixer failed: {}",
4083                    e.lines()
4084                        .next()
4085                        .unwrap_or("")
4086                        .chars()
4087                        .take(160)
4088                        .collect::<String>()
4089                ));
4090            }
4091        }
4092        let agent_id = seat.agent.clone();
4093        self.state.seats.insert(seat.key.clone(), seat);
4094
4095        let mut post = None;
4096        let mut lines = None;
4097        if why.is_none() {
4098            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
4099            if let Ok(r) = &rescued {
4100                self.state.note_withheld("graft", &r.withheld);
4101            }
4102            let head = git::rev_parse(&wt, "HEAD").await?;
4103            // What is measured must be what gets reviewed and gated: a failed
4104            // rescue, or anything still uncommitted (withheld lockfiles
4105            // aside, which are never part of the tree), rejects the graft.
4106            let dirty = match &rescued {
4107                Ok(r) => git::status_porcelain(&wt)
4108                    .await
4109                    .map(|st| {
4110                        st.lines().any(|l| {
4111                            !r.withheld
4112                                .iter()
4113                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
4114                        })
4115                    })
4116                    .unwrap_or(true),
4117                Err(_) => true,
4118            };
4119            if dirty {
4120                why = Some("the fixer left changes that could not be committed".to_owned());
4121            } else if head == pre {
4122                why = Some("the fixer changed nothing".to_owned());
4123            } else {
4124                lines = git::changed_lines(&wt, &pre, &head).await;
4125                if graft::within_budget(lines, limit) {
4126                    post = Some(head);
4127                } else {
4128                    why = Some(format!(
4129                        "{} changed lines against a limit of {limit}",
4130                        lines.map_or_else(
4131                            || "an unmeasurable number of".to_owned(),
4132                            |l| l.to_string()
4133                        )
4134                    ));
4135                }
4136            }
4137        }
4138        let (adoption, adoption_error) = match &out {
4139            AgentOutcome::Ok(o) => crate::graft_report::adoption_of(&o.text),
4140            _ => (None, None),
4141        };
4142        let requested = graft::requested(&plan);
4143        let outcome = match why {
4144            None => {
4145                self.state.event(
4146                    "graft",
4147                    format!(
4148                        "applied: {} changed lines (limit {limit})",
4149                        lines.unwrap_or(0)
4150                    ),
4151                );
4152                GraftOutcome::Applied
4153            }
4154            Some(why) => {
4155                // Not settled until the restore is confirmed: the record
4156                // stays `Running`, so a resume retries it.
4157                Self::graft_restore(&wt, &pre).await?;
4158                self.state.event(
4159                    "graft",
4160                    format!("rolled back: {why}; the winner is untouched"),
4161                );
4162                GraftOutcome::RolledBack(why)
4163            }
4164        };
4165        self.state.graft = Some(GraftRecord {
4166            pre,
4167            post,
4168            lines,
4169            limit: Some(limit),
4170            agent: Some(agent_id),
4171            outcome,
4172            requested,
4173            adoption,
4174            adoption_error,
4175        });
4176        self.state.save()?;
4177        Ok(())
4178    }
4179
4180    // ----------------------------------------------------------------- fold
4181
4182    async fn fold_losers(&mut self) -> Result<()> {
4183        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
4184            return Ok(());
4185        };
4186        let repo = self.state.repo.clone();
4187        let mut folded = Vec::new();
4188        for i in 0..self.state.candidates.len() {
4189            let c = &self.state.candidates[i];
4190            if c.label == winner || c.folded {
4191                continue;
4192            }
4193            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
4194            git::worktree_remove(&repo, &wt).await.ok();
4195            git::branch_delete(&repo, &branch).await.ok();
4196            self.state.candidates[i].folded = true;
4197            folded.push(label.to_string());
4198        }
4199        // The judges are finished; their checkouts are pure cost from here.
4200        let root = self.state.worktree_root();
4201        for j in 1..=self.roles.judges.len() {
4202            let wt = root.join(format!("judge-{j}"));
4203            if wt.exists() {
4204                git::worktree_remove(&repo, &wt).await.ok();
4205            }
4206        }
4207        // The design-deliberation stage is finished by the time a tally
4208        // exists — same reasoning as the judges above.
4209        if self.state.config.graph.advise {
4210            for k in 1..=self.state.config.graph.advisors {
4211                let wt = root.join(format!("advisor-{k}"));
4212                if wt.exists() {
4213                    git::worktree_remove(&repo, &wt).await.ok();
4214                }
4215            }
4216        }
4217        if !folded.is_empty() {
4218            self.state
4219                .event("fold", format!("folded candidates {}", folded.join(", ")));
4220            self.state.save()?;
4221        }
4222        Ok(())
4223    }
4224
4225    // ------------------------------------------------------------ base sync
4226
4227    /// Land the winner's tree on the current tip of `<remote>/<base>` before
4228    /// anything verifies it.
4229    ///
4230    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
4231    /// read whatever is checked out in the winner's worktree. Left alone that
4232    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
4233    /// it when the run *branched* - and a run takes long enough that the base
4234    /// has usually moved by the time it gets here. A gate that ran there
4235    /// answers "green on the commit this run started from", not "green on
4236    /// what is about to land", and the difference showed up three times in
4237    /// one day as a green run whose merge would have reverted a file another
4238    /// pull request had already landed.
4239    ///
4240    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
4241    /// `land::Step::Rebase` calls, rather than a second implementation of the
4242    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
4243    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
4244    /// doc warns about ("two answers to a question nobody notices until a
4245    /// diff is wrong").
4246    ///
4247    /// A conflict is not the end of the road: the standing rebase is handed
4248    /// to the fixer seat, at most `graph.review_rounds` times, counted in
4249    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
4250    /// land). Once it finishes, review and the gate run as usual on the
4251    /// rebased tree, which is where a breakage the new base caused is caught
4252    /// by the ordinary gate-fix round. magi resolves nothing itself.
4253    ///
4254    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
4255    /// `state.base_sync.attempts`, is how many times the base is *rebased
4256    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
4257    /// *conflict* was given to a fixer. When the fixer cannot finish the
4258    /// rebase the branch is restored, `state.base_sync.conflict` is set with
4259    /// what was tried (rounds spent, paths still conflicted) and the branch
4260    /// and worktree stay exactly as they were - untouched, for a person to
4261    /// look at - which is also what makes re-entering this function
4262    /// afterwards a no-op instead of a second attempt at the same wall. A
4263    /// push failure ends the same way.
4264    async fn sync_to_base(&mut self) -> Result<()> {
4265        if self.state.status == RunStatus::AlreadyInBase {
4266            return Ok(());
4267        }
4268        let conflicted = self
4269            .state
4270            .base_sync
4271            .as_ref()
4272            .is_some_and(|s| s.conflict.is_some());
4273        let Some(winner) = self.state.winner().cloned() else {
4274            return Ok(());
4275        };
4276
4277        let repo = self.state.repo.clone();
4278        let remote = self.state.config.merge.remote.clone();
4279        let base_branch = self.state.base_branch.clone();
4280        let tracking = format!("{remote}/{base_branch}");
4281
4282        git::fetch(&repo, &remote, &base_branch).await.ok();
4283        // No network, or the remote never had this branch: the run already
4284        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
4285        // otherwise), and one that got this far is not blocked by a fetch
4286        // that fails now. It just has no newer tip to compare against.
4287        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
4288            return Ok(());
4289        };
4290
4291        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4292        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
4293        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
4294
4295        // Before any rebase, and before a recorded conflict is honoured: a
4296        // branch whose change reached the base under other commit ids has
4297        // nothing to rebase and nothing to conflict with, and a run that
4298        // already stopped on that phantom conflict recovers here on resume.
4299        // `behind == 0` with head == tip is a branch the base has since taken
4300        // in whole, whether or not a conflict was ever recorded: the ancestry
4301        // proof must still run (`classify` ignores a head still on the start
4302        // commit).
4303        if (behind > 0 || conflicted || head == tip)
4304            && self
4305                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
4306                .await?
4307        {
4308            return Ok(());
4309        }
4310        if conflicted {
4311            return Ok(());
4312        }
4313
4314        if behind == 0 {
4315            // A fixer-finished rebase moves the branch ref before the
4316            // winner's worktree is told (`sync_to_head` below). A run that
4317            // died in between resumes here with `behind == 0` and a tree still
4318            // holding the pre-rebase files, which review and the gate would
4319            // then read. That state is exactly: HEAD moved off the tip the
4320            // rebase started from, yet the tree is still identical to that
4321            // tip. A tree with edits of its own differs from it, so nothing
4322            // is thrown away.
4323            if let Some(from) = self
4324                .state
4325                .rebase_fixes
4326                .iter()
4327                .rev()
4328                .find_map(|r| r.from.clone())
4329                && from != head
4330                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
4331                    .await
4332                    .is_ok_and(|o| o.ok())
4333            {
4334                git::sync_to_head(&winner.worktree).await?;
4335            }
4336            // An earlier attempt may have rebased the branch locally and died
4337            // before pushing it (only the fresh-rebase arm below pushes).
4338            // Publish it now, so the plain push at PR time is not refused as
4339            // a non-fast-forward. A run already holding a recorded conflict
4340            // never reaches here; that case is out of scope.
4341            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
4342            if let Some(why) = &conflict {
4343                self.state.status = RunStatus::Blocked;
4344                self.state.event("land", why.clone());
4345            }
4346            self.state.base_sync = Some(BaseSync {
4347                tip,
4348                behind: 0,
4349                attempts,
4350                conflict,
4351                already_in: None,
4352            });
4353            self.state.save()?;
4354            return Ok(());
4355        }
4356
4357        if attempts >= BASE_SYNC_ROUNDS {
4358            let why = format!(
4359                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
4360                 rebase(s); rebasing again would only race it",
4361                winner.branch
4362            );
4363            self.state.status = RunStatus::Blocked;
4364            self.state.base_sync = Some(BaseSync {
4365                tip,
4366                behind,
4367                attempts,
4368                conflict: Some(why.clone()),
4369                already_in: None,
4370            });
4371            self.state.event("land", why);
4372            self.state.save()?;
4373            return Ok(());
4374        }
4375
4376        self.state.event(
4377            "land",
4378            format!(
4379                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
4380                winner.branch
4381            ),
4382        );
4383        self.state.save()?;
4384
4385        // The remote's copy of the branch, read now and only if the fetch
4386        // really succeeded (a stale tracking ref must never pin a lease). It is
4387        // pushed over after a rebase only when it is a commit this branch
4388        // already contains, by ancestry or by patch (an earlier rebase of ours
4389        // that never reached the remote): anything else is somebody else's work.
4390        let branch_tracking = format!("{remote}/{}", winner.branch);
4391        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4392        let remote_tip = if self.state.task_pr_branch().is_none()
4393            && matches!(&fetched_branch, Ok(o) if o.ok())
4394        {
4395            git::rev_parse(&repo, &branch_tracking).await.ok()
4396        } else {
4397            None
4398        };
4399        // A remote tip this branch does not contain is somebody else's work:
4400        // rebasing would leave a local tip that can never be pushed. Stop
4401        // before touching anything and say so.
4402        if let Some(theirs) = &remote_tip
4403            && !git::is_ancestor(&repo, theirs, &head).await
4404            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4405                .await
4406                .is_ok_and(|missing| missing.is_empty())
4407        {
4408            let why = format!(
4409                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4410                 them",
4411                short(theirs),
4412                winner.branch
4413            );
4414            self.state.status = RunStatus::Blocked;
4415            self.state.base_sync = Some(BaseSync {
4416                tip,
4417                behind,
4418                attempts,
4419                conflict: Some(why.clone()),
4420                already_in: None,
4421            });
4422            self.state.event("land", why);
4423            self.state.save()?;
4424            return Ok(());
4425        }
4426
4427        let scratch = self.state.dir().join("base-sync");
4428        let rebased = match crate::rebase::rebase_with_fixer(
4429            &mut self.state,
4430            &scratch,
4431            &winner.branch,
4432            &tracking,
4433        )
4434        .await
4435        {
4436            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4437            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4438            Err(e) => Err(e),
4439        };
4440        let attempts = attempts + 1;
4441        match rebased {
4442            Ok(None) => {
4443                // The branch ref moved, but a worktree that already had it
4444                // checked out (the winner's) was not told; sync its index and
4445                // files before anything reads them.
4446                git::sync_to_head(&winner.worktree).await?;
4447                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4448                let mut conflict = None;
4449                if let Some(pinned) = &remote_tip {
4450                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4451                    match pushed {
4452                        Ok(o) if o.ok() => self.state.event(
4453                            "land",
4454                            format!("pushed rebased {} to {remote}", winner.branch),
4455                        ),
4456                        Ok(o) => {
4457                            conflict = Some(format!(
4458                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4459                                winner.branch,
4460                                short(pinned),
4461                                o.stderr.chars().take(600).collect::<String>()
4462                            ));
4463                        }
4464                        Err(e) => {
4465                            conflict = Some(format!(
4466                                "rebased {} locally but could not push it: {e:#}",
4467                                winner.branch
4468                            ));
4469                        }
4470                    }
4471                }
4472                if let Some(why) = &conflict {
4473                    self.state.status = RunStatus::Blocked;
4474                    self.state.event("land", why.clone());
4475                }
4476                self.state.base_sync = Some(BaseSync {
4477                    tip: tip.clone(),
4478                    behind: 0,
4479                    attempts,
4480                    conflict,
4481                    already_in: None,
4482                });
4483                self.state
4484                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4485            }
4486            Ok(Some(conflict)) => {
4487                let why = format!(
4488                    "{} conflicts with {tracking} and did not rebase: {}",
4489                    winner.branch,
4490                    conflict.chars().take(600).collect::<String>()
4491                );
4492                self.state.status = RunStatus::Blocked;
4493                self.state.base_sync = Some(BaseSync {
4494                    tip,
4495                    behind,
4496                    attempts,
4497                    conflict: Some(why.clone()),
4498                    already_in: None,
4499                });
4500                self.state.event("land", why);
4501            }
4502            Err(e) => {
4503                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4504                self.state.status = RunStatus::Blocked;
4505                self.state.base_sync = Some(BaseSync {
4506                    tip,
4507                    behind,
4508                    attempts,
4509                    conflict: Some(why.clone()),
4510                    already_in: None,
4511                });
4512                self.state.event("land", why);
4513            }
4514        }
4515        self.state.save()?;
4516        Ok(())
4517    }
4518
4519    /// Push a branch an earlier attempt rebased locally but never published,
4520    /// pinned to the remote tip read right after a successful fetch. Returns
4521    /// the reason when the run must stop; `None` when there was nothing to do
4522    /// (no remote copy, the same tip, or a remote copy this branch already
4523    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4524    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4525        // Task competitions publish only after the PR text gate, to their
4526        // public head; never resurrect a candidate branch on the remote.
4527        if self.state.task_pr_branch().is_some() {
4528            return None;
4529        }
4530        let repo = self.state.repo.clone();
4531        let remote = self.state.config.merge.remote.clone();
4532        let fetched = git::fetch(&repo, &remote, branch).await;
4533        if !matches!(&fetched, Ok(o) if o.ok()) {
4534            return None;
4535        }
4536        let branch_tracking = format!("{remote}/{branch}");
4537        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4538        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4539            return None;
4540        }
4541        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4542            .await
4543            .is_ok_and(|missing| missing.is_empty())
4544        {
4545            return Some(format!(
4546                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4547                 them",
4548                short(&theirs)
4549            ));
4550        }
4551        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4552            Ok(o) if o.ok() => {
4553                self.state
4554                    .event("land", format!("pushed rebased {branch} to {remote}"));
4555                None
4556            }
4557            Ok(o) => Some(format!(
4558                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4559                 someone may have pushed): {}",
4560                short(&theirs),
4561                o.stderr.chars().take(600).collect::<String>()
4562            )),
4563            Err(e) => Some(format!(
4564                "{branch} is rebased locally but could not be pushed: {e:#}"
4565            )),
4566        }
4567    }
4568
4569    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4570    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4571    /// whether it did.
4572    ///
4573    /// Checked only when the base is ahead of the branch. A failing check is
4574    /// "not proven" - the ordinary rebase path then decides - never a reason to
4575    /// stop the run.
4576    ///
4577    /// The remote copy of the branch is held to the same standard as the local
4578    /// one: if it carries a tip this worktree does not, that tip must itself be
4579    /// proven in the base, or nothing is settled (a pull request would
4580    /// otherwise be closed over commits nobody checked). The pull request is
4581    /// closed *before* the terminal status is saved; if that fails for a
4582    /// reason other than a refusal (no network, a `gh` error) the run is left
4583    /// `Blocked` with the reason as its conflict, which a resume retries -
4584    /// the same recovery a phantom conflict gets.
4585    async fn settle_already_in(
4586        &mut self,
4587        branch: &str,
4588        tip: &str,
4589        head: &str,
4590        attempts: usize,
4591        behind: usize,
4592    ) -> Result<bool> {
4593        let repo = self.state.repo.clone();
4594        let remote = self.state.config.merge.remote.clone();
4595        let start = self.state.base_commit.clone();
4596        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4597            Ok(Some(e)) => e,
4598            Ok(None) => return Ok(false),
4599            Err(e) => {
4600                tracing::warn!("already-in-base check for {branch}: {e:#}");
4601                return Ok(false);
4602            }
4603        };
4604        let mut verified = vec![head.to_owned()];
4605        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4606        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4607        if matches!(&fetched, Ok(o) if o.ok())
4608            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4609            && theirs != head
4610        {
4611            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4612                Ok(Some(_)) => verified.push(theirs),
4613                _ => return Ok(false),
4614            }
4615        }
4616        let base_branch = self.state.base_branch.clone();
4617        let message = format!(
4618            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4619             land",
4620            evidence.names(),
4621            evidence.proof.as_str()
4622        );
4623        let closed =
4624            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4625                .await;
4626        match closed {
4627            Ok(Ok(url)) => self
4628                .state
4629                .event("land", format!("closed {url}: superseded on {base_branch}")),
4630            Ok(Err(why)) => self
4631                .state
4632                .event("land", format!("did not close a pull request: {why}")),
4633            Err(e) => {
4634                let why = format!(
4635                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4636                     not be closed ({e:#}); resume to retry"
4637                );
4638                self.state.status = RunStatus::Blocked;
4639                self.state.base_sync = Some(BaseSync {
4640                    tip: tip.to_owned(),
4641                    behind,
4642                    attempts,
4643                    conflict: Some(why.clone()),
4644                    already_in: None,
4645                });
4646                self.state.event("land", why);
4647                self.state.save()?;
4648                return Ok(true);
4649            }
4650        }
4651        self.state.status = RunStatus::AlreadyInBase;
4652        self.state.base_sync = Some(BaseSync {
4653            tip: tip.to_owned(),
4654            behind,
4655            attempts,
4656            conflict: None,
4657            already_in: Some(evidence),
4658        });
4659        self.state.event("land", message);
4660        self.state.save()?;
4661        self.settle_questions();
4662        Ok(true)
4663    }
4664
4665    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4666    /// last landed the winner on, once it has run, else the commit the run
4667    /// branched from.
4668    ///
4669    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4670    /// and `vote` all happen before there is a winner to rebase, so they
4671    /// compare every candidate against the branch point on purpose, and a
4672    /// base that moves after they are already done cannot change an answer
4673    /// they already gave.
4674    fn landing_base(&self) -> String {
4675        self.state
4676            .base_sync
4677            .as_ref()
4678            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4679    }
4680
4681    // ------------------------------------------------------- operator fix
4682
4683    /// Route specific, already-recorded review findings to a fixer for a
4684    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4685    /// entry point.
4686    ///
4687    /// Distinct from `review_loop`'s own fix step in three ways: it never
4688    /// runs a reviewer wave, it never spends review-round budget, and what
4689    /// happened is recorded as an [`OperatorFixRequest`] appended to
4690    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4691    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4692    /// and vote must never be rewritten to look like a manufactured blocking
4693    /// verdict.
4694    ///
4695    /// Only meaningful once review has actually concluded: `Ready` (handed
4696    /// off with findings still open, or simply concluded clean while minor
4697    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4698    /// gate failed). Everything else is refused: a run still in progress
4699    /// should simply be resumed, and a `Merged` run's branch has already
4700    /// landed — reopening *this* run's own record cannot change that, so the
4701    /// answer there is a fresh `magi review <branch>`.
4702    ///
4703    /// A real commit here re-verifies through a fresh, ordinary review-only
4704    /// run on the same branch ([`Self::review`]) rather than reopening this
4705    /// run's own `review_loop`: once any round in this run's history went
4706    /// clean, `review_conclusion` treats that as permanent by design (the
4707    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4708    /// way to force one more genuine reviewer wave out of *this* run without
4709    /// either rewriting history or weakening that guarantee for every other
4710    /// caller. A review-only run costs nothing extra — no implementation, no
4711    /// judging, no vote — and exercises the exact same review → verify →
4712    /// gate → (human) merge path, unmodified.
4713    pub async fn fix_selected(
4714        &mut self,
4715        ids: &[String],
4716        reason: &str,
4717        allow_stale: bool,
4718    ) -> Result<()> {
4719        let reason = reason.trim();
4720        if reason.is_empty() {
4721            bail!("a fix request needs a reason — that is the operator's own record of why");
4722        }
4723        if ids.is_empty() {
4724            bail!("no finding id given");
4725        }
4726        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4727            bail!(
4728                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4729                 has already concluded — can be given a targeted fix. A run still \
4730                 in progress should simply be resumed; a `merged` run's branch has \
4731                 already landed, so its answer is a fresh `magi review <branch>`, \
4732                 not reopening this run's own record",
4733                self.state.id,
4734                self.state.status.as_str()
4735            );
4736        }
4737        let Some(winner) = self.state.winner().cloned() else {
4738            bail!("run {} has no winning candidate to fix", self.state.id);
4739        };
4740        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4741            bail!(
4742                "branch `{}` no longer exists; this run cannot be extended",
4743                winner.branch
4744            );
4745        }
4746        let home = crate::run::home();
4747        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4748            bail!(
4749                "run {} is currently being worked on by another magi process",
4750                self.state.id
4751            );
4752        }
4753        // Held for the rest of this call, including the follow-up review
4754        // below: two `magi fix` invocations against the same run must not
4755        // both reach the worktree manipulation further down, which would
4756        // otherwise race to remove and recreate the same directory — see
4757        // [`FixClaim`]'s own doc.
4758        let _claim = FixClaim::acquire(&self.state.dir())?;
4759
4760        // Resolve every id before spending anything — an unknown id refuses
4761        // the whole request rather than silently dropping it — and dedup
4762        // while keeping the operator's own order.
4763        let mut seen = BTreeSet::new();
4764        let mut findings = Vec::new();
4765        let mut missing = Vec::new();
4766        for id in ids {
4767            if !seen.insert(id.clone()) {
4768                continue;
4769            }
4770            match self.state.finding(id) {
4771                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4772                    id: f.id.clone(),
4773                    severity: f.severity,
4774                    reviewer_vote: rec.vote,
4775                    round: round.round,
4776                    round_head: round.head.clone(),
4777                    reviewer: rec.reviewer,
4778                    agent: rec.agent.clone(),
4779                    file: f.file.clone(),
4780                    line: f.line,
4781                    title: f.title.clone(),
4782                    detail: f.detail.clone(),
4783                    outcome: OperatorFixOutcome::Pending,
4784                }),
4785                None => missing.push(id.clone()),
4786            }
4787        }
4788        if !missing.is_empty() {
4789            bail!(
4790                "unknown finding id(s): {}; nothing was changed",
4791                missing.join(", ")
4792            );
4793        }
4794
4795        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4796        let stale_details: Vec<(String, String)> = findings
4797            .iter()
4798            .filter(|f| f.round_head != head_at_request)
4799            .map(|f| (f.id.clone(), f.round_head.clone()))
4800            .collect();
4801        let stale = !stale_details.is_empty();
4802        if stale && !allow_stale {
4803            bail!(
4804                "the branch has moved since some finding(s) were raised — {} — now \
4805                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4806                stale_details
4807                    .iter()
4808                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4809                    .collect::<Vec<_>>()
4810                    .join(", "),
4811                short(&head_at_request)
4812            );
4813        }
4814
4815        let request = OperatorFixRequest {
4816            requested_at: Timestamp::now(),
4817            reason: reason.to_owned(),
4818            findings,
4819            head_at_request: head_at_request.clone(),
4820            allow_stale,
4821            stale,
4822            fix: None,
4823            result_head: None,
4824            follow_up_review_run: None,
4825        };
4826        self.state.event(
4827            "fix",
4828            format!(
4829                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4830                request.findings.len(),
4831                request
4832                    .findings
4833                    .iter()
4834                    .map(|f| f.id.as_str())
4835                    .collect::<Vec<_>>()
4836                    .join(", "),
4837            ),
4838        );
4839        // Recorded now, before any worktree work or the fixer call itself —
4840        // and re-saved at each checkpoint below: a crash at any point after
4841        // this (mid fixer call, mid follow-up review) must not lose the fact
4842        // that this was requested, for which findings, and why. Everything
4843        // past this point reads and writes through `request_index` rather
4844        // than a local variable, since `request` itself is moved here.
4845        self.state.operator_fixes.push(request);
4846        self.state.save()?;
4847        let request_index = self.state.operator_fixes.len() - 1;
4848
4849        // A fresh, dedicated worktree for this one call, never the winner's
4850        // own worktree in place: that one may already be gone (folded away),
4851        // and reusing it in place would leave the branch checked out there
4852        // when the follow-up review below tries to check it out again. Freed
4853        // immediately after, either way — but only once confirmed clean:
4854        // `worktree_remove` is a `git worktree remove --force`, which would
4855        // otherwise discard uncommitted work left there by the operator or
4856        // another process before this had a chance to even look at it.
4857        if winner.worktree.exists() {
4858            // Lockfiles a rescue commit withheld stay untracked on purpose and
4859            // are already recorded; they are not the operator's work to protect.
4860            let dirty = git::git(
4861                &winner.worktree,
4862                &["status", "--porcelain", "--untracked-files=all"],
4863            )
4864            .await?;
4865            let only_withheld = dirty.lines().all(|l| {
4866                l.strip_prefix("?? ")
4867                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4868            });
4869            if !only_withheld {
4870                bail!(
4871                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4872                     discard them first",
4873                    winner.worktree.display()
4874                );
4875            }
4876            git::worktree_remove(&self.state.repo, &winner.worktree)
4877                .await
4878                .ok();
4879        }
4880        let fix_worktree = self.state.worktree_root().join("operator-fix");
4881        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4882        git::git(
4883            &self.state.repo,
4884            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4885        )
4886        .await
4887        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4888        if !git::is_clean(&fix_worktree).await? {
4889            git::worktree_remove(&self.state.repo, &fix_worktree)
4890                .await
4891                .ok();
4892            bail!(
4893                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4894                winner.branch
4895            );
4896        }
4897        let repo_for_setup = self.state.repo.clone();
4898        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4899            Ok(()) => {
4900                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4901            }
4902            Err(e) => Err(e),
4903        };
4904        if let Err(e) = setup {
4905            git::worktree_remove(&self.state.repo, &fix_worktree)
4906                .await
4907                .ok();
4908            return Err(e);
4909        }
4910
4911        let run_id = self.state.id.clone();
4912        let prompts = self.state.config.prompts.clone();
4913        let language = self.state.config.graph.language.clone();
4914        let sessions = self.state.config.graph.sessions;
4915        let artifacts = agent::artifacts_dir(&self.state.dir());
4916        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4917            .findings
4918            .iter()
4919            .map(|f| Finding {
4920                id: f.id.clone(),
4921                severity: f.severity,
4922                file: f.file.clone(),
4923                line: f.line,
4924                title: f.title.clone(),
4925                detail: f.detail.clone(),
4926            })
4927            .collect();
4928        let fix_prompt = prompt::operator_fix_in(
4929            &self.state.instruction,
4930            &finding_list,
4931            reason,
4932            &stale_details,
4933            &head_at_request,
4934            &language,
4935            &self.state.config.graph.github_language,
4936        );
4937        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4938        let (job, seat, out) = self
4939            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4940                prompt: fix_prompt.clone(),
4941                spec,
4942                seat,
4943                cwd: fix_worktree.clone(),
4944                timeout,
4945                allow_write: true,
4946                sessions,
4947                artifacts: artifacts.clone(),
4948                stem: "operator-fix".to_owned(),
4949                handover: None,
4950            })
4951            .await;
4952        let agent_id = seat.agent.clone();
4953
4954        let mut fix = FixRecord {
4955            agent: agent_id,
4956            addressed: Vec::new(),
4957            rejected: Vec::new(),
4958            notes: String::new(),
4959            committed: false,
4960            failed: None,
4961            duration_ms: 0,
4962            continuation: None,
4963        };
4964        let mut final_seat = seat.clone();
4965        match out {
4966            AgentOutcome::Ok(o) => {
4967                fix.duration_ms = o.duration_ms;
4968                let parsed = verdict::extract_json::<FixReport>(&o.text);
4969                let incomplete_reason = match &parsed {
4970                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4971                        "the reply parsed, but it reported a command whose own CLI \
4972                         never confirmed an exit status"
4973                            .to_owned(),
4974                    ),
4975                    Ok(_) => None,
4976                    Err(e) => Some(e.to_string()),
4977                };
4978                match incomplete_reason {
4979                    None => {
4980                        let report = parsed.expect("checked Ok above");
4981                        fix.addressed = report.addressed;
4982                        fix.rejected = report.rejected;
4983                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4984                    }
4985                    Some(reason) => {
4986                        let (resumed_seat, resolved, failure, cont) = self
4987                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4988                            .await;
4989                        fix.duration_ms += cont.cumulative_wait_ms;
4990                        fix.continuation = Some(cont);
4991                        final_seat = resumed_seat;
4992                        match resolved {
4993                            Some(report) => {
4994                                fix.addressed = report.addressed;
4995                                fix.rejected = report.rejected;
4996                                fix.notes =
4997                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4998                            }
4999                            None => fix.failed = failure,
5000                        }
5001                    }
5002                }
5003            }
5004            AgentOutcome::Dropped(o) => {
5005                fix.duration_ms = o.duration_ms;
5006                let why = o
5007                    .dropped
5008                    .as_ref()
5009                    .map(|d| d.why.as_str())
5010                    .unwrap_or("the CLI ended the stream without delivering its answer");
5011                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5012            }
5013            AgentOutcome::Quota(o) => {
5014                self.state.quota.push(QuotaLoss {
5015                    seat: final_seat.key.clone(),
5016                    node: "fix".to_owned(),
5017                    at: Timestamp::now(),
5018                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5019                });
5020                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5021            }
5022            AgentOutcome::Failed(e) => fix.failed = Some(e),
5023            AgentOutcome::ResourceBlocked(e) => {
5024                fix.failed = Some(format!("the fixer was not started: {e}"));
5025            }
5026        }
5027        if fix.continuation.is_none() {
5028            fix.continuation = Some(ContinuationRecord::not_needed());
5029        }
5030        self.state.seats.insert(final_seat.key.clone(), final_seat);
5031
5032        let rescue_message = format!(
5033            "magi: operator-selected fix ({}) (uncommitted work)",
5034            self.state.operator_fixes[request_index]
5035                .findings
5036                .iter()
5037                .map(|f| f.id.as_str())
5038                .collect::<Vec<_>>()
5039                .join(", ")
5040        );
5041        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
5042            self.state.note_withheld("fix", &r.withheld);
5043        }
5044        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
5045        fix.committed = after != head_at_request;
5046        git::worktree_remove(&self.state.repo, &fix_worktree)
5047            .await
5048            .ok();
5049
5050        self.state.event(
5051            "fix",
5052            match &fix.failed {
5053                Some(reason) => format!(
5054                    "operator fix: adoption report was lost ({reason}); {}",
5055                    if fix.committed {
5056                        "committed"
5057                    } else {
5058                        "NO new commit"
5059                    }
5060                ),
5061                None => format!(
5062                    "operator fix: {} addressed, {} rejected, {}",
5063                    fix.addressed.len(),
5064                    fix.rejected.len(),
5065                    if fix.committed {
5066                        "committed"
5067                    } else {
5068                        "NO new commit"
5069                    }
5070                ),
5071            },
5072        );
5073
5074        // Every selected finding gets an outcome — never left `Pending` once
5075        // the fixer's own turn is over. A report that never came back at all
5076        // marks every one of them `Unreported`, not silently "not addressed":
5077        // quota, a dropped stream, or an exhausted continuation are gaps in
5078        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
5079        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
5080        for f in &mut self.state.operator_fixes[request_index].findings {
5081            f.outcome = if fix.failed.is_some() {
5082                OperatorFixOutcome::Unreported
5083            } else if fix.addressed.contains(&f.id) {
5084                OperatorFixOutcome::Addressed
5085            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
5086                OperatorFixOutcome::Rejected { why: r.why.clone() }
5087            } else {
5088                OperatorFixOutcome::Unreported
5089            };
5090        }
5091
5092        let committed = fix.committed;
5093        if committed {
5094            self.state.operator_fixes[request_index].result_head = Some(after.clone());
5095        }
5096        self.state.operator_fixes[request_index].fix = Some(fix);
5097        // Saved again now that the fixer's own outcome is final, on top of
5098        // the save right after the request was first pushed above.
5099        self.state.save()?;
5100
5101        if committed {
5102            self.state.event(
5103                "fix",
5104                format!(
5105                    "operator fix committed {}; opening a follow-up review-only run",
5106                    short(&after)
5107                ),
5108            );
5109            // The operator asked for the fix, and the follow-up serves whatever
5110            // task the run it follows served.
5111            let origin =
5112                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
5113            match Self::review(
5114                &self.state.repo,
5115                &winner.branch,
5116                self.state.config.clone(),
5117                origin,
5118            )
5119            .await
5120            {
5121                Ok(mut follow_up) => {
5122                    follow_up.state.event(
5123                        "start",
5124                        format!(
5125                            "requested by an operator fix on run {} for finding(s) {}",
5126                            self.state.id,
5127                            self.state.operator_fixes[request_index]
5128                                .findings
5129                                .iter()
5130                                .map(|f| f.id.as_str())
5131                                .collect::<Vec<_>>()
5132                                .join(", "),
5133                        ),
5134                    );
5135                    follow_up.state.save()?;
5136                    let follow_up_id = follow_up.state.id.clone();
5137                    // The follow-up is a run like any other: it belongs to the
5138                    // task of the run it follows, or to one filed for it.
5139                    let adopted = match crate::direct::adopt(
5140                        &follow_up.state,
5141                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
5142                    ) {
5143                        Ok(a) => a,
5144                        Err(e) => {
5145                            // An ownerless run must not spend agent calls; it
5146                            // stays saved, and `magi run --resume` adopts it.
5147                            self.state.event(
5148                                "fix",
5149                                format!(
5150                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
5151                                ),
5152                            );
5153                            self.state.save()?;
5154                            return Ok(());
5155                        }
5156                    };
5157                    let executed = follow_up.execute().await;
5158                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
5159                    if let Some(a) = adopted {
5160                        a.finish(&follow_up.state, executed);
5161                    }
5162                    if let Some(e) = failure {
5163                        self.state.event(
5164                            "fix",
5165                            format!(
5166                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
5167                            ),
5168                        );
5169                    }
5170                    self.state.operator_fixes[request_index].follow_up_review_run =
5171                        Some(follow_up_id);
5172                }
5173                Err(e) => {
5174                    self.state.event(
5175                        "fix",
5176                        format!("committed the fix but could not open a follow-up review: {e:#}"),
5177                    );
5178                }
5179            }
5180            self.state.save()?;
5181        }
5182
5183        Ok(())
5184    }
5185
5186    // --------------------------------------------------------------- review
5187
5188    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
5189    /// names, in order, each at most once, moving on only when the call
5190    /// advances (an error, a quota hit or nothing usable - the same decision
5191    /// point `agent::chain_advances` is for the other chained roles).
5192    ///
5193    /// Each agent gets its own seat from `Runner::seat`: the same agent
5194    /// continues its conversation (the winner's own implementer seat when it
5195    /// is the winner's author, now that the competition is over), another
5196    /// takes a fresh one so the full prompt is sent again. A handover is
5197    /// recorded under `node`, which is what makes the fallback stick for the
5198    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
5199    /// is returned, so an exhausted chain reads exactly like a single failed
5200    /// fixer: one quota loss, the same wording, the same refund. An earlier
5201    /// attempt's edits are left in the tree and judged, with the final
5202    /// attempt's, by what git says afterwards.
5203    ///
5204    /// Returns the job that produced the outcome, for `continue_fix_report`.
5205    async fn ask_fixer(
5206        &mut self,
5207        winner: &Candidate,
5208        node: &'static str,
5209        round: Option<usize>,
5210        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5211    ) -> (SeatJob, SeatState, AgentOutcome) {
5212        let attempts = fixer::attempts(&self.state, &self.roles, winner);
5213        self.ask_fixer_among(attempts, node, round, None, build)
5214            .await
5215    }
5216
5217    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
5218    /// worktree and a commit: before the next agent is asked after one that
5219    /// did not satisfy the call, the worktree is put back on that commit, so
5220    /// a failed agent's partial edits are not carried into the next one.
5221    async fn ask_fixer_among(
5222        &mut self,
5223        attempts: Vec<(AgentSpec, String)>,
5224        node: &'static str,
5225        round: Option<usize>,
5226        restore: Option<(&Path, &str)>,
5227        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
5228    ) -> (SeatJob, SeatState, AgentOutcome) {
5229        let run_id = self.state.id.clone();
5230        let prompts = self.state.config.prompts.clone();
5231        let cache = self.state.config.cache_dir();
5232        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5233        let mut last = None;
5234        for (i, (spec, key)) in attempts.into_iter().enumerate() {
5235            let seat = self.seat(&key, &spec.id);
5236            let mut job = build(spec.clone(), seat);
5237            if i > 0 {
5238                job.stem = format!("{}-{}", job.stem, spec.id);
5239            }
5240            let ctx = WaveCtx {
5241                carry_seats: false,
5242                pauses: Some((&self.pause, &self.interrupt)),
5243                run: &run_id,
5244                node,
5245                prompts: &prompts,
5246                cache: cache.as_deref(),
5247                round,
5248            };
5249            let (seat, out) =
5250                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5251            // A resource block never advances the chain: the chosen agent
5252            // was not asked, so it must be the one asked again on resume.
5253            let advances = match &out {
5254                AgentOutcome::Ok(o) => agent::output_advances(o),
5255                AgentOutcome::ResourceBlocked(_) => false,
5256                _ => true,
5257            };
5258            if let Some(next) = ids.get(i + 1)
5259                && advances
5260            {
5261                self.state.seats.insert(seat.key.clone(), seat.clone());
5262                let class =
5263                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
5264                let reason = match &out {
5265                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
5266                    other => fail_reason(other),
5267                };
5268                record_handover(
5269                    &mut self.state,
5270                    node,
5271                    &seat.key,
5272                    &spec.id,
5273                    next,
5274                    &class,
5275                    &reason,
5276                );
5277                // A failed agent's edits must not reach the next one, and a
5278                // restore that did not work ends the chain: the call fails
5279                // and the caller's own (confirmed) restore decides.
5280                if let Some((wt, rev)) = restore
5281                    && let Err(e) = git::restore_to(wt, rev).await
5282                {
5283                    let why =
5284                        format!("could not restore the worktree before the next fixer: {e:#}");
5285                    tracing::warn!("{why}");
5286                    last = Some((job, seat, AgentOutcome::Failed(why)));
5287                    break;
5288                }
5289                continue;
5290            }
5291            last = Some((job, seat, out));
5292            break;
5293        }
5294        last.expect("the fixer chain always has an entry")
5295    }
5296
5297    /// The one repair a verification-only failure earns (see
5298    /// [`crate::recovery`]), before the first review round of a run that took
5299    /// a branch over.
5300    ///
5301    /// Saved as started *before* the fixer is called, so a resume never
5302    /// repairs twice; a start found on re-entry is rolled back to the commit
5303    /// it began from. A failed or unusable fixer leaves the tree as it was and
5304    /// the review goes on over it, so the first round's own verification
5305    /// reports the failure again. The failure is never carried into a round as
5306    /// a result: verification is re-run on whatever commit this leaves.
5307    async fn recovery_repair(&mut self, winner: &Candidate) -> Result<()> {
5308        use crate::recovery::{Mode, Repair};
5309        if !self.state.reviews.is_empty() {
5310            return Ok(());
5311        }
5312        let Some(mut rec) = self.state.recovery.clone() else {
5313            return Ok(());
5314        };
5315        if rec.mode != Mode::VerifyRepair {
5316            return Ok(());
5317        }
5318        let wt = winner.worktree.clone();
5319        let head = git::rev_parse(&wt, "HEAD").await?;
5320        match rec.repair.clone() {
5321            Repair::Done { .. } => return Ok(()),
5322            Repair::Started { before } => {
5323                // Interrupted: the unfinished edits are not trusted.
5324                // An unconfirmed rollback blocks the run with `Started` kept,
5325                // so a resume retries it; the tree is never reviewed as is.
5326                if let Err(e) = Self::graft_restore(&wt, &before).await {
5327                    return self
5328                        .stop_recovery(rec, &before, format!("interrupted; rollback failed: {e:#}"))
5329                        .await;
5330                }
5331                rec.repair = Repair::Done {
5332                    before: before.clone(),
5333                    after: before,
5334                    committed: false,
5335                    failed: Some("interrupted; rolled back".to_owned()),
5336                };
5337                self.state
5338                    .event("recovery", "the repair was interrupted and rolled back");
5339                self.state.recovery = Some(rec);
5340                self.state.save()?;
5341                return Ok(());
5342            }
5343            Repair::NotStarted => {}
5344        }
5345        if crate::recovery::recheck(&mut rec, &head) {
5346            self.state.event("recovery", rec.summary());
5347            self.state.recovery = Some(rec);
5348            self.state.save()?;
5349            return Ok(());
5350        }
5351        rec.repair = Repair::Started {
5352            before: head.clone(),
5353        };
5354        self.state.recovery = Some(rec.clone());
5355        self.state.save()?;
5356
5357        let language = self.state.config.graph.language.clone();
5358        let sessions = self.state.config.graph.sessions;
5359        let artifacts = agent::artifacts_dir(&self.state.dir());
5360        let fix_prompt = prompt::recovery_fix(
5361            &self.state.instruction,
5362            &rec,
5363            self.state.config.graph.review_rounds,
5364            &language,
5365        );
5366        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5367        self.state.event(
5368            "recovery",
5369            format!(
5370                "repair: the fixer starts from the failed verification on {}",
5371                short(&head)
5372            ),
5373        );
5374        let (_job, seat, out) = self
5375            .ask_fixer(winner, "fix", None, |spec, seat| SeatJob {
5376                prompt: fix_prompt.clone(),
5377                spec,
5378                seat,
5379                cwd: wt.clone(),
5380                timeout,
5381                allow_write: true,
5382                sessions,
5383                artifacts: artifacts.clone(),
5384                stem: "fix-recovery".to_owned(),
5385                handover: None,
5386            })
5387            .await;
5388        // Never spawned: nothing changed, so the repair goes back to "not
5389        // started" and the run stops resumably, as for a graft.
5390        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
5391            if let Err(e) = Self::graft_restore(&wt, &head).await {
5392                return self
5393                    .stop_recovery(rec, &head, format!("resource wait; restore failed: {e:#}"))
5394                    .await;
5395            }
5396            rec.repair = Repair::NotStarted;
5397            self.state.recovery = Some(rec);
5398            self.state.save()?;
5399            return self.stop_for_resource();
5400        }
5401        let failed = match &out {
5402            AgentOutcome::Ok(_) | AgentOutcome::ResourceBlocked(_) => None,
5403            AgentOutcome::Quota(o) => {
5404                self.state.quota.push(QuotaLoss {
5405                    seat: seat.key.clone(),
5406                    node: "fix".to_owned(),
5407                    at: Timestamp::now(),
5408                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5409                });
5410                Some("rate limited (quota); the fixer could not run".to_owned())
5411            }
5412            AgentOutcome::Dropped(_) => Some("the CLI dropped the stream".to_owned()),
5413            AgentOutcome::Failed(e) => Some(e.clone()),
5414        };
5415        self.state.seats.insert(seat.key.clone(), seat);
5416        let failed = match failed {
5417            // The agent's partial edits are not kept.
5418            Some(why) => {
5419                if let Err(e) = git::restore_to(&wt, &head).await {
5420                    return self
5421                        .stop_recovery(rec, &head, format!("{why}; restore failed: {e:#}"))
5422                        .await;
5423                }
5424                Some(why)
5425            }
5426            None => {
5427                if let Ok(r) = git::rescue_commit(
5428                    &wt,
5429                    "magi: recovery repair of the failed verification (uncommitted work)",
5430                )
5431                .await
5432                {
5433                    self.state.note_withheld("fix", &r.withheld);
5434                }
5435                None
5436            }
5437        };
5438        let after = git::rev_parse(&wt, "HEAD").await?;
5439        self.state.event(
5440            "recovery",
5441            match &failed {
5442                Some(why) => format!("repair: nothing kept ({why})"),
5443                None if after != head => {
5444                    format!("repair: committed {}", short(&after))
5445                }
5446                None => "repair: the fixer made no commit".to_owned(),
5447            },
5448        );
5449        rec.repair = Repair::Done {
5450            before: head.clone(),
5451            committed: after != head,
5452            after,
5453            failed,
5454        };
5455        self.state.recovery = Some(rec);
5456        self.state.save()?;
5457        Ok(())
5458    }
5459
5460    /// A repair whose rollback could not be confirmed: the run is blocked and
5461    /// resumable, and the unconfirmed state is never reviewed.
5462    async fn stop_recovery(
5463        &mut self,
5464        mut rec: crate::recovery::RecoveryRecord,
5465        head: &str,
5466        why: String,
5467    ) -> Result<()> {
5468        // `Started` stays recorded: a resume retries the restore.
5469        rec.repair = crate::recovery::Repair::Started {
5470            before: head.to_owned(),
5471        };
5472        self.state.recovery = Some(rec);
5473        self.state
5474            .event("recovery", format!("repair could not be undone: {why}"));
5475        self.state.status = RunStatus::Blocked;
5476        self.state.save()?;
5477        bail!("recovery repair could not be rolled back: {why}")
5478    }
5479
5480    async fn review_loop(&mut self) -> Result<()> {
5481        // A base that would not rebase is a person's decision, not a review
5482        // round: nothing here would change the answer, and reviewers and a
5483        // fixer would be spending real budget on a tree that cannot land
5484        // regardless of what they find.
5485        if self
5486            .state
5487            .base_sync
5488            .as_ref()
5489            .is_some_and(|s| s.conflict.is_some())
5490        {
5491            return Ok(());
5492        }
5493        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
5494        // agent files with `magi task add` name the run that paid for it. The
5495        // prompt overlay is cloned alongside it because the waves borrow it
5496        // while `self` is mutably borrowed by the node's own bookkeeping.
5497        let run_id = self.state.id.clone();
5498        let prompts = self.state.config.prompts.clone();
5499        let Some(winner) = self.state.winner().cloned() else {
5500            return Ok(());
5501        };
5502        let max_rounds = self.state.config.graph.review_rounds;
5503        // A clean round, an exhausted round budget, or a stalled tree (see
5504        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
5505        // they are recorded — recomputed here, not read off `status`, so a
5506        // reentry into a run that already stopped restates the identical
5507        // verdict instead of silently handing back whatever an earlier node
5508        // in this same walk clobbered `status` to (a solo-candidate
5509        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
5510        // below runs an empty range once the budget is spent, and would
5511        // otherwise fall through without touching `status` at all.
5512        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
5513            // A reentry after a crash between the last round's save and
5514            // `stop_reviewing` reaches the hand-off here, not there.
5515            if status == RunStatus::Gating {
5516                self.record_contested_handoff();
5517            }
5518            self.state.status = status;
5519            self.state.save()?;
5520            return Ok(());
5521        }
5522        self.state.status = RunStatus::Reviewing;
5523        // A last recorded round whose own verification never resolved
5524        // (`ResourceBlocked` — the shared build cache, not the patch) is
5525        // never a concluded round, whatever the round budget says: starting
5526        // a fresh round on top of it would spend a whole new reviewer wave
5527        // re-reading an unchanged patch instead of just retrying the one
5528        // check that actually needs it, and once the budget is spent the
5529        // loop below has nothing left to do at all (its range is empty).
5530        // Retry that check directly instead, exactly the same retry
5531        // `stop_reviewing` already does for its own catch-up case.
5532        if self
5533            .state
5534            .reviews
5535            .last()
5536            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
5537        {
5538            let shell = self.state.config.shell();
5539            return self
5540                .stop_reviewing(
5541                    "the last round's own verification never resolved",
5542                    &shell,
5543                    &winner.worktree,
5544                )
5545                .await;
5546        }
5547
5548        self.recovery_repair(&winner).await?;
5549        if self.resource_halt()? {
5550            return Ok(());
5551        }
5552
5553        let repo = self.state.repo.clone();
5554        let root = self.state.worktree_root();
5555        let language = self.state.config.graph.language.clone();
5556        let sessions = self.state.config.graph.sessions;
5557        let artifacts = self.merge_repair_artifacts();
5558        let base = self.landing_base();
5559        let base_short = short(&base);
5560        let reviewers = self.roles.reviewers.clone();
5561        let shell = self.state.config.shell();
5562
5563        for round in (self.state.reviews.len() + 1)..=max_rounds {
5564            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5565            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5566            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
5567            // The prior round's own record, already persisted — never a
5568            // hand-carried variable of just its failing output: that is
5569            // exactly what let a round's e2e result drift out of sync with
5570            // which commit it was actually about (see `SCHEMA`'s doc for
5571            // schema 8). Judged against `head`, the commit reviewers are
5572            // about to look at now, so the summary always reads as "an
5573            // earlier head" here — this round's own patch has not been
5574            // checked yet.
5575            let prev_verification = self
5576                .state
5577                .reviews
5578                .last()
5579                .and_then(|r| r.verification_summary(&head));
5580            // Only the first round of a run that took a branch over: later
5581            // rounds have this run's own history.
5582            let prior_section = match (&self.state.recovery, round) {
5583                (Some(rec), 1) if self.state.reviews.is_empty() => {
5584                    let since = match (rec.mode, rec.reviewed_head.as_deref()) {
5585                        (crate::recovery::Mode::VerifyRepair, Some(reviewed)) => {
5586                            Some(crate::recovery::head_of(
5587                                &git::diff(&winner.worktree, reviewed, "HEAD")
5588                                    .await
5589                                    .unwrap_or_default(),
5590                                crate::recovery::SINCE_DIFF_MAX,
5591                            ))
5592                        }
5593                        _ => None,
5594                    };
5595                    Some(prompt::recovery_review(rec, since.as_deref()))
5596                }
5597                _ => None,
5598            };
5599
5600            // Each reviewer gets its own detached checkout of exactly this
5601            // commit: nobody can perturb the winner's tree, and the fixer can
5602            // keep working without racing a reviewer.
5603            let mut jobs = Vec::new();
5604            for (r, spec) in reviewers.iter().cloned().enumerate() {
5605                let wt = root.join(format!("review-{}", r + 1));
5606                // `clean -fdx` in a reset wipes setup's products, so every
5607                // round sets the seat up again.
5608                if wt.exists() {
5609                    git::reset_detached(&wt, &head).await?;
5610                } else {
5611                    git::worktree_add_detached(&repo, &wt, &head).await?;
5612                }
5613                self.setup_seat_worktree(&repo, &wt, false).await?;
5614                let seat_key = format!("review-{}", r + 1);
5615                // The seat starts the round on whoever answered it last, not
5616                // on the agent the spec names, so a failure is not re-paid.
5617                let spec = pick_start_spec(
5618                    &self.roles.reviewer_roster,
5619                    spec,
5620                    self.state.seat_history.get(&seat_key),
5621                );
5622                let seat = self.seat(&seat_key, &spec.id);
5623                jobs.push(SeatJob {
5624                    prompt: prompt::review(&prompt::ReviewCtx {
5625                        instruction: &self.state.instruction,
5626                        branch: &winner.branch,
5627                        base_short: &base_short,
5628                        stat: &stat,
5629                        patch: &patch,
5630                        verification: prev_verification.as_ref(),
5631                        prior: prior_section.as_deref(),
5632                        reviewers: reviewers.len(),
5633                        round,
5634                        rounds: max_rounds,
5635                        // A review-only run has no rankings, so nothing
5636                        // competed for this patch and the reviewer is told so.
5637                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5638                        lens: Lens::for_seat(r),
5639                        language: &language,
5640                        github_language: &self.state.config.graph.github_language,
5641                    }),
5642                    spec,
5643                    seat,
5644                    cwd: wt,
5645                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5646                    allow_write: false,
5647                    sessions,
5648                    artifacts: artifacts.clone(),
5649                    stem: format!("review-{round}-{}", r + 1),
5650                    handover: None,
5651                });
5652            }
5653
5654            self.state.event(
5655                "review",
5656                format!(
5657                    "round {round}: {} reviewers on {}",
5658                    jobs.len(),
5659                    short(&head)
5660                ),
5661            );
5662            let mut quota_losses = Vec::new();
5663            let review_retries = self.state.config.graph.retries;
5664            let review_cache = self.state.config.cache_dir();
5665            let ctx = WaveCtx {
5666                carry_seats: true,
5667                pauses: Some((&self.pause, &self.interrupt)),
5668                run: &run_id,
5669                node: "review",
5670                prompts: &prompts,
5671                cache: review_cache.as_deref(),
5672                round: Some(round),
5673            };
5674            let results = ask_json_wave::<Review>(
5675                jobs,
5676                Arc::clone(&self.sem),
5677                review_retries,
5678                &self.roles.reviewer_roster,
5679                &ctx,
5680                &mut quota_losses,
5681                &mut self.state,
5682                &|_: &Review| Ok(()),
5683            )
5684            .await;
5685            if self.state.resource_block.is_some() {
5686                return self.stop_for_resource();
5687            }
5688            // Counted before the move below: how many of *this* round's
5689            // reviewer seats were lost to their own rate limit, as opposed to
5690            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5691            let round_quota_missing = quota_losses.len();
5692            self.state.quota.extend(quota_losses);
5693
5694            let mut records = Vec::new();
5695            let mut all_findings = Vec::new();
5696            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5697                let agent_id = seat.agent.clone();
5698                self.state.seats.insert(seat.key.clone(), seat);
5699                let mut record = ReviewRecord {
5700                    reviewer: r + 1,
5701                    agent: agent_id,
5702                    summary: String::new(),
5703                    findings: Vec::new(),
5704                    vote: None,
5705                    failed: None,
5706                    duration_ms: 0,
5707                    // Set for both outcomes: `failed: Some(_)` with
5708                    // `attempts > 0` is a seat every retry still lost, not a
5709                    // recovered one — only `failed: None` with `attempts > 0`
5710                    // reads as "answered after a nudge" (see this field's own
5711                    // doc).
5712                    attempts,
5713                };
5714                match res {
5715                    Ok((review, out)) => {
5716                        // Sanitized here, at the point every other piece of
5717                        // agent prose in this file is (candidate summaries,
5718                        // deliberation turns, vote reasons): a reviewer's own
5719                        // words are the one thing about it that could name
5720                        // it, and reconsideration below broadcasts this same
5721                        // summary and these same findings to every other
5722                        // seat on the panel.
5723                        record.summary =
5724                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5725                        record.vote = Some(review.vote);
5726                        record.duration_ms = out.duration_ms;
5727                        for (n, mut f) in review.findings.into_iter().enumerate() {
5728                            // ids are magi's, never the agent's: the fixer's
5729                            // adoption report is keyed by them.
5730                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5731                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5732                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5733                            // `file` is agent-supplied prose too, never
5734                            // checked against the real tree — the same
5735                            // exposure `title`/`detail` above have, just in
5736                            // a field easy to forget because it looks like a
5737                            // path rather than free text.
5738                            f.file = f
5739                                .file
5740                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5741                            all_findings.push(f.clone());
5742                            record.findings.push(f);
5743                        }
5744                        self.state.event(
5745                            "review",
5746                            format!(
5747                                "round {round}: reviewer {} voted {} with {} finding(s)",
5748                                r + 1,
5749                                review.vote.label(),
5750                                record.findings.len()
5751                            ),
5752                        );
5753                    }
5754                    Err(e) => {
5755                        record.failed = Some(e.to_string());
5756                        self.state.event(
5757                            "review",
5758                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5759                        );
5760                    }
5761                }
5762                records.push(record);
5763            }
5764
5765            // Tally the round's votes and, if they split, spend the one
5766            // round of reconsideration the split -> deliberate -> revote
5767            // shape `judge`/`vote` use for the panel, sized down to what a
5768            // read-only review round can afford: one round, and a revote
5769            // rather than an argument, because the panel already wrote its
5770            // reasoning down as findings the first time around.
5771            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5772            let vote_split =
5773                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5774            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5775            if vote_split {
5776                self.state.event(
5777                    "review",
5778                    format!(
5779                        "round {round}: votes split ({}) — one round of reconsideration",
5780                        initial_votes
5781                            .iter()
5782                            .map(|v| v.label())
5783                            .collect::<Vec<_>>()
5784                            .join(", ")
5785                    ),
5786                );
5787                // Seats read every seat's findings and votes, still numbered
5788                // and never named — the same anonymity `review` itself keeps.
5789                let panel: Vec<ReviewSeatReport<'_>> = records
5790                    .iter()
5791                    .filter_map(|r| {
5792                        r.vote.map(|vote| ReviewSeatReport {
5793                            reviewer: r.reviewer,
5794                            vote,
5795                            summary: &r.summary,
5796                            findings: &r.findings,
5797                        })
5798                    })
5799                    .collect();
5800
5801                let mut jobs = Vec::new();
5802                let mut seats_at = Vec::new();
5803                for (r, spec) in reviewers.iter().cloned().enumerate() {
5804                    // A seat with no initial vote has nothing to reconsider
5805                    // from and stays absent, the same as it stayed absent
5806                    // from `panel` above.
5807                    if records[r].vote.is_none() {
5808                        continue;
5809                    }
5810                    let wt = root.join(format!("review-{}", r + 1));
5811                    let seat_key = format!("review-{}", r + 1);
5812                    let spec = self.occupant(&seat_key, spec);
5813                    let seat = self.seat(&seat_key, &spec.id);
5814                    // A seat with no live session has already forgotten the
5815                    // initial review's prompt — restate the patch it is
5816                    // voting on, the same as `deliberate`/`vote` do for a
5817                    // judge in the same position.
5818                    // The panel already carries this seat's own review and
5819                    // vote, so restating the patch makes the prompt whole for
5820                    // a seat handed to another agent.
5821                    let build = |with_patch: bool| {
5822                        prompt::review_reconsider(&ReviewReconsiderCtx {
5823                            instruction: &self.state.instruction,
5824                            reviewer: r + 1,
5825                            lens: Lens::for_seat(r),
5826                            panel: &panel,
5827                            patch: with_patch.then_some(ReviewPatch {
5828                                branch: &winner.branch,
5829                                base_short: &base_short,
5830                                stat: &stat,
5831                                patch: &patch,
5832                            }),
5833                            round,
5834                            rounds: max_rounds,
5835                            language: &language,
5836                        })
5837                    };
5838                    let full = build(true);
5839                    let prompt = if has_context(&spec, &seat, sessions) {
5840                        build(false)
5841                    } else {
5842                        full.clone()
5843                    };
5844                    jobs.push(SeatJob {
5845                        prompt,
5846                        spec,
5847                        seat,
5848                        cwd: wt,
5849                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5850                        allow_write: false,
5851                        sessions,
5852                        artifacts: artifacts.clone(),
5853                        stem: format!("review-{round}-reconsider-{}", r + 1),
5854                        handover: Some(full),
5855                    });
5856                    seats_at.push(r);
5857                }
5858
5859                let mut recon_quota_losses = Vec::new();
5860                let recon_cache = self.state.config.cache_dir();
5861                let recon_ctx = WaveCtx {
5862                    carry_seats: true,
5863                    pauses: Some((&self.pause, &self.interrupt)),
5864                    run: &run_id,
5865                    node: "review",
5866                    prompts: &prompts,
5867                    cache: recon_cache.as_deref(),
5868                    round: Some(round),
5869                };
5870                let recon_results = ask_json_wave::<ReviewRevote>(
5871                    jobs,
5872                    Arc::clone(&self.sem),
5873                    review_retries,
5874                    &self.roles.reviewer_roster,
5875                    &recon_ctx,
5876                    &mut recon_quota_losses,
5877                    &mut self.state,
5878                    &|_: &ReviewRevote| Ok(()),
5879                )
5880                .await;
5881                if self.state.resource_block.is_some() {
5882                    return self.stop_for_resource();
5883                }
5884                self.state.quota.extend(recon_quota_losses);
5885
5886                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5887                    let agent_id = seat.agent.clone();
5888                    self.state.seats.insert(seat.key.clone(), seat);
5889                    let mut rec = ReviewRevoteRecord {
5890                        reviewer: r + 1,
5891                        agent: agent_id,
5892                        vote: None,
5893                        reason: String::new(),
5894                        failed: None,
5895                    };
5896                    match res {
5897                        Ok((rv, _)) => {
5898                            rec.vote = Some(rv.vote);
5899                            rec.reason =
5900                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5901                            self.state.event(
5902                                "review",
5903                                format!(
5904                                    "round {round}: reviewer {} revoted {}",
5905                                    r + 1,
5906                                    rv.vote.label()
5907                                ),
5908                            );
5909                        }
5910                        Err(e) => {
5911                            rec.failed = Some(e.to_string());
5912                            self.state.event(
5913                                "review",
5914                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5915                            );
5916                        }
5917                    }
5918                    reconsideration.push(rec);
5919                }
5920            } else if initial_votes.len() > 1 {
5921                self.state.event(
5922                    "review",
5923                    format!(
5924                        "round {round}: votes agreed ({}) — no reconsideration",
5925                        initial_votes[0].label()
5926                    ),
5927                );
5928            }
5929
5930            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5931            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5932            // A round that already has a blocking finding and a round left to
5933            // try is going back to the fixer no matter what `verify.e2e`
5934            // says, so running it first only spends the loop's slowest step
5935            // (minutes, for a Rust repo's full test suite) on a head about
5936            // to be rewritten. Deferred, never skipped: `verify.e2e` still
5937            // runs once a round has no blocking findings left (see
5938            // `round_is_clean`, which a deferred — empty — `e2e` can never
5939            // satisfy since `blocking` is nonzero whenever this branch is
5940            // taken), and `stop_reviewing` forces a real run before it will
5941            // ever read a deferred round as green.
5942            let defer_e2e =
5943                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5944            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5945                let reason =
5946                    format!("{blocking} blocking finding(s) already required a fix this round");
5947                self.state.event(
5948                    "verify",
5949                    format!(
5950                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5951                         {}); it will run once a round has none left",
5952                        short(&head)
5953                    ),
5954                );
5955                (Vec::new(), false, true, Some(reason))
5956            } else {
5957                let e2e_commands = self.state.config.verify.e2e.clone();
5958                let cache_dir = self.state.config.cache_dir();
5959                let context = format!("round {round}");
5960                let (e2e, verify_retried) = with_cache_lease(
5961                    &mut self.state,
5962                    cache_dir.as_deref(),
5963                    "e2e",
5964                    "e2e",
5965                    &winner.worktree,
5966                    &head,
5967                    verify_timeout,
5968                    &context,
5969                    |state, budget| {
5970                        let shell = shell.clone();
5971                        let e2e_commands = e2e_commands.clone();
5972                        let worktree = winner.worktree.clone();
5973                        let context = context.clone();
5974                        async move {
5975                            run_e2e_with_retry(
5976                                state,
5977                                &shell,
5978                                &e2e_commands,
5979                                &worktree,
5980                                budget,
5981                                &context,
5982                            )
5983                            .await
5984                        }
5985                    },
5986                )
5987                .await;
5988                (e2e, verify_retried, false, None)
5989            };
5990
5991            let expected = records.len();
5992            let answered = records.iter().filter(|r| r.failed.is_none()).count();
5993            let incomplete = answered < expected;
5994            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5995            let policy = self.state.config.graph.incomplete_review;
5996            let clean = round_is_clean(
5997                blocking,
5998                e2e_ok,
5999                answered,
6000                expected,
6001                round_quota_missing,
6002                policy,
6003            );
6004
6005            let mut round_record = ReviewRound {
6006                round,
6007                head: head.clone(),
6008                verified_head: None,
6009                verified_at: None,
6010                reviews: records,
6011                e2e,
6012                verify_retried,
6013                e2e_deferred,
6014                e2e_defer_reason,
6015                fix: None,
6016                blocking,
6017                answered,
6018                expected,
6019                clean,
6020                progressed: false,
6021                vote_split,
6022                reconsideration,
6023                verdict: None,
6024            };
6025            // The final vote per seat is its revote where reconsideration
6026            // ran and answered, its initial vote otherwise — the same
6027            // fallback `tally` uses for a judge whose private vote failed.
6028            round_record.verdict = ReviewVote::worst(
6029                round_record
6030                    .final_votes()
6031                    .into_iter()
6032                    .map(|(_, _, vote)| vote),
6033            );
6034            // Which commit and when magi actually attempted to check —
6035            // known the moment a command was dispatched against `head`,
6036            // whether or not it finished: a resource-blocked attempt still
6037            // targeted a specific commit at a specific time, and leaving
6038            // that unrecorded is exactly what made `verification_summary`
6039            // report a fresh attempt as "commit unknown ... recorded before
6040            // this was tracked", indistinguishable from a genuinely old,
6041            // untracked record. Only a deferred or unconfigured round never
6042            // ran at all and has nothing to record — see
6043            // `ReviewRound::verified_head`'s own doc.
6044            if !matches!(
6045                round_record.e2e_status(),
6046                E2eStatus::Deferred | E2eStatus::NotConfigured
6047            ) {
6048                round_record.verified_head = Some(head.clone());
6049                round_record.verified_at = Some(Timestamp::now());
6050            }
6051            let this_round_verification = round_record.verification_summary(&head);
6052
6053            if incomplete {
6054                let missing: Vec<String> = round_record
6055                    .reviews
6056                    .iter()
6057                    .filter(|r| r.failed.is_some())
6058                    .map(|r| format!("review-{}", r.reviewer))
6059                    .collect();
6060                self.state.event(
6061                    "review",
6062                    format!(
6063                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
6064                        missing.join(", ")
6065                    ),
6066                );
6067            }
6068
6069            if clean {
6070                self.state.event(
6071                    "review",
6072                    if incomplete && policy == IncompleteReviewPolicy::Warn {
6073                        format!(
6074                            "round {round}: clean (warn policy, incomplete panel) — no \
6075                             blocking findings from the seats that answered, verification green"
6076                        )
6077                    } else if incomplete {
6078                        format!(
6079                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
6080                             quorum) — no blocking findings from the seats that answered, \
6081                             verification green",
6082                            expected - answered
6083                        )
6084                    } else {
6085                        format!("round {round}: clean — no blocking findings, verification green")
6086                    },
6087                );
6088                self.state.reviews.push(round_record);
6089                self.state.status = RunStatus::Gating;
6090                self.state.save()?;
6091                return Ok(());
6092            }
6093
6094            // Nothing was raised and verification passed, but not every seat
6095            // answered and `round_is_clean` still refused to call it clean —
6096            // either a seat is missing for a reason other than its own quota
6097            // (a crash, a timeout, unparsable output — worth another try), or
6098            // every seat that could have answered lost its quota and nobody
6099            // is left to decide on: re-review rather than send the fixer
6100            // after a round with nothing to fix.
6101            if incomplete && blocking == 0 && e2e_ok {
6102                self.state.reviews.push(round_record);
6103                self.state.save()?;
6104                if round == max_rounds {
6105                    self.state.status = RunStatus::Blocked;
6106                    self.state.event(
6107                        "review",
6108                        format!(
6109                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
6110                             refusing to call it clean",
6111                            expected - answered
6112                        ),
6113                    );
6114                    return Ok(());
6115                }
6116                continue;
6117            }
6118
6119            // Nothing for the fixer to act on (`blocking == 0`) and the only
6120            // reason this round is not clean is that magi itself never got
6121            // a command to run — the shared build cache, not the patch (see
6122            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
6123            // the fixer would invite a change to appease contention that has
6124            // nothing to do with the diff, and would leave this attempt
6125            // sitting in the next round's prompt as if it were about an
6126            // earlier, superseded commit rather than what it actually is:
6127            // the same head, still waiting to be checked. Wait for it the
6128            // same way the final round's own contention is already handled,
6129            // whatever round this happens to be.
6130            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
6131                self.state.reviews.push(round_record);
6132                return self
6133                    .stop_reviewing(
6134                        "the round's own verification could not run",
6135                        &shell,
6136                        &winner.worktree,
6137                    )
6138                    .await;
6139            }
6140
6141            if round == max_rounds {
6142                self.state.reviews.push(round_record);
6143                return self
6144                    .stop_reviewing(
6145                        &format!(
6146                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
6147                        ),
6148                        &shell,
6149                        &winner.worktree,
6150                    )
6151                    .await;
6152            }
6153
6154            // Fix. The winner's own implementer seat continues its conversation:
6155            // the competition is over, so context is pure benefit now.
6156            let blocking_findings: Vec<_> = all_findings
6157                .iter()
6158                .filter(|f| f.severity.blocks())
6159                .cloned()
6160                .collect();
6161            let fix_prompt = prompt::fix_in(
6162                &self.state.instruction,
6163                &blocking_findings,
6164                this_round_verification.as_ref(),
6165                round,
6166                max_rounds,
6167                &language,
6168                &self.state.config.graph.github_language,
6169            );
6170            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6171            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6172            let (job, seat, out) = self
6173                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
6174                    prompt: fix_prompt.clone(),
6175                    spec,
6176                    seat,
6177                    cwd: winner.worktree.clone(),
6178                    timeout,
6179                    allow_write: true,
6180                    sessions,
6181                    artifacts: artifacts.clone(),
6182                    stem: format!("fix-{round}"),
6183                    handover: None,
6184                })
6185                .await;
6186            // Not asked: the round is not recorded (so a resume repeats it),
6187            // no fixer was handed over to, and the seat is left as it was.
6188            if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6189                return self.stop_for_resource();
6190            }
6191            let agent_id = seat.agent.clone();
6192
6193            let mut fix = FixRecord {
6194                agent: agent_id,
6195                addressed: Vec::new(),
6196                rejected: Vec::new(),
6197                notes: String::new(),
6198                committed: false,
6199                failed: None,
6200                duration_ms: 0,
6201                continuation: None,
6202            };
6203            let mut continuation = ContinuationRecord::not_needed();
6204            let mut final_seat = seat.clone();
6205            match out {
6206                AgentOutcome::Ok(o) => {
6207                    fix.duration_ms = o.duration_ms;
6208                    let parsed = verdict::extract_json::<FixReport>(&o.text);
6209                    // A parsed report standing next to a command this same
6210                    // reply's own CLI never confirmed the exit status of is
6211                    // not a resolved answer — the identical `CommandEvidence`
6212                    // `state.jobs` renders, read here instead of only on
6213                    // display, per the completion judgment and the shown
6214                    // record needing to agree.
6215                    let incomplete_reason = match &parsed {
6216                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
6217                            "the reply parsed, but it reported a command whose own CLI never \
6218                             confirmed an exit status"
6219                                .to_owned(),
6220                        ),
6221                        Ok(_) => None,
6222                        Err(e) => Some(e.to_string()),
6223                    };
6224                    match incomplete_reason {
6225                        None => {
6226                            let report = parsed.expect("checked Ok above");
6227                            fix.addressed = report.addressed;
6228                            fix.rejected = report.rejected;
6229                            fix.notes =
6230                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
6231                        }
6232                        Some(reason) => {
6233                            let (resumed_seat, resolved, failure, cont) = self
6234                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
6235                                .await;
6236                            fix.duration_ms += cont.cumulative_wait_ms;
6237                            continuation = cont;
6238                            final_seat = resumed_seat;
6239                            match resolved {
6240                                Some(report) => {
6241                                    fix.addressed = report.addressed;
6242                                    fix.rejected = report.rejected;
6243                                    fix.notes = blind::sanitize_prose(
6244                                        &report.notes,
6245                                        &self.state.config.blind,
6246                                    );
6247                                }
6248                                None => fix.failed = failure,
6249                            }
6250                        }
6251                    }
6252                }
6253                // The CLI's raw error JSON is not a fix report to parse.
6254                AgentOutcome::Dropped(o) => {
6255                    fix.duration_ms = o.duration_ms;
6256                    let why = o
6257                        .dropped
6258                        .as_ref()
6259                        .map(|d| d.why.as_str())
6260                        .unwrap_or("the CLI ended the stream without delivering its answer");
6261                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
6262                }
6263                AgentOutcome::Quota(o) => {
6264                    self.state.quota.push(QuotaLoss {
6265                        seat: final_seat.key.clone(),
6266                        node: "fix".to_owned(),
6267                        at: Timestamp::now(),
6268                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6269                    });
6270                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
6271                }
6272                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => fix.failed = Some(e),
6273            }
6274            fix.continuation = Some(continuation);
6275            self.state.seats.insert(final_seat.key.clone(), final_seat);
6276            if let Ok(r) = git::rescue_commit(
6277                &winner.worktree,
6278                &format!("magi: review round {round} fixes (uncommitted work)"),
6279            )
6280            .await
6281            {
6282                self.state.note_withheld("fix", &r.withheld);
6283            }
6284            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6285            fix.committed = after != before;
6286            // Judged by what `git` says moved against base, never by the
6287            // fixer's own `addressed`/`rejected` count — see
6288            // `ReviewRound::progressed`. Propagated with `?`, the same as the
6289            // `patch` snapshot above: swallowing this error would default
6290            // `diff_after` to empty, which almost always differs from a
6291            // non-empty `patch` and reads as "progressed" — exactly backwards
6292            // for a `git` failure the stagnation check cannot see through.
6293            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
6294            let progressed = diff_after != patch;
6295            let commit_note = if fix.committed {
6296                "committed"
6297            } else {
6298                "NO new commit"
6299            };
6300            let tree_note = if progressed {
6301                "changed vs base"
6302            } else {
6303                "unchanged vs base"
6304            };
6305            self.state.event(
6306                "fix",
6307                match &fix.failed {
6308                    // Distinct on purpose from "0 addressed, 0 rejected": the
6309                    // fixer's own diff still landed (blocking counts do keep
6310                    // falling round over round), only its adoption report did
6311                    // not come back, so this must never read like every
6312                    // finding was reviewed and declined.
6313                    Some(reason) => {
6314                        format!(
6315                            "round {round}: fixer's adoption report was lost ({reason}); \
6316                             {commit_note}, tree {tree_note}"
6317                        )
6318                    }
6319                    None => format!(
6320                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
6321                         {tree_note}{}",
6322                        fix.addressed.len(),
6323                        fix.rejected.len(),
6324                        if continuation.outcome == ContinuationOutcome::Resumed {
6325                            format!(
6326                                " (adoption report recovered after {} continuation(s))",
6327                                continuation.attempts
6328                            )
6329                        } else {
6330                            String::new()
6331                        },
6332                    ),
6333                },
6334            );
6335            round_record.fix = Some(fix);
6336            round_record.progressed = progressed;
6337            self.state.reviews.push(round_record);
6338            self.state.save()?;
6339
6340            // The fixer's own report never came back this round, even after
6341            // `continue_fix_report`'s own budget was spent on it — not an
6342            // ordinary "no report" (dropped stream, quota, plain failure),
6343            // which already reads that way and is left to the existing round
6344            // budget. Stopping here, rather than opening another round, is
6345            // what keeps a next reviewer/fixer wave from ever being
6346            // dispatched onto `winner.worktree` while whatever the seat's
6347            // last call may still have running there is unaccounted for: no
6348            // process liveness check exists (and none is being added — see
6349            // AGENTS.md/this task's own scope), so the only way to honour
6350            // "nothing starts before a valid report returns" is to not start
6351            // anything further on this worktree from this run at all.
6352            if matches!(
6353                continuation.outcome,
6354                ContinuationOutcome::Exhausted
6355                    | ContinuationOutcome::QuotaLost
6356                    | ContinuationOutcome::NoSession
6357            ) {
6358                return self
6359                    .stop_reviewing(
6360                        "the fixer's adoption report never came back, even after resuming its \
6361                         own seat; refusing to start another round against the same worktree \
6362                         while that is unresolved",
6363                        &shell,
6364                        &winner.worktree,
6365                    )
6366                    .await;
6367            }
6368
6369            let streak = self
6370                .state
6371                .reviews
6372                .iter()
6373                .rev()
6374                .take_while(|r| !r.progressed)
6375                .count();
6376            if streak >= STAGNANT_LIMIT {
6377                return self
6378                    .stop_reviewing(
6379                        &format!(
6380                            "the tree has not moved against base for {streak} round(s) in a row"
6381                        ),
6382                        &shell,
6383                        &winner.worktree,
6384                    )
6385                    .await;
6386            }
6387        }
6388        Ok(())
6389    }
6390
6391    /// Decide, from the last recorded round's own verification, whether
6392    /// stopping the review loop is a hand-off or a genuine block.
6393    ///
6394    /// Called once the loop has given up trying — the round budget is spent,
6395    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
6396    /// findings still open, never while a round is still clean or the
6397    /// incomplete-panel case handled inline above. Gate and e2e are facts
6398    /// about the tree; a lingering review finding is an opinion, and this
6399    /// workload's own `magi stats` puts reviewer precision low enough
6400    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
6401    /// must not by itself stand between a green, verified change and the
6402    /// human who decides what to do with it. A red e2e is not an opinion, so
6403    /// that case still blocks, with the failing command and a tail of its
6404    /// output recorded here rather than left in `run.json` for someone to go
6405    /// find.
6406    ///
6407    /// A round that deferred its own e2e (see [`Config::graph`]'s
6408    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
6409    /// only because nothing ran, and treating an empty list as a passing one
6410    /// here is exactly the "deferred painted green" bug this function exists
6411    /// to not have. When the last round's own verification never resolved —
6412    /// deferred on purpose, or a real attempt the shared build cache blocked
6413    /// — this makes (or retries) the real run, on the actual worktree this
6414    /// loop is about to stop touching, before deciding anything. A
6415    /// resource-blocked attempt is likewise never read as either green or
6416    /// red: it is evidence about the machine, not the patch (see
6417    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
6418    /// blocked cache leaves this call without deciding rather than guessing
6419    /// — the caller retries on a later reentry.
6420    /// Record, once, that the review loop handed off over a blocking finding
6421    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
6422    /// `land` asks the owner even with `land_approval` off. Called from every
6423    /// path that concludes `Gating`; a reentry keeps the first record.
6424    fn record_contested_handoff(&mut self) {
6425        if self.state.contested_handoff.is_some() {
6426            return;
6427        }
6428        let Some(contested) = self
6429            .state
6430            .reviews
6431            .last()
6432            .and_then(ReviewRound::contested_handoff)
6433        else {
6434            return;
6435        };
6436        self.state.event(
6437            "review",
6438            format!(
6439                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
6440                 wait for the owner's approval",
6441                contested.findings.len(),
6442                contested.rejecters.len()
6443            ),
6444        );
6445        self.state.contested_handoff = Some(contested);
6446    }
6447
6448    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
6449        let round_idx = self.state.reviews.len() - 1;
6450        // A deferred round and a resource-blocked one are the same shape
6451        // here: neither has a real result yet, and both get one more
6452        // attempt. Read off `e2e_status` — the single source for this —
6453        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
6454        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
6455        // still retries instead of being read as a settled result the
6456        // instant it stops being empty.
6457        let needs_catchup_run = matches!(
6458            self.state.reviews[round_idx].e2e_status(),
6459            E2eStatus::Deferred | E2eStatus::ResourceBlocked
6460        );
6461        if needs_catchup_run {
6462            let round = self.state.reviews[round_idx].round;
6463            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6464            let commands = self.state.config.verify.e2e.clone();
6465            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
6466            let cache_dir = self.state.config.cache_dir();
6467            let context = format!(
6468                "round {round}: verification unresolved, catching up before the final decision"
6469            );
6470            let (outcomes, verify_retried) = with_cache_lease(
6471                &mut self.state,
6472                cache_dir.as_deref(),
6473                "e2e",
6474                "e2e",
6475                worktree,
6476                &attempted_head,
6477                timeout,
6478                &context,
6479                |state, budget| {
6480                    let shell = shell.to_vec();
6481                    let commands = commands.clone();
6482                    let context = context.clone();
6483                    async move {
6484                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
6485                            .await
6486                    }
6487                },
6488            )
6489            .await;
6490            let last = &mut self.state.reviews[round_idx];
6491            last.e2e = outcomes;
6492            last.verify_retried = verify_retried;
6493            // Always the commit and time this attempt actually targeted,
6494            // whether or not it happens to equal the reviewed `head` and
6495            // whether or not a command finished — see
6496            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
6497            // attempt is recorded too, so a later reader sees "attempted
6498            // again at T2" rather than silence.
6499            last.verified_head = Some(attempted_head);
6500            last.verified_at = Some(Timestamp::now());
6501            if verify_inconclusive(&last.e2e) {
6502                // Still not a real result: `e2e_deferred` is left exactly
6503                // as it was, so `needs_catchup_run` above reads
6504                // `ResourceBlocked` (via `e2e_status`, which checks
6505                // `resource_blocked` before `e2e_deferred`) and retries
6506                // again on the next reentry, rather than recording
6507                // contention as a red e2e and blocking the run on it.
6508                self.state.save()?;
6509                return Ok(());
6510            }
6511            last.e2e_deferred = false;
6512        }
6513        let last = &self.state.reviews[round_idx];
6514        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
6515
6516        match last.e2e_status() {
6517            E2eStatus::Failed => {
6518                let red: Vec<String> = last
6519                    .e2e
6520                    .iter()
6521                    .filter(|o| !o.ok())
6522                    .map(|o| {
6523                        format!(
6524                            "`{}` -> {:?}\n{}",
6525                            o.command,
6526                            o.code,
6527                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6528                        )
6529                    })
6530                    .collect();
6531                self.state
6532                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
6533                self.state.status = RunStatus::Blocked;
6534            }
6535            // `needs_catchup_run` above already retried once this call; if
6536            // it is still blocked, this is magi's own admission it could
6537            // not get a command to run, never a verdict on the patch — the
6538            // run is left exactly where a later reentry can retry again.
6539            E2eStatus::ResourceBlocked => {
6540                self.state.event(
6541                    "review",
6542                    format!(
6543                        "{why}; e2e could not run (shared build cache unavailable); not \
6544                         deciding yet"
6545                    ),
6546                );
6547            }
6548            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
6549                self.state.event(
6550                    "review",
6551                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
6552                );
6553                self.record_contested_handoff();
6554                self.state.status = RunStatus::Gating;
6555            }
6556        }
6557        self.state.save()?;
6558        Ok(())
6559    }
6560
6561    // ----------------------------------------------------------------- gate
6562
6563    async fn gate(&mut self) -> Result<()> {
6564        // Judged by the review record itself, not by `status`: a solo
6565        // candidate's `judge`/`deliberate` skip rewrites `status` on every
6566        // reentry (see `judge`), and trusting it here is exactly how a run
6567        // that exhausted its review budget got gated and merged a second
6568        // time around. `review_conclusion` recomputes the review loop's own
6569        // verdict from the round records themselves — `Gating` for a clean
6570        // round or a hand-off (see `stop_reviewing`), anything else means the
6571        // loop is still going or genuinely blocked.
6572        // A base the winner could not be replayed onto is a decision, not a
6573        // round: there is no landing tree to gate. Read as its own record for
6574        // the same reason the review verdict is.
6575        if self.state.status == RunStatus::Failed
6576            || self
6577                .state
6578                .base_sync
6579                .as_ref()
6580                .is_some_and(|s| s.conflict.is_some())
6581            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6582                != Some(RunStatus::Gating)
6583        {
6584            return Ok(());
6585        }
6586        if self.state.gate_ran {
6587            // `review_loop` derives its conclusion from the clean review
6588            // record on every reentry and therefore puts a completed run back
6589            // in `Gating`. A recorded gate is a stronger, terminal fact:
6590            // retain its original command output (or lack of any, for a repo
6591            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
6592            // doc) and restore `Blocked` on a real failure rather than
6593            // pretending the command is still running or running it a second
6594            // time. `gate_ran == false` remains the only shape — unattempted,
6595            // or a resource-blocked retry — that may still need to execute a
6596            // command.
6597            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
6598                self.state.status = RunStatus::Blocked;
6599                self.state.save()?;
6600            }
6601            return Ok(());
6602        }
6603        let Some(winner) = self.state.winner().cloned() else {
6604            return Ok(());
6605        };
6606        self.state.status = RunStatus::Gating;
6607        let mut outcomes = self.run_gate(&winner).await?;
6608        loop {
6609            // A resource-blocked outcome means the gate command never actually
6610            // ran - the shared build cache could not be acquired or confirmed
6611            // fresh in time - which is evidence about the machine, not about
6612            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
6613            // Recording it as a red gate would mark a run `Blocked` on nothing
6614            // but contention magi has already logged; leaving `self.state.gate`
6615            // empty and `self.state.gate_ran` false instead keeps the shape
6616            // this function already treats as "still needs to run" (see the
6617            // early-return above), so the next call retries the command
6618            // rather than concluding anything.
6619            if verify_inconclusive(&outcomes) {
6620                self.state.save()?;
6621                return Ok(());
6622            }
6623            if outcomes.iter().all(CommandOutcome::ok) {
6624                break;
6625            }
6626            match self.gate_fix_round(&winner, &outcomes).await? {
6627                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6628                GateFix::Stop => break,
6629                GateFix::Defer => {
6630                    self.state.save()?;
6631                    return Ok(());
6632                }
6633            }
6634        }
6635        let passed = outcomes.iter().all(CommandOutcome::ok);
6636        self.state.gate = outcomes;
6637        self.state.gate_ran = true;
6638        self.state.gate_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
6639        if !passed {
6640            self.state.status = RunStatus::Blocked;
6641            let spent = self.state.gate_fixes.len();
6642            self.state.event(
6643                "gate",
6644                if spent == 0 {
6645                    "gate failed; not merging".to_owned()
6646                } else {
6647                    format!("gate failed after {spent} gate-fix round(s); not merging")
6648                },
6649            );
6650        }
6651        self.state.save()?;
6652        Ok(())
6653    }
6654
6655    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6656    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6657    /// after review is clean and never on a candidate awaiting judging.
6658    ///
6659    /// Never fails the run: a non-zero exit or timeout is a warning and a
6660    /// recorded outcome, and the gate remains the single arbiter. Nothing
6661    /// configured means nothing happens - no event, no commit. `commit_all`
6662    /// commits any leftover change under the neutral identity and returns
6663    /// `false` when the tree is clean, so no empty commit is ever made.
6664    async fn run_pre_gate(&mut self, winner: &Candidate) {
6665        let commands = self.state.config.verify.pre_gate.clone();
6666        if commands.is_empty() {
6667            return;
6668        }
6669        let shell = self.state.config.shell();
6670        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6671        let (outcomes, _) = run_commands(
6672            &mut self.state,
6673            "pre_gate",
6674            "pre_gate",
6675            0,
6676            &shell,
6677            &commands,
6678            &winner.worktree,
6679            timeout,
6680        )
6681        .await;
6682        for o in &outcomes {
6683            if !o.ok() {
6684                tracing::warn!(
6685                    "pre_gate `{}` failed ({:?}); the gate decides",
6686                    o.command,
6687                    o.code
6688                );
6689            }
6690            self.state.event(
6691                "pre_gate",
6692                format!(
6693                    "`{}` -> {}",
6694                    o.command,
6695                    if o.ok() {
6696                        "pass".to_owned()
6697                    } else {
6698                        format!(
6699                            "FAIL ({:?})\n{}",
6700                            o.code,
6701                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6702                        )
6703                    }
6704                ),
6705            );
6706        }
6707        self.state.pre_gate = outcomes;
6708        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6709            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6710                Ok(head) => {
6711                    self.state
6712                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
6713                    self.state.pre_gate_commit = Some(head);
6714                }
6715                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6716            },
6717            Ok(false) => {}
6718            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6719        }
6720        if let Err(e) = self.state.save() {
6721            tracing::warn!("could not persist the pre_gate record: {e:#}");
6722        }
6723    }
6724
6725    /// Run `verify.gate` once against the winner's current tree, logging one
6726    /// event per command. Empty when nothing is configured.
6727    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6728        self.run_pre_gate(winner).await;
6729        let shell = self.state.config.shell();
6730        let gate_commands = self.state.config.verify.gate.clone();
6731        // Zero commands has nothing to run and nothing that could touch the
6732        // shared build cache, so it never needs a lease: `Config::cache_dir`
6733        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
6734        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
6735        // otherwise queue behind an unrelated run's lease and come back
6736        // resource-blocked - `gate_ran` would stay false on nothing but
6737        // cache contention, for a step that had nothing to check in the
6738        // first place.
6739        let outcomes = if gate_commands.is_empty() {
6740            Vec::new()
6741        } else {
6742            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6743            let cache_dir = self.state.config.cache_dir();
6744            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6745            let (outcomes, _) = with_cache_lease(
6746                &mut self.state,
6747                cache_dir.as_deref(),
6748                "gate",
6749                "gate",
6750                &winner.worktree,
6751                &head,
6752                timeout,
6753                "final gate",
6754                |state, budget| {
6755                    let shell = shell.clone();
6756                    let gate_commands = gate_commands.clone();
6757                    let worktree = winner.worktree.clone();
6758                    async move {
6759                        let (outcomes, timed_out_pids) = run_commands(
6760                            state,
6761                            "gate",
6762                            "gate",
6763                            0,
6764                            &shell,
6765                            &gate_commands,
6766                            &worktree,
6767                            budget,
6768                        )
6769                        .await;
6770                        (outcomes, false, timed_out_pids)
6771                    }
6772                },
6773            )
6774            .await;
6775            outcomes
6776        };
6777        if outcomes.is_empty() {
6778            // Nothing configured to check — distinct from every other
6779            // silence in this run's event log, since an empty `gate` alone
6780            // no longer says whether the gate ran at all (see
6781            // `RunState::gate_ran`'s own doc).
6782            self.state.event(
6783                "gate",
6784                "no gate commands configured; nothing to check, passing",
6785            );
6786        }
6787        for o in &outcomes {
6788            self.state.event(
6789                "gate",
6790                format!(
6791                    "`{}` -> {}",
6792                    o.command,
6793                    if o.ok() {
6794                        "pass".to_owned()
6795                    } else {
6796                        format!(
6797                            "FAIL ({:?})\n{}",
6798                            o.code,
6799                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6800                        )
6801                    }
6802                ),
6803            );
6804        }
6805        Ok(outcomes)
6806    }
6807
6808    /// One bounded fix round for a failing gate.
6809    ///
6810    /// The fixer is told the failure came from the gate itself, not from a
6811    /// reviewer, and is shown the failed commands, their exit codes and a tail
6812    /// of their output - whatever `[verify].gate` holds, nothing here knows
6813    /// what those commands run. Only a normal non-zero exit that printed
6814    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6815    /// command or a full disk says nothing about the code, and a fixer sent
6816    /// after it can only appease the machine. The round is judged by what git
6817    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6818    /// again before the gate does, so a fix cannot trade a green gate for a
6819    /// red e2e unnoticed.
6820    async fn gate_fix_round(
6821        &mut self,
6822        winner: &Candidate,
6823        outcomes: &[CommandOutcome],
6824    ) -> Result<GateFix> {
6825        let cap = self.state.config.graph.gate_fix_rounds;
6826        let spent = self.state.gate_fixes.len();
6827        if spent >= cap {
6828            if cap > 0 {
6829                self.state.event(
6830                    "gate",
6831                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6832                );
6833            }
6834            return Ok(GateFix::Stop);
6835        }
6836        if !gate_fixable(outcomes) {
6837            self.state.event(
6838                "gate",
6839                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6840                 command or similar); not spending a fix round on it",
6841            );
6842            return Ok(GateFix::Stop);
6843        }
6844        let min_free = self.state.config.disk.min_free_bytes;
6845        if min_free > 0 {
6846            match crate::disk::free_bytes(&winner.worktree) {
6847                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6848                Ok(free) => {
6849                    self.state.event(
6850                        "gate",
6851                        format!(
6852                            "only {free} bytes free ({min_free} required by `[disk] \
6853                             min_free_bytes`); not spending a fix round on a failure the disk \
6854                             may explain"
6855                        ),
6856                    );
6857                    return Ok(GateFix::Stop);
6858                }
6859                Err(e) => {
6860                    self.state.event(
6861                        "gate",
6862                        format!("free disk space could not be measured ({e:#}); no fix round"),
6863                    );
6864                    return Ok(GateFix::Stop);
6865                }
6866            }
6867        }
6868
6869        let attempt = spent + 1;
6870        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6871        let base = self.landing_base();
6872        let fix_prompt = prompt::gate_fix_in(
6873            &self.state.instruction,
6874            &failed,
6875            attempt,
6876            cap,
6877            &self.state.config.graph.language,
6878            &self.state.config.graph.github_language,
6879        );
6880        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6881        let sessions = self.state.config.graph.sessions;
6882        let artifacts = self.merge_repair_artifacts();
6883        self.state.event(
6884            "gate",
6885            format!("gate failed; gate-fix round {attempt} of {cap}"),
6886        );
6887        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6888        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6889        let (_, seat, out) = self
6890            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6891                prompt: fix_prompt.clone(),
6892                spec,
6893                seat,
6894                cwd: winner.worktree.clone(),
6895                timeout,
6896                allow_write: true,
6897                sessions,
6898                artifacts: artifacts.clone(),
6899                stem: format!("gate-fix-{attempt}"),
6900                handover: None,
6901            })
6902            .await;
6903        // The fixer was never asked: no round is spent (nothing is recorded)
6904        // and a later reentry asks the same agent again.
6905        if matches!(out, AgentOutcome::ResourceBlocked(_)) {
6906            return Ok(GateFix::Defer);
6907        }
6908        let mut record = GateFixRecord {
6909            agent: seat.agent.clone(),
6910            failed,
6911            notes: String::new(),
6912            committed: false,
6913            error: None,
6914        };
6915        match out {
6916            AgentOutcome::Ok(o) => {
6917                // A missing report is not a failed fix: the round is judged
6918                // by the tree below, and the report only carries prose.
6919                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6920                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6921                }
6922            }
6923            AgentOutcome::Dropped(_) => {
6924                record.error = Some("the CLI dropped the stream".to_owned());
6925            }
6926            AgentOutcome::Quota(o) => {
6927                self.state.quota.push(QuotaLoss {
6928                    seat: seat.key.clone(),
6929                    node: "gate-fix".to_owned(),
6930                    at: Timestamp::now(),
6931                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6932                });
6933                record.error = Some("rate limited (quota); fixer could not run".to_owned());
6934            }
6935            AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => record.error = Some(e),
6936        }
6937        self.state.seats.insert(seat.key.clone(), seat);
6938        if let Ok(r) = git::rescue_commit(
6939            &winner.worktree,
6940            &format!("magi: gate fix {attempt} (uncommitted work)"),
6941        )
6942        .await
6943        {
6944            self.state.note_withheld("gate-fix", &r.withheld);
6945        }
6946        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6947        record.committed = after != before;
6948        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6949        let note = record.error.clone();
6950        self.state.gate_fixes.push(record);
6951        self.state.save()?;
6952        if !changed {
6953            self.state.event(
6954                "gate",
6955                match note {
6956                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6957                    None => format!("gate-fix round {attempt}: the tree did not change"),
6958                },
6959            );
6960            return Ok(GateFix::Stop);
6961        }
6962        self.state.event(
6963            "gate",
6964            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6965        );
6966
6967        let commands = self.state.config.verify.e2e.clone();
6968        if !commands.is_empty() {
6969            let shell = self.state.config.shell();
6970            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6971            let cache_dir = self.state.config.cache_dir();
6972            let context = format!("gate-fix round {attempt}");
6973            let (e2e, _) = with_cache_lease(
6974                &mut self.state,
6975                cache_dir.as_deref(),
6976                "e2e",
6977                "e2e",
6978                &winner.worktree,
6979                &after,
6980                timeout,
6981                &context,
6982                |state, budget| {
6983                    let shell = shell.clone();
6984                    let commands = commands.clone();
6985                    let context = context.clone();
6986                    let worktree = winner.worktree.clone();
6987                    async move {
6988                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6989                            .await
6990                    }
6991                },
6992            )
6993            .await;
6994            if verify_inconclusive(&e2e) {
6995                return Ok(GateFix::Defer);
6996            }
6997            if e2e.iter().any(|o| !o.ok()) {
6998                self.state.event(
6999                    "gate",
7000                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
7001                );
7002                return Ok(GateFix::Stop);
7003            }
7004        }
7005        Ok(GateFix::Retry)
7006    }
7007
7008    // ---------------------------------------------------------------- merge
7009
7010    /// One read-only rewrite by the summary's author; a text the gate still
7011    /// withholds goes to the owner (see `github_text`) and the fixed fallback
7012    /// applies on their word or on silence. `None` means the run is parked on
7013    /// that question and nothing may be pushed or opened yet.
7014    async fn guarded_pr_message(
7015        &mut self,
7016        winner: &Candidate,
7017        facts: Option<&BranchFacts>,
7018        posting: bool,
7019    ) -> Result<Option<PrMessage>> {
7020        let final_head = git::rev_parse(&winner.worktree, "HEAD").await.ok();
7021        let head = match final_head.as_deref() {
7022            Some(h) => crate::pr_outcome::FinalHead::At(h),
7023            None => crate::pr_outcome::FinalHead::Unknown,
7024        };
7025        // Only a pull request that is about to be posted pays for a summary.
7026        let summarized = if posting {
7027            Some(self.summarize_change(winner, final_head.as_deref()).await)
7028        } else {
7029            None
7030        };
7031        // A busy shared resource is not a permanent cause: publishing an
7032        // "unavailable" description would stick (a later head move never
7033        // edits the body). Stop before anything is pushed or opened; the
7034        // run is `Blocked` and a resume asks the summary again.
7035        if self.state.resource_block.is_some() {
7036            self.state.event(
7037                "pr-summary",
7038                "a shared resource was busy; nothing was published, resume to summarize again",
7039            );
7040            return Ok(None);
7041        }
7042        if posting && git::rev_parse(&winner.worktree, "HEAD").await.ok() != final_head {
7043            anyhow::bail!(
7044                "the final commit moved during public description generation; retry before publishing"
7045            );
7046        }
7047        let note = match (&summarized, &self.state.change_summary) {
7048            (Some(Ok(())), Some(c)) => ChangeNote::Summary(c),
7049            (Some(Err(why)), _) => ChangeNote::Unavailable(why),
7050            (Some(Ok(())), None) => ChangeNote::Unavailable("no summary was recorded"),
7051            (None, _) => ChangeNote::NotAsked,
7052        };
7053        let mut pr = pr_message_raw(&self.state, winner.label, facts, head, note);
7054        if !posting {
7055            let identity = crate::scrub::Identity::current();
7056            return Ok(Some(PrMessage {
7057                title: crate::scrub::scrub(&pr.title, &identity),
7058                body: crate::scrub::scrub(&pr.body, &identity),
7059            }));
7060        }
7061        // This very text was already rejected and asked about: no second
7062        // judge call, rewrite or question, just carry the record on.
7063        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
7064        if let Some(known) = self
7065            .state
7066            .github_text
7067            .clone()
7068            .filter(|g| g.fingerprint == raw_fp)
7069        {
7070            return self.settle_withheld(winner, &pr, known).await;
7071        }
7072        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
7073        let mut violations =
7074            crate::github_text::check_for(&self.state.config.graph, &pr.title, &pr.body, decision);
7075        let identity = crate::scrub::Identity::current();
7076        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
7077            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
7078            && !violations.contains(&crate::github_text::Violation::SensitiveData)
7079        {
7080            violations.push(crate::github_text::Violation::SensitiveData);
7081        }
7082        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
7083        // only a language violation starts one; sensitive data alone goes
7084        // straight to the owner, with the redacted text as the fallback.
7085        let has_language = violations
7086            .iter()
7087            .any(|v| *v != crate::github_text::Violation::SensitiveData);
7088        if self.state.config.graph.github_text_guard && !violations.is_empty() {
7089            self.state.event(
7090                "github-text",
7091                if has_language {
7092                    format!("description rejected: {violations:?}; requesting one rewrite")
7093                } else {
7094                    format!("description rejected: {violations:?}; asking the owner")
7095                },
7096            );
7097            let mut rewritten = false;
7098            let mut rejected_rewrite = None;
7099            if has_language && let Some(text) = self.rewrite_pr_text(winner, &pr, &violations).await
7100            {
7101                let output = text;
7102                if let Some(title) = summary_title(&output) {
7103                    // Judge the redacted candidate that will actually be posted.
7104                    // Sensitive data alone must not discard a useful rewrite.
7105                    let raw_body = summary_without_title(&output);
7106                    let clean_title = crate::scrub::scrub(&title, &identity);
7107                    let mut body = crate::scrub::scrub(&raw_body, &identity);
7108                    let redacted = clean_title != title || body != raw_body;
7109                    let title = clean_title;
7110                    let complete = crate::pr_outcome::covers_required_findings(&self.state, &body);
7111                    if !complete {
7112                        self.state.event(
7113                            "github-text",
7114                            "description rewrite omitted required findings",
7115                        );
7116                    }
7117                    if !body.trim().is_empty() {
7118                        body.push_str("\n\n");
7119                        body.push_str(&public_pr_evidence(&self.state, head));
7120                    }
7121                    let marker = format!("magi:run/{}", self.state.id);
7122                    if !body.trim().is_empty() && !body.contains(&marker) {
7123                        body.push_str(&format!("\n\n{marker}"));
7124                    }
7125                    let rewrite = PrMessage {
7126                        title: title.clone(),
7127                        body: body.clone(),
7128                    };
7129                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
7130                    let mut failed = crate::github_text::check_for(
7131                        &self.state.config.graph,
7132                        &title,
7133                        &body,
7134                        redecision,
7135                    );
7136                    if (!crate::github_text::shareable(&title)
7137                        || !crate::github_text::shareable(&body))
7138                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
7139                    {
7140                        failed.push(crate::github_text::Violation::SensitiveData);
7141                    }
7142                    if complete
7143                        && !title.trim().is_empty()
7144                        && !body.trim().is_empty()
7145                        && !failed.is_empty()
7146                    {
7147                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
7148                            title: title.clone(),
7149                            body: body.clone(),
7150                            failed: failed
7151                                .iter()
7152                                .map(|v| crate::github_text::category(*v).to_owned())
7153                                .collect(),
7154                        });
7155                    }
7156                    if complete
7157                        && !title.trim().is_empty()
7158                        && !raw_body.trim().is_empty()
7159                        && failed.is_empty()
7160                    {
7161                        decision = redecision;
7162                        pr = PrMessage { title, body };
7163                        rewritten = true;
7164                        self.state
7165                            .event("github-text", "description rewrite passed");
7166                        if redacted {
7167                            self.state.event(
7168                                "github-text",
7169                                "sensitive data removed from description rewrite",
7170                            );
7171                        }
7172                    }
7173                }
7174            }
7175            if !rewritten {
7176                self.state.event(
7177                    "github-text",
7178                    "description rewrite unavailable or rejected; asking the owner",
7179                );
7180                if let Some(w) =
7181                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
7182                {
7183                    // A different text than the one asked about before: that
7184                    // question no longer describes anything.
7185                    self.retire_github_text_question("the withheld text changed");
7186                    let record = crate::run::GithubTextAsk {
7187                        rewrite: rejected_rewrite,
7188                        fingerprint: raw_fp,
7189                        title: w.title,
7190                        body: w.body,
7191                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
7192                        question: None,
7193                        asks: 0,
7194                        resolved: false,
7195                        chosen_title: None,
7196                        choice: None,
7197                    };
7198                    return self.settle_withheld(winner, &pr, record).await;
7199                }
7200            }
7201        }
7202        let (title, body) =
7203            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
7204        Ok(Some(PrMessage { title, body }))
7205    }
7206
7207    /// Describe the whole change from its final diff in either run mode.
7208    ///
7209    /// The summary is tied to the final commit and to the merge base of the
7210    /// resolved remote base; a record for any other pair is dropped and the
7211    /// agent asked again, so a description never outlives the diff it was
7212    /// written from. The seat is read-only and the prompt tells it to read the
7213    /// diff and the code around it. `[roles] synthesizer` selects the agents
7214    /// (unset: the default pick), each asked at most once, advancing on an
7215    /// error, quota, unusable answer or a reply with no `TITLE:` line. `Err`
7216    /// carries the reason it is unavailable; nothing is invented then.
7217    async fn summarize_change(
7218        &mut self,
7219        winner: &Candidate,
7220        head: Option<&str>,
7221    ) -> std::result::Result<(), String> {
7222        let Some(head) = head else {
7223            return Err("the final commit could not be read".to_owned());
7224        };
7225        let remote = self.state.config.merge.remote.clone();
7226        let tracking = format!("{remote}/{}", self.state.base_branch);
7227        if !git::rev_exists(&winner.worktree, &tracking).await {
7228            return Err(format!("the remote base `{tracking}` could not be read"));
7229        }
7230        let base = match git::merge_base(&winner.worktree, &tracking, head).await {
7231            Ok(b) if !b.is_empty() => b,
7232            _ => return Err("the merge base with the remote base could not be read".to_owned()),
7233        };
7234        let evidence = crate::pr_outcome::summary_evidence(
7235            &self.state,
7236            crate::pr_outcome::FinalHead::At(head),
7237        );
7238        let fingerprint = crate::pr_outcome::summary_fingerprint(&self.state, &evidence);
7239        if self
7240            .state
7241            .change_summary
7242            .as_ref()
7243            .is_some_and(|c| c.head == head && c.base == base && c.evidence == fingerprint)
7244        {
7245            return Ok(());
7246        }
7247        if self.state.change_summary.take().is_some() {
7248            self.state.event(
7249                "pr-summary",
7250                "the final diff or evidence changed; the earlier change summary was discarded",
7251            );
7252        }
7253        // Save the source snapshot even if no generation agent is available.
7254        if let Err(e) = write_artifact(
7255            &self.state,
7256            &format!("public-description-{fingerprint}.md"),
7257            &format!("Final diff: {base}..{head}\n\n{evidence}"),
7258        ) {
7259            tracing::warn!("could not save public description evidence: {e:#}");
7260        }
7261        let chain = match agent::pick_chain(
7262            &self.state.config.agents,
7263            self.state.config.roles.synthesizer.as_ref(),
7264            &agent::installed,
7265            "synthesizer",
7266        ) {
7267            Ok(chain) => chain,
7268            Err(e) => {
7269                tracing::warn!("no agent for the change summary: {e:#}");
7270                self.state.event("pr-summary", "no summary agent available");
7271                return Err("no agent was available to summarize it".to_owned());
7272            }
7273        };
7274        let subjects = git::subjects(&winner.worktree, &base, head)
7275            .await
7276            .unwrap_or_default();
7277        let stat = git::diff_stat(&winner.worktree, &base, head)
7278            .await
7279            .unwrap_or_default();
7280        let stat: String = stat.chars().take(6_000).collect();
7281        let mut prompt = crate::prompt::change_summary_in(
7282            &base,
7283            head,
7284            &subjects,
7285            stat.trim(),
7286            &self.state.config.graph.github_language,
7287        );
7288        prompt.push_str(&format!("\n\n# Final-state evidence\n{evidence}\n\nUse these records as evidence, not public text to copy. Paraphrase every open or declined finding and its reason in the requested GitHub language, retaining its exact ID and severity when available. Do not translate or quote internal operator reports. Explain unresolved risks for a reviewer without the task conversation. Historical sandbox or commit failures are not the final state. Verification is appended from these records; do not make additional validation claims."));
7289        let sessions = self.state.config.graph.sessions;
7290        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
7291        let prompts = self.state.config.prompts.clone();
7292        let cache = self.state.config.cache_dir();
7293        let run = self.state.id.clone();
7294        for (i, spec) in chain.into_iter().enumerate() {
7295            let seat = crate::fixer::seat_for(&mut self.state, "change-summary", &spec.id);
7296            let job = SeatJob {
7297                spec: spec.clone(),
7298                seat,
7299                cwd: winner.worktree.clone(),
7300                prompt: prompt.clone(),
7301                timeout,
7302                allow_write: false,
7303                sessions,
7304                artifacts: agent::artifacts_dir(&self.state.dir()),
7305                stem: if i == 0 {
7306                    "change-summary".to_owned()
7307                } else {
7308                    format!("change-summary-{}", spec.id)
7309                },
7310                handover: None,
7311            };
7312            let ctx = WaveCtx {
7313                run: &run,
7314                node: "pr-summary",
7315                prompts: &prompts,
7316                cache: cache.as_deref(),
7317                round: None,
7318                carry_seats: false,
7319                pauses: Some((&self.pause, &self.interrupt)),
7320            };
7321            let (seat, outcome) =
7322                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7323            self.state.seats.insert(seat.key.clone(), seat);
7324            let text = match outcome {
7325                AgentOutcome::Ok(o) if !agent::output_advances(&o) => o.text,
7326                AgentOutcome::ResourceBlocked(why) => {
7327                    // No agent was asked: the run stops resumably before
7328                    // anything is published (see `guarded_pr_message`).
7329                    if self.state.resource_block.is_none() {
7330                        self.state.resource_block = Some(crate::run::ResourceBlock {
7331                            node: "pr-summary".to_owned(),
7332                            kind: "shared resource".to_owned(),
7333                            detail: why,
7334                            at: Timestamp::now(),
7335                        });
7336                    }
7337                    return Err("a shared resource was busy".to_owned());
7338                }
7339                _ => continue,
7340            };
7341            let blind = &self.state.config.blind;
7342            let Some(title) = summary_title(&text) else {
7343                continue;
7344            };
7345            let body: String = summary_without_title(&text)
7346                .lines()
7347                .filter(|l| !l.contains("magi:run/"))
7348                .collect::<Vec<_>>()
7349                .join("\n");
7350            let body = blind::sanitize_prose(body.trim(), blind);
7351            let title = blind::sanitize_prose(&title, blind);
7352            if body.trim().is_empty() || title.trim().is_empty() {
7353                continue;
7354            }
7355            if !crate::pr_outcome::covers_required_findings(&self.state, &body) {
7356                continue;
7357            }
7358            // The branch must still be the one the agent was shown.
7359            if git::rev_parse(&winner.worktree, "HEAD")
7360                .await
7361                .ok()
7362                .as_deref()
7363                != Some(head)
7364            {
7365                return Err("the branch moved while the summary was written".to_owned());
7366            }
7367            if git::merge_base(&winner.worktree, &tracking, head)
7368                .await
7369                .ok()
7370                .as_deref()
7371                != Some(base.as_str())
7372                || crate::pr_outcome::summary_evidence(
7373                    &self.state,
7374                    crate::pr_outcome::FinalHead::At(head),
7375                ) != evidence
7376            {
7377                return Err("the summary evidence changed while it was written".to_owned());
7378            }
7379            self.state.change_summary = Some(crate::run::ChangeSummary {
7380                head: head.to_owned(),
7381                base,
7382                evidence: fingerprint,
7383                title,
7384                body,
7385            });
7386            self.state.event(
7387                "pr-summary",
7388                format!("change summary written by {} for the final diff", spec.id),
7389            );
7390            if let Err(e) = self.state.save() {
7391                tracing::warn!("could not save the change summary: {e:#}");
7392            }
7393            return Ok(());
7394        }
7395        self.state
7396            .event("pr-summary", "no agent produced a change summary");
7397        Err("the summarizing agents were unavailable or gave no usable answer".to_owned())
7398    }
7399
7400    /// One read-only rewrite request for a withheld description. The
7401    /// implementer's own conversation is reused only when it exists and can be
7402    /// resumed; otherwise the `[roles] language_judge` chain (or the default
7403    /// pick) writes it in a separate conversation. The prompt carries the whole
7404    /// text, so no agent needs any memory. Each agent is asked at most once and
7405    /// the chain advances on an error, quota, unusable answer or a reply with
7406    /// no `TITLE:` line. Nothing is recorded as a handover.
7407    async fn rewrite_pr_text(
7408        &mut self,
7409        winner: &Candidate,
7410        pr: &PrMessage,
7411        violations: &[crate::github_text::Violation],
7412    ) -> Option<String> {
7413        let prompt = format!(
7414            "Rewrite only the following pull request description into English. The posting gate reported {violations:?}. Keep every substantive detail (file names, commands, numbers, findings, code blocks and <details> sections) and translate the prose. Remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the rewritten description. Include an explanation of every open or declined finding with its exact ID. Omit the generated Review rounds, Final verification and Open review findings sections, the Evidence line and run markers: magi appends those from its records.\n\nTITLE: {}\n{}",
7415            pr.title, pr.body
7416        );
7417        let sessions = self.state.config.graph.sessions;
7418        let timeout_full = Duration::from_secs(self.state.config.graph.timeout_implement);
7419        // (spec, seat, resumed)
7420        let mut attempts: Vec<(AgentSpec, SeatState, bool)> = Vec::new();
7421        let key = format!("impl-{}", winner.label);
7422        if let Some(spec) = self
7423            .state
7424            .config
7425            .agents
7426            .iter()
7427            .find(|a| a.id == winner.agent)
7428            .cloned()
7429            && let Some(existing) = self.state.seats.get(&key)
7430            && existing.agent == spec.id
7431            && agent::has_session(spec.kind, existing, sessions)
7432        {
7433            attempts.push((spec, existing.clone(), true));
7434        } else {
7435            let chain = agent::pick_chain(
7436                &self.state.config.agents,
7437                self.state.config.roles.language_judge.as_ref(),
7438                &agent::installed,
7439                "language_judge",
7440            );
7441            match chain {
7442                Ok(chain) => {
7443                    for spec in chain {
7444                        let seat = crate::fixer::seat_for(
7445                            &mut self.state,
7446                            "github-text-rewrite",
7447                            &spec.id,
7448                        );
7449                        attempts.push((spec, seat, false));
7450                    }
7451                }
7452                Err(e) => {
7453                    tracing::warn!("no agent for the description rewrite: {e:#}");
7454                    self.state
7455                        .event("github-text", "no rewrite agent available");
7456                    return None;
7457                }
7458            }
7459        }
7460        let prompts = self.state.config.prompts.clone();
7461        let cache = self.state.config.cache_dir();
7462        let run = self.state.id.clone();
7463        for (i, (spec, seat, resumed)) in attempts.into_iter().enumerate() {
7464            let job = SeatJob {
7465                spec: spec.clone(),
7466                seat,
7467                cwd: winner.worktree.clone(),
7468                prompt: prompt.clone(),
7469                timeout: retry_budget(timeout_full, resumed),
7470                allow_write: false,
7471                sessions,
7472                artifacts: agent::artifacts_dir(&self.state.dir()),
7473                stem: if i == 0 {
7474                    "github-text-rewrite".to_owned()
7475                } else {
7476                    format!("github-text-rewrite-{}", spec.id)
7477                },
7478                handover: None,
7479            };
7480            let ctx = WaveCtx {
7481                run: &run,
7482                node: "github-text",
7483                prompts: &prompts,
7484                cache: cache.as_deref(),
7485                round: None,
7486                carry_seats: false,
7487                pauses: Some((&self.pause, &self.interrupt)),
7488            };
7489            let (seat, outcome) =
7490                run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
7491            self.state.seats.insert(seat.key.clone(), seat);
7492            match outcome {
7493                AgentOutcome::Ok(o)
7494                    if !agent::output_advances(&o) && summary_title(&o.text).is_some() =>
7495                {
7496                    return Some(o.text);
7497                }
7498                AgentOutcome::ResourceBlocked(_) => return None,
7499                _ => {}
7500            }
7501        }
7502        None
7503    }
7504
7505    /// Abandon the standing posting-gate question, if any.
7506    fn retire_github_text_question(&mut self, why: &str) {
7507        let Some(id) = self
7508            .state
7509            .github_text
7510            .as_ref()
7511            .and_then(|g| g.question.clone())
7512        else {
7513            return;
7514        };
7515        let _ = ask::Questions::open().update(&id, |q| {
7516            q.abandon(why);
7517            Ok(())
7518        });
7519    }
7520
7521    /// Carry a withheld text to its end: file the question, park while it is
7522    /// open, and on an answer, silence or timeout pick the text to post. The
7523    /// decision is saved before it is used, so it applies once.
7524    async fn settle_withheld(
7525        &mut self,
7526        winner: &Candidate,
7527        pr: &PrMessage,
7528        mut g: crate::run::GithubTextAsk,
7529    ) -> Result<Option<PrMessage>> {
7530        use crate::github_text as gt;
7531        let store = ask::Questions::open();
7532        let timeout = self.state.config.graph.answer_timeout;
7533        if !g.resolved {
7534            let mut standing = g
7535                .question
7536                .as_ref()
7537                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
7538            if let Some(q) = standing.as_ref()
7539                && q.status.open()
7540                && gt::expired(q, timeout)
7541            {
7542                let why = format!("no answer within {}s of asking", timeout.max(1));
7543                standing = store
7544                    .update(&q.id, |q| {
7545                        q.abandon(&why);
7546                        Ok(())
7547                    })
7548                    .ok()
7549                    .map(|(q, ())| q);
7550            }
7551            let mut ask_again: Option<Option<Vec<String>>> = None;
7552            match standing {
7553                // Recorded but never written (a stop between the two saves),
7554                // or removed: ask again without spending the retry.
7555                None if g.asks <= 1 => {
7556                    g.asks = 0;
7557                    ask_again = Some(None);
7558                }
7559                None => {
7560                    g.resolved = true;
7561                    g.chosen_title = None;
7562                    g.choice = Some(GithubTextChoice::Fallback);
7563                }
7564                Some(q) if q.status.open() => {
7565                    self.park_on_github_text(&g)?;
7566                    return Ok(None);
7567                }
7568                Some(q) => match gt::read_reply(&q) {
7569                    gt::Reply::Fallback => {
7570                        g.resolved = true;
7571                        g.chosen_title = None;
7572                        g.choice = Some(GithubTextChoice::Fallback);
7573                    }
7574                    gt::Reply::Original => {
7575                        g.resolved = true;
7576                        g.chosen_title = None;
7577                        g.choice = Some(GithubTextChoice::Original);
7578                    }
7579                    gt::Reply::Rewrite => {
7580                        g.resolved = true;
7581                        g.chosen_title = None;
7582                        g.choice = Some(if g.rewrite.is_some() {
7583                            GithubTextChoice::Rewrite
7584                        } else {
7585                            GithubTextChoice::Fallback
7586                        });
7587                    }
7588                    gt::Reply::Title(text) => {
7589                        let first = text
7590                            .lines()
7591                            .map(str::trim)
7592                            .find(|l| !l.is_empty())
7593                            .unwrap_or("");
7594                        let decision = if first.is_empty() {
7595                            None
7596                        } else {
7597                            let candidate = PrMessage {
7598                                title: first.to_owned(),
7599                                body: String::new(),
7600                            };
7601                            self.judge_pr_language(&winner.worktree, &candidate).await
7602                        };
7603                        match gt::vet_title(
7604                            &text,
7605                            decision,
7606                            self.state.config.graph.github_english(),
7607                        ) {
7608                            Ok(title) => {
7609                                g.resolved = true;
7610                                g.chosen_title = Some(title);
7611                                g.choice = Some(GithubTextChoice::MyTitle);
7612                            }
7613                            Err(cats) if g.asks < 2 => {
7614                                ask_again = Some(Some(
7615                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
7616                                ))
7617                            }
7618                            Err(_) => {
7619                                g.resolved = true;
7620                                g.chosen_title = None;
7621                                g.choice = Some(GithubTextChoice::Fallback);
7622                            }
7623                        }
7624                    }
7625                },
7626            }
7627            if let Some(rejected) = ask_again {
7628                // A retry (after a rejected replacement title) is still about
7629                // the original text: the same ORIGINAL / FALLBACK comparison.
7630                let w = gt::Withheld {
7631                    title: g.title,
7632                    body: g.body,
7633                    categories: g.categories.clone(),
7634                };
7635                // Named by the original text's fingerprint alone, so a resume
7636                // rewrites the same file and the detail stays identical.
7637                let artifact = match crate::run::write_artifact(
7638                    &self.state,
7639                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
7640                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
7641                ) {
7642                    Ok(p) => Some(p),
7643                    Err(e) => {
7644                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
7645                        None
7646                    }
7647                };
7648                let (detail, comparison) = gt::question_content(
7649                    &w,
7650                    &pr.title,
7651                    &pr.body,
7652                    &self.state.id,
7653                    rejected.as_deref(),
7654                    artifact.as_deref(),
7655                    g.rewrite.as_ref(),
7656                );
7657                let mut q = ask::Question::new(
7658                    self.state.id.clone(),
7659                    gt::ASK_NODE.to_owned(),
7660                    gt::ASK_SEAT.to_owned(),
7661                    gt::question_summary(&self.state.config.graph.language, &w),
7662                    detail,
7663                    gt::question_choices(&w, g.rewrite.as_ref()),
7664                );
7665                q.posting_comparison = Some(comparison);
7666                q.answer_timeout = timeout;
7667                // Recorded before it is written: a stop in between leaves a
7668                // record naming a question the resume then files again.
7669                g.question = Some(q.id.clone());
7670                g.asks += 1;
7671                self.state.github_text = Some(g.clone());
7672                self.state.event(
7673                    "github-text",
7674                    format!("asking the owner about the withheld text ({})", q.short()),
7675                );
7676                self.state.save()?;
7677                store
7678                    .put(&mut q)
7679                    .context("file the posting-gate question")?;
7680                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
7681                    tracing::warn!(
7682                        "could not notify about posting-gate question {}: {e:#}",
7683                        q.short()
7684                    );
7685                }
7686                self.park_on_github_text(&g)?;
7687                return Ok(None);
7688            }
7689        }
7690        let resolved = g.chosen_title.clone();
7691        let original = g.choice == Some(GithubTextChoice::Original);
7692        // Field names only: the event log never records the text.
7693        self.state.event(
7694            "github-text",
7695            if original {
7696                format!(
7697                    "posting the original text for: {}",
7698                    match (g.title, g.body) {
7699                        (true, true) => "title, description",
7700                        (true, false) => "title",
7701                        _ => "description",
7702                    }
7703                )
7704            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
7705                "posting the saved rewrite title and description".to_owned()
7706            } else if resolved.is_some() {
7707                "posting the owner's replacement title".to_owned()
7708            } else {
7709                "posting the neutral text for the withheld fields".to_owned()
7710            },
7711        );
7712        self.state.github_text = Some(g.clone());
7713        self.state.save()?;
7714        if g.choice == Some(GithubTextChoice::Rewrite)
7715            && let Some(r) = g.rewrite.as_ref()
7716        {
7717            return Ok(Some(PrMessage {
7718                title: r.title.clone(),
7719                body: r.body.clone(),
7720            }));
7721        }
7722        let w = gt::Withheld {
7723            title: g.title,
7724            body: g.body,
7725            categories: g.categories.clone(),
7726        };
7727        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
7728        if let Some(t) = resolved {
7729            title = t;
7730        }
7731        // Every withheld field is now neutral or vetted English.
7732        let english = gt::LanguageDecision {
7733            title_english: true,
7734            body_english: true,
7735            body_complete: true,
7736        };
7737        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
7738        if original {
7739            // The owner's explicit choice overrides the gate for the fields it
7740            // was made for; the others keep what the gate produced.
7741            if g.title {
7742                title = pr.title.clone();
7743            }
7744            if g.body {
7745                body = pr.body.clone();
7746            }
7747        }
7748        Ok(Some(PrMessage { title, body }))
7749    }
7750
7751    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
7752        self.state.github_text = Some(g.clone());
7753        self.state.end_pass_parked(ParkCause::Question);
7754        self.state.parked = true;
7755        self.state.event(
7756            "github-text",
7757            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
7758        );
7759        self.state.save()
7760    }
7761
7762    /// Ask `[roles] language_judge` about this text and record which source
7763    /// decided; the text itself is never recorded.
7764    async fn judge_pr_language(
7765        &mut self,
7766        cwd: &Path,
7767        pr: &PrMessage,
7768    ) -> Option<crate::github_text::LanguageDecision> {
7769        self.state.config.roles.language_judge.as_ref()?;
7770        if !self.state.config.graph.language_gate() {
7771            return None;
7772        }
7773        let decision =
7774            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
7775        self.state.event(
7776            "github-text",
7777            if decision.is_some() {
7778                "language decided by the language judge"
7779            } else {
7780                "language judge unavailable; using built-in heuristics"
7781            },
7782        );
7783        decision
7784    }
7785
7786    async fn merge(&mut self) -> Result<()> {
7787        // Same reasoning as `gate`: ask the review and gate records directly
7788        // rather than `status`, which a solo-candidate `judge`/`deliberate`
7789        // skip can rewrite on reentry to something that no longer says
7790        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
7791        // so a hand-off (open findings, green verification) reaches merge
7792        // exactly like a genuinely clean round does.
7793        //
7794        // A run resumed mid-`land` never reaches here at all: `execute`
7795        // recognises `RunStatus::Landing` before it even calls `prep`, and
7796        // routes straight to `run_land` instead. That has to happen a level
7797        // up from this function, not with a check in here, because
7798        // `review_loop`'s own status recomputation (see its doc) runs
7799        // *before* `merge` on every reentry and would otherwise overwrite
7800        // the `Landing` marker with `Gating` before this node ever saw it.
7801        if self
7802            .state
7803            .base_sync
7804            .as_ref()
7805            .is_some_and(|s| s.conflict.is_some())
7806            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
7807                != Some(RunStatus::Gating)
7808            // `gate_ran == false` is not "passed" - `gate` leaves it false
7809            // both before it has ever run and when its last attempt was
7810            // resource-blocked (see `Runner::gate`'s own doc), and neither is
7811            // permission to merge on nothing but the review record. Only a
7812            // gate that actually ran - zero commands configured and
7813            // vacuously passed, or one or more that all exited 0 - may
7814            // proceed; `RunState::gate_status` is the single place that
7815            // reading is computed.
7816            || !self.state.gate_status().ok()
7817        {
7818            return Ok(());
7819        }
7820        // This node's own record, not `status`: `status == Ready` is not
7821        // unique to the harmless `MergeMode::None` path this line was
7822        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
7823        // run's PR was closed without merging — and on that run `mode` is
7824        // still `Pr`, so a reentry that fell through here would push and
7825        // open a second pull request. `self.state.merge` is set exactly once
7826        // this node (or `land`) has already produced a verdict, under every
7827        // mode, which is what "already done" actually means here.
7828        if self.state.merge.is_some() && !self.state.pr_publication_pending {
7829            return Ok(());
7830        }
7831        let Some(winner) = self.state.winner().cloned() else {
7832            return Ok(());
7833        };
7834        let repo = self.state.repo.clone();
7835        let base = self.state.base_branch.clone();
7836        let mode = self.state.config.merge.mode;
7837        let style = self.state.config.merge.style;
7838        let facts = if is_review_run(&self.state) {
7839            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
7840            let start = review_base(
7841                &repo,
7842                &self.state.config.merge.remote,
7843                &base,
7844                &self.state.base_commit,
7845                &winner.branch,
7846            )
7847            .await;
7848            branch_facts(&repo, &start, &winner.branch).await
7849        } else {
7850            None
7851        };
7852        // `None` when the base could not be freshly read: then an adopted
7853        // pull request's title is left alone.
7854        let leaked = if is_review_run(&self.state) {
7855            leaked_subjects(&self.state, &winner.branch).await
7856        } else {
7857            Some(Vec::new())
7858        };
7859        // `None`: parked on the owner's word about a withheld title or body.
7860        // Nothing has been pushed or opened, and `merge` stays unrecorded so
7861        // the resume arrives here again.
7862        let Some(pr) = self
7863            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
7864            .await?
7865        else {
7866            return Ok(());
7867        };
7868        let message = pr.commit_message();
7869
7870        let outcome = match mode {
7871            MergeMode::None => MergeOutcome {
7872                mode,
7873                ok: true,
7874                detail: manual_merge_command(style, &repo, &winner.branch, &message),
7875                empty: false,
7876            },
7877            MergeMode::Pr | MergeMode::Local
7878                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
7879            {
7880                MergeOutcome {
7881                    mode,
7882                    ok: false,
7883                    detail: empty_candidate_detail(&self.state, &base),
7884                    empty: true,
7885                }
7886            }
7887            MergeMode::Local => {
7888                let on = git::current_branch(&repo).await?;
7889                if on.as_deref() != Some(base.as_str()) {
7890                    MergeOutcome {
7891                        mode,
7892                        ok: false,
7893                        detail: format!(
7894                            "{} has {} checked out, not the base branch {base}",
7895                            repo.display(),
7896                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
7897                        ),
7898                        empty: false,
7899                    }
7900                } else if !git::is_clean(&repo).await? {
7901                    MergeOutcome {
7902                        mode,
7903                        ok: false,
7904                        detail: format!("{} is dirty; refusing to merge", repo.display()),
7905                        empty: false,
7906                    }
7907                } else {
7908                    let out = match style {
7909                        MergeStyle::Merge => {
7910                            git::merge_no_ff(&repo, &winner.branch, &message).await?
7911                        }
7912                        MergeStyle::Squash => {
7913                            git::merge_squash(&repo, &winner.branch, &message).await?
7914                        }
7915                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
7916                    };
7917                    MergeOutcome {
7918                        mode,
7919                        ok: out.ok(),
7920                        detail: if out.ok() { out.stdout } else { out.stderr },
7921                        empty: false,
7922                    }
7923                }
7924            }
7925            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
7926                self.state.pr_publication_pending = true;
7927                self.state.save()?;
7928                let out =
7929                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
7930                self.state.pr_publication_pending = out.is_err();
7931                MergeOutcome {
7932                    mode,
7933                    ok: out.is_ok(),
7934                    detail: match out {
7935                        Ok(url) => url,
7936                        Err(e) => format!("{e:#}"),
7937                    },
7938                    empty: false,
7939                }
7940            }
7941            MergeMode::Pr => {
7942                let remote = self.state.config.merge.remote.clone();
7943                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
7944                if !pushed.ok() {
7945                    MergeOutcome {
7946                        mode,
7947                        ok: false,
7948                        detail: pushed.stderr,
7949                        empty: false,
7950                    }
7951                } else {
7952                    // A retry or resume of a run whose branch already has an
7953                    // open pull request adopts it rather than failing on a
7954                    // duplicate. Only this winner branch into this base:
7955                    // `branch_for` derives the name from the run id, so a
7956                    // different run's pull request never matches.
7957                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7958                    let out = match pr_merge_plan(found) {
7959                        PrPlan::Create => {
7960                            gh_pr_create(
7961                                &winner.worktree,
7962                                &base,
7963                                &winner.branch,
7964                                &pr.title,
7965                                &pr.body,
7966                            )
7967                            .await
7968                        }
7969                        PrPlan::Adopt { url, title } => {
7970                            self.state
7971                                .event("merge", format!("Pr: adopted open pull request {url}"));
7972                            if self.state.github_text.as_ref().is_some_and(|g| {
7973                                g.resolved
7974                                    && g.choice == Some(GithubTextChoice::Rewrite)
7975                                    && g.rewrite.is_some()
7976                            }) {
7977                                // The owner chose the entire pair, even if the
7978                                // existing title already matches or was edited.
7979                                land::set_pr_text_verbatim(
7980                                    &winner.worktree,
7981                                    &url,
7982                                    &pr.title,
7983                                    &pr.body,
7984                                )
7985                                .await
7986                                .map(|()| url)
7987                            } else {
7988                                if title != pr.title
7989                                    && (!is_review_run(&self.state)
7990                                        || leaked
7991                                            .as_deref()
7992                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7993                                    && let Err(e) =
7994                                        if self.state.github_text.as_ref().is_some_and(|g| {
7995                                            g.resolved
7996                                                && g.title
7997                                                && g.choice == Some(GithubTextChoice::Original)
7998                                        }) {
7999                                            // The owner chose to post this title unmodified.
8000                                            land::set_pr_title_verbatim(
8001                                                &winner.worktree,
8002                                                &url,
8003                                                &pr.title,
8004                                            )
8005                                            .await
8006                                        } else {
8007                                            land::set_pr_title(
8008                                                &mut self.state,
8009                                                &winner.worktree,
8010                                                &url,
8011                                                &pr.title,
8012                                            )
8013                                            .await
8014                                        }
8015                                {
8016                                    tracing::warn!("could not refresh title of {url}: {e:#}");
8017                                    self.state
8018                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
8019                                }
8020                                Ok(url)
8021                            }
8022                        }
8023                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
8024                    };
8025                    match out {
8026                        Ok(url) => MergeOutcome {
8027                            mode,
8028                            ok: true,
8029                            detail: url,
8030                            empty: false,
8031                        },
8032                        Err(e) => MergeOutcome {
8033                            mode,
8034                            ok: false,
8035                            detail: e.to_string(),
8036                            empty: false,
8037                        },
8038                    }
8039                }
8040            }
8041        };
8042
8043        self.state.status = match (mode, outcome.ok) {
8044            (MergeMode::None, _) => RunStatus::Ready,
8045            (_, true) => RunStatus::Merged,
8046            (_, false) => RunStatus::Blocked,
8047        };
8048        self.state.event(
8049            "merge",
8050            format!(
8051                "{:?}: {}",
8052                mode,
8053                outcome.detail.lines().next().unwrap_or("")
8054            ),
8055        );
8056        self.state.merge = Some(outcome);
8057        self.state.save()?;
8058
8059        // The PR is open and the run would historically stop here, leaving the
8060        // operator to watch checks, feed review comments back to a fixer, and
8061        // merge. That was done by hand six times in one session before this
8062        // existed. Opt-in, because merging is the one irreversible thing magi
8063        // can do to a repository.
8064        if self.state.config.graph.land
8065            && mode == MergeMode::Pr
8066            && self.state.status == RunStatus::Merged
8067        {
8068            self.run_land().await?;
8069        }
8070        // `run_land` may have left `status` at `Landing` - still waiting on
8071        // CI or the owner's approval, not actually settled - so this has to
8072        // read whatever `status` ended up as here, not the `Merged` this
8073        // function set a few lines up.
8074        self.settle_questions();
8075        Ok(())
8076    }
8077
8078    /// Enter `land`.
8079    ///
8080    /// Shared between a fresh run's first pass through [`Runner::merge`] and
8081    /// a resumed run's re-entry. `land::land` itself is what serialises the
8082    /// two git-mutating moments inside the loop — the rebase push and
8083    /// `gh pr merge` — per repository (see its own doc); nothing here needs
8084    /// to hold a lock across the whole call, and doing so would serialise
8085    /// this run's CI wait against a *different* run's land-approval resume
8086    /// in the same repository, which is exactly the "must not wait on
8087    /// another task" property the daemon's slot-freeing exists to give.
8088    async fn run_land(&mut self) -> Result<()> {
8089        crate::pr_head::ensure_owner(&self.state)?;
8090        let url = self
8091            .state
8092            .merge
8093            .as_ref()
8094            .map(|m| m.detail.clone())
8095            .unwrap_or_default();
8096        let url = url.lines().next().unwrap_or("").trim().to_owned();
8097        if !url.starts_with("http") {
8098            return Ok(());
8099        }
8100        // A land failure is not a lost run: the work is on a branch and the
8101        // pull request is open, which is exactly where a human takes over.
8102        loop {
8103            match land::land(&mut self.state, &url).await {
8104                Ok(_)
8105                    if self
8106                        .state
8107                        .merge_repairs
8108                        .last()
8109                        .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Requested) =>
8110                {
8111                    if self.repair_merge_review().await? {
8112                        continue;
8113                    }
8114                }
8115                Ok(pr) if self.state.parked => {
8116                    // `land` already saved the parked marker; nothing here
8117                    // overrides `status` back to a terminal value while an
8118                    // approval is still outstanding.
8119                    let _ = pr;
8120                }
8121                Ok(pr) => {
8122                    self.state.status = match pr.state {
8123                        land::PrLifecycle::Merged => RunStatus::Merged,
8124                        _ => RunStatus::Blocked,
8125                    };
8126                    // Downstream of a confirmed merge only - see
8127                    // `bump::should_release_bump`'s own doc for why this one
8128                    // check covers all three of `land`'s success paths.
8129                    // Best-effort: the run already landed, so a failure here
8130                    // (the decision call, `gh`, `cargo`) is recorded and never
8131                    // turns a landed run into a failed one.
8132                    if bump::should_release_bump(self.state.status)
8133                        && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
8134                    {
8135                        // The event is the run's own record. Not-eligible cases
8136                        // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
8137                        // `Err` is a bump that was tried and failed:
8138                        // `after_merge` itself raises the operator notice for
8139                        // that, whether or not a release PR exists yet.
8140                        self.state
8141                            .event("bump", format!("release bump skipped: {e:#}"));
8142                    }
8143                    // Independent of the bump, and best-effort in the same way:
8144                    // findings the merge left open become follow-up tasks.
8145                    if self.state.status == RunStatus::Merged {
8146                        crate::followup::after_merge(&mut self.state, &pr.url).await;
8147                    }
8148                    self.state.save()?;
8149                }
8150                Err(e) => {
8151                    self.state.status = RunStatus::Blocked;
8152                    self.state.event("land", format!("gave up: {e}"));
8153                    self.state.save()?;
8154                }
8155            }
8156            break;
8157        }
8158        Ok(())
8159    }
8160
8161    /// One repair cycle stays under the existing driver/task claim. An
8162    /// interrupted fixer is rolled back and held rather than run twice.
8163    async fn repair_merge_review(&mut self) -> Result<bool> {
8164        use crate::run::MergeRepairPhase as Phase;
8165        crate::pr_head::ensure_owner(&self.state)?;
8166        let i = self.state.merge_repairs.len() - 1;
8167        let request = self.state.merge_repairs[i].clone();
8168        if request.phase == Phase::Fixing {
8169            let restored = match self.state.winner() {
8170                Some(winner) => {
8171                    crate::recovery::restore_repair(&winner.worktree, &request.head).await
8172                }
8173                None => Err(anyhow::anyhow!("repair has no winning branch")),
8174            };
8175            let why = match restored {
8176                Ok(()) => "repair was interrupted during the fixer and rolled back; inspect its artifacts and the PR head, then run an explicit review".to_owned(),
8177                Err(e) => format!("repair was interrupted; rollback failed: {e:#}; restore the reviewed branch before running an explicit review"),
8178            };
8179            self.hold_merge_repair(i, why)?;
8180            return Ok(false);
8181        }
8182        let failure = if i >= self.state.config.graph.land_rounds
8183            || self.state.config.graph.review_rounds == 0
8184        {
8185            Some("repair generation budget exhausted or review disabled; increase the configured budget and request a new review explicitly".to_owned())
8186        } else {
8187            None
8188        };
8189        if let Some(why) = failure {
8190            self.hold_merge_repair(i, why)?;
8191            return Ok(false);
8192        }
8193        if request.phase == Phase::Requested {
8194            let pr = match land::repair_preflight(&self.state, &request.pr, &request.head).await {
8195                Ok(pr) => pr,
8196                Err(e) => {
8197                    self.hold_merge_repair(i, format!("cannot resume repair: {e:#}; restore the reviewed PR branch and run an explicit review"))?;
8198                    return Ok(false);
8199                }
8200            };
8201            self.state.merge_repairs[i].phase = Phase::Fixing;
8202            self.state.land_approval = None;
8203            self.state.land_armed_head = None;
8204            self.state.save()?;
8205            // Serialize the exact records, including finding ids/details,
8206            // reconsideration reasons, final votes and verification output.
8207            let evidence = serde_json::to_string_pretty(&request)?;
8208            let reason = format!(
8209                "The owner requested fix findings and re-review, without merging. Address the unresolved Major findings and final rejecting votes in this exact review evidence. Do not merge or approve the PR.\n\n{evidence}"
8210            );
8211            let budget = self.state.config.graph.land_rounds;
8212            let result = land::fix_round(&mut self.state, &pr, i + 1, budget, &reason, "").await;
8213            match result {
8214                Ok(land::Fixed::Committed { .. } | land::Fixed::Declined) => {}
8215                Ok(land::Fixed::Failed(why)) => {
8216                    self.hold_merge_repair(
8217                        i,
8218                        format!(
8219                            "repair failed: {why}; inspect the branch and run an explicit review"
8220                        ),
8221                    )?;
8222                    return Ok(false);
8223                }
8224                Err(e) => {
8225                    self.hold_merge_repair(
8226                        i,
8227                        format!(
8228                            "repair failed: {e:#}; inspect the branch and run an explicit review"
8229                        ),
8230                    )?;
8231                    return Ok(false);
8232                }
8233            }
8234            // Even no change gets a real new reviewer wave. The previous
8235            // evidence remains in the request; a former clean/concluded round
8236            // cannot short-circuit the new review or gate.
8237            self.begin_merge_repair_review(i)?;
8238        }
8239        self.review_loop().await?;
8240        self.gate().await?;
8241        if self.resource_halt()? || self.state.parked {
8242            return Ok(false);
8243        }
8244        if review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
8245            != Some(RunStatus::Gating)
8246            || !self.state.gate_status().ok()
8247        {
8248            self.hold_merge_repair(i, "updated review or gate did not pass; inspect the new results and resume review explicitly".to_owned())?;
8249            return Ok(false);
8250        }
8251        // Review and gate may have added commits after the initial repair.
8252        // Publish exactly their final head to the existing PR, with a lease.
8253        let winner = self
8254            .state
8255            .winner()
8256            .cloned()
8257            .context("repair has no winning branch")?;
8258        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
8259        if self.state.gate_head.as_deref() != Some(after.as_str())
8260            || !git::is_clean(&winner.worktree).await?
8261        {
8262            self.hold_merge_repair(i, "repair branch changed after its recorded gate, or the verified head is unknown; nothing was published. Inspect the branch and run an explicit review before requesting a new merge decision".to_owned())?;
8263            return Ok(false);
8264        }
8265        let expected = self
8266            .state
8267            .pr_pushed_head
8268            .as_deref()
8269            .unwrap_or(&request.head)
8270            .to_owned();
8271        let public = self.state.pr_branch().unwrap_or(&winner.branch).to_owned();
8272        // Push the checked commit itself: a local ref moving after the check
8273        // must not change which commit the forge receives.
8274        let lease = format!("--force-with-lease=refs/heads/{public}:{expected}");
8275        let refspec = format!("{after}:refs/heads/{public}");
8276        let push = git::git_raw(
8277            &winner.worktree,
8278            &["push", &lease, &self.state.config.merge.remote, &refspec],
8279        )
8280        .await;
8281        match push {
8282            Ok(out) if out.ok() => self.state.pr_pushed_head = Some(after),
8283            Ok(out) => {
8284                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {}; inspect the PR head and resume review explicitly", out.stderr))?;
8285                return Ok(false);
8286            }
8287            Err(e) => {
8288                self.hold_merge_repair(i, format!("publishing reviewed repair failed: {e:#}; inspect the PR head and resume review explicitly"))?;
8289                return Ok(false);
8290            }
8291        }
8292        self.state.merge_repairs[i].phase = Phase::Complete;
8293        self.state.status = RunStatus::Landing;
8294        self.state.event(
8295            "repair",
8296            "new review and gate completed; fresh owner merge decision required",
8297        );
8298        self.state.save()?;
8299        Ok(true)
8300    }
8301
8302    fn merge_repair_artifacts(&self) -> PathBuf {
8303        let artifacts = agent::artifacts_dir(&self.state.dir());
8304        match self
8305            .state
8306            .merge_repairs
8307            .last()
8308            .filter(|r| r.phase == crate::run::MergeRepairPhase::Reviewing)
8309        {
8310            Some(r) => artifacts.join(format!("merge-repair-{}", r.question)),
8311            None => artifacts,
8312        }
8313    }
8314
8315    fn begin_merge_repair_review(&mut self, index: usize) -> Result<()> {
8316        self.state.reviews.clear();
8317        self.state.gate.clear();
8318        self.state.gate_ran = false;
8319        self.state.gate_head = None;
8320        self.state.gate_fixes.clear();
8321        self.state.pre_gate.clear();
8322        self.state.pre_gate_commit = None;
8323        self.state.contested_handoff = None;
8324        self.state.land_reruns = None;
8325        self.state.land_approval = None;
8326        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Reviewing;
8327        self.state.status = RunStatus::Reviewing;
8328        self.state.save()
8329    }
8330
8331    fn hold_merge_repair(&mut self, index: usize, why: String) -> Result<()> {
8332        self.state.merge_repairs[index].phase = crate::run::MergeRepairPhase::Failed;
8333        self.state.merge_repairs[index].failure = Some(why.clone());
8334        self.state.status = RunStatus::Blocked;
8335        self.state.event("repair", why);
8336        self.state.save()
8337    }
8338
8339    // -------------------------------------------------------------- helpers
8340
8341    /// Fetch or create a seat, keeping its conversation across nodes.
8342    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
8343        if let Some(existing) = self.state.seats.get(key)
8344            && existing.agent == agent
8345        {
8346            return existing.clone();
8347        }
8348        // A seat that changes agent mints its session id from the agent too,
8349        // like a handover: the old agent's uuid is already taken by the CLI.
8350        let fresh = if self.state.seats.contains_key(key) {
8351            handover_seat(key, agent, self.state.next_seat_seed())
8352        } else {
8353            SeatState::new(key, agent, self.state.seed)
8354        };
8355        self.state.seats.insert(key.to_owned(), fresh.clone());
8356        fresh
8357    }
8358
8359    /// The agent now holding seat `key`: `spec`, unless a handover moved the
8360    /// seat to another roster agent, in which case that agent. Nodes that
8361    /// continue a seat's conversation (deliberation, the votes, a reviewer's
8362    /// reconsideration) must keep talking to whoever answered it, not slip
8363    /// back to the agent that failed it.
8364    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
8365        match self.state.seats.get(key) {
8366            Some(s) if s.agent != spec.id => {
8367                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
8368            }
8369            _ => spec,
8370        }
8371    }
8372
8373    /// A candidate rendered for judging, with the leak policy applied.
8374    fn view(&self, c: &Candidate) -> CandidateView {
8375        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
8376            .unwrap_or_default();
8377        let (patch, _) = blind::sanitize_patch(
8378            &format!("candidate {} patch", c.label),
8379            &raw,
8380            &self.state.config.blind,
8381        );
8382        CandidateView {
8383            label: c.label,
8384            branch: c.branch.clone(),
8385            summary: c.summary.clone(),
8386            stat: c.stat.clone(),
8387            patch,
8388        }
8389    }
8390
8391    /// The full candidate set as prompt text, for seats with no live session.
8392    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
8393        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
8394        prompt::judge(
8395            "(see above)",
8396            &views,
8397            self.roles.judges.len(),
8398            base_short,
8399            "en",
8400        )
8401    }
8402
8403    /// The final-vote prompt with everything a seat that has no session of its
8404    /// own needs: the candidates, the seat's own ranking and reasons, and the
8405    /// anonymised deliberation it took part in (only when there was one, so a
8406    /// handed-over seat never sees more than the seat it replaces did). The
8407    /// `Final vote` heading stays first.
8408    fn vote_prompt_full(
8409        &self,
8410        j: usize,
8411        labels: &[char],
8412        language: &str,
8413        candidates: &[Candidate],
8414        base_short: &str,
8415    ) -> String {
8416        let mut text = format!(
8417            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
8418            prompt::final_vote(labels, language),
8419            self.state.instruction,
8420            self.candidate_block(candidates, base_short)
8421        );
8422        if let Some(own) = self
8423            .state
8424            .judgements
8425            .get(j)
8426            .filter(|r| !r.ranking.is_empty())
8427        {
8428            let reasons = own
8429                .reasons
8430                .iter()
8431                .map(|(k, v)| format!("- {k}: {v}"))
8432                .collect::<Vec<_>>()
8433                .join("\n");
8434            text.push_str(&format!(
8435                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
8436                own.ranking.iter().collect::<String>(),
8437                if reasons.is_empty() {
8438                    ""
8439                } else {
8440                    ", because:\n"
8441                }
8442            ));
8443        }
8444        if !self.state.deliberation.is_empty() {
8445            text.push_str("\n# What was argued before this vote\n");
8446            for t in self.transcript(&[], j) {
8447                text.push_str(&format!(
8448                    "\n## {}{}\n\n{}\n",
8449                    t.who,
8450                    if t.is_self { " (you)" } else { "" },
8451                    t.body.trim()
8452                ));
8453            }
8454        }
8455        text
8456    }
8457
8458    /// Anonymised transcript for judge `self_idx`.
8459    ///
8460    /// The initial rankings are always the opening statements. Seeding them
8461    /// only when no turn had been taken yet meant every judge after the first
8462    /// argued against a single voice instead of against the actual split — the
8463    /// disagreement is the information, so it is always on the table.
8464    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
8465        let mut turns = Vec::new();
8466        for j in &self.state.judgements {
8467            if j.ranking.is_empty() {
8468                continue;
8469            }
8470            let reasons = j
8471                .reasons
8472                .iter()
8473                .map(|(k, v)| format!("- {k}: {v}"))
8474                .collect::<Vec<_>>()
8475                .join("\n");
8476            turns.push(Turn {
8477                who: format!("Judge {} (opening ranking)", j.judge),
8478                is_self: j.judge == self_idx + 1,
8479                body: format!(
8480                    "Ranked {}{}{reasons}",
8481                    j.ranking.iter().collect::<String>(),
8482                    if reasons.is_empty() {
8483                        ""
8484                    } else {
8485                        ", because:\n"
8486                    }
8487                ),
8488            });
8489        }
8490        for t in self
8491            .state
8492            .deliberation
8493            .iter()
8494            .flat_map(|r| r.turns.iter())
8495            .chain(current)
8496        {
8497            turns.push(Turn {
8498                who: format!("Judge {}", t.judge),
8499                is_self: t.judge == self_idx + 1,
8500                body: t.body.clone(),
8501            });
8502        }
8503        turns
8504    }
8505}
8506
8507/// Does this seat still hold the context a follow-up prompt would rely on?
8508fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
8509    agent::has_session(spec.kind, seat, sessions)
8510}
8511
8512/// The next entry in `roster` after `start`, never wrapping back to the
8513/// front, whose id is not in `tried` yet.
8514///
8515/// Starts one past `start` rather than at the front of `roster`: `start` is
8516/// the seat's own original position, and a seat whose candidate slot already
8517/// sits on the roster's second entry must fall through to the third next, not
8518/// restart at the first — which is very likely a different candidate's own
8519/// agent already. Never wraps back past `start`, for the same reason: an
8520/// entry earlier in the roster than the seat's own position is almost
8521/// certainly some *other* candidate slot's own agent, and once the tail of
8522/// the roster is exhausted there are no more untried agents for *this* seat
8523/// to fall through to — the caller's fallback chain ends there, exactly as
8524/// "no further untried agents remain in the list for that seat" asks for.
8525///
8526/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
8527/// the same id twice (an operator's `roles.implementers` typo, or a
8528/// `[[agents]]` list reused across roles) must not let
8529/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
8530/// over `roster` either finds an untried id or runs out, so this always
8531/// terminates regardless of duplicates.
8532fn next_untried_in_roster<'a>(
8533    roster: &'a [AgentSpec],
8534    start: usize,
8535    tried: &BTreeSet<String>,
8536) -> Option<&'a AgentSpec> {
8537    roster
8538        .get(start + 1..)?
8539        .iter()
8540        .find(|s| !tried.contains(&s.id))
8541}
8542
8543/// The successor for a seat, shared by all four seat kinds (implement, judge,
8544/// review, advise). `others` holds the ids that *currently* occupy the other
8545/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
8546/// sees them), so roster entries beyond the seat count act as spares: a failed
8547/// seat goes to an agent no other seat holds whenever the roster permits.
8548///
8549/// `carried` is `Some` only for the review loop's carried failure history.
8550/// Order: (1) forward from `start`, never wrapping, untried, not a carried
8551/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
8552/// whole roster: untried and not another seat's occupant; (3) the plain walk
8553/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
8554/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
8555/// a better outcome than an empty seat, and it keeps the answer to "is there
8556/// a successor at all" exactly what it was before occupants were considered,
8557/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
8558/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
8559/// carried failure is a cheaper bet than doubling an agent on the panel, and
8560/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
8561/// the same round are handled one at a time, so a seat later in the batch
8562/// sees an earlier one's new occupant but not yet its own freed agent.
8563fn pick_successor<'a>(
8564    roster: &'a [AgentSpec],
8565    start: usize,
8566    tried: &BTreeSet<String>,
8567    carried: Option<&BTreeSet<String>>,
8568    others: &BTreeSet<String>,
8569) -> Option<&'a AgentSpec> {
8570    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
8571    roster
8572        .get(start + 1..)
8573        .and_then(|tail| {
8574            tail.iter()
8575                .filter(free)
8576                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
8577        })
8578        .or_else(|| {
8579            carried
8580                .is_some()
8581                .then(|| roster.iter().find(free))
8582                .flatten()
8583        })
8584        .or_else(|| match carried {
8585            Some(c) => next_for_seat(roster, start, tried, c),
8586            None => next_untried_in_roster(roster, start, tried),
8587        })
8588}
8589
8590/// The next agent for a seat that carries its failure history across rounds
8591/// (the review loop). `round_tried` is this round's own bound and starts
8592/// empty every round; `carried_failed` only decides priority.
8593///
8594/// First: an id walking forward from `start`, never wrapping, that is neither
8595/// tried this round nor failed in an earlier one. Only when that is exhausted
8596/// does it rescue: the first roster id (in roster order, so this one *does*
8597/// look before `start`) not yet tried this round, which is by then a carried
8598/// failure. Each id is rescued at most once per round, so it cannot loop.
8599fn next_for_seat<'a>(
8600    roster: &'a [AgentSpec],
8601    start: usize,
8602    round_tried: &BTreeSet<String>,
8603    carried_failed: &BTreeSet<String>,
8604) -> Option<&'a AgentSpec> {
8605    roster
8606        .get(start + 1..)?
8607        .iter()
8608        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
8609        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
8610}
8611
8612/// Where a reviewer seat starts a round: the agent that last answered it when
8613/// it is still on the roster and not marked failed, else the spec's own agent
8614/// unless it failed, else the next roster agent that has not failed, else the
8615/// spec's own agent again (the whole roster failed). Ids no longer on the
8616/// roster are ignored. An empty roster has no handover, so the spec stands.
8617fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
8618    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
8619        return spec;
8620    };
8621    let ok = |id: &str| !h.failed.contains(id);
8622    if let Some(last) = h.last_ok.as_deref()
8623        && ok(last)
8624        && let Some(s) = roster.iter().find(|s| s.id == last)
8625    {
8626        return s.clone();
8627    }
8628    if ok(&spec.id) {
8629        return spec;
8630    }
8631    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
8632    roster
8633        .get(from + 1..)
8634        .into_iter()
8635        .flatten()
8636        .chain(roster.iter())
8637        .find(|s| ok(&s.id))
8638        .cloned()
8639        .unwrap_or(spec)
8640}
8641
8642/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
8643/// seed so a CLI that mints its session id up front (`--session-id`) never
8644/// reuses the uuid the previous agent already opened under the same seat key.
8645pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
8646    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
8647}
8648
8649/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
8650/// so the classifier below and the code that builds the message cannot drift.
8651const TIMED_OUT: &str = "timed out";
8652
8653impl FailClass {
8654    /// `None` for an answer; otherwise how the turn failed.
8655    fn of(out: &AgentOutcome) -> Option<Self> {
8656        match out {
8657            AgentOutcome::Ok(_) => None,
8658            AgentOutcome::Quota(_) => Some(Self::Quota),
8659            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
8660            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
8661            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
8662            // Not a failure of the agent. Callers check for it first; `None`
8663            // here means "no class to hand over on".
8664            AgentOutcome::ResourceBlocked(_) => None,
8665        }
8666    }
8667
8668    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
8669    fn stem_word(&self) -> &'static str {
8670        match self {
8671            Self::Quota => "quota",
8672            _ => "handover",
8673        }
8674    }
8675}
8676
8677/// The message's first line with its variable parts removed — digit runs and
8678/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
8679/// as one kind of failure.
8680fn failure_signature(msg: &str) -> String {
8681    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
8682    let mut out = Vec::new();
8683    for word in line.split_whitespace() {
8684        if word.contains('/') || word.contains('\\') {
8685            out.push("<path>".to_owned());
8686            continue;
8687        }
8688        let mut w = String::new();
8689        let mut in_digits = false;
8690        for c in word.chars() {
8691            if c.is_ascii_digit() {
8692                if !in_digits {
8693                    w.push('#');
8694                }
8695                in_digits = true;
8696            } else {
8697                in_digits = false;
8698                w.push(c);
8699            }
8700        }
8701        out.push(w);
8702    }
8703    out.join(" ").chars().take(120).collect()
8704}
8705
8706/// Whether a seat that just failed with `cur` may go to the next roster agent.
8707/// A quota or a timeout always may. Any other failure may not when the agent
8708/// before it failed the same way: an error the prompt causes would otherwise
8709/// walk the whole roster. `prev` is the class of the immediately preceding
8710/// agent's failure, so a quota or timeout in between breaks the run of
8711/// identical failures by itself.
8712fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
8713    match cur {
8714        FailClass::Quota | FailClass::Timeout => true,
8715        FailClass::Other(_) => prev != Some(cur),
8716    }
8717}
8718
8719/// A short human reason for a failed outcome, for the handover record.
8720fn fail_reason(out: &AgentOutcome) -> String {
8721    match out {
8722        AgentOutcome::Ok(_) => String::new(),
8723        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
8724        AgentOutcome::Dropped(o) => format!(
8725            "the CLI dropped the stream ({})",
8726            o.dropped
8727                .as_ref()
8728                .map(|d| d.why.as_str())
8729                .unwrap_or("it ended without delivering its answer")
8730        ),
8731        AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
8732            e.lines().next().unwrap_or("").chars().take(160).collect()
8733        }
8734    }
8735}
8736
8737/// Note one handover in the run: the structured record and, in the timeline,
8738/// the sentence a person reads. A quota keeps the wording it always had.
8739pub(crate) fn record_handover(
8740    state: &mut RunState,
8741    node: &str,
8742    seat: &str,
8743    from: &str,
8744    to: &str,
8745    class: &FailClass,
8746    reason: &str,
8747) {
8748    let message = if *class == FailClass::Quota {
8749        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
8750    } else {
8751        format!("{seat}: handed over {from} -> {to} ({reason})")
8752    };
8753    state.event(node, message);
8754    state.handovers.push(Handover {
8755        at: Timestamp::now(),
8756        node: node.to_owned(),
8757        seat: seat.to_owned(),
8758        from: from.to_owned(),
8759        to: to.to_owned(),
8760        reason: reason.to_owned(),
8761    });
8762}
8763
8764/// Did this reply report running a command whose own CLI never confirmed an
8765/// exit status?
8766///
8767/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
8768/// command *finished* (see that type's own doc), so this can only be `true`
8769/// for a command whose completion event carried no readable exit code — not
8770/// for one that simply is not mentioned at all. That is the one signal this
8771/// crate can read, from the same record `state.jobs` renders, about a reply
8772/// standing next to work its own CLI cannot vouch for finishing; it is
8773/// deliberately not a check on the exit code's *value* (a fixer legitimately
8774/// runs a command that fails mid-iteration before it succeeds) and not a
8775/// guess at a command still running in the background (which emits no event
8776/// at all, and so leaves no evidence here to find).
8777fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
8778    commands.iter().any(|c| c.exit_code.is_none())
8779}
8780
8781/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
8782/// trusted as a verified no-op — the adoption guard's own text-level half.
8783///
8784/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
8785/// timed out): a marker only earns the benefit of the doubt from a turn the
8786/// CLI itself vouches for finishing properly, the same house style
8787/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
8788/// *fix* report to for `commands`. A candidate that timed out, exited
8789/// non-zero, or left a command unconfirmed is read as the ordinary loss it
8790/// is, whatever prose it wrote — this returns `None` before it ever looks at
8791/// `text`. The remaining guards (the tree really is empty, the evidence is
8792/// non-empty) are the caller's: this only reads what the reply *claimed*.
8793fn verified_noop_claim(
8794    usable: bool,
8795    commands: &[agent::CommandEvidence],
8796    text: &str,
8797) -> Option<String> {
8798    (usable && !has_unconfirmed_command(commands))
8799        .then(|| verdict::verified_noop(text))
8800        .flatten()
8801}
8802
8803fn short(commit: &str) -> String {
8804    commit.chars().take(7).collect()
8805}
8806
8807fn make_executable(path: &Path) -> Result<()> {
8808    #[cfg(unix)]
8809    {
8810        use std::os::unix::fs::PermissionsExt as _;
8811        let mut perms = std::fs::metadata(path)?.permissions();
8812        perms.set_mode(0o755);
8813        std::fs::set_permissions(path, perms)?;
8814    }
8815    #[cfg(not(unix))]
8816    {
8817        let _ = path;
8818    }
8819    Ok(())
8820}
8821
8822/// What every seat in one batch shares: where the answers are attributed, the
8823/// prompt overlay they inherit, and the build cache they are told to use.
8824///
8825/// A struct rather than four more parameters: `wave` also needs the run's
8826/// state (to record who is answering right now) and the attempt number, and
8827/// eight positional arguments is both unreadable and a clippy error.
8828struct WaveCtx<'a> {
8829    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
8830    /// paid for it.
8831    run: &'a str,
8832    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
8833    node: &'a str,
8834    prompts: &'a Prompts,
8835    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
8836    cache: Option<&'a Path>,
8837    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
8838    /// `JobRecord::round`. `None` for every other node.
8839    round: Option<usize>,
8840    /// Carry each seat's failed-agent history across waves (the review loop
8841    /// only): start-of-round priority and handover choice read
8842    /// [`RunState::seat_history`], and every answer or failure writes it.
8843    carry_seats: bool,
8844    /// The run's park / interrupt requests, so a wait for a shared resource
8845    /// can be abandoned the moment the operator asks to park.
8846    pauses: Option<(&'a Pause, &'a Pause)>,
8847}
8848
8849/// Run one job, honouring the parallelism budget.
8850async fn run_one(
8851    job: SeatJob,
8852    sem: Arc<Semaphore>,
8853    ctx: &WaveCtx<'_>,
8854    state: &mut RunState,
8855    attempt: usize,
8856) -> (SeatState, AgentOutcome) {
8857    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
8858        .await
8859        .pop()
8860        .expect("one job in, one result out");
8861    (seat, out)
8862}
8863
8864/// Run every job concurrently, capped by the semaphore, preserving order.
8865///
8866/// Every seat in the batch is recorded into [`RunState::active`] before the
8867/// wave starts and cleared as each answer lands, so the run's own record says
8868/// who is still being waited on rather than only who finished.
8869async fn wave(
8870    jobs: Vec<SeatJob>,
8871    sem: Arc<Semaphore>,
8872    ctx: &WaveCtx<'_>,
8873    state: &mut RunState,
8874    attempt: usize,
8875) -> Vec<(usize, SeatState, AgentOutcome)> {
8876    let WaveCtx {
8877        run,
8878        node,
8879        prompts,
8880        cache,
8881        round,
8882        carry_seats: _,
8883        pauses,
8884    } = *ctx;
8885    for job in &jobs {
8886        state.seat_started(node, &job.seat.key, job.timeout, attempt);
8887    }
8888    if let Err(e) = state.save() {
8889        // A failed persist of "who is answering right now" must not abort the
8890        // wave: the seats are already being asked, and the alternative is
8891        // losing the answers to save a status line nobody may even be
8892        // watching.
8893        tracing::warn!("could not persist in-progress seats: {e:#}");
8894    }
8895    // Hold the shared build cache's lease for the whole batch, not per job:
8896    // several candidates (an implement wave) or a fixer legitimately share
8897    // one cache concurrently within this run, and that stays untouched — a
8898    // single lease taken once for the whole wave and released once it is
8899    // done is what stops a *different* borrower (another run's own wave, its
8900    // e2e/gate, a human's `magi review`) from interleaving a build into the
8901    // same directory while this one is in flight. Best-effort, not
8902    // all-or-nothing: a wave that cannot get the lease within its own
8903    // longest job's budget still runs — an hour of paid implementer calls is
8904    // not thrown away over cache contention — but every write-allowed seat
8905    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
8906    // below), the same fallback a read-only seat always gets, rather than
8907    // building into a directory this run was never granted. The identity
8908    // record is still invalidated below either way, so the next tracked
8909    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
8910    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
8911    // The wait for the lease has its own bound, `timeout_resource_wait`; it is
8912    // never borrowed from a job's agent budget (and never charged to it either,
8913    // see below). The agent's clock starts in `agent::invoke`, at spawn.
8914    let resource_limit = Duration::from_secs(state.config.graph.timeout_resource_wait);
8915    let cache_guard = if let Some(cache_dir) = cache {
8916        if jobs_had_a_writer {
8917            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
8918            let cancel = move || pauses.is_some_and(|(a, b)| a.parked() || b.parked());
8919            match acquire_cache_lease(state, cache_dir, &owner, resource_limit, node, &cancel).await
8920            {
8921                Ok(Some(g)) => Some(g),
8922                // Parking: leave without spawning anything. The run parks at
8923                // the next boundary and the seats are asked afresh on resume.
8924                Ok(None) => {
8925                    state.resource_block = Some(crate::run::ResourceBlock {
8926                        node: node.to_owned(),
8927                        kind: "shared build cache".to_owned(),
8928                        detail: "the run is parking".to_owned(),
8929                        at: Timestamp::now(),
8930                    });
8931                    return block_wave(
8932                        jobs,
8933                        state,
8934                        node,
8935                        attempt,
8936                        "parked while waiting for the shared build cache",
8937                    );
8938                }
8939                // An unreadable lease is not contention: keep the old
8940                // fallback (no shared cache for this wave's seats).
8941                Err(e) if e.downcast_ref::<CacheCheckError>().is_some() => None,
8942                Err(e) => {
8943                    // Writing seats may not build into a cache this run does
8944                    // not hold, and an agent that cannot be given one is not
8945                    // asked at all: the run stops resumably instead.
8946                    let why = format!("{e:#}");
8947                    state.resource_block = Some(crate::run::ResourceBlock {
8948                        node: node.to_owned(),
8949                        kind: "shared build cache".to_owned(),
8950                        detail: why.clone(),
8951                        at: Timestamp::now(),
8952                    });
8953                    state.event(
8954                        "resource",
8955                        format!("{node}: no agent was started - {why}; resume to ask the same agents again"),
8956                    );
8957                    return block_wave(jobs, state, node, attempt, &why);
8958                }
8959            }
8960        } else {
8961            None
8962        }
8963    } else {
8964        None
8965    };
8966    // Every job keeps its whole budget: the wait above was not the agent's.
8967    // Restart the seats' clocks so "started" means "being spawned now".
8968    state.seats_spawning(node, attempt);
8969    if let Err(e) = state.save() {
8970        tracing::warn!("could not persist the start of a wave: {e:#}");
8971    }
8972    // Queueing behind this wave's own seats for a parallelism slot is normal
8973    // (each of them is bounded by its own agent timeout); only a wait beyond
8974    // all of them plus the resource limit means the slots never came back.
8975    let slot_limit = resource_limit
8976        + jobs
8977            .iter()
8978            .map(|j| j.timeout)
8979            .max()
8980            .unwrap_or_default()
8981            .saturating_mul(jobs.len() as u32);
8982    let mut set = tokio::task::JoinSet::new();
8983    // Seats announce themselves here the moment they hold a parallelism
8984    // permit, i.e. just before their CLI is spawned.
8985    let (spawned_tx, mut spawned_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
8986    let overlay = prompts.overlay(node);
8987    for (i, mut job) in jobs.into_iter().enumerate() {
8988        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
8989        if cache.is_some() {
8990            job.prompt.push('\n');
8991            job.prompt
8992                .push_str(&prompt::build_cache_note(node, job.allow_write));
8993        }
8994        let sem = Arc::clone(&sem);
8995        let run = run.to_owned();
8996        let node = node.to_owned();
8997        // Only implementers were told about the task's attachments, so only
8998        // their seats get the directory widened for reading.
8999        let attachments = if node == "implement" {
9000            state.attachments.clone()
9001        } else {
9002            Vec::new()
9003        };
9004        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
9005        // `prompt::build_cache_note`'s doc for why setting it anyway is
9006        // exactly how a sandboxed reviewer's write refusal got reported as a
9007        // defect in the patch, not a property of its own seat. And a
9008        // write-allowed one is handed it only when the lease above was
9009        // actually acquired: a wave that could not get it (`cache_guard` is
9010        // `None`, see its own comment) must not send seats to build into a
9011        // directory this run does not hold - that is the exact concurrent,
9012        // unmanaged-write race this module exists to prevent, not something
9013        // "proceeding anyway" is allowed to reintroduce.
9014        let cache = cache
9015            .filter(|_| job.allow_write && cache_guard.is_some())
9016            .map(Path::to_path_buf);
9017        let spawned_tx = spawned_tx.clone();
9018        set.spawn(async move {
9019            // A wait for a parallelism slot is scheduling, not the agent: it
9020            // is bounded by its own limit and ends as a resource block, never
9021            // as a timeout of the agent that was not started.
9022            let _permit = match tokio::time::timeout(slot_limit, sem.acquire()).await {
9023                Ok(p) => p,
9024                Err(_) => {
9025                    return (
9026                        i,
9027                        job.seat,
9028                        AgentOutcome::ResourceBlocked(format!(
9029                            "no parallel slot became free within {}s",
9030                            slot_limit.as_secs()
9031                        )),
9032                    );
9033                }
9034            };
9035            spawned_tx.send(job.seat.key.clone()).ok();
9036            let mut seat = job.seat;
9037            let out = agent::invoke(
9038                &job.spec,
9039                &mut seat,
9040                &Invocation {
9041                    cwd: &job.cwd,
9042                    prompt: &job.prompt,
9043                    timeout: job.timeout,
9044                    allow_write: job.allow_write,
9045                    unsandboxed: false,
9046                    sessions: job.sessions,
9047                    artifacts: &job.artifacts,
9048                    stem: &job.stem,
9049                    run: &run,
9050                    node: &node,
9051                    cache_dir: cache.as_deref(),
9052                    attachments: &attachments,
9053                    writable: &[],
9054                },
9055            )
9056            .await;
9057            let out = match out {
9058                Ok(o) if o.usable() => AgentOutcome::Ok(o),
9059                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
9060                // Billed work the CLI failed to hand over is not an ordinary
9061                // failure, but its text is the CLI's raw error JSON, not an
9062                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
9063                // read it as one by forgetting to check. `usable()` is always
9064                // false here (dropped implies an empty response), so this has
9065                // to be checked before the catch-all `Failed` below or the
9066                // one shape this exists for is lost with the rest.
9067                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
9068                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
9069                Ok(o) => AgentOutcome::Failed(format!(
9070                    "exited with {:?} and no usable output",
9071                    o.exit_code
9072                )),
9073                Err(e) => AgentOutcome::Failed(e.to_string()),
9074            };
9075            (i, seat, out)
9076        });
9077    }
9078    drop(spawned_tx);
9079    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
9080    loop {
9081        let joined = tokio::select! {
9082            Some(key) = spawned_rx.recv() => {
9083                // The clock of this seat starts now, not when it queued.
9084                state.seat_spawned(&key);
9085                if let Err(e) = state.save() {
9086                    tracing::warn!("could not persist a seat's start: {e:#}");
9087                }
9088                continue;
9089            }
9090            joined = set.join_next() => joined,
9091        };
9092        let Some(joined) = joined else {
9093            break;
9094        };
9095        let (i, seat, out) = match joined {
9096            Ok(v) => v,
9097            // No seat to clear: a panicked task never reported which one it
9098            // was. The defensive sweep below this loop is what stops that
9099            // seat's `active` entry from surviving forever.
9100            Err(e) => {
9101                tracing::error!("agent task panicked: {e}");
9102                continue;
9103            }
9104        };
9105        state.seat_finished(&seat.key);
9106        if let AgentOutcome::ResourceBlocked(why) = &out
9107            && state.resource_block.is_none()
9108        {
9109            state.resource_block = Some(crate::run::ResourceBlock {
9110                node: node.to_owned(),
9111                kind: "parallel slot".to_owned(),
9112                detail: why.clone(),
9113                at: Timestamp::now(),
9114            });
9115            state.event("resource", format!("{node}: no agent was started - {why}"));
9116        }
9117        record_jobs(state, node, round, &seat.key, &out);
9118        if let Err(e) = state.save() {
9119            tracing::warn!("could not persist a seat's completion: {e:#}");
9120        }
9121        if collected.len() <= i {
9122            collected.resize_with(i + 1, || None);
9123        }
9124        collected[i] = Some((i, seat, out));
9125    }
9126    // Belt-and-braces for the panic branch above: every seat this exact batch
9127    // started shares this `(node, attempt)` pair, and every seat that finished
9128    // normally already cleared itself, so anything left tagged with it here
9129    // can only be a panicked task's leftover. Cleared unconditionally rather
9130    // than left to read as still answering forever.
9131    if state
9132        .active
9133        .values()
9134        .any(|a| a.node == node && a.attempt == attempt)
9135    {
9136        state
9137            .active
9138            .retain(|_, a| !(a.node == node && a.attempt == attempt));
9139        if let Err(e) = state.save() {
9140            tracing::warn!("could not persist the end of a wave: {e:#}");
9141        }
9142    }
9143    // Whether or not the lease above was actually held, several worktrees
9144    // may just have built into the cache with nothing here able to name one
9145    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
9146    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
9147    // clean it might not have strictly needed; trusting a stale match would
9148    // cost it a wrong answer.
9149    if let Some(cache_dir) = cache
9150        && jobs_had_a_writer
9151    {
9152        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
9153    }
9154    if let Some(guard) = cache_guard {
9155        guard.release();
9156    }
9157    collected.into_iter().flatten().collect()
9158}
9159
9160/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
9161/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
9162/// this is data collection, not the fix-specific completion contract in
9163/// [`Runner::continue_fix_report`], and applies regardless of which node
9164/// asked.
9165///
9166/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
9167/// evidence from; `Failed` does not, and correctly contributes nothing — a
9168/// timeout or crash is not itself evidence about a command the seat may have
9169/// started.
9170fn record_jobs(
9171    state: &mut RunState,
9172    node: &str,
9173    round: Option<usize>,
9174    seat: &str,
9175    out: &AgentOutcome,
9176) {
9177    let commands: &[agent::CommandEvidence] = match out {
9178        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
9179        AgentOutcome::Failed(_) | AgentOutcome::ResourceBlocked(_) => &[],
9180    };
9181    let checked_at = Timestamp::now();
9182    for c in commands {
9183        state.jobs.push(JobRecord {
9184            node: node.to_owned(),
9185            round,
9186            seat: seat.to_owned(),
9187            id: c.id.clone(),
9188            description: c.description.clone(),
9189            checked_at,
9190            status: match c.exit_code {
9191                Some(0) => JobStatus::Completed,
9192                Some(_) => JobStatus::Failed,
9193                None => JobStatus::Unknown,
9194            },
9195            exit_code: c.exit_code,
9196            result_summary: c.result_summary.clone(),
9197            source: c.source.clone(),
9198        });
9199    }
9200}
9201
9202/// Is a review round clean, given how many reviewer seats answered against
9203/// how many the round expected?
9204///
9205/// A seat that never answered (timeout, crash, unparsable output) is not a
9206/// seat that read the patch and found nothing — treating it as such is
9207/// exactly the bug this function exists to close. Under the default `block`
9208/// policy a missing seat can never be clean; `warn` still requires the seats
9209/// that *did* answer to have found nothing blocking and verification to be
9210/// green.
9211///
9212/// `quota_missing` narrows that `block` default for exactly one cause of
9213/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
9214/// a session limit lifts by the very next round buys nothing — the seat is
9215/// asked again with the same quota — so once every missing seat is accounted
9216/// for by a quota loss (and at least one seat *did* answer, so a decision has
9217/// something to rest on) the round is decided on the panel that could answer,
9218/// same as `warn` would. A panel that lost every seat to quota is not
9219/// decided here: `answered == 0` falls through to the existing `block`
9220/// fallback so a fully collapsed panel still waits rather than landing on no
9221/// review at all.
9222fn round_is_clean(
9223    blocking: usize,
9224    e2e_ok: bool,
9225    answered: usize,
9226    expected: usize,
9227    quota_missing: usize,
9228    policy: IncompleteReviewPolicy,
9229) -> bool {
9230    if blocking != 0 || !e2e_ok {
9231        return false;
9232    }
9233    if answered == expected || policy == IncompleteReviewPolicy::Warn {
9234        return true;
9235    }
9236    answered > 0 && expected - answered <= quota_missing
9237}
9238
9239/// The review loop's own conclusion, derived entirely from its persisted
9240/// round records and the round budget that produced them — never from
9241/// `status`, so a reentry (or `gate`/`merge` reading it independently)
9242/// recomputes the identical answer regardless of what an earlier node in the
9243/// same walk, or a previous walk, did to `status`.
9244///
9245/// `None` while more rounds remain to try, including when review never ran
9246/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
9247/// gone clean, or the budget is spent, or the tree has stopped moving (see
9248/// [`STAGNANT_LIMIT`]), the answer is one of two things:
9249///
9250/// - An incomplete panel that raised nothing is missing input, not a
9251///   verified tree — never a hand-off candidate, whatever verification said
9252///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
9253/// - Otherwise, green e2e on the last round hands off (see
9254///   [`Runner::stop_reviewing`]); red e2e blocks.
9255///
9256/// A last round whose own verification is still `ResourceBlocked` — magi
9257/// itself never got a command to run, not evidence the patch is broken —
9258/// is neither: this returns `None` for it too, the same as "more rounds
9259/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
9260/// handling of that shape) instead of this cheap recomputation guessing a
9261/// verdict a real attempt never produced.
9262fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
9263    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
9264        return Some(RunStatus::Gating);
9265    }
9266    let last = reviews.last()?;
9267    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
9268    if reviews.len() < max_rounds && !stagnant {
9269        return None;
9270    }
9271    if last.incomplete() && last.blocking == 0 {
9272        return Some(RunStatus::Blocked);
9273    }
9274    if last.e2e_status() == E2eStatus::ResourceBlocked {
9275        return None;
9276    }
9277    Some(if last.e2e.iter().all(CommandOutcome::ok) {
9278        RunStatus::Gating
9279    } else {
9280        RunStatus::Blocked
9281    })
9282}
9283
9284/// How long a re-ask may take, given the budget the first attempt had.
9285///
9286/// A `nudged` retry is a request to restate an answer the seat has already
9287/// worked out: it carries no new work, so it does not deserve the original
9288/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
9289/// 133 seconds while a third sat for over ten minutes on a resumed session
9290/// holding 410 KB of prior output - and because the retry had inherited the
9291/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
9292/// judging round whose other seats were long finished.
9293///
9294/// A quarter of the budget, with a floor so that a deliberately short timeout
9295/// does not collapse to nothing. A retry that re-sends the whole prompt
9296/// (because the seat kept no context) is the original job again, and keeps the
9297/// original budget.
9298pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
9299    if nudged {
9300        (full / 4).max(Duration::from_secs(120)).min(full)
9301    } else {
9302        full
9303    }
9304}
9305
9306/// Run a wave and parse each reply, re-asking the seats whose reply was
9307/// unusable.
9308///
9309/// The re-ask is a nudge rather than the whole prompt again when the seat still
9310/// holds its conversation, which is the difference between a cheap retry and
9311/// paying for the entire candidate set twice.
9312///
9313/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
9314/// successor in `roster` is handed to it instead of being re-asked: the
9315/// handover is the retry. A seat with no successor left (a single-agent
9316/// roster, the roster's tail) is nudged as before, up to `retries` times. So
9317/// the asks to one seat in one node number at most
9318/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
9319/// is asked once (a dropped stream is nudged first), and only the last agent
9320/// of the chain gets the `retries` same-agent nudges. Each roster agent is
9321/// tried at most once per seat, walking forward from the seat's own position and never wrapping
9322/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
9323/// other failure stops the chain when the previous agent failed the same way
9324/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
9325/// [`has_context`] is false and the job's own full prompt and full budget are
9326/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
9327/// intermediate ones are not losses) and is returned as a failure like any
9328/// other absent seat — the caller decides whether the panel still has a
9329/// quorum. An empty `roster` disables handover: failures are nudged as they
9330/// always were, and a quota is simply lost. A reply that fails to parse or
9331/// validate is the prompt's doing and is only ever nudged, never handed over.
9332///
9333/// The returned [`SeatState`] names the agent that answered (or tried last).
9334#[allow(clippy::too_many_arguments)]
9335async fn ask_json_wave<T>(
9336    jobs: Vec<SeatJob>,
9337    sem: Arc<Semaphore>,
9338    retries: usize,
9339    roster: &[AgentSpec],
9340    ctx: &WaveCtx<'_>,
9341    losses: &mut Vec<QuotaLoss>,
9342    state: &mut RunState,
9343    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
9344) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9345where
9346    T: serde::de::DeserializeOwned + Send + 'static,
9347{
9348    ask_wave_with(
9349        jobs,
9350        sem,
9351        retries,
9352        roster,
9353        ctx,
9354        losses,
9355        state,
9356        &|text: &str| {
9357            let v = verdict::extract_json::<T>(text)?;
9358            validate(&v)?;
9359            Ok(v)
9360        },
9361    )
9362    .await
9363}
9364
9365/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
9366/// a node whose answer is prose (deliberation) shares the same handover,
9367/// failure classification, quota bookkeeping and bounds instead of a copy.
9368///
9369/// A seat handed to another roster agent is sent the job's `handover` prompt
9370/// (when it has one) rather than `prompt`: the new agent has no session, so a
9371/// resume-style prompt would be incomplete. That holds for the handover ask
9372/// and for every nudge to that agent whose `has_context` is false.
9373#[allow(clippy::too_many_arguments)]
9374async fn ask_wave_with<T>(
9375    jobs: Vec<SeatJob>,
9376    sem: Arc<Semaphore>,
9377    retries: usize,
9378    roster: &[AgentSpec],
9379    ctx: &WaveCtx<'_>,
9380    losses: &mut Vec<QuotaLoss>,
9381    state: &mut RunState,
9382    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
9383) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
9384where
9385    T: Send + 'static,
9386{
9387    let n = jobs.len();
9388    let originals: Vec<SeatJob> = jobs;
9389    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
9390    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
9391    // Nudges each seat's *current* agent has taken — 0 for a first-ask
9392    // answer, N once it has gone through N nudges. Read back once this
9393    // returns, so a caller building a history record (`ReviewRecord`) can
9394    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
9395    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
9396    // field's own doc.
9397    let mut nudges: Vec<usize> = vec![0; n];
9398    // The agent now occupying each seat, the ids it has already been through,
9399    // where in the roster the walk began, the class of the last failure, and
9400    // the stem word of a handover not yet asked (full prompt, full budget).
9401    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
9402    let mut tried: Vec<BTreeSet<String>> = specs
9403        .iter()
9404        .map(|s| BTreeSet::from([s.id.clone()]))
9405        .collect();
9406    let starts: Vec<usize> = specs
9407        .iter()
9408        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
9409        .collect();
9410    let carry = ctx.carry_seats && !roster.is_empty();
9411    // Carried across rounds: ids that failed the seat earlier, and how the
9412    // last failure went (so a repeat of it is not handed over again).
9413    let carried: Vec<BTreeSet<String>> = originals
9414        .iter()
9415        .map(|j| {
9416            state
9417                .seat_history
9418                .get(&j.seat.key)
9419                .filter(|_| carry)
9420                .map(|h| h.failed.clone())
9421                .unwrap_or_default()
9422        })
9423        .collect();
9424    let mut prev: Vec<Option<FailClass>> = originals
9425        .iter()
9426        .map(|j| {
9427            state
9428                .seat_history
9429                .get(&j.seat.key)
9430                .filter(|_| carry)
9431                .and_then(|h| h.last_fail.clone())
9432        })
9433        .collect();
9434    let next_agent =
9435        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
9436            let others: BTreeSet<String> = specs
9437                .iter()
9438                .enumerate()
9439                .filter(|(j, _)| *j != i)
9440                .map(|(_, s)| s.id.clone())
9441                .collect();
9442            pick_successor(
9443                roster,
9444                starts[i],
9445                tried,
9446                carry.then(|| &carried[i]),
9447                &others,
9448            )
9449            .cloned()
9450        };
9451    let mut fresh: Vec<Option<String>> = vec![None; n];
9452    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
9453    let mut pending: Vec<usize> = (0..n).collect();
9454
9455    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
9456    // asks: an agent with a successor is asked once and handed over, and only
9457    // a seat with no successor spends `retries` nudges on the same agent. This
9458    // only guarantees the loop's own termination whatever those say.
9459    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
9460    for round in 0..max_rounds {
9461        if pending.is_empty() {
9462            break;
9463        }
9464        let mut batch = Vec::with_capacity(pending.len());
9465        let mut renudged: Vec<&str> = Vec::new();
9466        for &i in &pending {
9467            let src = &originals[i];
9468            // A seat now held by another agent than the job named has no
9469            // session of its own: it gets the full-context prompt whenever
9470            // it is asked in full (the handover ask, a nudge it cannot
9471            // resume).
9472            let full: &str = match &src.handover {
9473                Some(h) if specs[i].id != src.spec.id => h,
9474                _ => &src.prompt,
9475            };
9476            // The prompt and the budget are one decision: a nudge restates
9477            // finished work, a re-sent prompt redoes it.
9478            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
9479                (
9480                    full.to_owned(),
9481                    src.timeout,
9482                    format!("{}-{word}-{}", src.stem, specs[i].id),
9483                )
9484            } else if nudges[i] == 0 {
9485                (full.to_owned(), src.timeout, src.stem.clone())
9486            } else {
9487                renudged.push(src.seat.key.as_str());
9488                let why = done[i]
9489                    .as_ref()
9490                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
9491                    .unwrap_or_else(|| "no parsable answer".to_owned());
9492                let nudge = prompt::nudge(&why);
9493                let nudged = has_context(&specs[i], &seats[i], src.sessions);
9494                let prompt = if nudged {
9495                    nudge
9496                } else {
9497                    format!("{full}\n\n---\n\n{nudge}")
9498                };
9499                (
9500                    prompt,
9501                    retry_budget(src.timeout, nudged),
9502                    format!("{}-retry{}", src.stem, nudges[i]),
9503                )
9504            };
9505            batch.push(SeatJob {
9506                spec: specs[i].clone(),
9507                seat: seats[i].clone(),
9508                cwd: src.cwd.clone(),
9509                prompt,
9510                timeout,
9511                allow_write: src.allow_write,
9512                sessions: src.sessions,
9513                artifacts: src.artifacts.clone(),
9514                stem,
9515                handover: None,
9516            });
9517        }
9518
9519        if !renudged.is_empty() {
9520            state.event(
9521                ctx.node,
9522                format!("retry {round}: re-asking {}", renudged.join(", ")),
9523            );
9524        }
9525        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
9526        let mut still = Vec::new();
9527        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
9528            seats[i] = seat;
9529            // No agent was asked: nothing to hand over, nudge, remember or
9530            // charge. The caller sees `resource_block` and stops the node.
9531            if let AgentOutcome::ResourceBlocked(why) = &out {
9532                done[i] = Some(Err(anyhow::anyhow!("{why}")));
9533                continue;
9534            }
9535            let class = FailClass::of(&out);
9536            // A dropped stream is nudged first (the conversation is still
9537            // there to pick up); only a seat whose nudges are spent hands over.
9538            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
9539                && nudges[i] < retries
9540                && !roster.is_empty();
9541            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
9542                let next = should_hand_over(prev[i].as_ref(), &cur)
9543                    .then(|| next_agent(i, &tried[i], &specs))
9544                    .flatten();
9545                if carry {
9546                    let h = state
9547                        .seat_history
9548                        .entry(originals[i].seat.key.clone())
9549                        .or_default();
9550                    h.failed.insert(specs[i].id.clone());
9551                    h.last_fail = Some(cur.clone());
9552                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
9553                        h.last_ok = None;
9554                    }
9555                    // Saved before the next agent is asked, so a restart in
9556                    // between does not forget who failed.
9557                    if let Err(e) = state.save() {
9558                        tracing::warn!("could not persist a seat's failure history: {e:#}");
9559                    }
9560                }
9561                if let Some(next) = next {
9562                    record_handover(
9563                        state,
9564                        ctx.node,
9565                        &originals[i].seat.key,
9566                        &specs[i].id,
9567                        &next.id,
9568                        &cur,
9569                        &fail_reason(&out),
9570                    );
9571                    tried[i].insert(next.id.clone());
9572                    prev[i] = Some(cur.clone());
9573                    seats[i] =
9574                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
9575                    specs[i] = next;
9576                    fresh[i] = Some(cur.stem_word().to_owned());
9577                    nudges[i] = 0;
9578                    done[i] = Some(Err(anyhow::anyhow!(
9579                        "handed over after: {}",
9580                        fail_reason(&out)
9581                    )));
9582                    still.push(i);
9583                    continue;
9584                }
9585            }
9586            if carry
9587                && !nudge_first
9588                && let Some(cur) = class.clone()
9589            {
9590                // The chain ended here (no successor, or a repeated failure).
9591                let h = state
9592                    .seat_history
9593                    .entry(originals[i].seat.key.clone())
9594                    .or_default();
9595                h.failed.insert(specs[i].id.clone());
9596                h.last_fail = Some(cur);
9597            }
9598            let parsed = match out {
9599                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
9600                AgentOutcome::Quota(o) => {
9601                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
9602                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
9603                }
9604                // Not a parseable answer: the nudge loop re-asks it, which is
9605                // exactly what a dropped stream needs. Just don't hand its raw
9606                // error JSON to `extract_json`.
9607                AgentOutcome::Dropped(o) => {
9608                    let why = o
9609                        .dropped
9610                        .as_ref()
9611                        .map(|d| d.why.as_str())
9612                        .unwrap_or("the CLI ended the stream without delivering its answer");
9613                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
9614                }
9615                AgentOutcome::Failed(e) | AgentOutcome::ResourceBlocked(e) => {
9616                    Err(anyhow::anyhow!(e))
9617                }
9618            };
9619            let quota = class == Some(FailClass::Quota);
9620            let failed = parsed.is_err();
9621            done[i] = Some(parsed);
9622            if carry && !failed {
9623                let h = state
9624                    .seat_history
9625                    .entry(originals[i].seat.key.clone())
9626                    .or_default();
9627                h.failed.remove(&specs[i].id);
9628                h.last_ok = Some(specs[i].id.clone());
9629                h.last_fail = None;
9630            }
9631            // Do not re-ask a rate-limited seat (quota) — a retry is known to
9632            // fail the same way; and never re-ask a seat that already parsed.
9633            // A failed agent that still has a successor is not re-asked
9634            // either: the handover was its remedy and has just been refused
9635            // (the chain stops on a repeated failure class). A seat with no
9636            // successor left (a single-agent roster, the roster's tail, or an
9637            // empty roster) keeps the same-agent nudge, bounded by `retries`.
9638            let agent_failure = class.is_some()
9639                && !nudge_first
9640                && !roster.is_empty()
9641                && next_agent(i, &tried[i], &specs).is_some();
9642            if failed && !quota && !agent_failure && nudges[i] < retries {
9643                nudges[i] += 1;
9644                still.push(i);
9645            }
9646        }
9647        pending = still;
9648    }
9649
9650    // One loss per seat whose chain ended on a quota: the intermediate ones
9651    // were absorbed by a handover and are not losses.
9652    for (i, q) in last_quota.into_iter().enumerate() {
9653        if let Some(reset) = q {
9654            losses.push(QuotaLoss {
9655                seat: originals[i].seat.key.clone(),
9656                node: ctx.node.to_owned(),
9657                at: Timestamp::now(),
9658                reset,
9659            });
9660        }
9661    }
9662
9663    seats
9664        .into_iter()
9665        .zip(done)
9666        .zip(nudges)
9667        .map(|((seat, res), attempts)| {
9668            (
9669                seat,
9670                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
9671                attempts,
9672            )
9673        })
9674        .collect()
9675}
9676
9677/// Did the wave end without asking any agent (see [`block_wave`])?
9678fn wave_blocked(results: &[(usize, SeatState, AgentOutcome)]) -> bool {
9679    results
9680        .iter()
9681        .any(|(_, _, o)| matches!(o, AgentOutcome::ResourceBlocked(_)))
9682}
9683
9684/// Settle a wave that never spawned anything: every job answers
9685/// [`AgentOutcome::ResourceBlocked`], its seat leaves `active`, and nothing
9686/// else about the seat (session, history, quota) is touched.
9687fn block_wave(
9688    jobs: Vec<SeatJob>,
9689    state: &mut RunState,
9690    node: &str,
9691    attempt: usize,
9692    why: &str,
9693) -> Vec<(usize, SeatState, AgentOutcome)> {
9694    state.resource_wait = None;
9695    state
9696        .active
9697        .retain(|_, a| !(a.node == node && a.attempt == attempt));
9698    if let Err(e) = state.save() {
9699        tracing::warn!("could not persist a resource-blocked wave: {e:#}");
9700    }
9701    jobs.into_iter()
9702        .enumerate()
9703        .map(|(i, j)| (i, j.seat, AgentOutcome::ResourceBlocked(why.to_owned())))
9704        .collect()
9705}
9706
9707/// Acquire the shared build cache's lease, waiting out contention within
9708/// `budget` (never past it — see AGENTS.md's build-cache section on why an
9709/// unbounded wait is never acceptable).
9710///
9711/// A first, non-blocking check happens before ever waiting; if it finds the
9712/// lease busy, that fact is logged as a `verify` event *and* flushed with
9713/// [`RunState::save`] immediately — not only once the wait finally succeeds
9714/// or gives up — so a `magi show` run by a different process while this one
9715/// is still waiting reads a `run.json` that says so, rather than whatever it
9716/// looked like before the wait started. The same applies to the terminal
9717/// failure: logged and saved before this returns `Err`, so a caller that
9718/// could not get the lease at all still leaves a legible record of why.
9719async fn acquire_cache_lease(
9720    state: &mut RunState,
9721    cache_dir: &Path,
9722    owner: &crate::cache::Owner,
9723    budget: Duration,
9724    context: &str,
9725    cancel: &(dyn Fn() -> bool + Sync),
9726) -> Result<Option<crate::cache::Guard>> {
9727    let home = crate::run::home();
9728    let started = Instant::now();
9729    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
9730        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(Some(g)),
9731        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
9732        Err(e) => {
9733            state.event(
9734                "verify",
9735                format!("{context}: could not check the shared build cache: {e:#}"),
9736            );
9737            if let Err(e2) = state.save() {
9738                tracing::warn!("could not persist a cache-check failure: {e2:#}");
9739            }
9740            return Err(anyhow::Error::new(CacheCheckError(format!("{e:#}"))));
9741        }
9742    };
9743    state.event(
9744        "verify",
9745        format!(
9746            "{context}: waiting for the shared build cache at {} ({})",
9747            cache_dir.display(),
9748            busy.describe()
9749        ),
9750    );
9751    // Seats of this node are waiting, not running: say so, so the countdown a
9752    // reader sees only ever measures an agent that has been spawned.
9753    state.resource_wait = Some(crate::run::ResourceWait {
9754        node: context.to_owned(),
9755        kind: "shared build cache".to_owned(),
9756        holder: busy.describe(),
9757        since: Timestamp::now(),
9758        limit_secs: budget.as_secs(),
9759    });
9760    if let Err(e) = state.save() {
9761        tracing::warn!("could not persist a cache wait: {e:#}");
9762    }
9763    let remaining = budget.saturating_sub(started.elapsed());
9764    let waited = crate::cache::wait_for_unless(
9765        &home,
9766        cache_dir,
9767        owner,
9768        remaining,
9769        Duration::from_secs(5),
9770        cancel,
9771    )
9772    .await;
9773    state.resource_wait = None;
9774    match waited {
9775        Ok(g) => Ok(g),
9776        Err(e) => {
9777            state.event("verify", format!("{context}: {e:#}"));
9778            if let Err(e2) = state.save() {
9779                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
9780            }
9781            Err(e)
9782        }
9783    }
9784}
9785
9786/// The lease state could not be read at all (unreadable lease file, a
9787/// permission problem). Unlike a busy lease that outlasts its bound, nothing is
9788/// known to be contended, so a wave degrades to running without a shared cache
9789/// instead of stopping on a hold that [`crate::triage`] could never release.
9790#[derive(Debug)]
9791struct CacheCheckError(String);
9792
9793impl std::fmt::Display for CacheCheckError {
9794    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9795        f.write_str(&self.0)
9796    }
9797}
9798
9799impl std::error::Error for CacheCheckError {}
9800
9801/// Run `body` — a verify command batch — while holding the shared build
9802/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
9803/// never interleave with another borrower's build against the same
9804/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
9805/// timeout, or a human's own `magi review`. See the `cache` module doc for
9806/// why this matters more than Cargo's own per-target locking covers — two
9807/// *different* worktrees building the same package name/version into one
9808/// cache directory is a staleness bug, not a lock contention one.
9809///
9810/// The wait for the lease is carved out of `budget`, never on top of it —
9811/// `body` is handed whatever is left, so a caller's own node timeout is the
9812/// only clock involved, exactly what AGENTS.md's build-cache section asks
9813/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
9814/// cache configured at all — this is a pass-through: `body` runs with the
9815/// full budget and nothing is leased.
9816///
9817/// A lease that cannot be acquired within `budget` is reported as a single
9818/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
9819/// verification — the same shape a spawn failure already takes in
9820/// [`run_commands`], so a caller need not special-case it.
9821#[allow(clippy::too_many_arguments)]
9822async fn with_cache_lease<'s, F, Fut>(
9823    state: &'s mut RunState,
9824    cache_dir: Option<&Path>,
9825    node: &str,
9826    seat: &str,
9827    worktree: &Path,
9828    head: &str,
9829    budget: Duration,
9830    context: &str,
9831    body: F,
9832) -> (Vec<CommandOutcome>, bool)
9833where
9834    F: FnOnce(&'s mut RunState, Duration) -> Fut,
9835    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
9836{
9837    let Some(cache_dir) = cache_dir else {
9838        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
9839        return (outcomes, retried);
9840    };
9841    let home = crate::run::home();
9842    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
9843    let started = Instant::now();
9844    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context, &|| false)
9845        .await
9846        .and_then(|g| {
9847            g.ok_or_else(|| anyhow::anyhow!("the wait for the build cache was cancelled"))
9848        }) {
9849        Ok(g) => g,
9850        Err(e) => {
9851            return (
9852                vec![CommandOutcome {
9853                    command: "(waiting for the shared build cache)".to_owned(),
9854                    code: None,
9855                    output_tail: e.to_string(),
9856                    duration_ms: started.elapsed().as_millis() as u64,
9857                    resource_blocked: true,
9858                }],
9859                false,
9860            );
9861        }
9862    };
9863    let identity = crate::cache::Identity::new(worktree, head);
9864    if let Err(e) = crate::cache::ensure_fresh(
9865        &home,
9866        cache_dir,
9867        &identity,
9868        &state.config.verify.cargo_manifests(),
9869    ) {
9870        // A failed freshness check means this process cannot vouch for what
9871        // is sitting in the cache right now - on Windows this is exactly the
9872        // "a stale test executable is still locked, `cargo clean -p` cannot
9873        // remove it" case the evidence log records. Running verify anyway
9874        // and reporting whatever it says would let a result nobody can trust
9875        // stand for the tree it claims to have checked; fail the step
9876        // instead of the patch.
9877        state.event(
9878            "verify",
9879            format!(
9880                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
9881                worktree.display(),
9882                short(head)
9883            ),
9884        );
9885        guard.release();
9886        return (
9887            vec![CommandOutcome {
9888                command: "(confirming the shared build cache is fresh)".to_owned(),
9889                code: None,
9890                output_tail: e.to_string(),
9891                duration_ms: started.elapsed().as_millis() as u64,
9892                resource_blocked: true,
9893            }],
9894            false,
9895        );
9896    }
9897    let remaining = budget.saturating_sub(started.elapsed());
9898    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
9899    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
9900    // `start_kill`); confirm it actually has before handing the directory to
9901    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
9902    // for what this can and cannot see.
9903    if !timed_out_pids.is_empty() {
9904        wait_for_timed_out_children_to_die(&timed_out_pids).await;
9905    }
9906    guard.release();
9907    (outcomes, retried)
9908}
9909
9910/// Poll `pids` — commands [`run_commands`] reports as still running when its
9911/// own timeout elapsed — until every one is confirmed gone, or
9912/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
9913///
9914/// Real confirmation where confirmation is possible, not a substitute for
9915/// full process-tree observation: a grandchild the timed-out process spawned
9916/// and that survives independently of it is invisible to a pid check the
9917/// same way it always was, and continuing to observe and collect *that*
9918/// stays a different piece of work with its own owner. This only narrows a
9919/// fixed blind wait into an actual check of the pids this process does know
9920/// about.
9921async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
9922    wait_for_pids_with(
9923        pids,
9924        crate::proc::pid_alive,
9925        LEASE_RELEASE_POLL,
9926        LEASE_RELEASE_MAX_WAIT,
9927    )
9928    .await;
9929}
9930
9931/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
9932/// interval and ceiling supplied by the caller, so the polling *logic* -
9933/// returns as soon as every pid reports dead, gives up at the ceiling
9934/// otherwise - is testable on millisecond durations without asking the real
9935/// OS about a pid at all.
9936async fn wait_for_pids_with<F: Fn(u32) -> bool>(
9937    pids: &[u32],
9938    alive: F,
9939    poll: Duration,
9940    max_wait: Duration,
9941) {
9942    let deadline = Instant::now() + max_wait;
9943    loop {
9944        if pids.iter().all(|&pid| !alive(pid)) {
9945            return;
9946        }
9947        if Instant::now() >= deadline {
9948            return;
9949        }
9950        tokio::time::sleep(poll).await;
9951    }
9952}
9953
9954/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
9955/// admission that it could not even get a verify command to run, as opposed
9956/// to evidence the command actually produced? A caller that would otherwise
9957/// read a resource-blocked outcome as a red command must check this first:
9958/// see [`Runner::gate`], which retries rather than records `Blocked` when
9959/// this is true.
9960fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
9961    outcomes.iter().any(|o| o.resource_blocked)
9962}
9963
9964/// What [`Runner::gate_fix_round`] decided.
9965enum GateFix {
9966    /// The tree changed and `verify.e2e` is still green: run the gate again.
9967    Retry,
9968    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
9969    /// last failure stands and the run ends blocked.
9970    Stop,
9971    /// `verify.e2e` could not run after the fix (magi's own contention):
9972    /// decide nothing now, a later reentry retries.
9973    Defer,
9974}
9975
9976/// Is every red command in `outcomes` an ordinary failure the code could
9977/// explain: it ran, exited non-zero, and said something?
9978///
9979/// A timeout, a spawn failure and a killed process all leave `code` `None`;
9980/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
9981/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
9982/// what the command is stays the gate's business.
9983fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
9984    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
9985    red.peek().is_some()
9986        && red.all(|o| {
9987            !o.resource_blocked
9988                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
9989                && !o.output_tail.trim().is_empty()
9990        })
9991}
9992
9993/// Describe one verify command's outcome for the event log, distinguishing a
9994/// build/link failure — the toolchain never produced a binary to run — from
9995/// an actual test failure, since only the latter is a verdict on the patch.
9996fn e2e_outcome_label(o: &CommandOutcome) -> String {
9997    if o.ok() {
9998        return "pass".to_owned();
9999    }
10000    let reason = if o.build_failed() {
10001        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
10002    } else {
10003        format!("FAIL ({:?})", o.code)
10004    };
10005    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
10006}
10007
10008/// Run `verify.e2e`, retrying once if the first attempt could not build or
10009/// link — a build/link failure is frequently a race against a shared
10010/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
10011/// `verify` event per command, tagged with `context` (normally `"round N"`)
10012/// so the two call sites that need this — the ordinary per-round leg in
10013/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
10014/// it will ever call a round green — read identically in the event log.
10015async fn run_e2e_with_retry(
10016    state: &mut RunState,
10017    shell: &[String],
10018    commands: &[String],
10019    worktree: &Path,
10020    timeout: Duration,
10021    context: &str,
10022) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
10023    let (mut e2e, mut timed_out_pids) = run_commands(
10024        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
10025    )
10026    .await;
10027    for o in &e2e {
10028        state.event(
10029            "verify",
10030            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
10031        );
10032    }
10033    // A build/link failure is not a verdict on the patch — it is frequently a
10034    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
10035    // one retry before letting a red like that decide the round.
10036    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
10037    if verify_retried {
10038        state.event(
10039            "verify",
10040            format!(
10041                "{context}: verify could not build/link, not a test result — retrying once \
10042                 before concluding"
10043            ),
10044        );
10045        let retried = run_commands(
10046            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
10047        )
10048        .await;
10049        e2e = retried.0;
10050        // Both attempts' timeouts matter, not just the last one: the first
10051        // attempt's descendants may still be alive alongside the retry's.
10052        timed_out_pids.extend(retried.1);
10053        for o in &e2e {
10054            state.event(
10055                "verify",
10056                format!(
10057                    "{context}: retry `{}` -> {}",
10058                    o.command,
10059                    e2e_outcome_label(o)
10060                ),
10061            );
10062        }
10063    }
10064    (e2e, verify_retried, timed_out_pids)
10065}
10066
10067/// Run configured shell commands in `cwd`, in order. The second element is
10068/// the pid of every command that hit `timeout` and was still running when
10069/// this stopped waiting on it (best-effort: `None` when the platform did not
10070/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
10071/// that releases a shared resource afterward needs to know.
10072///
10073/// Records `task` into [`RunState::active`] at every command boundary
10074/// (`RunState::task_command`) and clears it once the whole list has run
10075/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
10076/// for minutes with no seat and no output of its own to show for it (see
10077/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
10078/// apart from "not yet run" without this), and this is the only place that
10079/// knows which command is running right now and how many are left. Three
10080/// saves per command — start, not per second — matching the same "only at a
10081/// boundary" rule [`wave`] already follows for seats.
10082#[allow(clippy::too_many_arguments)]
10083async fn run_commands(
10084    state: &mut RunState,
10085    node: &str,
10086    task: &str,
10087    attempt: usize,
10088    shell: &[String],
10089    commands: &[String],
10090    cwd: &Path,
10091    timeout: Duration,
10092) -> (Vec<CommandOutcome>, Vec<u32>) {
10093    if commands.is_empty() {
10094        // Nothing to mark as running and nothing to clear — an empty list
10095        // means "not configured", and touching `active` (or the disk) over
10096        // that would be a write for every round of a repo with no
10097        // `verify.e2e` / `verify.gate` commands at all.
10098        return (Vec::new(), Vec::new());
10099    }
10100    let mut out = Vec::new();
10101    let mut timed_out_pids = Vec::new();
10102    let total = commands.len();
10103    for (idx, command) in commands.iter().enumerate() {
10104        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
10105        if let Err(e) = state.save() {
10106            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
10107        }
10108        let started = Instant::now();
10109        let mut cmd = tokio::process::Command::new(&shell[0]);
10110        cmd.quiet();
10111        cmd.args(&shell[1..])
10112            .arg(command)
10113            .current_dir(cwd)
10114            .stdin(std::process::Stdio::null())
10115            .stdout(std::process::Stdio::piped())
10116            .stderr(std::process::Stdio::piped())
10117            .kill_on_drop(true);
10118        let spawned = cmd.spawn();
10119        let (code, body) = match spawned {
10120            Ok(child) => {
10121                // Captured before the child is consumed below: `kill_on_drop`
10122                // only *asks* the process to die when the timeout branch
10123                // drops it, and the pid is the only way anyone downstream can
10124                // later check whether that request actually took.
10125                let pid = child.id();
10126                match tokio::time::timeout(timeout, child.wait_with_output()).await {
10127                    Ok(Ok(o)) => {
10128                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
10129                        body.push_str(&String::from_utf8_lossy(&o.stderr));
10130                        (o.status.code(), body)
10131                    }
10132                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
10133                    Err(_) => {
10134                        if let Some(pid) = pid {
10135                            timed_out_pids.push(pid);
10136                        }
10137                        (None, format!("timed out after {}s", timeout.as_secs()))
10138                    }
10139                }
10140            }
10141            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
10142        };
10143        out.push(CommandOutcome {
10144            command: command.clone(),
10145            code,
10146            output_tail: tail(&body, OUTPUT_TAIL),
10147            duration_ms: started.elapsed().as_millis() as u64,
10148            resource_blocked: false,
10149        });
10150    }
10151    state.task_finished(task);
10152    if let Err(e) = state.save() {
10153        tracing::warn!("could not persist the end of {task}: {e:#}");
10154    }
10155    (out, timed_out_pids)
10156}
10157
10158/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
10159/// section (`report::run`) and in the `merge` event this node records — for
10160/// the operator to run by hand.
10161///
10162/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
10163/// branch whose ruleset forbids merge commits (GitHub's "must not contain
10164/// merge commits", or "require linear history") rejects the push a `--no-ff`
10165/// merge would produce, which is exactly the guidance this function replaces.
10166/// `message`'s first line becomes the squash commit's subject, matching the
10167/// note `report::run` prints alongside this command — see that function for
10168/// why an explicit subject is not optional there.
10169fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
10170    let repo = repo.display();
10171    match style {
10172        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
10173        MergeStyle::Squash => {
10174            // The subject sits inside double quotes, and a title an agent
10175            // wrote may carry the characters that break out of them.
10176            let subject = message
10177                .lines()
10178                .next()
10179                .unwrap_or(branch)
10180                .replace(['\\', '"', '$', '`'], "");
10181            format!(
10182                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
10183            )
10184        }
10185        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
10186    }
10187}
10188
10189/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
10190/// under the hood, rejects a `title` over 256 characters and the whole
10191/// command fails — no PR at all, for a run whose body was otherwise fine
10192/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
10193/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
10194/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
10195/// this bound. It is a margin, not a guarantee — a title packed
10196/// with multi-unit characters could still in principle land close to the
10197/// edge, but a real task title's occasional emoji or accented letter fits
10198/// comfortably inside it.
10199const PR_TITLE_MAX: usize = 240;
10200
10201/// A pull request title from the opening line of `text`, or `None` when that
10202/// line is not English (GitHub text is) or has no letters.
10203///
10204/// A line within `max` is kept as is. A longer one is cut at the end of its
10205/// first sentence when that falls inside `max`, else at a word boundary with a
10206/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
10207/// make every merely-truncated title look non-English). The language check
10208/// runs on the kept text before any mark is added, so only what GitHub will
10209/// show is judged: an English opening followed by non-ASCII far past the cut
10210/// still passes.
10211fn english_title(text: &str, max: usize, ascii_only: bool) -> Option<String> {
10212    let line = queue::first_line(text)?;
10213    let chars: Vec<char> = line.chars().collect();
10214    let (kept, mark) = if chars.len() <= max {
10215        (line.to_owned(), "")
10216    } else if let Some(end) = sentence_end(&chars, max) {
10217        (chars[..end].iter().collect::<String>(), "")
10218    } else {
10219        let room = max.saturating_sub(3);
10220        // Cut at the last space inside the room; when the char just past the
10221        // room is a space the room already ends on a word.
10222        let cut = if chars[room].is_whitespace() {
10223            room
10224        } else {
10225            chars[..room]
10226                .iter()
10227                .rposition(|c| c.is_whitespace())
10228                .unwrap_or(room)
10229        };
10230        let head: String = chars[..cut].iter().collect();
10231        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
10232        (head.to_owned(), "...")
10233    };
10234    if kept.is_empty() {
10235        return None;
10236    }
10237    if ascii_only && (!kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic())) {
10238        return None;
10239    }
10240    if !ascii_only && !kept.chars().any(char::is_alphabetic) {
10241        return None;
10242    }
10243    Some(format!("{kept}{mark}"))
10244}
10245
10246/// The char length of the first sentence of `chars` when it ends within `max`
10247/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
10248/// abbreviations so `e.g. foo` does not end a title early.
10249fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
10250    const MIN: usize = 20;
10251    for i in MIN..max.min(chars.len()) {
10252        if !matches!(chars[i], '.' | '!' | '?') {
10253            continue;
10254        }
10255        let Some(&next) = chars.get(i + 1) else {
10256            continue;
10257        };
10258        if !next.is_whitespace() {
10259            continue;
10260        }
10261        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
10262        if after.is_some_and(|c| c.is_ascii_lowercase()) {
10263            continue;
10264        }
10265        let word: String = chars[..i]
10266            .iter()
10267            .rev()
10268            .take_while(|c| !c.is_whitespace())
10269            .collect::<Vec<_>>()
10270            .into_iter()
10271            .rev()
10272            .collect();
10273        let word = word.to_ascii_lowercase();
10274        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
10275            continue;
10276        }
10277        let end = chars[..i]
10278            .iter()
10279            .rposition(|c| !c.is_whitespace())
10280            .map_or(i, |p| p + 1);
10281        return Some(end);
10282    }
10283    None
10284}
10285
10286/// What `merge = "pr"` (and the merge commit of the other modes) says about a
10287/// change: a title and a body describing what was *implemented*, not the task
10288/// that asked for it. A task reads as a request; a reader of the merged
10289/// history wants the change.
10290struct PrMessage {
10291    title: String,
10292    body: String,
10293}
10294
10295impl PrMessage {
10296    /// Title, blank line, body. The first line is the squash/merge commit
10297    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
10298    /// has to stay one sensible line.
10299    fn commit_message(&self) -> String {
10300        format!("{}\n\n{}", self.title, self.body)
10301    }
10302}
10303
10304/// The text after a leading `TITLE:` (any case) on `line`.
10305fn title_marker(line: &str) -> Option<&str> {
10306    let line = line.trim();
10307    let head = line.get(..6)?;
10308    head.eq_ignore_ascii_case("title:")
10309        .then(|| line[6..].trim())
10310}
10311
10312/// The implementer's own one-line title: the `TITLE:` line the implement
10313/// prompt asks for at the top of its SUMMARY. Candidate commits are all
10314/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
10315/// source, and a title that says as much is refused here too.
10316fn summary_title(summary: &str) -> Option<String> {
10317    let first = summary.lines().find(|l| !l.trim().is_empty())?;
10318    let raw = title_marker(first)?;
10319    if raw.is_empty() {
10320        return None;
10321    }
10322    let title = queue::title_from(raw, PR_TITLE_MAX);
10323    let lower = title.to_ascii_lowercase();
10324    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10325        return None;
10326    }
10327    Some(title)
10328}
10329
10330/// How `open_review`'s instruction begins; see [`landing_title`].
10331const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
10332
10333/// Marker `open_review` gives a candidate that nothing in the roster wrote.
10334const EXISTING_BRANCH: &str = "(existing branch)";
10335
10336/// Does this run review work that already existed, rather than implement a
10337/// task? Runs recorded before `reviewed_commits` existed carry only the
10338/// candidate marker.
10339fn is_review_run(state: &RunState) -> bool {
10340    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
10341}
10342
10343/// The title of a review-only run: the subject of the oldest commit under
10344/// review. Later commits are usually fixups, and `instruction` is the review
10345/// prompt, which says nothing about the change. GitHub text is English, so a
10346/// non-ASCII or blank subject yields `None` and the caller's neutral title.
10347fn review_title(state: &RunState) -> Option<String> {
10348    english_subject(
10349        state.reviewed_commits.as_ref()?.first()?,
10350        state.config.graph.github_english(),
10351    )
10352}
10353
10354/// `raw` as a pull request title, or `None` when it is blank, not English
10355/// (GitHub text is), or one of magi's own candidate commit subjects.
10356fn english_subject(raw: &str, ascii_only: bool) -> Option<String> {
10357    let raw = raw.trim();
10358    if raw.is_empty() {
10359        return None;
10360    }
10361    if ascii_only && (!raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic())) {
10362        return None;
10363    }
10364    if !ascii_only && !raw.chars().any(char::is_alphabetic) {
10365        return None;
10366    }
10367    let title = queue::title_from(raw, PR_TITLE_MAX);
10368    let lower = title.to_ascii_lowercase();
10369    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
10370        return None;
10371    }
10372    Some(title)
10373}
10374
10375/// What a review-only run's branch says about itself, read at the moment the
10376/// pull request is opened.
10377#[derive(Debug, Clone, PartialEq, Eq)]
10378struct BranchFacts {
10379    /// `(subject, body)` of each commit, oldest first.
10380    commits: Vec<(String, String)>,
10381    /// Trimmed `git diff --stat`.
10382    stat: String,
10383}
10384
10385/// Longest diff stat shown: this many file lines plus the summary line.
10386const STAT_FILE_LINES: usize = 25;
10387/// Cap on the commit list, well inside GitHub's 65536-character body limit.
10388const COMMITS_MAX_CHARS: usize = 20_000;
10389
10390/// Read the commits and diff stat of `base..branch`. `None` when git cannot
10391/// say or finds nothing, so the caller falls back to what the run recorded.
10392async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
10393    let commits = git::commit_log(repo, base, branch).await.ok()?;
10394    if commits.is_empty() {
10395        return None;
10396    }
10397    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
10398    let lines: Vec<&str> = stat.lines().collect();
10399    let stat = if lines.len() > STAT_FILE_LINES + 1 {
10400        let omitted = lines.len() - 1 - STAT_FILE_LINES;
10401        let more = format!(" ... {omitted} more file(s)");
10402        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
10403        kept.push(&more);
10404        kept.push(lines[lines.len() - 1]);
10405        kept.join("\n")
10406    } else {
10407        lines.join("\n")
10408    };
10409    Some(BranchFacts { commits, stat })
10410}
10411
10412/// Defang what would break the surrounding markdown: a closing `</details>`
10413/// and a code fence.
10414fn markdown_safe(text: &str) -> String {
10415    text.replace("</details>", "&lt;/details&gt;")
10416        .replace("\x60\x60\x60", "~~~")
10417}
10418
10419fn neutral_title(state: &RunState, winner: char) -> String {
10420    format!(
10421        "chore: land candidate {} of run {}",
10422        winner.to_ascii_uppercase(),
10423        state.id
10424    )
10425}
10426
10427/// The pull request title to hand to `land::merge_subject`. A review-only run
10428/// opened by an earlier build titled its pull request with the review prompt;
10429/// that title is dropped (empty, so the fallback applies) rather than landed.
10430/// Any other title, including an operator's rename, passes through untouched,
10431/// and so does every title of a run that implements a task.
10432pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
10433    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
10434        ""
10435    } else {
10436        pr_title
10437    }
10438}
10439
10440/// What the squash subject falls back to when the pull request title is empty
10441/// or candidate-shaped: for a review-only run the derived title, never the
10442/// review prompt held in `instruction`.
10443pub fn landing_subject_source(state: &RunState) -> String {
10444    if is_review_run(state) {
10445        let winner = state.candidates.first().map_or('A', |c| c.label);
10446        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
10447    }
10448    state.instruction.clone()
10449}
10450
10451/// `summary` without its `TITLE:` line, which the pull request title already
10452/// carries.
10453fn summary_without_title(summary: &str) -> String {
10454    let mut lines = summary.trim().lines().peekable();
10455    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
10456        lines.next();
10457    }
10458    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
10459}
10460
10461/// The pull request title and body for the winning candidate.
10462///
10463/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
10464/// the task's own opening line via [`queue::title_from`] when there is none.
10465/// `state.instruction` can open with blank lines (`task_text` only rejects a
10466/// body that is blank *entirely*), which `title_from` skips.
10467///
10468/// Publishing uses an independent description generated from the final diff
10469/// and structured evidence. Internal summaries and reports remain local.
10470/// Non-publishing previews may display the original task and candidate summary.
10471/// The footer retains the run and candidate provenance.
10472#[cfg(test)]
10473fn pr_message(state: &RunState, winner: char) -> PrMessage {
10474    pr_message_with(state, winner, None)
10475}
10476
10477/// [`pr_message`] with what the branch of a review-only run says about itself.
10478/// `facts` is ignored for a run that implements a task.
10479#[cfg(test)]
10480fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
10481    let raw = pr_message_raw(
10482        state,
10483        winner,
10484        facts,
10485        crate::pr_outcome::FinalHead::Unreported,
10486        ChangeNote::NotAsked,
10487    );
10488    let id = crate::scrub::Identity::current();
10489    PrMessage {
10490        title: crate::scrub::scrub(&raw.title, &id),
10491        body: crate::scrub::scrub(&raw.body, &id),
10492    }
10493}
10494
10495/// What the public description may say about the change itself.
10496#[derive(Debug, Clone, Copy)]
10497enum ChangeNote<'a> {
10498    /// A local preview or non-publishing merge mode: no public generation needed.
10499    NotAsked,
10500    /// A summary written from the final diff.
10501    Summary(&'a crate::run::ChangeSummary),
10502    /// A summary was asked for and could not be produced; the text says why.
10503    Unavailable(&'a str),
10504}
10505
10506fn pr_message_raw(
10507    state: &RunState,
10508    winner: char,
10509    facts: Option<&BranchFacts>,
10510    head: crate::pr_outcome::FinalHead<'_>,
10511    change: ChangeNote<'_>,
10512) -> PrMessage {
10513    if !matches!(change, ChangeNote::NotAsked) {
10514        let (title, mut body) = match change {
10515            ChangeNote::Summary(c) => (
10516                c.title.clone(),
10517                format!("## Summary\n\n{}\n\n", c.body.trim()),
10518            ),
10519            ChangeNote::Unavailable(why) => (
10520                neutral_title(state, winner),
10521                format!(
10522                    "## Summary\n\nNo description of what this change does could be generated ({why}). Review the final diff; internal reports remain in local records.\n\n"
10523                ),
10524            ),
10525            ChangeNote::NotAsked => unreachable!(),
10526        };
10527        body.push_str(&public_pr_evidence(state, head));
10528        body.push_str(&format!(
10529            "\n---\nmagi:run/{} magi:candidate-{}\n",
10530            state.id,
10531            winner.to_ascii_lowercase()
10532        ));
10533        return PrMessage { title, body };
10534    }
10535    pr_preview(state, winner, facts, head)
10536}
10537
10538/// Append public facts from the run records after every prose generation,
10539/// including posting-gate rewrites. The agent cannot drop this provenance.
10540fn public_pr_evidence(state: &RunState, head: crate::pr_outcome::FinalHead<'_>) -> String {
10541    let mut body = crate::pr_outcome::review_rounds(state);
10542    body.push_str(&crate::pr_outcome::final_verification(state, head));
10543    let open = state.last_round_findings();
10544    if !open.is_empty() {
10545        body.push_str("## Open review findings\n\n");
10546        for f in open {
10547            body.push_str(&format!("- `{}` [{:?}]\n", f.id, f.severity));
10548        }
10549        body.push('\n');
10550    }
10551    if let Some(c) = state.change_summary.as_ref()
10552        && head == crate::pr_outcome::FinalHead::At(&c.head)
10553    {
10554        body.push_str(&format!(
10555            "Evidence: final diff `{}`..`{}`; review and verification records from this run.\n",
10556            c.base, c.head
10557        ));
10558    }
10559    body
10560}
10561
10562/// Local-only preview of the operator's task and candidate summary.
10563fn pr_preview(
10564    state: &RunState,
10565    winner: char,
10566    facts: Option<&BranchFacts>,
10567    head: crate::pr_outcome::FinalHead<'_>,
10568) -> PrMessage {
10569    let summary = state
10570        .candidates
10571        .iter()
10572        .find(|c| c.label == winner)
10573        .map(|c| c.summary.as_str())
10574        .unwrap_or_default();
10575    // The fallback is the operator's own words and may not be English; GitHub
10576    // text always is, so a non-English task gets a neutral title instead.
10577    let review = is_review_run(state);
10578    let title = if review {
10579        facts
10580            .and_then(|f| {
10581                english_subject(&f.commits.first()?.0, state.config.graph.github_english())
10582            })
10583            .or_else(|| review_title(state))
10584            .or_else(|| {
10585                state
10586                    .candidates
10587                    .iter()
10588                    .find(|c| c.label == winner)
10589                    .filter(|c| !c.branch.starts_with("magi/"))
10590                    .and_then(|c| english_subject(&c.branch, state.config.graph.github_english()))
10591            })
10592            .unwrap_or_else(|| neutral_title(state, winner))
10593    } else {
10594        summary_title(summary).unwrap_or_else(|| {
10595            english_title(
10596                &state.instruction,
10597                PR_TITLE_MAX,
10598                state.config.graph.github_english(),
10599            )
10600            .unwrap_or_else(|| neutral_title(state, winner))
10601        })
10602    };
10603
10604    let mut body = String::new();
10605    let what = summary_without_title(summary);
10606    if !what.is_empty() {
10607        body.push_str("## Summary\n\n");
10608        body.push_str(&what);
10609        body.push_str("\n\n");
10610    }
10611
10612    // Review history uses structured facts across all rounds, never reports.
10613    body.push_str(&crate::pr_outcome::review_rounds(state));
10614    body.push_str(&crate::pr_outcome::final_verification(state, head));
10615    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
10616
10617    let open = state.open_findings();
10618    if !open.is_empty() {
10619        body.push_str("## Open review findings\n\n");
10620        for f in &open {
10621            body.push_str(&format!(
10622                "- `{}` [{:?}] {}\n",
10623                f.id, f.severity, "Context retained in local review records."
10624            ));
10625        }
10626        body.push('\n');
10627    }
10628
10629    if let Some(fix) = fix
10630        && !fix.rejected.is_empty()
10631    {
10632        body.push_str("## Declined by the fixer\n\n");
10633        for r in &fix.rejected {
10634            body.push_str(&format!(
10635                "- `{}`: {}\n",
10636                r.id, "Reason retained in local review records."
10637            ));
10638        }
10639        body.push('\n');
10640    }
10641
10642    if review {
10643        // The review prompt is not the task; list what the branch carries.
10644        body.push_str("## Commits under review\n\n");
10645        if let Some(facts) = facts {
10646            let mut left = COMMITS_MAX_CHARS;
10647            for (i, (subject, text)) in facts.commits.iter().enumerate() {
10648                let mut entry = format!("- {}\n", markdown_safe(subject));
10649                for l in markdown_safe(text).lines() {
10650                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
10651                }
10652                if left == 0 {
10653                    body.push_str(&format!(
10654                        "- ... {} more commit(s)\n",
10655                        facts.commits.len() - i
10656                    ));
10657                    break;
10658                }
10659                if entry.len() > left {
10660                    // Even the first commit is cut: one huge body must not
10661                    // push the whole description past GitHub's limit.
10662                    let mut end = left;
10663                    while !entry.is_char_boundary(end) {
10664                        end -= 1;
10665                    }
10666                    entry.truncate(end);
10667                    entry.push_str("\n  ... (truncated)\n");
10668                    left = 0;
10669                } else {
10670                    left -= entry.len();
10671                }
10672                body.push_str(&entry);
10673            }
10674            if !facts.stat.trim().is_empty() {
10675                body.push_str(&format!(
10676                    "\n## Diff stat\n\n```\n{}\n```\n",
10677                    markdown_safe(facts.stat.trim())
10678                ));
10679            }
10680        } else {
10681            match &state.reviewed_commits {
10682                Some(subjects) => {
10683                    for s in subjects {
10684                        body.push_str(&format!("- {}\n", s.trim()));
10685                    }
10686                }
10687                None => {
10688                    // An older run kept only the prompt, with the commit list
10689                    // after its first paragraph.
10690                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
10691                    body.push_str(rest.trim());
10692                    body.push('\n');
10693                }
10694            }
10695        }
10696    } else {
10697        let task = state.instruction.trim();
10698        let task = if task.is_empty() {
10699            "(empty task)"
10700        } else {
10701            task
10702        };
10703        body.push_str(&format!(
10704            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
10705            task.replace("</details>", "&lt;/details&gt;")
10706        ));
10707    }
10708
10709    body.push_str(&format!(
10710        "\n---\nmagi:run/{} magi:candidate-{}\n",
10711        state.id,
10712        winner.to_ascii_lowercase()
10713    ));
10714
10715    PrMessage { title, body }
10716}
10717
10718/// The task with what the repository says about the existing work it names
10719/// appended, so an implementer knows what it started from and what it must
10720/// not redo. Unchanged when the task names nothing.
10721fn seeded_instruction(state: &RunState) -> String {
10722    match refs::describe(&state.seeds) {
10723        Some(facts) => format!(
10724            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
10725             Candidates start from the unmerged branch named above, when there \
10726             is one, and carry any unmerged commit named by sha as a \
10727             cherry-pick. Check that this is what the task meant before \
10728             building on it.",
10729            state.instruction
10730        ),
10731        None => state.instruction.clone(),
10732    }
10733}
10734
10735/// Does the winner have no commits ahead of the base it would land on?
10736/// Any failure to find out reads as "not empty": the merge then behaves as it
10737/// always did rather than refusing on a guess.
10738async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
10739    let base = &state.base_branch;
10740    let mut against = base.clone();
10741    if mode == MergeMode::Pr {
10742        // A pull request lands on the remote's base, never the local branch
10743        // of the same name: if that cannot be read, "not empty" is the safe
10744        // answer.
10745        let remote = &state.config.merge.remote;
10746        let tracking = format!("{remote}/{base}");
10747        let fetched = git::fetch(repo, remote, base).await;
10748        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
10749            against = tracking;
10750        } else {
10751            return false;
10752        }
10753    }
10754    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
10755}
10756
10757/// Why nothing was opened for an empty winner, with what the task's own
10758/// references resolved to.
10759fn empty_candidate_detail(state: &RunState, base: &str) -> String {
10760    let mut detail = format!(
10761        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
10762         nothing to open a pull request for"
10763    );
10764    match refs::describe(&state.seeds) {
10765        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
10766        None => detail.push_str(
10767            "\nThe task names no existing branch or commit; if it means to land work \
10768             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
10769        ),
10770    }
10771    detail
10772}
10773
10774/// What the `Pr` merge does once it knows whether the branch already has an
10775/// open pull request.
10776#[derive(Debug, PartialEq, Eq)]
10777enum PrPlan {
10778    Create,
10779    Adopt { url: String, title: String },
10780    Stop(String),
10781}
10782
10783/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
10784/// a failed lookup -> stop with the real reason. Never guesses.
10785fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
10786    match found {
10787        Ok(land::OpenPr::None) => PrPlan::Create,
10788        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
10789        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
10790            "several open pull requests exist for this branch, not picking one: {}",
10791            urls.join(" ")
10792        )),
10793        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
10794    }
10795}
10796
10797/// `gh pr create`, returning the PR url.
10798pub(crate) async fn gh_pr_create(
10799    cwd: &Path,
10800    base: &str,
10801    head: &str,
10802    title: &str,
10803    body: &str,
10804) -> Result<String> {
10805    let out = tokio::process::Command::new("gh")
10806        .args([
10807            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
10808        ])
10809        .current_dir(cwd)
10810        .quiet()
10811        .stdin(std::process::Stdio::null())
10812        .output()
10813        .await
10814        .context("spawn gh")?;
10815    if out.status.success() {
10816        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
10817    } else {
10818        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
10819    }
10820}
10821
10822/// Tear a run's worktrees and branches down.
10823///
10824/// `home` is where the updated `run.json` is saved (via
10825/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
10826/// a housekeeping pass already has its own honest `home` handed to it, and
10827/// falling through to the global here would write back through whichever
10828/// directory some other process or test pinned into that `OnceLock` first,
10829/// not the one the caller actually resolved its `runs` and `state` from.
10830pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
10831    if crate::pr_recovery::sources_retained(home, &state.id) {
10832        return Ok(Vec::new());
10833    }
10834    let repo = state.repo.clone();
10835    let root = state.worktree_root();
10836    let winner = state.tally.as_ref().map(|t| t.winner);
10837    let mut removed = Vec::new();
10838
10839    for i in 0..state.candidates.len() {
10840        let c = state.candidates[i].clone();
10841        let is_winner = Some(c.label) == winner;
10842        if is_winner && !drop_winner {
10843            continue;
10844        }
10845        if c.worktree.exists() {
10846            git::worktree_remove(&repo, &c.worktree).await.ok();
10847            removed.push(c.worktree.to_string_lossy().into_owned());
10848        }
10849        // A branch handed to a later run (and its pull request) is not this
10850        // run's to delete.
10851        let handed_over = state.released_branches.contains(&c.branch);
10852        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
10853            git::branch_delete(&repo, &c.branch).await.ok();
10854            removed.push(c.branch.clone());
10855        }
10856        state.candidates[i].folded = true;
10857    }
10858
10859    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
10860        let path = name.path();
10861        let keep = !drop_winner
10862            && winner.is_some_and(|w| {
10863                path.file_name()
10864                    .is_some_and(|n| n == format!("cand-{w}").as_str())
10865            });
10866        if keep {
10867            continue;
10868        }
10869        git::worktree_remove(&repo, &path).await.ok();
10870        removed.push(path.to_string_lossy().into_owned());
10871    }
10872
10873    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
10874    // judge worktrees, so once the loop above has cleared all of them out,
10875    // the parent is a bare directory nobody else was ever going to remove -
10876    // git only ever managed what was inside it. Left alone, one of these
10877    // accumulates per fully-folded run; the operator's own machine had 74.
10878    // `remove_if_empty` re-checks rather than assuming: a run whose winner
10879    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
10880    // so does anything a run never claimed that happens to share the bay.
10881    remove_if_empty(&root);
10882
10883    if state.enabled_worktree_config && drop_winner {
10884        // A release, not a raw disable: some sibling run in this repository
10885        // may still hold its own reference (see `git::acquire_worktree_config`),
10886        // and only the last release actually turns the setting back off.
10887        git::release_worktree_config(&repo).await.ok();
10888        state.enabled_worktree_config = false;
10889    }
10890    state.save_under(home)?;
10891    Ok(removed)
10892}
10893
10894/// Remove `dir` if it exists and has nothing in it.
10895///
10896/// Best-effort and silent by design: a directory that is not empty (a run
10897/// whose winner is still parked there, a stray file some other process left)
10898/// is exactly the case this must refuse, and a directory that is already gone
10899/// is not a failure worth reporting either. `std::fs::remove_dir` itself
10900/// already refuses a non-empty directory, so the emptiness check below is
10901/// belt, not suspenders - it is what keeps this from ever attempting the
10902/// removal in the case that matters, rather than trusting `remove_dir`'s
10903/// error path to have no side effects if it ever changed.
10904fn remove_if_empty(dir: &Path) {
10905    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
10906        std::fs::remove_dir(dir).ok();
10907    }
10908}
10909
10910/// Severity of the worst open finding in the last review round, for reporting.
10911pub fn worst_open(state: &RunState) -> Option<Severity> {
10912    state
10913        .reviews
10914        .last()?
10915        .reviews
10916        .iter()
10917        .flat_map(|r| r.findings.iter())
10918        .map(|f| f.severity)
10919        .max()
10920}
10921
10922/// `[worktree] setup` hides its products per worktree, which needs
10923/// `extensions.worktreeConfig`. Taken once per run and released with the run's
10924/// worktrees, like the hook's; every place that runs setup calls this first.
10925async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
10926    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
10927        git::acquire_worktree_config(repo).await?;
10928        state.enabled_worktree_config = true;
10929    }
10930    Ok(())
10931}
10932
10933#[cfg(test)]
10934mod tests {
10935    #[test]
10936    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
10937        let leaked = vec!["chore(deps): update a crate".to_owned()];
10938        let own = "fix(daemon): apply a chosen action";
10939        assert!(should_retitle("", own, &leaked));
10940        assert!(should_retitle(
10941            &format!("{REVIEW_PROMPT_OPENING} `x`"),
10942            own,
10943            &leaked
10944        ));
10945        assert!(should_retitle(
10946            "chore: land candidate A of run 1",
10947            own,
10948            &leaked
10949        ));
10950        assert!(should_retitle(
10951            "magi: candidate A (uncommitted work)",
10952            own,
10953            &leaked
10954        ));
10955        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
10956        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
10957        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
10958    }
10959
10960    #[test]
10961    fn pr_merge_plan_creates_adopts_or_stops() {
10962        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
10963        assert_eq!(
10964            pr_merge_plan(Ok(land::OpenPr::One {
10965                url: "u".into(),
10966                title: "t".into()
10967            })),
10968            PrPlan::Adopt {
10969                url: "u".into(),
10970                title: "t".into()
10971            }
10972        );
10973        let PrPlan::Stop(many) =
10974            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
10975        else {
10976            panic!("many must stop");
10977        };
10978        assert!(many.contains('a') && many.contains('b'));
10979        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
10980            panic!("a failed lookup must stop");
10981        };
10982        assert!(err.contains("bad token"));
10983    }
10984
10985    use super::*;
10986    use crate::run::GateStatus;
10987    use std::collections::BTreeMap;
10988    use std::time::Duration;
10989
10990    fn conductor() -> AgentSpec {
10991        AgentSpec {
10992            id: "conductor".to_owned(),
10993            kind: crate::config::AgentKind::Command,
10994            model: None,
10995            command: vec!["true".to_owned()],
10996            extra_args: Vec::new(),
10997            env: BTreeMap::new(),
10998            prompt_delivery: None,
10999        }
11000    }
11001
11002    fn spec(id: &str) -> AgentSpec {
11003        AgentSpec {
11004            id: id.to_owned(),
11005            kind: crate::config::AgentKind::Command,
11006            model: None,
11007            command: vec!["true".to_owned()],
11008            extra_args: Vec::new(),
11009            env: BTreeMap::new(),
11010            prompt_delivery: None,
11011        }
11012    }
11013
11014    fn ids(xs: &[&str]) -> BTreeSet<String> {
11015        xs.iter().map(|s| (*s).to_owned()).collect()
11016    }
11017
11018    #[test]
11019    fn pick_successor_skips_an_agent_another_seat_holds() {
11020        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
11021        let roster = [spec("a"), spec("b"), spec("c")];
11022        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11023        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11024    }
11025
11026    #[test]
11027    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
11028        // The other seat started on c but was handed to d; c is free again.
11029        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
11030        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11031        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11032        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
11033        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
11034    }
11035
11036    #[test]
11037    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
11038        let roster = [spec("a"), spec("b")];
11039        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
11040        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11041        let carried = ids(&["b"]);
11042        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
11043        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11044    }
11045
11046    #[test]
11047    fn pick_successor_returns_none_without_any_untried_successor() {
11048        let roster = [spec("a"), spec("b")];
11049        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
11050        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
11051        let carried = ids(&["a"]);
11052        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
11053    }
11054
11055    #[test]
11056    fn pick_successor_rescue_avoids_another_seats_occupant() {
11057        // b is a carried failure and free; a is held by the other seat.
11058        let roster = [spec("a"), spec("b"), spec("c")];
11059        let carried = ids(&["b", "c"]);
11060        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
11061        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11062    }
11063
11064    #[test]
11065    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
11066        let roster = [spec("a"), spec("b"), spec("c")];
11067        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
11068        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
11069    }
11070
11071    #[test]
11072    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
11073        let roster = [spec("a"), spec("b"), spec("c")];
11074        let failed = ids(&["a", "b", "c"]);
11075        // Rescue looks at the whole roster, once per id, then runs out.
11076        let mut tried = ids(&["b"]);
11077        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11078        assert_eq!(first.id, "a");
11079        tried.insert(first.id.clone());
11080        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
11081        assert_eq!(second.id, "c");
11082        tried.insert(second.id.clone());
11083        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
11084    }
11085
11086    #[test]
11087    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
11088        let roster = [spec("a"), spec("b")];
11089        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
11090        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11091    }
11092
11093    #[test]
11094    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
11095        let roster = [spec("a"), spec("b"), spec("c")];
11096        let h = SeatHistory {
11097            failed: ids(&["a"]),
11098            last_ok: Some("b".to_owned()),
11099            last_fail: None,
11100        };
11101        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
11102        // A last answerer that left the roster, or later failed, is ignored.
11103        let gone = SeatHistory {
11104            last_ok: Some("zzz".to_owned()),
11105            ..h.clone()
11106        };
11107        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
11108        let failed = SeatHistory {
11109            failed: ids(&["a", "b"]),
11110            last_ok: Some("b".to_owned()),
11111            last_fail: None,
11112        };
11113        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
11114    }
11115
11116    #[test]
11117    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
11118        let first = SeatState::new("review-1", "alpha", 7);
11119        let next = handover_seat("review-1", "gamma", 7);
11120        assert_ne!(first.claude_session, next.claude_session);
11121    }
11122
11123    #[test]
11124    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
11125        let mut state = state_with_summary("x", "y");
11126        let a = handover_seat("review-1", "beta", state.next_seat_seed());
11127        let b = handover_seat("review-1", "beta", state.next_seat_seed());
11128        assert_ne!(a.claude_session, b.claude_session);
11129    }
11130
11131    #[test]
11132    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
11133        let roster = [spec("a"), spec("b")];
11134        let h = SeatHistory {
11135            failed: ids(&["a", "b"]),
11136            ..SeatHistory::default()
11137        };
11138        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
11139        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
11140        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
11141    }
11142
11143    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
11144    // fallback loop depends on to terminate: it must walk forward from the
11145    // seat's own position, never restart at the front of the roster, and it
11146    // must never hand back an id already tried, however many times that id
11147    // happens to appear.
11148
11149    #[test]
11150    fn failure_signature_ignores_numbers_and_paths() {
11151        assert_eq!(
11152            failure_signature("exited with Some(2) and no usable output"),
11153            failure_signature("exited with Some(137) and no usable output")
11154        );
11155        assert_eq!(
11156            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
11157            failure_signature("cannot open /var/x.txt: denied")
11158        );
11159        assert_ne!(failure_signature("boom"), failure_signature("bang"));
11160    }
11161
11162    #[test]
11163    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
11164        let other = FailClass::Other("x".into());
11165        assert!(should_hand_over(None, &FailClass::Quota));
11166        assert!(should_hand_over(Some(&other), &FailClass::Quota));
11167        assert!(should_hand_over(
11168            Some(&FailClass::Timeout),
11169            &FailClass::Timeout
11170        ));
11171        assert!(should_hand_over(None, &other));
11172        assert!(!should_hand_over(Some(&other), &other));
11173        assert!(should_hand_over(
11174            Some(&other),
11175            &FailClass::Other("y".into())
11176        ));
11177        // A quota or timeout in between ends the run of identical failures.
11178        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
11179        assert!(should_hand_over(Some(&FailClass::Quota), &other));
11180    }
11181
11182    #[test]
11183    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
11184        let a = SeatState::new("judge-1", "alpha", 7);
11185        let b = handover_seat("judge-1", "beta", 7);
11186        assert_ne!(a.claude_session, b.claude_session);
11187        assert_eq!(b.turns, 0);
11188    }
11189
11190    #[test]
11191    fn a_timeout_is_classified_apart_from_other_failures() {
11192        assert_eq!(
11193            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
11194            Some(FailClass::Timeout)
11195        );
11196        assert!(matches!(
11197            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
11198            Some(FailClass::Other(_))
11199        ));
11200    }
11201
11202    #[test]
11203    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
11204        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11205        let tried = BTreeSet::from(["beta".to_owned()]);
11206        // beta sits at index 1; the next candidate is gamma, never alpha —
11207        // which is very likely a different candidate slot's own agent.
11208        let next = next_untried_in_roster(&roster, 1, &tried);
11209        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
11210    }
11211
11212    #[test]
11213    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
11214        let roster = vec![spec("alpha"), spec("beta")];
11215        let tried = BTreeSet::from(["beta".to_owned()]);
11216        // beta is the roster's last entry: nothing follows it, and alpha —
11217        // earlier in the roster, almost certainly a different candidate
11218        // slot's own agent — must not be reached by wrapping back to it.
11219        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11220    }
11221
11222    #[test]
11223    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
11224        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
11225        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
11226        // beta (index 1) and gamma (index 2, the only entry after it) have
11227        // both been tried; alpha (index 0) never has, but it comes before
11228        // beta's own position, so there is nothing further for this seat.
11229        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
11230    }
11231
11232    #[test]
11233    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
11234        let roster = vec![spec("a"), spec("a"), spec("b")];
11235        let tried = BTreeSet::from(["a".to_owned()]);
11236        let next = next_untried_in_roster(&roster, 0, &tried);
11237        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
11238    }
11239
11240    #[test]
11241    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
11242        let roster = vec![spec("a"), spec("b")];
11243        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
11244        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
11245    }
11246
11247    #[test]
11248    fn remove_if_empty_only_ever_takes_a_bare_directory() {
11249        let dir = tempfile::tempdir().unwrap();
11250        let bay = dir.path().join("ffff");
11251
11252        // Not there yet: nothing to do, nothing to panic on.
11253        remove_if_empty(&bay);
11254        assert!(!bay.exists());
11255
11256        // Something still inside - the winner's worktree, or a stray file -
11257        // keeps the directory standing.
11258        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
11259        remove_if_empty(&bay);
11260        assert!(bay.exists(), "non-empty directory must survive");
11261
11262        // Once the last entry is gone, so is the directory itself.
11263        std::fs::remove_dir(bay.join("cand-A")).unwrap();
11264        remove_if_empty(&bay);
11265        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
11266    }
11267
11268    // `round_is_clean` is the exact decision this task fixed: a round with a
11269    // seat that never answered must not read the same as a round every seat
11270    // actually reviewed. These are deterministic and process-free by design —
11271    // the equivalent end-to-end check (a real reviewer timing out under a
11272    // live graph run) is a genuine race against wall-clock contention, and a
11273    // spawn slow enough to blow even a generous budget under a loaded test
11274    // run must not turn this specific regression check flaky.
11275
11276    #[test]
11277    fn a_full_panel_that_found_nothing_is_clean() {
11278        assert!(round_is_clean(
11279            0,
11280            true,
11281            2,
11282            2,
11283            0,
11284            IncompleteReviewPolicy::Block
11285        ));
11286    }
11287
11288    #[test]
11289    fn a_missing_seat_is_never_clean_under_the_default_policy() {
11290        assert!(!round_is_clean(
11291            0,
11292            true,
11293            1,
11294            2,
11295            0,
11296            IncompleteReviewPolicy::Block
11297        ));
11298    }
11299
11300    #[test]
11301    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
11302        assert!(!round_is_clean(
11303            1,
11304            true,
11305            1,
11306            2,
11307            0,
11308            IncompleteReviewPolicy::Warn
11309        ));
11310    }
11311
11312    #[test]
11313    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
11314        assert!(round_is_clean(
11315            0,
11316            true,
11317            1,
11318            2,
11319            0,
11320            IncompleteReviewPolicy::Warn
11321        ));
11322    }
11323
11324    #[test]
11325    fn a_full_panel_with_an_open_finding_is_not_clean() {
11326        assert!(!round_is_clean(
11327            1,
11328            true,
11329            2,
11330            2,
11331            0,
11332            IncompleteReviewPolicy::Block
11333        ));
11334    }
11335
11336    #[test]
11337    fn a_full_panel_with_a_red_e2e_is_not_clean() {
11338        assert!(!round_is_clean(
11339            0,
11340            false,
11341            2,
11342            2,
11343            0,
11344            IncompleteReviewPolicy::Block
11345        ));
11346    }
11347
11348    // The stall this task closes: under the default `block` policy, a seat
11349    // missing only because it was rate limited must not force a wait for a
11350    // session limit that will not lift by the next round. `round_is_clean`
11351    // is where that quorum carve-out lives; the review loop around it never
11352    // changes what a reviewer's vote or a finding's severity means.
11353
11354    #[test]
11355    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
11356        // 1 of 2 answered, and the one missing was quota'd — the exact
11357        // "review-2 rate limited (quota)" shape from the field report.
11358        assert!(round_is_clean(
11359            0,
11360            true,
11361            1,
11362            2,
11363            1,
11364            IncompleteReviewPolicy::Block
11365        ));
11366    }
11367
11368    #[test]
11369    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
11370        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
11371        // not a quota loss (`quota_missing` stays 0) — worth another try.
11372        assert!(!round_is_clean(
11373            0,
11374            true,
11375            1,
11376            2,
11377            0,
11378            IncompleteReviewPolicy::Block
11379        ));
11380    }
11381
11382    #[test]
11383    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
11384        assert!(!round_is_clean(
11385            1,
11386            true,
11387            1,
11388            2,
11389            1,
11390            IncompleteReviewPolicy::Block
11391        ));
11392        assert!(!round_is_clean(
11393            0,
11394            false,
11395            1,
11396            2,
11397            1,
11398            IncompleteReviewPolicy::Block
11399        ));
11400    }
11401
11402    #[test]
11403    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
11404        // Every seat quota'd, nobody answered: there is no panel to decide
11405        // on, so this must fall through to the existing block-and-retry
11406        // fallback rather than call an unreviewed patch clean.
11407        assert!(!round_is_clean(
11408            0,
11409            true,
11410            0,
11411            2,
11412            2,
11413            IncompleteReviewPolicy::Block
11414        ));
11415    }
11416
11417    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
11418        CommandOutcome {
11419            command: "test".to_owned(),
11420            code,
11421            output_tail: String::new(),
11422            duration_ms: 0,
11423            resource_blocked,
11424        }
11425    }
11426
11427    #[test]
11428    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
11429        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
11430        assert!(
11431            !verify_inconclusive(&[outcome(Some(1), false)]),
11432            "an ordinary failure is still evidence about the patch"
11433        );
11434        assert!(verify_inconclusive(&[outcome(None, true)]));
11435        assert!(
11436            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
11437            "one inconclusive outcome taints the whole batch"
11438        );
11439        assert!(!verify_inconclusive(&[]));
11440    }
11441
11442    #[tokio::test]
11443    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
11444        // Alive for the first two checks, then dead - confirms the loop
11445        // actually re-polls rather than deciding once and sleeping out the
11446        // ceiling regardless.
11447        let calls = std::sync::atomic::AtomicUsize::new(0);
11448        let started = Instant::now();
11449        wait_for_pids_with(
11450            &[123],
11451            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
11452            Duration::from_millis(5),
11453            Duration::from_secs(5),
11454        )
11455        .await;
11456        assert!(
11457            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
11458            "must keep checking rather than deciding on the first answer"
11459        );
11460        assert!(
11461            started.elapsed() < Duration::from_secs(1),
11462            "must return the moment it is confirmed dead, not wait out the ceiling"
11463        );
11464    }
11465
11466    #[tokio::test]
11467    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
11468        let started = Instant::now();
11469        wait_for_pids_with(
11470            &[123],
11471            |_| true, // never reports dead
11472            Duration::from_millis(5),
11473            Duration::from_millis(30),
11474        )
11475        .await;
11476        let elapsed = started.elapsed();
11477        assert!(
11478            elapsed >= Duration::from_millis(30),
11479            "must not give up before its own ceiling: {elapsed:?}"
11480        );
11481        assert!(
11482            elapsed < Duration::from_secs(1),
11483            "must not wait past its own ceiling either: {elapsed:?}"
11484        );
11485    }
11486
11487    #[tokio::test]
11488    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
11489        let started = Instant::now();
11490        wait_for_pids_with(
11491            &[],
11492            |_| true,
11493            Duration::from_secs(5),
11494            Duration::from_secs(5),
11495        )
11496        .await;
11497        assert!(
11498            started.elapsed() < Duration::from_millis(200),
11499            "an empty pid list has nothing to confirm"
11500        );
11501    }
11502
11503    // `review_conclusion` is the exact decision the review hand-off task
11504    // fixed: a round budget spent (or a tree that stopped moving) must not
11505    // collapse into `Blocked` regardless of what verification actually
11506    // said. Deterministic and process-free for the same reason the
11507    // `round_is_clean` family above is.
11508    fn review_round(
11509        clean: bool,
11510        blocking: usize,
11511        answered: usize,
11512        expected: usize,
11513        progressed: bool,
11514        e2e_ok: bool,
11515    ) -> ReviewRound {
11516        ReviewRound {
11517            round: 1,
11518            head: "h".to_owned(),
11519            verified_head: None,
11520            verified_at: None,
11521            reviews: Vec::new(),
11522            e2e: vec![CommandOutcome {
11523                command: "test".to_owned(),
11524                code: Some(if e2e_ok { 0 } else { 1 }),
11525                output_tail: String::new(),
11526                duration_ms: 0,
11527                resource_blocked: false,
11528            }],
11529            verify_retried: false,
11530            e2e_deferred: false,
11531            e2e_defer_reason: None,
11532            fix: None,
11533            blocking,
11534            answered,
11535            expected,
11536            clean,
11537            progressed,
11538            vote_split: false,
11539            reconsideration: Vec::new(),
11540            verdict: None,
11541        }
11542    }
11543
11544    #[test]
11545    fn review_conclusion_is_none_when_nothing_has_run() {
11546        assert_eq!(review_conclusion(&[], 3), None);
11547    }
11548
11549    #[test]
11550    fn review_conclusion_is_none_while_rounds_remain() {
11551        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
11552        assert_eq!(review_conclusion(&rounds, 3), None);
11553    }
11554
11555    #[test]
11556    fn review_conclusion_is_gating_once_a_round_is_clean() {
11557        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
11558        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
11559    }
11560
11561    #[test]
11562    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
11563        let rounds = vec![
11564            review_round(false, 1, 2, 2, true, true),
11565            review_round(false, 1, 2, 2, true, true),
11566        ];
11567        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
11568    }
11569
11570    #[test]
11571    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
11572        let rounds = vec![
11573            review_round(false, 1, 2, 2, true, true),
11574            review_round(false, 1, 2, 2, true, false),
11575        ];
11576        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
11577    }
11578
11579    #[test]
11580    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
11581        // Magi never got a command to run against this round's own head — a
11582        // resource-blocked attempt, not a red one — so this must never
11583        // settle on `Blocked` the way a genuine e2e failure would. `None`
11584        // here is what tells `Runner::review_loop` to retry the check
11585        // itself rather than trust this cheap recomputation with a verdict
11586        // it cannot actually produce.
11587        let mut blocked = review_round(false, 1, 2, 2, true, false);
11588        blocked.e2e[0].resource_blocked = true;
11589        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
11590        assert_eq!(review_conclusion(&rounds, 2), None);
11591    }
11592
11593    #[test]
11594    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
11595        // Missing input, not a verified tree — never a hand-off candidate.
11596        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
11597        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
11598    }
11599
11600    #[test]
11601    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
11602        let rounds = vec![
11603            review_round(false, 1, 2, 2, false, true),
11604            review_round(false, 1, 2, 2, false, true),
11605        ];
11606        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
11607    }
11608
11609    fn secs(n: u64) -> Duration {
11610        Duration::from_secs(n)
11611    }
11612
11613    /// A throwaway repo with one commit on `main`, for tests that need `merge`
11614    /// to make real (and, if it runs at all, real*ly fail*) git calls.
11615    #[tokio::test]
11616    async fn selected_public_ancestry_survives_obsolete_candidate_and_current_base_sync() {
11617        ask_test_home();
11618        let tmp = tempfile::tempdir().unwrap();
11619        let repo = tmp.path().join("repo");
11620        std::fs::create_dir_all(&repo).unwrap();
11621        init_repo(&repo);
11622        let g = |args: &[&str]| {
11623            let out = std::process::Command::new("git")
11624                .args(args)
11625                .current_dir(&repo)
11626                .quiet()
11627                .output()
11628                .unwrap();
11629            assert!(
11630                out.status.success(),
11631                "{}",
11632                String::from_utf8_lossy(&out.stderr)
11633            );
11634        };
11635        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11636        g(&["checkout", "-b", "historical"]);
11637        std::fs::write(repo.join("implementation"), "incompatible alternative").unwrap();
11638        g(&["add", "."]);
11639        g(&["commit", "-m", "old alternative"]);
11640        let obsolete = git::rev_parse(&repo, "HEAD").await.unwrap();
11641        g(&["checkout", "-b", "magi/task-selected", &base]);
11642        std::fs::write(repo.join("infrastructure"), "prerequisite").unwrap();
11643        g(&["add", "."]);
11644        g(&["commit", "-m", "measurement infrastructure"]);
11645        let prerequisite = git::rev_parse(&repo, "HEAD").await.unwrap();
11646        std::fs::write(repo.join("implementation"), "measured implementation").unwrap();
11647        g(&["add", "."]);
11648        g(&["commit", "-m", "dependent implementation"]);
11649        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
11650        g(&["checkout", "main"]);
11651        std::fs::write(repo.join("unrelated"), "current main change").unwrap();
11652        g(&["add", "."]);
11653        g(&["commit", "-m", "unrelated base change"]);
11654        let current = git::rev_parse(&repo, "HEAD").await.unwrap();
11655        let origin = tmp.path().join("origin.git");
11656        g(&[
11657            "clone",
11658            "--bare",
11659            repo.to_str().unwrap(),
11660            origin.to_str().unwrap(),
11661        ]);
11662        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
11663        g(&["fetch", "origin"]);
11664        let selection = crate::pr_recovery::Selection {
11665            url: "https://example.test/project/pull/1".into(),
11666            branch: "magi/task-selected".into(),
11667            head: head.clone(),
11668            instruction: "preserve measured behavior".into(),
11669            approved_at: jiff::Timestamp::now(),
11670        };
11671        let mut template = runner_at(RunStatus::Reviewing);
11672        template.state.repo = repo.clone();
11673        template.state.base_commit = current.clone();
11674        template.state.config.graph.worktree_root = Some(tmp.path().join("worktrees"));
11675        template.state.origin = Some(crate::run::Origin::queue("selected"));
11676        template.state.pr_head = Some(selection.branch.clone());
11677        template.state.pr_recovery = Some(selection.clone());
11678        let mut runner =
11679            Runner::open_selected(&repo, template.state, template.roles, 1, current, selection)
11680                .await
11681                .unwrap();
11682        let wt = runner.state.winner().unwrap().worktree.clone();
11683        assert_eq!(git::rev_parse(&wt, "HEAD").await.unwrap(), head);
11684        assert!(git::is_ancestor(&wt, &prerequisite, "HEAD").await);
11685        assert!(!git::is_ancestor(&wt, &obsolete, "HEAD").await);
11686        assert!(runner.state.seeds.is_empty());
11687        assert_eq!(runner.state.instruction, "preserve measured behavior");
11688        runner.sync_to_base().await.unwrap();
11689        assert_eq!(
11690            std::fs::read_to_string(wt.join("infrastructure")).unwrap(),
11691            "prerequisite"
11692        );
11693        assert_eq!(
11694            std::fs::read_to_string(wt.join("implementation")).unwrap(),
11695            "measured implementation"
11696        );
11697        assert_eq!(
11698            std::fs::read_to_string(wt.join("unrelated")).unwrap(),
11699            "current main change"
11700        );
11701        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11702        assert_eq!(
11703            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11704            head
11705        );
11706
11707        // A failed selected-source setup stays inspectable, never resumable as a new competition.
11708        let mut partial = runner_at(RunStatus::Reviewing);
11709        partial.state.repo = repo.clone();
11710        partial.state.config.graph.worktree_root = Some(tmp.path().join("failed-worktrees"));
11711        partial.state.config.worktree.setup = vec![crate::config::SetupStep {
11712            run: Some("exit 1".into()),
11713            ..Default::default()
11714        }];
11715        let selection = runner.state.pr_recovery.clone().unwrap();
11716        partial.state.pr_recovery = Some(selection.clone());
11717        partial.state.pr_head = Some(selection.branch.clone());
11718        partial.state.origin = Some(crate::run::Origin::queue("selected"));
11719        let id = partial.state.id.clone();
11720        let error = match Runner::open_selected(
11721            &repo,
11722            partial.state,
11723            partial.roles,
11724            1,
11725            runner.state.base_commit.clone(),
11726            selection,
11727        )
11728        .await
11729        {
11730            Ok(_) => panic!("setup must fail"),
11731            Err(e) => e,
11732        };
11733        assert_eq!(
11734            error
11735                .downcast_ref::<crate::pr_recovery::StartupFailed>()
11736                .unwrap()
11737                .run,
11738            id
11739        );
11740        let failed = RunState::load(&id).unwrap();
11741        assert_eq!(failed.status, RunStatus::Blocked);
11742        assert_eq!(failed.liveness(false), crate::run::Liveness::Dead);
11743        assert!(Runner::resume(&id).is_err());
11744        runner.state.status = RunStatus::Ready;
11745        runner.state.driver_exited = true;
11746        runner.state.save().unwrap();
11747        let mut retry = runner_at(RunStatus::Reviewing);
11748        retry.state.repo = repo.clone();
11749        retry.state.config.graph.worktree_root = Some(tmp.path().join("retry-worktrees"));
11750        let mut reselected = runner.state.pr_recovery.clone().unwrap();
11751        reselected.approved_at = jiff::Timestamp::now();
11752        retry.state.origin = Some(crate::run::Origin::queue("selected"));
11753        retry.state.pr_head = Some(reselected.branch.clone());
11754        retry.state.pr_recovery = Some(reselected.clone());
11755        crate::pr_head::check_predecessors(&retry.state, &reselected.branch)
11756            .await
11757            .unwrap();
11758        let restarted = Runner::open_selected(
11759            &repo,
11760            retry.state,
11761            retry.roles,
11762            1,
11763            runner.state.base_commit.clone(),
11764            reselected,
11765        )
11766        .await
11767        .unwrap();
11768        assert_eq!(
11769            git::rev_parse(&restarted.state.winner().unwrap().worktree, "HEAD")
11770                .await
11771                .unwrap(),
11772            head
11773        );
11774        assert_eq!(git::rev_parse(&repo, "historical").await.unwrap(), obsolete);
11775        assert_eq!(
11776            git::rev_parse(&repo, "magi/task-selected").await.unwrap(),
11777            head
11778        );
11779    }
11780
11781    #[tokio::test]
11782    async fn preparation_retries_after_explicit_release_and_rebuilds_partial_candidates() {
11783        ask_test_home();
11784        let tmp = tempfile::tempdir().unwrap();
11785        let repo = tmp.path().join("repo");
11786        std::fs::create_dir_all(&repo).unwrap();
11787        init_repo(&repo);
11788        let base = git::rev_parse(&repo, "HEAD").await.unwrap();
11789        git::git(&repo, &["checkout", "-b", "seed"]).await.unwrap();
11790        std::fs::write(repo.join("referenced"), "required commit").unwrap();
11791        git::git(&repo, &["add", "."]).await.unwrap();
11792        git::git(&repo, &["commit", "-m", "required seed"])
11793            .await
11794            .unwrap();
11795        let seed = git::rev_parse(&repo, "HEAD").await.unwrap();
11796        git::git(&repo, &["checkout", "main"]).await.unwrap();
11797        let mut runner = runner_at(RunStatus::Prep);
11798        runner.state.repo = repo.clone();
11799        runner.state.base_commit = base.clone();
11800        runner.state.config.graph.worktree_root = Some(tmp.path().join("wt"));
11801        runner.state.config.graph.advise = false;
11802        runner.state.config.blind.commit_msg_hook = false;
11803        runner.state.seeds = vec![refs::Seed {
11804            token: seed.clone(),
11805            sha: seed.clone(),
11806            kind: refs::SeedKind::Unmerged,
11807            branch: false,
11808            detail: String::new(),
11809        }];
11810        runner.roles.implementers = vec![spec("one"), spec("two")];
11811        runner.roles.judges = vec![spec("judge")];
11812        let label = blind::assign_labels(2, runner.state.seed)[0];
11813        let branch = runner.state.branch_for(label);
11814        let worktree = runner.state.worktree_root().join(format!("cand-{label}"));
11815        git::worktree_add_branch(&repo, &worktree, &branch, &base)
11816            .await
11817            .unwrap();
11818        runner.state.candidates.push(Candidate {
11819            index: 0,
11820            label,
11821            agent: "one".into(),
11822            branch,
11823            worktree,
11824            summary: String::new(),
11825            stat: String::new(),
11826            files: 0,
11827            commits: 0,
11828            empty: false,
11829            failed: None,
11830            verified_noop: None,
11831            duration_ms: 0,
11832            folded: false,
11833        });
11834        runner.state.save().unwrap();
11835        // Reload the checkpoint immediately before its first cherry-pick.
11836        runner.state = RunState::load(&runner.state.id).unwrap();
11837        assert_eq!(runner.state.prep_complete, Some(false));
11838        runner.prep().await.unwrap();
11839        assert_eq!(runner.state.prep_complete, Some(true));
11840        assert_eq!(runner.state.candidates.len(), 2);
11841        for candidate in &runner.state.candidates {
11842            assert_eq!(
11843                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11844                git::tree_of(&repo, &seed).await.unwrap()
11845            );
11846            assert_eq!(
11847                std::fs::read_to_string(candidate.worktree.join("referenced")).unwrap(),
11848                "required commit"
11849            );
11850        }
11851        assert!(runner.state.worktree_root().join("judge-1").exists());
11852        let heads: Vec<String> = candidate_heads(&runner.state.candidates).await;
11853        runner.prep().await.unwrap();
11854        assert_eq!(candidate_heads(&runner.state.candidates).await, heads);
11855
11856        runner.state.prep_complete = Some(false);
11857        runner.state.config.worktree.setup = vec![crate::config::SetupStep {
11858            run: Some(format!(
11859                "test -f '{}'",
11860                tmp.path().join("dependency-ready").display()
11861            )),
11862            ..Default::default()
11863        }];
11864        assert!(runner.prep().await.is_err());
11865        assert!(runner.state.prep_failure.is_some());
11866        std::fs::write(tmp.path().join("dependency-ready"), "available").unwrap();
11867        runner.state = RunState::load(&runner.state.id).unwrap();
11868        assert!(
11869            runner.prep().await.is_err(),
11870            "correcting the cause alone must not auto-retry"
11871        );
11872        let failed_at = runner.state.events.last().unwrap().at;
11873        assert!(
11874            runner.retry_preparation(failed_at).is_err(),
11875            "old approvals cannot unlock the hold"
11876        );
11877        let mut task = crate::queue::Task::new(
11878            "retry".into(),
11879            "retry setup".into(),
11880            repo.clone(),
11881            crate::queue::Source::Human,
11882        );
11883        task.start(runner.state.id.clone());
11884        task.hold_machine(Some("setup failed".into()));
11885        task.release_operator("cli", None);
11886        runner
11887            .retry_preparation(task.resumption.as_ref().unwrap().at)
11888            .unwrap();
11889        runner.prep().await.unwrap();
11890        assert!(runner.state.prep_failure.is_none());
11891        assert_eq!(runner.state.prep_complete, Some(true));
11892        assert_eq!(runner.state.candidates.len(), 2);
11893        assert!(runner.state.worktree_root().join("judge-1").exists());
11894        for candidate in &runner.state.candidates {
11895            assert_eq!(
11896                git::tree_of(&candidate.worktree, "HEAD").await.unwrap(),
11897                git::tree_of(&repo, &seed).await.unwrap()
11898            );
11899        }
11900    }
11901
11902    async fn candidate_heads(candidates: &[Candidate]) -> Vec<String> {
11903        let mut heads = Vec::new();
11904        for candidate in candidates {
11905            heads.push(git::rev_parse(&candidate.worktree, "HEAD").await.unwrap());
11906        }
11907        heads
11908    }
11909
11910    fn init_repo(dir: &Path) {
11911        let run = |args: &[&str]| {
11912            let out = std::process::Command::new("git")
11913                .args(args)
11914                .current_dir(dir)
11915                .quiet()
11916                .output()
11917                .expect("spawn git");
11918            assert!(
11919                out.status.success(),
11920                "git {args:?} failed: {}",
11921                String::from_utf8_lossy(&out.stderr)
11922            );
11923        };
11924        run(&["init", "-b", "main"]);
11925        run(&["config", "user.name", "magi test"]);
11926        run(&["config", "user.email", "magi@example.com"]);
11927        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
11928        run(&["add", "-A"]);
11929        run(&["commit", "-m", "init"]);
11930    }
11931
11932    // `settle_questions` is what closes the ghost the phone showed: a run's
11933    // seat asked something, the run then ended, and nothing was left to
11934    // abandon the question it left `open`. `HOME` is a process-wide
11935    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
11936    // first time it runs in the binary — every test below still reaches the
11937    // same directory whichever call won, and each gets its own run id from
11938    // `RunState::new`, so they never collide there.
11939    fn ask_test_home() {
11940        crate::run::pin_test_home();
11941    }
11942
11943    /// A minimal, git-free `Runner` at a given status — `settle_questions`
11944    /// reads nothing else off it.
11945    fn runner_at(status: RunStatus) -> Runner {
11946        let mut state = RunState::new(
11947            PathBuf::from("/nonexistent/repo"),
11948            "main".to_owned(),
11949            "deadbeef".to_owned(),
11950            "task".to_owned(),
11951            Config::default(),
11952        );
11953        // These fixtures share one home and save concurrently. Short clock
11954        // IDs can collide, making an ownership test read another test's run.
11955        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11956        state.id = format!(
11957            "20000101-000000-{:04x}",
11958            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
11959        );
11960        state.status = status;
11961        Runner {
11962            state,
11963            roles: ResolvedRoles {
11964                implementers: Vec::new(),
11965                judges: Vec::new(),
11966                reviewers: Vec::new(),
11967                fixer: None,
11968                conductor: conductor(),
11969                implementer_roster: Vec::new(),
11970                judge_roster: Vec::new(),
11971                reviewer_roster: Vec::new(),
11972            },
11973            sem: Arc::new(Semaphore::new(1)),
11974            pause: Pause::new(),
11975            interrupt: Pause::new(),
11976        }
11977    }
11978
11979    fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
11980        crate::run::MergeRepair {
11981            question: "repair-question".into(),
11982            head: "old-head".into(),
11983            pr: "https://github.com/o/r/pull/42".into(),
11984            reviews: vec![review_round(false, 1, 1, 1, false, true)],
11985            gate: vec![outcome(Some(0), false)],
11986            gate_head: Some("old-head".into()),
11987            gate_fixes: Vec::new(),
11988            pre_gate: Vec::new(),
11989            phase,
11990            failure: None,
11991        }
11992    }
11993
11994    #[tokio::test]
11995    async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
11996        crate::run::pin_test_home();
11997        let mut runner = runner_at(RunStatus::Landing);
11998        runner.state.merge_repairs.push(merge_repair_request(
11999            crate::run::MergeRepairPhase::Requested,
12000        ));
12001        runner.state.save().unwrap();
12002        let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
12003        assert!(
12004            runner
12005                .execute()
12006                .await
12007                .unwrap_err()
12008                .to_string()
12009                .contains("already running")
12010        );
12011        assert_eq!(
12012            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12013            crate::run::MergeRepairPhase::Requested
12014        );
12015        drop(claim);
12016        let mut disk = runner.state.clone();
12017        disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12018        disk.save().unwrap();
12019        assert!(
12020            runner
12021                .execute()
12022                .await
12023                .unwrap_err()
12024                .to_string()
12025                .contains("decision changed")
12026        );
12027        assert_eq!(
12028            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12029            crate::run::MergeRepairPhase::Complete
12030        );
12031    }
12032
12033    #[test]
12034    fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12035        crate::run::pin_test_home();
12036        let mut runner = runner_at(RunStatus::Landing);
12037        let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12038        runner.state.reviews = request.reviews.clone();
12039        runner.state.gate = request.gate.clone();
12040        runner.state.gate_ran = true;
12041        runner.state.land_approval = Some(crate::run::LandApproval {
12042            question: "old".into(),
12043            head: "old-head".into(),
12044        });
12045        runner.state.merge_repairs.push(request);
12046        runner.begin_merge_repair_review(0).unwrap();
12047        assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12048        assert!(!runner.state.gate_status().ok());
12049        assert!(runner.state.land_approval.is_none());
12050        let disk = RunState::load(&runner.state.id).unwrap();
12051        assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12052        assert!(disk.merge_repairs[0].gate[0].ok());
12053        assert_eq!(
12054            disk.merge_repairs[0].phase,
12055            crate::run::MergeRepairPhase::Reviewing
12056        );
12057        assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12058        assert!(disk.reviews.is_empty());
12059    }
12060
12061    #[tokio::test]
12062    async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12063        crate::run::pin_test_home();
12064        for phase in [
12065            crate::run::MergeRepairPhase::Fixing,
12066            crate::run::MergeRepairPhase::Requested,
12067        ] {
12068            let mut runner = runner_at(RunStatus::Landing);
12069            runner.state.config.graph.land_rounds = 0;
12070            runner.state.merge_repairs.push(merge_repair_request(phase));
12071            runner.state.save().unwrap();
12072            assert!(!runner.repair_merge_review().await.unwrap());
12073            assert_eq!(runner.state.status, RunStatus::Blocked);
12074            assert_eq!(
12075                runner.state.merge_repairs[0].phase,
12076                crate::run::MergeRepairPhase::Failed
12077            );
12078            let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12079            assert!(
12080                reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12081                    "interrupted"
12082                } else {
12083                    "budget exhausted"
12084                })
12085            );
12086            assert!(runner.state.merge.is_none());
12087        }
12088    }
12089
12090    /// `park_here` folding in the reason `Pause::park_because` recorded -
12091    /// this is what lets an operator reading a run's events tell an
12092    /// interrupt-driven park from an ordinary shutdown park.
12093    #[test]
12094    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12095        crate::run::pin_test_home();
12096        let mut runner = runner_at(RunStatus::Implementing);
12097        let interrupt = Pause::new();
12098        runner.watch_interrupt(interrupt.clone());
12099
12100        interrupt.park_because("task a1b2 asked to run first");
12101
12102        assert!(runner.park_here().expect("park_here"));
12103        assert!(runner.state.parked);
12104        let last = runner.state.events.last().expect("a park event");
12105        assert_eq!(last.node, "park");
12106        assert!(
12107            last.message.contains("task a1b2 asked to run first"),
12108            "expected the interrupt reason in {:?}",
12109            last.message
12110        );
12111    }
12112
12113    /// The cause is stored on the pass when it parks, and the first cause a
12114    /// shared pause was given is the one that stays.
12115    #[test]
12116    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12117        crate::run::pin_test_home();
12118        let mut runner = runner_at(RunStatus::Implementing);
12119        runner.state.begin_pass();
12120        let shutdown = Pause::new();
12121        runner.on_pause(shutdown.clone());
12122        let upgrade = ParkCause::Upgrade {
12123            from: "0.121.0".to_owned(),
12124            to: Some("0.121.1".to_owned()),
12125        };
12126        shutdown.park_for(upgrade.clone());
12127        shutdown.park_for(ParkCause::Operator);
12128
12129        assert!(runner.park_here().expect("park_here"));
12130        assert_eq!(
12131            runner.state.passes[0].end,
12132            Some(crate::run::PassEnd::Parked { cause: upgrade })
12133        );
12134        let last = runner.state.events.last().expect("a park event");
12135        assert!(last.message.contains("0.121.0"), "{}", last.message);
12136    }
12137
12138    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
12139    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
12140    /// cleared) must not make a *different* run - one only watching its own,
12141    /// unshared interrupt `Pause` - see itself as parked. If a future change
12142    /// ever collapsed these back into one handle, the interrupt scheduler
12143    /// would park every run for the rest of the daemon's life, not just the
12144    /// one it meant to interrupt.
12145    #[test]
12146    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12147        crate::run::pin_test_home();
12148        let mut runner = runner_at(RunStatus::Implementing);
12149        let shutdown = Pause::new();
12150        runner.on_pause(shutdown.clone());
12151        let interrupt = Pause::new();
12152        runner.watch_interrupt(interrupt.clone());
12153
12154        // Nobody has asked for anything yet.
12155        assert!(!runner.park_here().expect("park_here"));
12156        assert!(!runner.state.parked);
12157
12158        // Only the interrupt handle fires; the shutdown handle stays clear.
12159        interrupt.park_because("test");
12160        assert!(!shutdown.parked());
12161        assert!(runner.park_here().expect("park_here"));
12162    }
12163
12164    /// The property every prior attempt at this feature failed to pin down:
12165    /// asking a run to park while one of its nodes has a real, in-flight
12166    /// async operation running (an agent call, in production) must not cut
12167    /// that operation short. `park_here` is only ever consulted *between*
12168    /// `execute`'s node calls - see its own doc - so nothing inside a node
12169    /// can observe a park request until the node itself returns. This proves
12170    /// that structurally, with real `tokio` concurrency and a channel
12171    /// handshake (never a sleep, which would only prove "usually", not
12172    /// "cannot"): the "node" below reports that it has genuinely started,
12173    /// and only then is the park requested; the node still has to be told to
12174    /// finish before `park_here` is ever called, exactly mirroring every
12175    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
12176    /// in `execute`.
12177    #[tokio::test]
12178    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12179        crate::run::pin_test_home();
12180        let mut runner = runner_at(RunStatus::Implementing);
12181        let interrupt = Pause::new();
12182        runner.watch_interrupt(interrupt.clone());
12183
12184        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12185        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12186
12187        // Stands in for one node's in-flight agent call: it proves it has
12188        // genuinely started, then blocks - exactly as a spawned CLI process
12189        // does - until told to finish.
12190        let node = async move {
12191            started_tx.send(()).expect("send started");
12192            finish_rx.await.expect("recv finish");
12193            "node finished"
12194        };
12195
12196        let interrupter = async move {
12197            started_rx.await.expect("recv started");
12198            // The call is now genuinely in flight. Ask it to park.
12199            interrupt.park_because("higher-priority task waiting");
12200            // Nothing the node does can observe this yet - there is no
12201            // check inside it, by construction - so let the executor run
12202            // anything pending and then let the node finish on its own.
12203            tokio::task::yield_now().await;
12204            finish_tx.send(()).expect("send finish");
12205        };
12206
12207        let (node_result, ()) = tokio::join!(node, interrupter);
12208        assert_eq!(
12209            node_result, "node finished",
12210            "the in-flight call ran to completion"
12211        );
12212
12213        // Only now, at the boundary the real `execute` would check right
12214        // after this node, does the park take effect.
12215        assert!(runner.park_here().expect("park_here"));
12216        assert!(runner.state.parked);
12217    }
12218
12219    /// A run parked mid-competition carries every field it had accumulated
12220    /// through the exact same disk round-trip an ordinary resume uses -
12221    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
12222    /// Nothing about parking for an interrupt is a special case of that path;
12223    /// this is what proves it rather than assuming it.
12224    #[test]
12225    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12226        crate::run::pin_test_home();
12227        let mut runner = runner_at(RunStatus::Judging);
12228        // `Runner::resume` re-resolves roles from the saved config, which
12229        // refuses an empty roster - give it the same minimal one `conductor`
12230        // itself uses.
12231        runner.state.config.agents = vec![conductor()];
12232        runner.state.candidates = vec![Candidate {
12233            index: 0,
12234            label: 'A',
12235            agent: "alpha".to_owned(),
12236            branch: "magi/x/A".to_owned(),
12237            worktree: PathBuf::from("/nonexistent/worktree"),
12238            summary: "did the thing".to_owned(),
12239            stat: "1 file changed".to_owned(),
12240            files: 1,
12241            commits: 1,
12242            empty: false,
12243            failed: None,
12244            verified_noop: None,
12245            duration_ms: 1234,
12246            folded: false,
12247        }];
12248        let run_id = runner.state.id.clone();
12249
12250        let interrupt = Pause::new();
12251        runner.watch_interrupt(interrupt.clone());
12252        interrupt.park_because("task c3d4 asked to run first");
12253        assert!(runner.park_here().expect("park_here"));
12254
12255        let resumed = Runner::resume(&run_id).expect("resume");
12256        assert_eq!(resumed.state.candidates.len(), 1);
12257        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12258        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12259        assert_eq!(resumed.state.status, runner.state.status);
12260        assert!(
12261            resumed.state.parked,
12262            "still parked until `execute` actually walks the graph again"
12263        );
12264        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12265    }
12266
12267    /// A fresh open question on `run`, stored and handed back for assertions.
12268    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12269        let mut q = ask::Question::new(
12270            run.to_owned(),
12271            "implement".to_owned(),
12272            "impl-A".to_owned(),
12273            "Which storage backend should the cache use?".to_owned(),
12274            String::new(),
12275            vec!["SQLite".to_owned(), "Redis".to_owned()],
12276        );
12277        store.put(&mut q).unwrap();
12278        q
12279    }
12280
12281    #[test]
12282    fn a_failed_runs_open_question_is_abandoned() {
12283        ask_test_home();
12284        let store = ask::Questions::open();
12285        let mut runner = runner_at(RunStatus::Failed);
12286        let run = runner.state.id.clone();
12287        let q = ask_open_question(&store, &run);
12288
12289        runner.settle_questions();
12290
12291        let back = store.get(&q.id).unwrap();
12292        assert!(
12293            !back.status.open(),
12294            "the seat that asked died with the run; nobody is left to read an answer"
12295        );
12296        assert!(
12297            back.detail.contains(&run) && back.detail.contains("failed"),
12298            "the reason names what the run became, not just that it is gone: {}",
12299            back.detail
12300        );
12301    }
12302
12303    #[test]
12304    fn a_merged_runs_open_question_is_abandoned_too() {
12305        ask_test_home();
12306        let store = ask::Questions::open();
12307        // A run that finishes cleanly still leaves nobody to read an answer -
12308        // this is not only a failure-path cleanup.
12309        for status in [RunStatus::Merged, RunStatus::Ready] {
12310            let mut runner = runner_at(status);
12311            let run = runner.state.id.clone();
12312            let q = ask_open_question(&store, &run);
12313
12314            runner.settle_questions();
12315
12316            let back = store.get(&q.id).unwrap();
12317            assert!(
12318                !back.status.open(),
12319                "{status:?} run's question must not outlive the run"
12320            );
12321        }
12322    }
12323
12324    #[test]
12325    fn a_still_resumable_runs_open_question_is_left_alone() {
12326        ask_test_home();
12327        let store = ask::Questions::open();
12328        // `Blocked` and `Stalled` can still be resumed — the candidates, the
12329        // review round and the seat sessions are all still on disk — so a
12330        // question asked mid-round may yet get a real answer from a real
12331        // resume. Sweeping it here would be exactly the failure mode this
12332        // whole feature exists to avoid on the other side.
12333        for status in [RunStatus::Blocked, RunStatus::Stalled] {
12334            let mut runner = runner_at(status);
12335            let run = runner.state.id.clone();
12336            let q = ask_open_question(&store, &run);
12337
12338            runner.settle_questions();
12339
12340            let back = store.get(&q.id).unwrap();
12341            assert!(
12342                back.status.open(),
12343                "{status:?} is still alive; the question must still be waiting"
12344            );
12345        }
12346    }
12347
12348    #[test]
12349    fn settle_questions_never_touches_an_already_answered_question() {
12350        ask_test_home();
12351        let store = ask::Questions::open();
12352        let mut runner = runner_at(RunStatus::Failed);
12353        let run = runner.state.id.clone();
12354        let mut q = ask_open_question(&store, &run);
12355        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12356            .unwrap();
12357        store.put(&mut q).unwrap();
12358
12359        // Called twice, the way a crash-recovered daemon reclaim and the
12360        // graph's own cleanup both can for the same run — `abandon_for_run`
12361        // only ever touches what is still open, so this must be inert both
12362        // times, not merely the second.
12363        runner.settle_questions();
12364        runner.settle_questions();
12365
12366        let back = store.get(&q.id).unwrap();
12367        assert_eq!(
12368            back.status,
12369            ask::QuestionStatus::Answered,
12370            "a real answer is a decision on record, never overwritten by a sweep"
12371        );
12372    }
12373
12374    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
12375    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
12376    /// without merging, whose winner is still the operator's answer to read.
12377    /// Nothing previously called `fold_run` itself with a real `tally`, so
12378    /// this is the first test to pin down the one distinction the whole
12379    /// automatic-fold feature depends on: the winner's worktree and branch
12380    /// must survive, everything else sharing the run's worktree bay - a
12381    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
12382    /// second sweep treats every non-winner directory under the bay alike -
12383    /// must not.
12384    #[tokio::test]
12385    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12386        crate::run::pin_test_home();
12387        let tmp = tempfile::tempdir().expect("tempdir");
12388        let repo = tmp.path().join("repo");
12389        std::fs::create_dir_all(&repo).unwrap();
12390        init_repo(&repo);
12391
12392        let mut config = Config::default();
12393        config.graph.worktree_root = Some(tmp.path().join("wt"));
12394
12395        let mut state = RunState::new(
12396            repo.clone(),
12397            "main".to_owned(),
12398            "deadbeef".to_owned(),
12399            "task".to_owned(),
12400            config,
12401        );
12402        let root = state.worktree_root();
12403        let wt_a = root.join("cand-A");
12404        let wt_b = root.join("cand-B");
12405        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12406            .await
12407            .expect("worktree A");
12408        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12409            .await
12410            .expect("worktree B");
12411
12412        state.candidates = vec![
12413            Candidate {
12414                index: 0,
12415                label: 'A',
12416                agent: "alpha".to_owned(),
12417                branch: "magi/x/A".to_owned(),
12418                worktree: wt_a.clone(),
12419                summary: String::new(),
12420                stat: String::new(),
12421                files: 0,
12422                commits: 0,
12423                empty: false,
12424                failed: None,
12425                verified_noop: None,
12426                duration_ms: 0,
12427                folded: false,
12428            },
12429            Candidate {
12430                index: 1,
12431                label: 'B',
12432                agent: "beta".to_owned(),
12433                branch: "magi/x/B".to_owned(),
12434                worktree: wt_b.clone(),
12435                summary: String::new(),
12436                stat: String::new(),
12437                files: 0,
12438                commits: 0,
12439                empty: false,
12440                failed: None,
12441                verified_noop: None,
12442                duration_ms: 0,
12443                folded: false,
12444            },
12445        ];
12446        state.tally = Some(Tally {
12447            first_choice: BTreeMap::from([('A', 1)]),
12448            borda: BTreeMap::new(),
12449            winner: 'A',
12450            rankings: 1,
12451            unanimous_initial: true,
12452            deliberated: false,
12453            changed_votes: 0,
12454            unanimous_final: true,
12455            tie_break: None,
12456            judges: 1,
12457            present: 1,
12458            quorum: 1,
12459            met_quorum: true,
12460            uncontested: None,
12461        });
12462        state.status = RunStatus::Ready;
12463
12464        fold_run(&mut state, false, &crate::run::home())
12465            .await
12466            .expect("fold_run");
12467
12468        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12469        assert!(
12470            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12471            "the unmerged winner's branch survives"
12472        );
12473        assert!(
12474            !state.candidates[0].folded,
12475            "the winner is not marked folded"
12476        );
12477
12478        assert!(!wt_b.exists(), "the loser's worktree is removed");
12479        assert!(
12480            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12481            "the loser's branch is removed"
12482        );
12483        assert!(state.candidates[1].folded, "the loser is marked folded");
12484    }
12485
12486    /// A branch handed to a later run is that run's (and its pull request's):
12487    /// folding the run that released it must not delete it.
12488    #[tokio::test]
12489    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12490        let tmp = tempfile::tempdir().expect("tempdir");
12491        let repo = tmp.path().join("repo");
12492        std::fs::create_dir_all(&repo).unwrap();
12493        init_repo(&repo);
12494        let home = tmp.path().join("home");
12495
12496        let mut config = Config::default();
12497        config.graph.worktree_root = Some(tmp.path().join("wt"));
12498        let mut state = RunState::new(
12499            repo.clone(),
12500            "main".to_owned(),
12501            "deadbeef".to_owned(),
12502            "task".to_owned(),
12503            config,
12504        );
12505        // The worktree is already gone (released); the branch survives.
12506        git::git(&repo, &["branch", "magi/x/A", "main"])
12507            .await
12508            .expect("branch");
12509        state.candidates = vec![Candidate {
12510            index: 0,
12511            label: 'A',
12512            agent: "alpha".to_owned(),
12513            branch: "magi/x/A".to_owned(),
12514            worktree: state.worktree_root().join("cand-A"),
12515            summary: String::new(),
12516            stat: String::new(),
12517            files: 0,
12518            commits: 0,
12519            empty: false,
12520            failed: None,
12521            verified_noop: None,
12522            duration_ms: 0,
12523            folded: true,
12524        }];
12525        state.released_to = Some("20260901-000000-new1".to_owned());
12526        state.released_branches = vec!["magi/x/A".to_owned()];
12527
12528        fold_run(&mut state, true, &home).await.expect("fold_run");
12529
12530        assert!(
12531            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12532            "the handed-over branch survives a fold"
12533        );
12534    }
12535
12536    /// A winner with nothing ahead of the base is caught before `gh` is ever
12537    /// asked for a pull request, and the message carries what the task's
12538    /// references resolved to.
12539    #[tokio::test]
12540    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12541        let tmp = tempfile::tempdir().expect("tempdir");
12542        let repo = tmp.path().join("repo");
12543        std::fs::create_dir_all(&repo).unwrap();
12544        init_repo(&repo);
12545        let run = |args: &[&str]| {
12546            let out = std::process::Command::new("git")
12547                .quiet()
12548                .args(args)
12549                .current_dir(&repo)
12550                .output()
12551                .expect("spawn git");
12552            assert!(out.status.success(), "git {args:?}");
12553        };
12554        run(&["branch", "magi/x/A"]);
12555        run(&["checkout", "-q", "-b", "magi/x/B"]);
12556        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12557        run(&["add", "-A"]);
12558        run(&["commit", "-q", "-m", "work"]);
12559        run(&["checkout", "-q", "main"]);
12560
12561        // A pull request is compared against the remote's base, so the
12562        // fixture needs one. Before it exists nothing can be read, and the
12563        // answer must be "not empty".
12564        let probe = RunState::new(
12565            repo.clone(),
12566            "main".to_owned(),
12567            "deadbeef".to_owned(),
12568            "task".to_owned(),
12569            Config::default(),
12570        );
12571        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12572        let bare = tmp.path().join("origin.git");
12573        let out = std::process::Command::new("git")
12574            .quiet()
12575            .args(["init", "-q", "--bare"])
12576            .arg(&bare)
12577            .output()
12578            .expect("spawn git");
12579        assert!(out.status.success(), "git init --bare");
12580        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12581        run(&["push", "-q", "origin", "main"]);
12582
12583        let mut state = RunState::new(
12584            repo.clone(),
12585            "main".to_owned(),
12586            "deadbeef".to_owned(),
12587            "task".to_owned(),
12588            Config::default(),
12589        );
12590        state.seeds = vec![refs::Seed {
12591            token: "magi/27b2/A".to_owned(),
12592            kind: refs::SeedKind::Unresolved,
12593            sha: String::new(),
12594            branch: true,
12595            detail: "no branch or commit named magi/27b2/A".to_owned(),
12596        }];
12597
12598        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12599        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12600        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12601        let detail = empty_candidate_detail(&state, "main");
12602        assert!(detail.starts_with("empty candidate"), "{detail}");
12603        assert!(detail.contains("magi/27b2/A"), "{detail}");
12604    }
12605
12606    /// `status == Ready` used to be read as "this is the harmless
12607    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
12608    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
12609    /// run's PR was closed without merging — and reentering `merge` with
12610    /// `mode` still `Pr` does not know the difference, so it pushed and
12611    /// opened a second pull request. `mode == Local` reproduces the same
12612    /// blind spot without a network call: reentry must not attempt another
12613    /// git merge once this node has already recorded an outcome.
12614    #[tokio::test]
12615    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12616        let tmp = tempfile::tempdir().expect("tempdir");
12617        let repo = tmp.path().join("repo");
12618        std::fs::create_dir_all(&repo).unwrap();
12619        init_repo(&repo);
12620
12621        let mut config = Config::default();
12622        config.merge.mode = MergeMode::Local;
12623
12624        let mut state = RunState::new(
12625            repo.clone(),
12626            "main".to_owned(),
12627            "deadbeef".to_owned(),
12628            "task".to_owned(),
12629            config,
12630        );
12631        state.candidates = vec![Candidate {
12632            index: 0,
12633            label: 'A',
12634            agent: "alpha".to_owned(),
12635            branch: "does-not-exist".to_owned(),
12636            worktree: repo.clone(),
12637            summary: String::new(),
12638            stat: String::new(),
12639            files: 0,
12640            commits: 0,
12641            empty: false,
12642            failed: None,
12643            verified_noop: None,
12644            duration_ms: 0,
12645            folded: false,
12646        }];
12647        state.tally = Some(Tally {
12648            first_choice: BTreeMap::from([('A', 1)]),
12649            borda: BTreeMap::new(),
12650            winner: 'A',
12651            rankings: 1,
12652            unanimous_initial: true,
12653            deliberated: false,
12654            changed_votes: 0,
12655            unanimous_final: true,
12656            tie_break: None,
12657            judges: 0,
12658            present: 0,
12659            quorum: 0,
12660            met_quorum: true,
12661            uncontested: Some("only candidate A produced a change".to_owned()),
12662        });
12663        state.reviews = vec![ReviewRound {
12664            round: 1,
12665            head: "deadbeef".to_owned(),
12666            verified_head: None,
12667            verified_at: None,
12668            reviews: Vec::new(),
12669            e2e: Vec::new(),
12670            fix: None,
12671            blocking: 0,
12672            answered: 0,
12673            expected: 0,
12674            clean: true,
12675            verify_retried: false,
12676            e2e_deferred: false,
12677            e2e_defer_reason: None,
12678            progressed: false,
12679            vote_split: false,
12680            reconsideration: Vec::new(),
12681            verdict: None,
12682        }];
12683        state.gate = vec![CommandOutcome {
12684            command: "test".to_owned(),
12685            code: Some(0),
12686            output_tail: String::new(),
12687            duration_ms: 0,
12688            resource_blocked: false,
12689        }];
12690        state.gate_ran = true;
12691        // Reached its conclusion already — e.g. `land` closing the PR without
12692        // merging it, which (like the honest `MergeMode::None` path) leaves
12693        // `status` at `Ready`. The recorded outcome is what actually marks
12694        // this node done.
12695        state.status = RunStatus::Ready;
12696        state.merge = Some(MergeOutcome {
12697            mode: MergeMode::Local,
12698            ok: false,
12699            detail: "already concluded".to_owned(),
12700            empty: false,
12701        });
12702
12703        let mut runner = Runner {
12704            state,
12705            roles: ResolvedRoles {
12706                implementers: Vec::new(),
12707                judges: Vec::new(),
12708                reviewers: Vec::new(),
12709                fixer: None,
12710                conductor: conductor(),
12711                implementer_roster: Vec::new(),
12712                judge_roster: Vec::new(),
12713                reviewer_roster: Vec::new(),
12714            },
12715            sem: Arc::new(Semaphore::new(1)),
12716            pause: Pause::new(),
12717            interrupt: Pause::new(),
12718        };
12719
12720        runner.merge().await.expect("merge");
12721
12722        assert_eq!(
12723            runner.state.status,
12724            RunStatus::Ready,
12725            "a concluded run's status must not change on reentry"
12726        );
12727        assert_eq!(
12728            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12729            Some("already concluded"),
12730            "merge must not run again once the node already recorded an outcome"
12731        );
12732    }
12733
12734    /// `gate` leaves `state.gate_ran` false both before it has ever run and
12735    /// when its last attempt was resource-blocked (the shared build cache
12736    /// could not be acquired or confirmed fresh in time - see
12737    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
12738    /// `Vec` this also leaves behind used to read as "nothing failed" and let
12739    /// a run merge a tree the gate never actually checked - exactly the case
12740    /// a contended cache produces on every retry until it clears. `merge`
12741    /// must refuse until `gate` has actually recorded an attempt.
12742    #[tokio::test]
12743    async fn merge_refuses_a_gate_that_has_not_actually_run() {
12744        let tmp = tempfile::tempdir().expect("tempdir");
12745        let repo = tmp.path().join("repo");
12746        std::fs::create_dir_all(&repo).unwrap();
12747        init_repo(&repo);
12748
12749        let mut config = Config::default();
12750        config.merge.mode = MergeMode::Local;
12751
12752        let mut state = RunState::new(
12753            repo.clone(),
12754            "main".to_owned(),
12755            "deadbeef".to_owned(),
12756            "task".to_owned(),
12757            config,
12758        );
12759        state.candidates = vec![Candidate {
12760            index: 0,
12761            label: 'A',
12762            agent: "alpha".to_owned(),
12763            branch: "does-not-exist".to_owned(),
12764            worktree: repo.clone(),
12765            summary: String::new(),
12766            stat: String::new(),
12767            files: 0,
12768            commits: 0,
12769            empty: false,
12770            failed: None,
12771            verified_noop: None,
12772            duration_ms: 0,
12773            folded: false,
12774        }];
12775        state.tally = Some(Tally {
12776            first_choice: BTreeMap::from([('A', 1)]),
12777            borda: BTreeMap::new(),
12778            winner: 'A',
12779            rankings: 1,
12780            unanimous_initial: true,
12781            deliberated: false,
12782            changed_votes: 0,
12783            unanimous_final: true,
12784            tie_break: None,
12785            judges: 0,
12786            present: 0,
12787            quorum: 0,
12788            met_quorum: true,
12789            uncontested: Some("only candidate A produced a change".to_owned()),
12790        });
12791        state.reviews = vec![ReviewRound {
12792            round: 1,
12793            head: "deadbeef".to_owned(),
12794            verified_head: None,
12795            verified_at: None,
12796            reviews: Vec::new(),
12797            e2e: Vec::new(),
12798            fix: None,
12799            blocking: 0,
12800            answered: 0,
12801            expected: 0,
12802            clean: true,
12803            verify_retried: false,
12804            e2e_deferred: false,
12805            e2e_defer_reason: None,
12806            progressed: false,
12807            vote_split: false,
12808            reconsideration: Vec::new(),
12809            verdict: None,
12810        }];
12811        // The point: `gate` has not recorded anything yet.
12812        state.gate = Vec::new();
12813        state.gate_ran = false;
12814        state.status = RunStatus::Gating;
12815
12816        let mut runner = Runner {
12817            state,
12818            roles: ResolvedRoles {
12819                implementers: Vec::new(),
12820                judges: Vec::new(),
12821                reviewers: Vec::new(),
12822                fixer: None,
12823                conductor: conductor(),
12824                implementer_roster: Vec::new(),
12825                judge_roster: Vec::new(),
12826                reviewer_roster: Vec::new(),
12827            },
12828            sem: Arc::new(Semaphore::new(1)),
12829            pause: Pause::new(),
12830            interrupt: Pause::new(),
12831        };
12832
12833        runner.merge().await.expect("merge");
12834
12835        assert!(
12836            runner.state.merge.is_none(),
12837            "an empty gate must never be read as a passing one: {:?}",
12838            runner.state.merge
12839        );
12840    }
12841
12842    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
12843    /// commands configured and `merge.mode` is `none` (a review-only run).
12844    /// `gate` must still record a real attempt — zero commands, vacuously
12845    /// passed — rather than leaving `state.gate` empty in a way `merge`
12846    /// cannot tell apart from "never ran"; otherwise the run reaches
12847    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
12848    #[tokio::test]
12849    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12850        let tmp = tempfile::tempdir().expect("tempdir");
12851        let repo = tmp.path().join("repo");
12852        std::fs::create_dir_all(&repo).unwrap();
12853        init_repo(&repo);
12854
12855        // Default config: `verify.gate` empty, `merge.mode` is `none`.
12856        let config = Config::default();
12857
12858        let mut state = RunState::new(
12859            repo.clone(),
12860            "main".to_owned(),
12861            "deadbeef".to_owned(),
12862            "task".to_owned(),
12863            config,
12864        );
12865        state.candidates = vec![Candidate {
12866            index: 0,
12867            label: 'A',
12868            agent: "alpha".to_owned(),
12869            branch: "does-not-exist".to_owned(),
12870            worktree: repo.clone(),
12871            summary: String::new(),
12872            stat: String::new(),
12873            files: 0,
12874            commits: 0,
12875            empty: false,
12876            failed: None,
12877            verified_noop: None,
12878            duration_ms: 0,
12879            folded: false,
12880        }];
12881        state.tally = Some(Tally {
12882            first_choice: BTreeMap::from([('A', 1)]),
12883            borda: BTreeMap::new(),
12884            winner: 'A',
12885            rankings: 1,
12886            unanimous_initial: true,
12887            deliberated: false,
12888            changed_votes: 0,
12889            unanimous_final: true,
12890            tie_break: None,
12891            judges: 0,
12892            present: 0,
12893            quorum: 0,
12894            met_quorum: true,
12895            uncontested: Some("only candidate A produced a change".to_owned()),
12896        });
12897        state.reviews = vec![ReviewRound {
12898            round: 1,
12899            head: "deadbeef".to_owned(),
12900            verified_head: None,
12901            verified_at: None,
12902            reviews: Vec::new(),
12903            e2e: Vec::new(),
12904            fix: None,
12905            blocking: 0,
12906            answered: 0,
12907            expected: 0,
12908            clean: true,
12909            verify_retried: false,
12910            e2e_deferred: false,
12911            e2e_defer_reason: None,
12912            progressed: false,
12913            vote_split: false,
12914            reconsideration: Vec::new(),
12915            verdict: None,
12916        }];
12917
12918        let mut runner = Runner {
12919            state,
12920            roles: ResolvedRoles {
12921                implementers: Vec::new(),
12922                judges: Vec::new(),
12923                reviewers: Vec::new(),
12924                fixer: None,
12925                conductor: conductor(),
12926                implementer_roster: Vec::new(),
12927                judge_roster: Vec::new(),
12928                reviewer_roster: Vec::new(),
12929            },
12930            sem: Arc::new(Semaphore::new(1)),
12931            pause: Pause::new(),
12932            interrupt: Pause::new(),
12933        };
12934
12935        runner.gate().await.expect("gate");
12936        assert!(
12937            runner.state.gate_ran,
12938            "zero configured commands is still a real attempt, not an unrun gate"
12939        );
12940        assert!(runner.state.gate.is_empty());
12941        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12942        assert_ne!(
12943            runner.state.status,
12944            RunStatus::Blocked,
12945            "a gate with nothing to check must not read as failed"
12946        );
12947
12948        runner.merge().await.expect("merge");
12949        assert_eq!(
12950            runner.state.status,
12951            RunStatus::Ready,
12952            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12953        );
12954    }
12955
12956    /// `Config::cache_dir` is derived from `verify.e2e` as well as
12957    /// `verify.gate` (so the e2e leg and the final gate never build against
12958    /// different directories). With zero `verify.gate` commands but a
12959    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
12960    /// that lease before discovering it had nothing to run - so a repo with
12961    /// no gate commands could come back `resource_blocked` (and therefore
12962    /// still `gate_ran == false`) on nothing but an unrelated run holding the
12963    /// cache, exactly the contention this run's own zero commands could
12964    /// never have touched. `gate` must recognise there is nothing to check
12965    /// before it ever asks for the lease.
12966    #[tokio::test]
12967    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12968        crate::run::pin_test_home();
12969        let home = crate::run::home();
12970
12971        let tmp = tempfile::tempdir().expect("tempdir");
12972        let repo = tmp.path().join("repo");
12973        std::fs::create_dir_all(&repo).unwrap();
12974        init_repo(&repo);
12975        // Unique to this test, so holding its lease cannot collide with
12976        // another test sharing the same process-wide `home`.
12977        let cache_dir = tmp.path().join("target");
12978
12979        let mut config = Config::default();
12980        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12981        // `verify.gate` stays empty (the default). Bounded so a regression
12982        // that does start waiting fails the test in seconds, not hangs it.
12983        config.graph.timeout_verify = Some(2);
12984
12985        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12986        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12987            .expect("no io error acquiring directly")
12988        {
12989            crate::cache::AcquireOutcome::Acquired(g) => g,
12990            crate::cache::AcquireOutcome::Busy(b) => {
12991                panic!("expected the direct acquire to win the lease first: {b:?}")
12992            }
12993        };
12994
12995        let mut state = RunState::new(
12996            repo.clone(),
12997            "main".to_owned(),
12998            "deadbeef".to_owned(),
12999            "task".to_owned(),
13000            config,
13001        );
13002        state.candidates = vec![Candidate {
13003            index: 0,
13004            label: 'A',
13005            agent: "alpha".to_owned(),
13006            branch: "does-not-exist".to_owned(),
13007            worktree: repo.clone(),
13008            summary: String::new(),
13009            stat: String::new(),
13010            files: 0,
13011            commits: 0,
13012            empty: false,
13013            failed: None,
13014            verified_noop: None,
13015            duration_ms: 0,
13016            folded: false,
13017        }];
13018        state.tally = Some(Tally {
13019            first_choice: BTreeMap::from([('A', 1)]),
13020            borda: BTreeMap::new(),
13021            winner: 'A',
13022            rankings: 1,
13023            unanimous_initial: true,
13024            deliberated: false,
13025            changed_votes: 0,
13026            unanimous_final: true,
13027            tie_break: None,
13028            judges: 0,
13029            present: 0,
13030            quorum: 0,
13031            met_quorum: true,
13032            uncontested: Some("only candidate A produced a change".to_owned()),
13033        });
13034        state.reviews = vec![ReviewRound {
13035            round: 1,
13036            head: "deadbeef".to_owned(),
13037            verified_head: None,
13038            verified_at: None,
13039            reviews: Vec::new(),
13040            e2e: Vec::new(),
13041            fix: None,
13042            blocking: 0,
13043            answered: 0,
13044            expected: 0,
13045            clean: true,
13046            verify_retried: false,
13047            e2e_deferred: false,
13048            e2e_defer_reason: None,
13049            progressed: false,
13050            vote_split: false,
13051            reconsideration: Vec::new(),
13052            verdict: None,
13053        }];
13054
13055        let mut runner = Runner {
13056            state,
13057            roles: ResolvedRoles {
13058                implementers: Vec::new(),
13059                judges: Vec::new(),
13060                reviewers: Vec::new(),
13061                fixer: None,
13062                conductor: conductor(),
13063                implementer_roster: Vec::new(),
13064                judge_roster: Vec::new(),
13065                reviewer_roster: Vec::new(),
13066            },
13067            sem: Arc::new(Semaphore::new(1)),
13068            pause: Pause::new(),
13069            interrupt: Pause::new(),
13070        };
13071
13072        let started = std::time::Instant::now();
13073        runner.gate().await.expect("gate");
13074        assert!(
13075            started.elapsed() < Duration::from_secs(1),
13076            "a gate with nothing to run must never wait on a lease it never needed"
13077        );
13078        assert!(
13079            runner.state.gate_ran,
13080            "zero commands is still a real, immediate attempt"
13081        );
13082        assert!(runner.state.gate.is_empty());
13083        assert_ne!(
13084            runner.state.status,
13085            RunStatus::Blocked,
13086            "must not read as resource-blocked on a lease it never asked for"
13087        );
13088    }
13089
13090    /// The addendum's second gap: a `verify.gate` command running for real
13091    /// wall-clock time had nothing at all to show for it in `active` before
13092    /// `run_commands` learned to record it — a run could sit in `Gating` for
13093    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
13094    /// what was actually happening. Proven with a genuinely still-running
13095    /// command, not just a before/after check on the final state: a poller
13096    /// task reads the same `run.json` `gate()` is writing, the same way the
13097    /// phone or `magi show` would, while the shell command is still blocked
13098    /// on its own release marker.
13099    #[tokio::test]
13100    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
13101        crate::run::pin_test_home();
13102
13103        let tmp = tempfile::tempdir().expect("tempdir");
13104        let repo = tmp.path().join("repo");
13105        std::fs::create_dir_all(&repo).unwrap();
13106        init_repo(&repo);
13107
13108        let mut config = Config::default();
13109        config.verify.gate = vec![
13110            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
13111             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
13112                .to_owned(),
13113        ];
13114
13115        let mut state = RunState::new(
13116            repo.clone(),
13117            "main".to_owned(),
13118            "deadbeef".to_owned(),
13119            "task".to_owned(),
13120            config,
13121        );
13122        let run_id = state.id.clone();
13123        state.candidates = vec![Candidate {
13124            index: 0,
13125            label: 'A',
13126            agent: "alpha".to_owned(),
13127            branch: "does-not-exist".to_owned(),
13128            worktree: repo.clone(),
13129            summary: String::new(),
13130            stat: String::new(),
13131            files: 0,
13132            commits: 0,
13133            empty: false,
13134            failed: None,
13135            verified_noop: None,
13136            duration_ms: 0,
13137            folded: false,
13138        }];
13139        state.tally = Some(Tally {
13140            first_choice: BTreeMap::from([('A', 1)]),
13141            borda: BTreeMap::new(),
13142            winner: 'A',
13143            rankings: 1,
13144            unanimous_initial: true,
13145            deliberated: false,
13146            changed_votes: 0,
13147            unanimous_final: true,
13148            tie_break: None,
13149            judges: 0,
13150            present: 0,
13151            quorum: 0,
13152            met_quorum: true,
13153            uncontested: Some("only candidate A produced a change".to_owned()),
13154        });
13155        state.reviews = vec![ReviewRound {
13156            round: 1,
13157            head: "deadbeef".to_owned(),
13158            verified_head: None,
13159            verified_at: None,
13160            reviews: Vec::new(),
13161            e2e: Vec::new(),
13162            fix: None,
13163            blocking: 0,
13164            answered: 0,
13165            expected: 0,
13166            clean: true,
13167            verify_retried: false,
13168            e2e_deferred: false,
13169            e2e_defer_reason: None,
13170            progressed: false,
13171            vote_split: false,
13172            reconsideration: Vec::new(),
13173            verdict: None,
13174        }];
13175
13176        let mut runner = Runner {
13177            state,
13178            roles: ResolvedRoles {
13179                implementers: Vec::new(),
13180                judges: Vec::new(),
13181                reviewers: Vec::new(),
13182                fixer: None,
13183                conductor: conductor(),
13184                implementer_roster: Vec::new(),
13185                judge_roster: Vec::new(),
13186                reviewer_roster: Vec::new(),
13187            },
13188            sem: Arc::new(Semaphore::new(1)),
13189            pause: Pause::new(),
13190            interrupt: Pause::new(),
13191        };
13192
13193        let started_marker = repo.join("started.marker");
13194        let release_marker = repo.join("release.marker");
13195        let poller = tokio::spawn(async move {
13196            // Bounded so a regression that never records the task entry
13197            // fails this test in seconds instead of hanging the suite —
13198            // the same shape `a_park_requested_while_a_seat_is_mid_call_
13199            // does_not_cut_it_short` uses for the same reason.
13200            for _ in 0..100 {
13201                if started_marker.exists()
13202                    && let Ok(s) = crate::run::RunState::load(&run_id)
13203                    && let Some(a) = s.active.get("gate")
13204                {
13205                    std::fs::write(&release_marker, b"go").expect("release marker");
13206                    return Some(a.clone());
13207                }
13208                tokio::time::sleep(Duration::from_millis(50)).await;
13209            }
13210            None
13211        });
13212
13213        runner.gate().await.expect("gate");
13214        let captured = poller.await.expect("poller task");
13215        let captured = captured.expect(
13216            "the poller never saw a `gate` task entry in run.json while the command was \
13217             still blocked on its own release marker",
13218        );
13219
13220        assert_eq!(captured.task.as_deref(), Some("gate"));
13221        assert_eq!(captured.node, "gate");
13222        assert_eq!(captured.index, Some(1));
13223        assert_eq!(captured.total, Some(1));
13224        assert!(
13225            captured
13226                .command
13227                .as_deref()
13228                .is_some_and(|c| c.contains("started.marker")),
13229            "{captured:?}"
13230        );
13231
13232        assert!(
13233            runner.state.active.is_empty(),
13234            "the entry must be cleared once the command actually finished: {:?}",
13235            runner.state.active
13236        );
13237        assert!(runner.state.gate_ran);
13238        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
13239    }
13240
13241    /// The hand-off over a blocking finding a reviewer rejected on leaves a
13242    /// record for `land`; one with only a Minor, or no reject, leaves none.
13243    #[tokio::test]
13244    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
13245        use crate::verdict::{Finding, ReviewVote, Severity};
13246        crate::run::pin_test_home();
13247        let tmp = tempfile::tempdir().expect("tempdir");
13248        let repo = tmp.path().join("repo");
13249        std::fs::create_dir_all(&repo).unwrap();
13250        init_repo(&repo);
13251
13252        for (severity, vote, expect) in [
13253            (Severity::Major, ReviewVote::Reject, true),
13254            (Severity::Minor, ReviewVote::Reject, false),
13255            (Severity::Major, ReviewVote::Approve, false),
13256        ] {
13257            let mut round = review_round(false, 1, 1, 1, false, true);
13258            round.reviews = vec![ReviewRecord {
13259                reviewer: 1,
13260                agent: "alpha".to_owned(),
13261                summary: String::new(),
13262                findings: vec![Finding {
13263                    id: "R1-1-1".to_owned(),
13264                    severity,
13265                    file: None,
13266                    line: None,
13267                    title: "t".to_owned(),
13268                    detail: String::new(),
13269                }],
13270                vote: Some(vote),
13271                failed: None,
13272                duration_ms: 0,
13273                attempts: 0,
13274            }];
13275            let mut state = RunState::new(
13276                repo.clone(),
13277                "main".to_owned(),
13278                "deadbeef".to_owned(),
13279                "task".to_owned(),
13280                Config::default(),
13281            );
13282            state.reviews = vec![round];
13283            let mut runner = Runner {
13284                state,
13285                roles: ResolvedRoles {
13286                    implementers: Vec::new(),
13287                    judges: Vec::new(),
13288                    reviewers: Vec::new(),
13289                    fixer: None,
13290                    conductor: conductor(),
13291                    implementer_roster: Vec::new(),
13292                    judge_roster: Vec::new(),
13293                    reviewer_roster: Vec::new(),
13294                },
13295                sem: Arc::new(Semaphore::new(1)),
13296                pause: Pause::new(),
13297                interrupt: Pause::new(),
13298            };
13299            let shell = runner.state.config.shell();
13300            runner
13301                .stop_reviewing("round budget spent", &shell, &repo)
13302                .await
13303                .expect("stop_reviewing");
13304            assert_eq!(runner.state.status, RunStatus::Gating);
13305            assert_eq!(
13306                runner.state.contested_handoff.is_some(),
13307                expect,
13308                "{severity:?} + {vote:?}"
13309            );
13310        }
13311    }
13312
13313    /// The shape the incident this whole fix responds to actually had: the
13314    /// round budget spent, the last round's own e2e blocked on the shared
13315    /// build cache (held here by a live pid — this test process — exactly
13316    /// `cache`'s own unit tests' pattern for "another owner, still alive"
13317    /// without forking a process). `stop_reviewing` must retry it — not
13318    /// silently leave the round looking untouched (the catch-up-only half of
13319    /// the bug), and not read the contention as a red `e2e` and block the
13320    /// run on it (the other half). Called directly, the same way
13321    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
13322    /// above exercises `gate`, so this never needs a real cargo build to
13323    /// reach: the lease is never released, so `with_cache_lease` never gets
13324    /// past acquiring it into anything that would need a real workspace.
13325    #[tokio::test]
13326    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
13327        crate::run::pin_test_home();
13328        let home = crate::run::home();
13329
13330        let tmp = tempfile::tempdir().expect("tempdir");
13331        let repo = tmp.path().join("repo");
13332        std::fs::create_dir_all(&repo).unwrap();
13333        init_repo(&repo);
13334        let head = crate::git::rev_parse(&repo, "HEAD")
13335            .await
13336            .expect("rev-parse");
13337        // Unique to this test, so holding its lease cannot collide with
13338        // another test sharing the same process-wide `home`.
13339        let cache_dir = tmp.path().join("target");
13340
13341        let mut config = Config::default();
13342        config.verify.e2e = vec![format!(
13343            "CARGO_TARGET_DIR='{}' test -f README.md",
13344            cache_dir.display()
13345        )];
13346        config.graph.review_rounds = 1;
13347        // Bounded so a regression that does start waiting fails the test in
13348        // seconds, not hangs it.
13349        config.graph.timeout_verify = Some(2);
13350
13351        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13352        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13353            .expect("no io error acquiring directly")
13354        {
13355            crate::cache::AcquireOutcome::Acquired(g) => g,
13356            crate::cache::AcquireOutcome::Busy(b) => {
13357                panic!("expected the direct acquire to win the lease first: {b:?}")
13358            }
13359        };
13360
13361        let mut state = RunState::new(
13362            repo.clone(),
13363            "main".to_owned(),
13364            head.clone(),
13365            "task".to_owned(),
13366            config,
13367        );
13368        state.candidates = vec![Candidate {
13369            index: 0,
13370            label: 'A',
13371            agent: "alpha".to_owned(),
13372            branch: "does-not-exist".to_owned(),
13373            worktree: repo.clone(),
13374            summary: String::new(),
13375            stat: String::new(),
13376            files: 0,
13377            commits: 0,
13378            empty: false,
13379            failed: None,
13380            verified_noop: None,
13381            duration_ms: 0,
13382            folded: false,
13383        }];
13384        state.tally = Some(Tally {
13385            first_choice: BTreeMap::from([('A', 1)]),
13386            borda: BTreeMap::new(),
13387            winner: 'A',
13388            rankings: 1,
13389            unanimous_initial: true,
13390            deliberated: false,
13391            changed_votes: 0,
13392            unanimous_final: true,
13393            tie_break: None,
13394            judges: 0,
13395            present: 0,
13396            quorum: 0,
13397            met_quorum: true,
13398            uncontested: Some("only candidate A produced a change".to_owned()),
13399        });
13400        // The round budget's last round, deferred: `needs_catchup_run`'s
13401        // other trigger. `stop_reviewing`'s retry machinery must treat this
13402        // exactly like a resource-blocked attempt once it actually runs.
13403        state.reviews = vec![ReviewRound {
13404            round: 1,
13405            head: head.clone(),
13406            verified_head: None,
13407            verified_at: None,
13408            reviews: Vec::new(),
13409            e2e: Vec::new(),
13410            fix: None,
13411            blocking: 1,
13412            answered: 1,
13413            expected: 1,
13414            clean: false,
13415            verify_retried: false,
13416            e2e_deferred: true,
13417            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13418            progressed: false,
13419            vote_split: false,
13420            reconsideration: Vec::new(),
13421            verdict: None,
13422        }];
13423
13424        let mut runner = Runner {
13425            state,
13426            roles: ResolvedRoles {
13427                implementers: Vec::new(),
13428                judges: Vec::new(),
13429                reviewers: Vec::new(),
13430                fixer: None,
13431                conductor: conductor(),
13432                implementer_roster: Vec::new(),
13433                judge_roster: Vec::new(),
13434                reviewer_roster: Vec::new(),
13435            },
13436            sem: Arc::new(Semaphore::new(1)),
13437            pause: Pause::new(),
13438            interrupt: Pause::new(),
13439        };
13440
13441        let shell = runner.state.config.shell();
13442        runner
13443            .stop_reviewing("round budget spent", &shell, &repo)
13444            .await
13445            .expect("stop_reviewing");
13446
13447        let last = runner.state.reviews.last().expect("round record");
13448        assert_eq!(
13449            last.e2e_status(),
13450            E2eStatus::ResourceBlocked,
13451            "the shared cache is still held; the attempt must read as blocked, not deferred or \
13452             failed: {last:?}"
13453        );
13454        assert_eq!(
13455            last.verified_head.as_deref(),
13456            Some(head.as_str()),
13457            "which commit this attempt targeted is known even though nothing finished checking \
13458             it"
13459        );
13460        let first_attempt_at = last
13461            .verified_at
13462            .expect("when this attempt ran is known too");
13463        assert_ne!(
13464            runner.state.status,
13465            RunStatus::Blocked,
13466            "contention is evidence about the machine, not the patch — it must not settle the \
13467             run as blocked: {:?}",
13468            runner.state.status
13469        );
13470        assert!(
13471            !runner
13472                .state
13473                .events
13474                .iter()
13475                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13476            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13477            runner.state.events
13478        );
13479
13480        // The cache is still held: a later reentry must retry the same
13481        // round's verification again — not leave it looking exactly as
13482        // untouched as the first blocked attempt, which is indistinguishable
13483        // from never having tried again at all.
13484        runner
13485            .stop_reviewing("round budget spent", &shell, &repo)
13486            .await
13487            .expect("stop_reviewing retry");
13488        assert_eq!(
13489            runner.state.reviews.len(),
13490            1,
13491            "no new round was started: {:?}",
13492            runner.state.reviews
13493        );
13494        let last = runner.state.reviews.last().expect("round record");
13495        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13496        assert!(
13497            last.verified_at.expect("still known") > first_attempt_at,
13498            "a second reentry must be a fresh attempt, not a stale copy of the first"
13499        );
13500        assert_ne!(runner.state.status, RunStatus::Blocked);
13501
13502        held.release();
13503    }
13504
13505    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
13506    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
13507    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
13508    /// own top-of-function fast path (`review_conclusion`) correctly reads
13509    /// this shape as `None` rather than guessing `Blocked`, and the loop's
13510    /// own `for` range is empty once the round budget is spent, so
13511    /// `review_loop` must retry the check itself rather than silently doing
13512    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
13513    /// above exercises directly, but through `review_loop`'s own entry point
13514    /// this time, proving the wiring between the two rather than just the
13515    /// retry logic in isolation.
13516    #[tokio::test]
13517    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13518        crate::run::pin_test_home();
13519        let home = crate::run::home();
13520
13521        let tmp = tempfile::tempdir().expect("tempdir");
13522        let repo = tmp.path().join("repo");
13523        std::fs::create_dir_all(&repo).unwrap();
13524        init_repo(&repo);
13525        let head = crate::git::rev_parse(&repo, "HEAD")
13526            .await
13527            .expect("rev-parse");
13528        let cache_dir = tmp.path().join("target");
13529
13530        let mut config = Config::default();
13531        config.verify.e2e = vec![format!(
13532            "CARGO_TARGET_DIR='{}' test -f README.md",
13533            cache_dir.display()
13534        )];
13535        config.graph.review_rounds = 1;
13536        config.graph.timeout_verify = Some(2);
13537
13538        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13539        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13540            .expect("no io error acquiring directly")
13541        {
13542            crate::cache::AcquireOutcome::Acquired(g) => g,
13543            crate::cache::AcquireOutcome::Busy(b) => {
13544                panic!("expected the direct acquire to win the lease first: {b:?}")
13545            }
13546        };
13547
13548        let mut state = RunState::new(
13549            repo.clone(),
13550            "main".to_owned(),
13551            head.clone(),
13552            "task".to_owned(),
13553            config,
13554        );
13555        state.candidates = vec![Candidate {
13556            index: 0,
13557            label: 'A',
13558            agent: "alpha".to_owned(),
13559            branch: "does-not-exist".to_owned(),
13560            worktree: repo.clone(),
13561            summary: String::new(),
13562            stat: String::new(),
13563            files: 0,
13564            commits: 0,
13565            empty: false,
13566            failed: None,
13567            verified_noop: None,
13568            duration_ms: 0,
13569            folded: false,
13570        }];
13571        state.tally = Some(Tally {
13572            first_choice: BTreeMap::from([('A', 1)]),
13573            borda: BTreeMap::new(),
13574            winner: 'A',
13575            rankings: 1,
13576            unanimous_initial: true,
13577            deliberated: false,
13578            changed_votes: 0,
13579            unanimous_final: true,
13580            tie_break: None,
13581            judges: 0,
13582            present: 0,
13583            quorum: 0,
13584            met_quorum: true,
13585            uncontested: Some("only candidate A produced a change".to_owned()),
13586        });
13587        // The exact shape a prior process's `stop_reviewing` would have left
13588        // on disk: the round budget's last round, a real attempt already
13589        // made and already resource-blocked.
13590        state.reviews = vec![ReviewRound {
13591            round: 1,
13592            head: head.clone(),
13593            verified_head: Some(head.clone()),
13594            verified_at: Some(jiff::Timestamp::now()),
13595            reviews: Vec::new(),
13596            e2e: vec![CommandOutcome {
13597                command: format!(
13598                    "CARGO_TARGET_DIR='{}' test -f README.md",
13599                    cache_dir.display()
13600                ),
13601                code: None,
13602                output_tail: "waiting for the shared build cache".to_owned(),
13603                duration_ms: 0,
13604                resource_blocked: true,
13605            }],
13606            fix: None,
13607            blocking: 1,
13608            answered: 1,
13609            expected: 1,
13610            clean: false,
13611            verify_retried: false,
13612            e2e_deferred: false,
13613            e2e_defer_reason: None,
13614            progressed: false,
13615            vote_split: false,
13616            reconsideration: Vec::new(),
13617            verdict: None,
13618        }];
13619
13620        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13621        let mut runner = Runner {
13622            state,
13623            roles: ResolvedRoles {
13624                implementers: Vec::new(),
13625                judges: Vec::new(),
13626                reviewers: Vec::new(),
13627                fixer: None,
13628                conductor: conductor(),
13629                implementer_roster: Vec::new(),
13630                judge_roster: Vec::new(),
13631                reviewer_roster: Vec::new(),
13632            },
13633            sem: Arc::new(Semaphore::new(1)),
13634            pause: Pause::new(),
13635            interrupt: Pause::new(),
13636        };
13637
13638        // The lease is still held throughout, so this reentry's own retry is
13639        // also contended — proving `review_loop` actually tried again (not
13640        // that it happened to succeed) is what the timestamp comparison
13641        // below is for.
13642        runner.review_loop().await.expect("review_loop");
13643
13644        assert_eq!(
13645            runner.state.reviews.len(),
13646            1,
13647            "no new round was started on top of the unresolved one: {:?}",
13648            runner.state.reviews
13649        );
13650        let last = &runner.state.reviews[0];
13651        assert_eq!(
13652            last.e2e_status(),
13653            E2eStatus::ResourceBlocked,
13654            "still contended: {last:?}"
13655        );
13656        assert!(
13657            last.verified_at.expect("still known") > first_attempt_at,
13658            "review_loop must have actually retried the check, not left it exactly as found"
13659        );
13660        assert_ne!(
13661            runner.state.status,
13662            RunStatus::Blocked,
13663            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13664            runner.state.status
13665        );
13666
13667        held.release();
13668    }
13669
13670    #[tokio::test]
13671    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13672        crate::run::pin_test_home();
13673        let tmp = tempfile::tempdir().expect("tempdir");
13674        let repo = tmp.path().join("repo");
13675        std::fs::create_dir_all(&repo).unwrap();
13676        init_repo(&repo);
13677
13678        let mut config = Config::default();
13679        config.merge.mode = MergeMode::Pr;
13680        config.graph.land = true;
13681        config.graph.land_approval = false;
13682
13683        let mut state = RunState::new(
13684            repo.clone(),
13685            "main".to_owned(),
13686            "deadbeef".to_owned(),
13687            "task".to_owned(),
13688            config,
13689        );
13690        state.candidates = vec![Candidate {
13691            index: 0,
13692            label: 'A',
13693            agent: "alpha".to_owned(),
13694            branch: "does-not-exist".to_owned(),
13695            worktree: repo.clone(),
13696            summary: String::new(),
13697            stat: String::new(),
13698            files: 0,
13699            commits: 0,
13700            empty: false,
13701            failed: None,
13702            verified_noop: None,
13703            duration_ms: 0,
13704            folded: false,
13705        }];
13706        state.tally = Some(Tally {
13707            first_choice: BTreeMap::from([('A', 1)]),
13708            borda: BTreeMap::new(),
13709            winner: 'A',
13710            rankings: 1,
13711            unanimous_initial: true,
13712            deliberated: false,
13713            changed_votes: 0,
13714            unanimous_final: true,
13715            tie_break: None,
13716            judges: 0,
13717            present: 0,
13718            quorum: 0,
13719            met_quorum: true,
13720            uncontested: Some("only candidate A produced a change".to_owned()),
13721        });
13722        state.reviews = vec![ReviewRound {
13723            round: 1,
13724            head: "deadbeef".to_owned(),
13725            verified_head: None,
13726            verified_at: None,
13727            reviews: Vec::new(),
13728            e2e: Vec::new(),
13729            fix: None,
13730            blocking: 0,
13731            answered: 0,
13732            expected: 0,
13733            clean: true,
13734            verify_retried: false,
13735            e2e_deferred: false,
13736            e2e_defer_reason: None,
13737            progressed: false,
13738            vote_split: false,
13739            reconsideration: Vec::new(),
13740            verdict: None,
13741        }];
13742        state.gate = vec![CommandOutcome {
13743            command: "test".to_owned(),
13744            code: Some(0),
13745            output_tail: String::new(),
13746            duration_ms: 0,
13747            resource_blocked: false,
13748        }];
13749        state.gate_ran = true;
13750        // A first pass through `merge` already pushed and opened this pull
13751        // request; `status` is `Landing` because a previous call into `land`
13752        // parked or was interrupted before it reached a terminal outcome.
13753        state.status = RunStatus::Landing;
13754        state.merge = Some(MergeOutcome {
13755            mode: MergeMode::Pr,
13756            ok: true,
13757            detail: "https://example.invalid/x/y/pull/1".to_owned(),
13758            empty: false,
13759        });
13760
13761        // The Landing-resume shortcut calls `run_land` directly rather than
13762        // through `merge`, which is exactly the call site that used to skip
13763        // `settle_questions` - see the fixture below.
13764        ask_test_home();
13765        // A real resume loads a persisted run; driver ownership is checked
13766        // against that record before the Landing shortcut is entered.
13767        state.save().expect("save landing run");
13768        let store = ask::Questions::open();
13769        let q = ask_open_question(&store, &state.id);
13770
13771        let mut runner = Runner {
13772            state,
13773            roles: ResolvedRoles {
13774                implementers: Vec::new(),
13775                judges: Vec::new(),
13776                reviewers: Vec::new(),
13777                fixer: None,
13778                conductor: conductor(),
13779                implementer_roster: Vec::new(),
13780                judge_roster: Vec::new(),
13781                reviewer_roster: Vec::new(),
13782            },
13783            sem: Arc::new(Semaphore::new(1)),
13784            pause: Pause::new(),
13785            interrupt: Pause::new(),
13786        };
13787
13788        // `execute`, not `merge` directly: the Landing-resume shortcut lives
13789        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
13790        // exactly because `review_loop` would otherwise clobber the marker
13791        // first.
13792        runner.execute().await.expect("execute");
13793
13794        assert_eq!(
13795            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13796            Some("https://example.invalid/x/y/pull/1"),
13797            "reentry must not push again or open a second pull request over the \
13798             one `land` is already watching"
13799        );
13800        assert_ne!(
13801            runner.state.status,
13802            RunStatus::Landing,
13803            "land could not actually reach the fake pull request, so it must \
13804             have given up rather than left the run silently parked forever"
13805        );
13806        // `land` could not reach the fake pull request, so it gave up into
13807        // `Blocked` - still resumable, so the question must not have been
13808        // swept just because this branch now also calls `settle_questions`.
13809        assert_eq!(runner.state.status, RunStatus::Blocked);
13810        assert!(
13811            store.get(&q.id).unwrap().status.open(),
13812            "Blocked is still alive; settle_questions must have been a no-op here"
13813        );
13814    }
13815
13816    fn state_with_round(round: ReviewRound) -> RunState {
13817        let mut s = RunState::new(
13818            PathBuf::from("/repo"),
13819            "main".to_owned(),
13820            "abc1234".to_owned(),
13821            "add retries".to_owned(),
13822            Config::default(),
13823        );
13824        s.reviews = vec![round];
13825        s
13826    }
13827
13828    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13829        crate::verdict::Finding {
13830            id: id.to_owned(),
13831            severity,
13832            file: None,
13833            line: None,
13834            title: title.to_owned(),
13835            detail: String::new(),
13836        }
13837    }
13838
13839    #[test]
13840    fn pr_body_names_open_findings_and_declined_ones() {
13841        let round = ReviewRound {
13842            round: 2,
13843            head: "deadbee".to_owned(),
13844            verified_head: None,
13845            verified_at: None,
13846            reviews: vec![ReviewRecord {
13847                attempts: 0,
13848                reviewer: 1,
13849                agent: "alpha".to_owned(),
13850                summary: String::new(),
13851                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13852                vote: None,
13853                failed: None,
13854                duration_ms: 0,
13855            }],
13856            e2e: vec![CommandOutcome {
13857                command: "cargo test".to_owned(),
13858                code: Some(0),
13859                output_tail: String::new(),
13860                duration_ms: 0,
13861                resource_blocked: false,
13862            }],
13863            verify_retried: false,
13864            e2e_deferred: false,
13865            e2e_defer_reason: None,
13866            fix: Some(FixRecord {
13867                agent: "alpha".to_owned(),
13868                addressed: Vec::new(),
13869                rejected: vec![crate::verdict::Rejection {
13870                    id: "R1-1-1".to_owned(),
13871                    why: "not reachable from any caller".to_owned(),
13872                }],
13873                notes: String::new(),
13874                committed: true,
13875                failed: None,
13876                duration_ms: 0,
13877                continuation: None,
13878            }),
13879            blocking: 0,
13880            answered: 1,
13881            expected: 1,
13882            clean: false,
13883            progressed: true,
13884            vote_split: false,
13885            reconsideration: Vec::new(),
13886            verdict: None,
13887        };
13888        let state = state_with_round(round);
13889        let body = pr_message(&state, 'A').body;
13890
13891        assert!(body.contains("add retries"), "the task must still be there");
13892        assert!(body.contains("R2-1-1"), "{body}");
13893        assert!(!body.contains("unused import"), "{body}");
13894        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13895        assert!(
13896            !body.contains("not reachable from any caller"),
13897            "the reason it was declined: {body}"
13898        );
13899    }
13900
13901    #[test]
13902    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13903        let round = ReviewRound {
13904            round: 1,
13905            head: "deadbee".to_owned(),
13906            verified_head: None,
13907            verified_at: None,
13908            reviews: vec![ReviewRecord {
13909                attempts: 0,
13910                reviewer: 1,
13911                agent: "alpha".to_owned(),
13912                summary: String::new(),
13913                findings: Vec::new(),
13914                vote: None,
13915                failed: None,
13916                duration_ms: 0,
13917            }],
13918            e2e: Vec::new(),
13919            verify_retried: false,
13920            e2e_deferred: false,
13921            e2e_defer_reason: None,
13922            fix: None,
13923            blocking: 0,
13924            answered: 1,
13925            expected: 1,
13926            clean: true,
13927            progressed: false,
13928            vote_split: false,
13929            reconsideration: Vec::new(),
13930            verdict: None,
13931        };
13932        let state = state_with_round(round);
13933        let body = pr_message(&state, 'A').body;
13934        assert!(!body.contains("Open review findings"), "{body}");
13935        assert!(!body.contains("Declined"), "{body}");
13936    }
13937
13938    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13939        let mut state = RunState::new(
13940            PathBuf::from("/repo"),
13941            "main".to_owned(),
13942            "abc1234".to_owned(),
13943            instruction.to_owned(),
13944            Config::default(),
13945        );
13946        state.candidates.push(Candidate {
13947            index: 0,
13948            label: 'A',
13949            agent: "alpha".to_owned(),
13950            branch: "magi/x/A".to_owned(),
13951            worktree: PathBuf::from("/wt"),
13952            summary: summary.to_owned(),
13953            stat: String::new(),
13954            files: 1,
13955            commits: 1,
13956            empty: false,
13957            failed: None,
13958            verified_noop: None,
13959            folded: false,
13960            duration_ms: 0,
13961        });
13962        state
13963    }
13964
13965    fn review_state(subjects: &[&str]) -> RunState {
13966        let mut state = state_with_summary(
13967            "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",
13968            "",
13969        );
13970        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13971        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13972        state
13973    }
13974
13975    /// A branch rebased onto a main that moved past the recorded
13976    /// `base_commit` is titled from its own first commit, never main's.
13977    #[tokio::test]
13978    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13979        ask_test_home();
13980        let tmp = tempfile::tempdir().unwrap();
13981        let repo = tmp.path().join("repo");
13982        std::fs::create_dir_all(&repo).unwrap();
13983        init_repo(&repo);
13984        let origin = tmp.path().join("origin.git");
13985        let g = |dir: &Path, args: &[&str]| {
13986            let out = std::process::Command::new("git")
13987                .args(args)
13988                .current_dir(dir)
13989                .quiet()
13990                .output()
13991                .expect("spawn git");
13992            assert!(
13993                out.status.success(),
13994                "git {args:?}: {}",
13995                String::from_utf8_lossy(&out.stderr)
13996            );
13997        };
13998        g(
13999            tmp.path(),
14000            &[
14001                "clone",
14002                "--bare",
14003                "-q",
14004                repo.to_str().unwrap(),
14005                origin.to_str().unwrap(),
14006            ],
14007        );
14008        g(
14009            &repo,
14010            &["remote", "add", "origin", origin.to_str().unwrap()],
14011        );
14012        let c1 = git::rev_parse(&repo, "main").await.unwrap();
14013
14014        // Main moves on; the branch is built on top of the new main.
14015        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14016        g(&repo, &["add", "-A"]);
14017        g(
14018            &repo,
14019            &["commit", "-q", "-m", "chore(deps): update a crate"],
14020        );
14021        g(&repo, &["push", "-q", "origin", "main"]);
14022        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14023        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14024        g(&repo, &["add", "-A"]);
14025        g(
14026            &repo,
14027            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14028        );
14029        g(&repo, &["checkout", "-q", "main"]);
14030
14031        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14032        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14033        // Without a readable tracking ref the recorded base's merge base is used.
14034        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14035        assert_eq!(fallback, c1);
14036
14037        let mut state = review_state(&[
14038            "chore(deps): update a crate",
14039            "fix(daemon): apply a chosen action",
14040        ]);
14041        state.repo = repo.clone();
14042        state.base_branch = "main".to_owned();
14043        state.base_commit = c1;
14044        refresh_reviewed_commits(&mut state, "feat/own").await;
14045        assert_eq!(
14046            state.reviewed_commits,
14047            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14048        );
14049        assert_eq!(
14050            review_title(&state).as_deref(),
14051            Some("fix(daemon): apply a chosen action")
14052        );
14053        assert_eq!(
14054            leaked_subjects(&state, "feat/own").await,
14055            Some(vec!["chore(deps): update a crate".to_owned()])
14056        );
14057        // A stale tracking ref is still used when the fetch fails, but the
14058        // leak list is withheld.
14059        state.config.merge.remote = "nowhere".to_owned();
14060        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14061    }
14062
14063    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
14064    /// with `origin/main` one commit behind it, and a summarizing agent that
14065    /// prints whatever `reply` holds.
14066    struct SummaryFixture {
14067        _tmp: tempfile::TempDir,
14068        repo: PathBuf,
14069        reply: PathBuf,
14070        runner: Runner,
14071    }
14072
14073    fn summary_fixture() -> SummaryFixture {
14074        ask_test_home();
14075        let tmp = tempfile::tempdir().unwrap();
14076        let repo = tmp.path().join("repo");
14077        std::fs::create_dir_all(&repo).unwrap();
14078        init_repo(&repo);
14079        let origin = tmp.path().join("origin.git");
14080        let g = |args: &[&str]| {
14081            let out = std::process::Command::new("git")
14082                .args(args)
14083                .current_dir(&repo)
14084                .quiet()
14085                .output()
14086                .expect("spawn git");
14087            assert!(
14088                out.status.success(),
14089                "git {args:?}: {}",
14090                String::from_utf8_lossy(&out.stderr)
14091            );
14092        };
14093        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14094        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14095        g(&["push", "-q", "origin", "main"]);
14096        g(&["checkout", "-q", "-b", "feat/own"]);
14097        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14098        g(&["add", "-A"]);
14099        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14100        let reply = tmp.path().join("reply.txt");
14101        std::fs::write(
14102            &reply,
14103            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14104        )
14105        .unwrap();
14106        let mut runner = runner_at(RunStatus::Gating);
14107        let mut state = review_state(&["feat(own): add the own file"]);
14108        state.repo = repo.clone();
14109        state.base_branch = "main".to_owned();
14110        state.candidates[0].branch = "feat/own".to_owned();
14111        state.candidates[0].worktree = repo.clone();
14112        let mut author = spec("alpha");
14113        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14114        author
14115            .env
14116            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14117        state.config.agents = vec![author];
14118        runner.state = state;
14119        SummaryFixture {
14120            _tmp: tmp,
14121            repo,
14122            reply,
14123            runner,
14124        }
14125    }
14126
14127    async fn posted(f: &mut SummaryFixture) -> PrMessage {
14128        let winner = f.runner.state.candidates[0].clone();
14129        f.runner
14130            .guarded_pr_message(&winner, None, true)
14131            .await
14132            .unwrap()
14133            .expect("an English description never parks")
14134    }
14135
14136    fn clean_round_with_minor_findings() -> ReviewRound {
14137        let mut round = passing_round(2, None);
14138        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14139        round.reviews = vec![ReviewRecord {
14140            attempts: 0,
14141            reviewer: 1,
14142            agent: "alpha".into(),
14143            summary: "内部レビュー".into(),
14144            findings: vec![
14145                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14146                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14147            ],
14148            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14149            failed: None,
14150            duration_ms: 0,
14151        }];
14152        round
14153    }
14154
14155    #[tokio::test]
14156    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14157        for review_only in [false, true] {
14158            let mut f = summary_fixture();
14159            if !review_only {
14160                f.runner.state.candidates[0].agent = "alpha".into();
14161            }
14162            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14163            assert!(f.runner.state.reviews[0].clean);
14164            assert!(f.runner.state.reviews[0].fix.is_none());
14165            assert!(f.runner.state.open_findings().is_empty());
14166            assert_eq!(
14167                crate::pr_outcome::required_finding_ids(&f.runner.state),
14168                ["R2-1-1", "R2-1-2"]
14169            );
14170            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14171            let evidence = crate::pr_outcome::summary_evidence(
14172                &f.runner.state,
14173                crate::pr_outcome::FinalHead::At(&head),
14174            );
14175            assert!(
14176                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14177            );
14178            // The ordinary reply omits both IDs: no cached description is adopted.
14179            let omitted = posted(&mut f).await;
14180            assert!(omitted.body.contains("No description"));
14181            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14182            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14183            assert!(f.runner.state.change_summary.is_none());
14184            std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nAdds the own file. R2-1-1 [Minor]: improve documentation for callers. R2-1-2 [Minor]: consider the remaining edge case.").unwrap();
14185            let described = posted(&mut f).await;
14186            assert!(described.body.contains("improve documentation for callers"));
14187            assert!(described.body.contains("remaining edge case"));
14188            assert!(described.body.contains("## Open review findings"));
14189            assert!(!described.body.contains("未修正"));
14190        }
14191    }
14192
14193    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14194        let mut f = summary_fixture();
14195        if !review_only {
14196            f.runner.state.candidates[0].agent = "alpha".into();
14197        }
14198        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14199        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14200        fixed
14201            .fix
14202            .as_mut()
14203            .unwrap()
14204            .rejected
14205            .push(crate::verdict::Rejection {
14206                id: "R1-1-1".into(),
14207                why: "呼び出されないコードのため辞退".into(),
14208            });
14209        let mut clean = clean_round_with_minor_findings();
14210        clean.verified_head = Some(head.clone());
14211        f.runner.state.reviews = vec![fixed, clean];
14212        ok_gate(&mut f.runner.state, Some(&head));
14213        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14214        f.runner.state.config.agents.push(scripted("beta", reply));
14215        f.runner.state.config.roles.language_judge =
14216            Some(crate::config::AgentChoice::One("beta".into()));
14217        f
14218    }
14219
14220    #[tokio::test]
14221    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14222        for review_only in [false, true] {
14223            let mut f = rewrite_fixture_with_findings(review_only,
14224                "TITLE: fix: public description\nR1-1-1: declined because no caller reaches this code. R2-1-1 [Minor]: documentation remains incomplete. R2-1-2 [Minor]: an edge case needs follow-up.").await;
14225            let m = posted(&mut f).await;
14226            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14227            let expected =
14228                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14229            assert!(m.body.contains(&expected));
14230            assert!(m.body.contains("## Review rounds"));
14231            assert!(
14232                m.body
14233                    .contains("Gate (`verify.gate`): passed on the final commit")
14234            );
14235            assert!(
14236                m.body
14237                    .contains("Tests (`verify.e2e`): passed on the final commit")
14238            );
14239            let c = f.runner.state.change_summary.as_ref().unwrap();
14240            assert!(
14241                m.body
14242                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14243            );
14244            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14245            assert!(crate::pr_outcome::covers_required_findings(
14246                &f.runner.state,
14247                &m.body
14248            ));
14249            assert!(m.body.contains("documentation remains incomplete"));
14250            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14251            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14252            assert!(f.runner.state.github_text.is_none());
14253        }
14254    }
14255
14256    #[tokio::test]
14257    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14258        for review_only in [false, true] {
14259            for reply in [
14260                "TITLE: fix: public description\nA short English description.",
14261                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14262            ] {
14263                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14264                let winner = f.runner.state.candidates[0].clone();
14265                let posted = f
14266                    .runner
14267                    .guarded_pr_message(&winner, None, true)
14268                    .await
14269                    .unwrap();
14270                assert!(posted.is_none());
14271                assert!(f.runner.state.parked);
14272                let withheld = f.runner.state.github_text.as_ref().unwrap();
14273                assert!(
14274                    withheld.rewrite.is_none(),
14275                    "an incomplete rewrite must not be selectable"
14276                );
14277                let q = ask::Questions::open()
14278                    .get(withheld.question.as_ref().unwrap())
14279                    .unwrap();
14280                assert!(
14281                    !q.choices
14282                        .contains(&crate::github_text::USE_REWRITE.to_owned())
14283                );
14284                assert!(
14285                    q.detail.contains("R1-1-1")
14286                        && q.detail.contains("R2-1-1")
14287                        && q.detail.contains("R2-1-2")
14288                );
14289            }
14290        }
14291    }
14292
14293    #[tokio::test]
14294    async fn japanese_reports_stay_local_in_both_public_run_modes() {
14295        for review_only in [false, true] {
14296            let mut f = summary_fixture();
14297            if !review_only {
14298                f.runner.state.candidates[0].agent = "alpha".into();
14299            }
14300            f.runner.state.instruction = "内部タスクの説明".into();
14301            f.runner.state.candidates[0].summary = "内部の候補報告".into();
14302            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14303            f.runner.state.reviews = vec![
14304                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14305                round_with_notes(2, Some("二回目の内部修正報告です。")),
14306                passing_round(3, Some(&head)),
14307            ];
14308            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14309            ok_gate(&mut f.runner.state, Some(&head));
14310            let captured = f.repo.join("public-prompt.txt");
14311            let author = &mut f.runner.state.config.agents[0];
14312            author.command = vec![
14313                "sh".into(),
14314                "-c".into(),
14315                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14316            ];
14317            author.env.insert(
14318                "PROMPT_FILE".into(),
14319                captured.to_string_lossy().into_owned(),
14320            );
14321            let m = posted(&mut f).await;
14322            assert!(m.body.contains("Adds `own.txt`"));
14323            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14324            assert!(m.body.contains("R1-1-1"));
14325            assert!(
14326                m.body
14327                    .contains("Gate (`verify.gate`): passed on the final commit")
14328            );
14329            assert!(
14330                m.body
14331                    .contains("Tests (`verify.e2e`): passed on the final commit")
14332            );
14333            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14334            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14335            let prompt = std::fs::read_to_string(captured).unwrap();
14336            assert!(prompt.contains(&format!(
14337                "git diff {} {head}",
14338                f.runner.state.change_summary.as_ref().unwrap().base
14339            )));
14340            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14341            assert_eq!(
14342                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14343                "二回目の内部修正報告です。"
14344            );
14345        }
14346    }
14347
14348    #[tokio::test]
14349    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14350        for review_only in [false, true] {
14351            let mut f = summary_fixture();
14352            if !review_only {
14353                f.runner.state.candidates[0].agent = "alpha".into();
14354            }
14355            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14356            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14357            std::fs::write(&f.reply, "no usable description").unwrap();
14358            let failed = posted(&mut f).await;
14359            assert!(
14360                failed
14361                    .body
14362                    .contains("No description of what this change does could be generated")
14363            );
14364            assert!(failed.body.is_ascii());
14365            assert!(f.runner.state.github_text.is_none());
14366            assert!(f.runner.state.change_summary.is_none());
14367            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14368            let evidence = crate::pr_outcome::summary_evidence(
14369                &f.runner.state,
14370                crate::pr_outcome::FinalHead::At(&head),
14371            );
14372            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14373            assert!(
14374                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14375                    .exists()
14376            );
14377            f.runner.state =
14378                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14379            std::fs::write(
14380                &f.reply,
14381                "TITLE: feat(own): add the own file\nAdds the committed own file.",
14382            )
14383            .unwrap();
14384            let retried = posted(&mut f).await;
14385            assert!(retried.body.contains("Adds the committed own file."));
14386            assert_eq!(
14387                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14388                "日本語の修正報告"
14389            );
14390        }
14391    }
14392
14393    #[tokio::test]
14394    async fn same_head_verification_and_findings_invalidate_public_prose() {
14395        let mut f = summary_fixture();
14396        posted(&mut f).await;
14397        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14398        let original = f
14399            .runner
14400            .state
14401            .change_summary
14402            .as_ref()
14403            .unwrap()
14404            .evidence
14405            .clone();
14406        ok_gate(&mut f.runner.state, Some(&head));
14407        std::fs::write(
14408            &f.reply,
14409            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14410        )
14411        .unwrap();
14412        let verified = posted(&mut f).await;
14413        assert!(verified.body.contains("now verified"));
14414        assert_ne!(
14415            f.runner.state.change_summary.as_ref().unwrap().evidence,
14416            original
14417        );
14418        let mut round = round_with_notes(1, Some("内部報告"));
14419        round.reviews.push(ReviewRecord {
14420            attempts: 0,
14421            reviewer: 1,
14422            agent: "alpha".into(),
14423            summary: "内部レビュー".into(),
14424            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14425            vote: None,
14426            failed: None,
14427            duration_ms: 0,
14428        });
14429        round
14430            .fix
14431            .as_mut()
14432            .unwrap()
14433            .rejected
14434            .push(crate::verdict::Rejection {
14435                id: "R1-1-1".into(),
14436                why: "呼び出し元から到達できない".into(),
14437            });
14438        f.runner.state.reviews = vec![round];
14439        // A description omitting the required ID is unusable, even with a title.
14440        let omitted = posted(&mut f).await;
14441        assert!(omitted.body.contains("No description"));
14442        assert!(omitted.body.contains("R1-1-1"));
14443        assert!(!omitted.body.contains("到達"));
14444        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: declined because the code is unreachable from callers; confirm that assumption when adding callers.").unwrap();
14445        let described = posted(&mut f).await;
14446        assert!(described.body.contains("unreachable from callers"));
14447        assert!(described.body.contains("R1-1-1"));
14448        assert!(!described.body.contains("到達"));
14449        let evidence = f
14450            .runner
14451            .state
14452            .change_summary
14453            .as_ref()
14454            .unwrap()
14455            .evidence
14456            .clone();
14457        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14458        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14459        assert!(posted(&mut f).await.body.contains("rationale updated"));
14460        assert_ne!(
14461            f.runner.state.change_summary.as_ref().unwrap().evidence,
14462            evidence
14463        );
14464        // Old serialized summaries have no evidence fingerprint and must regenerate.
14465        f.runner
14466            .state
14467            .change_summary
14468            .as_mut()
14469            .unwrap()
14470            .evidence
14471            .clear();
14472        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14473        assert!(posted(&mut f).await.body.contains("current specification"));
14474    }
14475
14476    #[tokio::test]
14477    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14478        let mut f = summary_fixture();
14479        let m = posted(&mut f).await;
14480        assert_eq!(m.title, "feat(own): add the own file");
14481        assert!(
14482            m.body
14483                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14484            "{}",
14485            m.body
14486        );
14487        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14488        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14489        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14490        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14491        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14492        let kept = f.runner.state.change_summary.as_ref().unwrap();
14493        assert_eq!(
14494            (kept.head.as_str(), kept.base.as_str()),
14495            (head.as_str(), base.as_str())
14496        );
14497        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14498    }
14499
14500    #[tokio::test]
14501    async fn review_only_summary_receives_the_configured_github_language() {
14502        let mut f = summary_fixture();
14503        f.runner.state.config.graph.github_language = "ja".into();
14504        let captured = f.repo.join("summary-prompt.txt");
14505        let author = &mut f.runner.state.config.agents[0];
14506        author.command = vec![
14507            "sh".into(),
14508            "-c".into(),
14509            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14510        ];
14511        author.env.insert(
14512            "PROMPT_FILE".into(),
14513            captured.to_string_lossy().into_owned(),
14514        );
14515        std::fs::write(
14516            &f.reply,
14517            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14518        )
14519        .unwrap();
14520        let m = posted(&mut f).await;
14521        let prompt = std::fs::read_to_string(captured).unwrap();
14522        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14523        assert!(!prompt.contains("in English"));
14524        assert_eq!(m.title, "feat(own): ファイルを追加");
14525        assert!(m.body.contains("own.txt を追加します。"));
14526    }
14527
14528    #[tokio::test]
14529    async fn review_fixes_come_after_the_whole_change_summary() {
14530        let mut f = summary_fixture();
14531        f.runner.state.reviews = vec![
14532            round_with_notes(1, Some("handled the PATH case")),
14533            round_with_notes(2, None),
14534        ];
14535        let m = posted(&mut f).await;
14536        let summary = m.body.find("## Summary").expect("summary");
14537        let fixes = m.body.find("## Review rounds").expect("review rounds");
14538        assert!(summary < fixes, "{}", m.body);
14539        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14540        assert!(m.body.contains("## Final verification"), "{}", m.body);
14541    }
14542
14543    #[tokio::test]
14544    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14545        let mut f = summary_fixture();
14546        let first = posted(&mut f).await;
14547        let first_head = f.runner.state.change_summary.clone().unwrap().head;
14548        // The same head reuses the record: a different reply is not read.
14549        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14550        let again = posted(&mut f).await;
14551        assert_eq!((again.title, again.body), (first.title, first.body));
14552
14553        let g = |args: &[&str]| {
14554            let out = std::process::Command::new("git")
14555                .args(args)
14556                .current_dir(&f.repo)
14557                .quiet()
14558                .output()
14559                .expect("spawn git");
14560            assert!(out.status.success(), "git {args:?}");
14561        };
14562        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14563        g(&["add", "-A"]);
14564        g(&["commit", "-q", "-m", "fix(own): handle more"]);
14565        let second = posted(&mut f).await;
14566        let kept = f.runner.state.change_summary.clone().unwrap();
14567        assert_ne!(kept.head, first_head);
14568        assert!(second.body.contains("Not read yet."), "{}", second.body);
14569        assert!(!second.body.contains("so the first summary is read"));
14570        assert_eq!(second.title, "feat(own): other");
14571    }
14572
14573    #[tokio::test]
14574    async fn an_unavailable_summary_is_stated_and_never_invented() {
14575        let mut f = summary_fixture();
14576        std::fs::write(&f.reply, "no title line here").unwrap();
14577        let m = posted(&mut f).await;
14578        assert!(
14579            m.body
14580                .contains("No description of what this change does could be generated"),
14581            "{}",
14582            m.body
14583        );
14584        assert!(m.body.contains("internal reports remain in local records"));
14585        assert!(f.runner.state.change_summary.is_none());
14586        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14587        // A preview asks nobody and claims nothing.
14588        let winner = f.runner.state.candidates[0].clone();
14589        let preview = f
14590            .runner
14591            .guarded_pr_message(&winner, None, false)
14592            .await
14593            .unwrap()
14594            .unwrap();
14595        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14596    }
14597
14598    #[test]
14599    fn pr_message_review_single_commit_uses_its_subject() {
14600        let state = review_state(&["feat(nats): per-role user"]);
14601        let m = pr_message(&state, 'A');
14602        assert_eq!(m.title, "feat(nats): per-role user");
14603        assert!(!m.body.contains("Review the work already"), "{}", m.body);
14604        assert!(m.body.contains("## Commits under review"), "{}", m.body);
14605    }
14606
14607    #[test]
14608    fn pr_message_review_multi_commit_takes_the_oldest() {
14609        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14610        let m = pr_message(&state, 'A');
14611        assert_eq!(m.title, "feat: the change");
14612        for s in ["feat: the change", "fix: typo", "fix: again"] {
14613            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14614        }
14615    }
14616
14617    #[test]
14618    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14619        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14620            let mut state = review_state(&[first, "fix: later fixup"]);
14621            state.candidates[0].branch = "機能/ログイン".to_owned();
14622            let m = pr_message(&state, 'A');
14623            assert!(
14624                m.title.starts_with("chore: land candidate A of run"),
14625                "{}",
14626                m.title
14627            );
14628        }
14629    }
14630
14631    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14632        BranchFacts {
14633            commits: commits
14634                .iter()
14635                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14636                .collect(),
14637            stat: stat.to_owned(),
14638        }
14639    }
14640
14641    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14642        let mut r = review_round(true, 0, 1, 1, true, true);
14643        r.round = round;
14644        r.fix = notes.map(|n| FixRecord {
14645            agent: "fixer".to_owned(),
14646            addressed: Vec::new(),
14647            rejected: Vec::new(),
14648            notes: n.to_owned(),
14649            committed: true,
14650            failed: None,
14651            duration_ms: 0,
14652            continuation: None,
14653        });
14654        r
14655    }
14656
14657    #[test]
14658    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14659        let state = review_state(&["ignored"]);
14660        let f = facts(
14661            &[
14662                (
14663                    "fix(login): resolve PATH on macOS",
14664                    "Login shells skip rc files.",
14665                ),
14666                ("fix: address review", ""),
14667            ],
14668            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14669        );
14670        let m = pr_message_with(&state, 'A', Some(&f));
14671        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14672        assert!(
14673            m.body
14674                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
14675            "{}",
14676            m.body
14677        );
14678        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14679        assert!(m.body.contains("## Diff stat"), "{}", m.body);
14680        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14681        for banned in [
14682            "Review the work already",
14683            "no task statement",
14684            "Original task",
14685        ] {
14686            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14687        }
14688        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14689    }
14690
14691    #[test]
14692    fn pr_message_review_truncates_a_huge_first_commit_body() {
14693        let state = review_state(&["ignored"]);
14694        let f = facts(
14695            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14696            "s",
14697        );
14698        let m = pr_message_with(&state, 'A', Some(&f));
14699        assert!(m.body.len() < 30_000, "{}", m.body.len());
14700        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14701        assert!(
14702            m.body.contains("- ... 1 more commit(s)"),
14703            "{}",
14704            m.body.len()
14705        );
14706        assert!(m.body.ends_with("magi:candidate-a\n"));
14707    }
14708
14709    #[test]
14710    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14711        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14712        assert_eq!(m.title, "feat: x");
14713        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14714        assert!(!m.body.contains("Diff stat"), "{}", m.body);
14715        assert!(!m.body.contains("no task statement"), "{}", m.body);
14716    }
14717
14718    #[test]
14719    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14720        let mut state = review_state(&["日本語の件名"]);
14721        state.candidates[0].branch = "fix/macos-login-path".to_owned();
14722        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14723        state.candidates[0].branch = "機能/ログイン".to_owned();
14724        assert!(
14725            pr_message(&state, 'A')
14726                .title
14727                .starts_with("chore: land candidate A")
14728        );
14729    }
14730
14731    #[test]
14732    fn pr_message_review_fixes_survive_a_clean_final_round() {
14733        let mut state = review_state(&["feat: x"]);
14734        state.reviews = vec![
14735            round_with_notes(1, Some("handled the PATH case")),
14736            round_with_notes(2, None),
14737        ];
14738        let body = pr_message(&state, 'A').body;
14739        assert!(
14740            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14741            "{body}"
14742        );
14743        assert!(body.contains("### Round 1"), "{body}");
14744
14745        state.reviews = vec![
14746            round_with_notes(1, Some("first fix")),
14747            round_with_notes(2, Some("")),
14748            round_with_notes(3, Some("second fix")),
14749            round_with_notes(4, None),
14750        ];
14751        let body = pr_message(&state, 'A').body;
14752        assert!(
14753            body.contains("### Round 1") && !body.contains("first fix"),
14754            "{body}"
14755        );
14756        assert!(
14757            body.contains("### Round 3") && !body.contains("second fix"),
14758            "{body}"
14759        );
14760        assert!(body.contains("### Round 2"), "{body}");
14761    }
14762
14763    const FINAL: &str = "abcdef1234567890";
14764
14765    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14766        let mut r = round_with_notes(round, None);
14767        r.verified_head = verified.map(str::to_owned);
14768        r
14769    }
14770
14771    fn ok_gate(state: &mut RunState, head: Option<&str>) {
14772        state.gate = vec![CommandOutcome {
14773            command: "gate".to_owned(),
14774            code: Some(0),
14775            output_tail: String::new(),
14776            duration_ms: 0,
14777            resource_blocked: false,
14778        }];
14779        state.gate_ran = true;
14780        state.gate_head = head.map(str::to_owned);
14781    }
14782
14783    fn body_at(state: &RunState) -> String {
14784        pr_message_raw(
14785            state,
14786            'A',
14787            None,
14788            crate::pr_outcome::FinalHead::At(FINAL),
14789            ChangeNote::NotAsked,
14790        )
14791        .body
14792    }
14793
14794    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14795
14796    #[test]
14797    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14798        let mut state = review_state(&["feat: x"]);
14799        state.reviews = vec![
14800            round_with_notes(1, Some(SANDBOXED)),
14801            passing_round(2, Some(FINAL)),
14802        ];
14803        ok_gate(&mut state, Some(FINAL));
14804        let body = body_at(&state);
14805        assert!(!body.contains("Handled the PATH case."), "{body}");
14806        assert!(!body.contains("could not commit"), "{body}");
14807        assert!(!body.contains("could not run"), "{body}");
14808        assert!(
14809            body.contains("The fix is committed on the branch"),
14810            "{body}"
14811        );
14812        assert!(!body.contains("sandbox"), "{body}");
14813        assert!(
14814            body.contains("Tests (`verify.e2e`): passed on the final commit"),
14815            "{body}"
14816        );
14817        assert!(
14818            body.contains("Gate (`verify.gate`): passed on the final commit"),
14819            "{body}"
14820        );
14821        // The raw report is untouched in the record.
14822        assert!(
14823            state.reviews[0]
14824                .fix
14825                .as_ref()
14826                .unwrap()
14827                .notes
14828                .contains("could not commit")
14829        );
14830    }
14831
14832    #[test]
14833    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14834        let mut state = review_state(&["feat: x"]);
14835        let mut r1 = round_with_notes(1, Some(SANDBOXED));
14836        r1.fix.as_mut().unwrap().committed = false;
14837        let mut r2 = passing_round(2, Some(FINAL));
14838        r2.e2e[0].code = Some(1);
14839        state.reviews = vec![r1, r2];
14840        let body = body_at(&state);
14841        assert!(!body.contains("could not commit"), "{body}");
14842        assert!(!body.contains("could not run"), "{body}");
14843        assert!(body.contains("This round produced no commit"), "{body}");
14844        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14845        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14846        assert!(!body.contains("passed"), "{body}");
14847    }
14848
14849    #[test]
14850    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14851        let mut state = review_state(&["feat: x"]);
14852        state.reviews = vec![
14853            round_with_notes(1, Some(SANDBOXED)),
14854            passing_round(2, Some("0123456789abcdef")),
14855        ];
14856        ok_gate(&mut state, Some("0123456789abcdef"));
14857        let body = body_at(&state);
14858        assert!(!body.contains("HTTP tests could not run"), "{body}");
14859        // The commit claim is still settled by the structured record.
14860        assert!(!body.contains("could not commit"), "{body}");
14861        assert!(
14862            body.contains("passed on an earlier commit (`0123456`)"),
14863            "{body}"
14864        );
14865        assert!(!body.contains("passed on the final commit"), "{body}");
14866
14867        // A gate recorded before `gate_head` existed is "not recorded".
14868        ok_gate(&mut state, None);
14869        let body = body_at(&state);
14870        assert!(
14871            body.contains("passed on a commit that was not recorded"),
14872            "{body}"
14873        );
14874    }
14875
14876    #[test]
14877    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14878        let state = state_with_summary(
14879            "add retries to the client",
14880            "TITLE: feat: retries\n\nDid it.",
14881        );
14882        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14883        assert_eq!(m.title, "feat: retries");
14884        assert!(
14885            m.body.contains("<summary>Original task</summary>"),
14886            "{}",
14887            m.body
14888        );
14889        assert!(!m.body.contains("Commits under review"), "{}", m.body);
14890        assert!(
14891            m.body
14892                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14893            "{}",
14894            m.body
14895        );
14896    }
14897
14898    #[test]
14899    fn pr_message_review_bounds_a_long_english_subject() {
14900        let long = format!("feat: {}", "word ".repeat(100));
14901        let m = pr_message(&review_state(&[&long]), 'A');
14902        assert!(m.title.starts_with("feat: word"), "{}", m.title);
14903        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14904    }
14905
14906    #[test]
14907    fn pr_message_implementation_run_is_unchanged_by_review_support() {
14908        let state = state_with_summary("add retries\n\ndetails", "- did some things");
14909        let m = pr_message(&state, 'A');
14910        assert_eq!(m.title, "add retries");
14911        assert!(m.body.contains("<summary>Original task</summary>"));
14912        assert!(!m.body.contains("Commits under review"));
14913        assert_eq!(landing_subject_source(&state), state.instruction);
14914    }
14915
14916    #[test]
14917    fn review_run_squash_subject_is_the_change_not_the_prompt() {
14918        let state = review_state(&["feat: the change", "fix: typo"]);
14919        let source = landing_subject_source(&state);
14920        assert_eq!(land::merge_subject("", &source), "feat: the change");
14921        assert_eq!(
14922            land::merge_subject("magi: candidate A (uncommitted work)", &source),
14923            "feat: the change"
14924        );
14925        // An operator's rename still wins.
14926        assert_eq!(
14927            land::merge_subject("feat: renamed by hand", &source),
14928            "feat: renamed by hand"
14929        );
14930        let blank = review_state(&["日本語"]);
14931        assert!(
14932            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14933        );
14934    }
14935
14936    #[test]
14937    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
14938        let state = review_state(&["feat: the change"]);
14939        let source = landing_subject_source(&state);
14940        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
14941        assert_eq!(
14942            land::merge_subject(landing_title(&state, old), &source),
14943            "feat: the change"
14944        );
14945        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
14946        let task = state_with_summary("add retries", "");
14947        assert_eq!(landing_title(&task, old), old);
14948    }
14949
14950    #[test]
14951    fn pr_message_describes_the_change_not_the_task() {
14952        let state = state_with_summary(
14953            "今回やってほしいこと: results projector を直す",
14954            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
14955        );
14956        let m = pr_message(&state, 'A');
14957        assert_eq!(m.title, "fix(web): batch the runs list reads");
14958        assert!(
14959            m.body.starts_with("## Summary\n\n- reads run.json once"),
14960            "{}",
14961            m.body
14962        );
14963        assert!(!m.body.contains("TITLE:"), "{}", m.body);
14964        let task_at = m.body.find("今回やってほしいこと").unwrap();
14965        let details_at = m.body.find("<details>").unwrap();
14966        assert!(
14967            details_at < task_at,
14968            "the task lives inside <details>: {}",
14969            m.body
14970        );
14971        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
14972        assert!(m.body.contains("magi:candidate-a"));
14973    }
14974
14975    #[test]
14976    fn pr_message_falls_back_to_the_task_without_a_title_line() {
14977        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
14978        let m = pr_message(&state, 'A');
14979        assert_eq!(m.title, "add retries");
14980        assert!(
14981            m.body.contains("## Summary\n\n- did some things"),
14982            "{}",
14983            m.body
14984        );
14985
14986        let none = RunState::new(
14987            PathBuf::from("/repo"),
14988            "main".to_owned(),
14989            "abc1234".to_owned(),
14990            "add retries".to_owned(),
14991            Config::default(),
14992        );
14993        let m = pr_message(&none, 'A');
14994        assert_eq!(m.title, "add retries");
14995        assert!(!m.body.contains("## Summary"), "{}", m.body);
14996    }
14997
14998    #[test]
14999    fn pr_message_refuses_the_candidate_commit_subject() {
15000        for bad in [
15001            "TITLE: magi: candidate A (uncommitted work)",
15002            "TITLE: chore: stuff (uncommitted work)",
15003            "TITLE:   ",
15004        ] {
15005            let state = state_with_summary("add retries", bad);
15006            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15007        }
15008    }
15009
15010    #[test]
15011    fn pr_message_bounds_a_very_long_task_and_title() {
15012        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15013        let state = state_with_summary(&long, "- nothing");
15014        let m = pr_message(&state, 'A');
15015        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15016        assert!(!m.title.contains('\n'));
15017
15018        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15019        let m = pr_message(&state, 'A');
15020        assert!(m.title.starts_with("feat: "));
15021        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15022        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15023    }
15024
15025    fn long_title_of(instruction: &str) -> String {
15026        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15027    }
15028
15029    #[test]
15030    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15031        let first = "Make the landing path keep a readable title for long tasks";
15032        let line = format!(
15033            "{first}. {}",
15034            "And then keep going with more words ".repeat(20)
15035        );
15036        let t = long_title_of(&line);
15037        assert_eq!(t, first);
15038        assert!(!t.starts_with("chore: land"));
15039    }
15040
15041    #[test]
15042    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15043        let line = "word ".repeat(200);
15044        let t = long_title_of(&line);
15045        assert!(t.ends_with("word..."), "{t}");
15046        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15047    }
15048
15049    #[test]
15050    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15051        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15052            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15053        }
15054    }
15055
15056    #[test]
15057    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15058        let mut state = state_with_summary("再試行を追加する", "- nothing");
15059        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15060        state.config.graph.github_language = "ja".into();
15061        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15062    }
15063
15064    #[test]
15065    fn pr_message_title_limit_is_exact() {
15066        let at = "a".repeat(PR_TITLE_MAX);
15067        assert_eq!(long_title_of(&at), at);
15068        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15069        assert!(over.ends_with("..."), "{over}");
15070        assert_eq!(over.chars().count(), PR_TITLE_MAX);
15071    }
15072
15073    #[test]
15074    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15075        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15076        let t = long_title_of(&line);
15077        assert!(t.ends_with("..."), "{t}");
15078        assert!(t.is_ascii(), "{t}");
15079    }
15080
15081    #[test]
15082    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15083        let line = format!(
15084            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15085            "plus more filler words ".repeat(20)
15086        );
15087        let t = long_title_of(&line);
15088        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15089    }
15090
15091    #[test]
15092    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15093        let line = format!("\n\n# {}", "title words ".repeat(40));
15094        let state = state_with_summary(&line, "- nothing");
15095        let m = pr_message(&state, 'A');
15096        assert!(m.title.starts_with("title words"), "{}", m.title);
15097        assert_eq!(
15098            land::merge_subject(&m.title, &landing_subject_source(&state)),
15099            m.title
15100        );
15101        // An operator's rename wins untouched.
15102        assert_eq!(
15103            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15104            "feat: renamed by hand"
15105        );
15106    }
15107
15108    #[tokio::test]
15109    async fn github_text_rewrite_is_bounded_and_falls_back() {
15110        crate::run::pin_test_home();
15111        for (reply, accepted) in [
15112            (
15113                "TITLE: fix: retries\nAdd retries for failed requests.",
15114                true,
15115            ),
15116            (
15117                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15118                false,
15119            ),
15120            (
15121                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15122                true,
15123            ),
15124            (
15125                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15126                true,
15127            ),
15128            (
15129                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15130                true,
15131            ),
15132            (
15133                "TITLE: fix: retries\n日本語の説明です。 token=secret",
15134                false,
15135            ),
15136            ("TITLE: fix: retries\n", false),
15137        ] {
15138            let dir = tempfile::tempdir().unwrap();
15139            let mut runner = runner_at(RunStatus::Gating);
15140            runner.state = state_with_summary(
15141                "add retries",
15142                "TITLE: fix: retries\n日本語の説明を書きます。",
15143            );
15144            runner.state.repo = dir.path().to_owned();
15145            runner.state.candidates[0].worktree = dir.path().to_owned();
15146            let mut author = spec("alpha");
15147            author.command = vec![
15148                "sh".into(),
15149                "-c".into(),
15150                "printf '%s' \"$REWRITE_REPLY\"".into(),
15151            ];
15152            author.env.insert("REWRITE_REPLY".into(), reply.into());
15153            runner.state.config.agents = vec![author];
15154            seed_public_summary(&mut runner);
15155            let winner = runner.state.candidates[0].clone();
15156            let first = runner
15157                .guarded_pr_message(&winner, None, true)
15158                .await
15159                .unwrap();
15160            let message = if accepted {
15161                first.expect("a passing rewrite never asks")
15162            } else {
15163                // Withheld: the run parks on a question and posts nothing yet.
15164                assert!(first.is_none());
15165                assert!(runner.state.parked);
15166                let store = ask::Questions::open();
15167                let asked: Vec<_> = store
15168                    .list()
15169                    .into_iter()
15170                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15171                    .collect();
15172                assert_eq!(asked.len(), 1);
15173                assert!(!asked[0].detail.contains("secret"));
15174                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15175                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15176                assert_eq!(
15177                    asked[0]
15178                        .choices
15179                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
15180                    rewrite.is_some()
15181                );
15182                // Silence: the question lapses and the neutral text is used,
15183                // without a second rewrite call or a second question.
15184                store
15185                    .update(&asked[0].id, |q| {
15186                        q.abandon("test");
15187                        Ok(())
15188                    })
15189                    .unwrap();
15190                let again = runner
15191                    .guarded_pr_message(&winner, None, true)
15192                    .await
15193                    .unwrap()
15194                    .expect("an abandoned question falls back");
15195                let n = store
15196                    .list()
15197                    .into_iter()
15198                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15199                    .count();
15200                assert_eq!(n, 1, "asked once per rejected text");
15201                again
15202            };
15203            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15204            assert!(!message.body.contains("secret"));
15205            assert!(!message.body.contains("prefix"));
15206            assert!(!message.title.contains("secret"));
15207            assert_eq!(
15208                message.body.contains("Add retries for failed requests."),
15209                accepted
15210            );
15211            assert_eq!(
15212                runner.state.seats["github-text-rewrite"].turns, 1,
15213                "only one rewrite invocation"
15214            );
15215            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15216            if accepted {
15217                assert!(runner.state.github_text.is_none());
15218                assert!(!runner.state.parked);
15219                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15220                assert!(message.body.contains("## Final verification"));
15221                assert!(message.body.contains("Evidence: final diff"));
15222                assert!(
15223                    message
15224                        .body
15225                        .contains(&format!("magi:run/{}", runner.state.id))
15226                );
15227            }
15228            if !accepted {
15229                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15230                assert!(
15231                    message
15232                        .body
15233                        .contains(&format!("magi:run/{}", runner.state.id))
15234                );
15235            }
15236        }
15237    }
15238
15239    fn scripted(id: &str, reply: &str) -> AgentSpec {
15240        let mut a = spec(id);
15241        a.command = vec![
15242            "sh".into(),
15243            "-c".into(),
15244            "printf '%s' \"$REWRITE_REPLY\"".into(),
15245        ];
15246        a.env.insert("REWRITE_REPLY".into(), reply.into());
15247        a
15248    }
15249
15250    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15251    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15252
15253    /// Gate tests inject generated public output, not internal reports.
15254    fn seed_public_summary(runner: &mut Runner) {
15255        let repo = &runner.state.repo;
15256        let g = |args: &[&str]| {
15257            let out = std::process::Command::new("git")
15258                .args(args)
15259                .current_dir(repo)
15260                .quiet()
15261                .output()
15262                .unwrap();
15263            assert!(out.status.success(), "git {args:?}");
15264            String::from_utf8_lossy(&out.stdout).trim().to_owned()
15265        };
15266        g(&["init", "-q", "-b", "main"]);
15267        g(&[
15268            "-c",
15269            "user.name=Test",
15270            "-c",
15271            "user.email=test@example.invalid",
15272            "commit",
15273            "-q",
15274            "--allow-empty",
15275            "-m",
15276            "feat: fixture",
15277        ]);
15278        let head = g(&["rev-parse", "HEAD"]);
15279        g(&["update-ref", "refs/remotes/origin/main", &head]);
15280        let evidence = crate::pr_outcome::summary_evidence(
15281            &runner.state,
15282            crate::pr_outcome::FinalHead::At(&head),
15283        );
15284        let generated = &runner.state.candidates[0].summary;
15285        runner.state.change_summary = Some(crate::run::ChangeSummary {
15286            base: head.clone(),
15287            head,
15288            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15289            title: summary_title(generated).unwrap(),
15290            body: summary_without_title(generated),
15291        });
15292    }
15293
15294    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15295        let mut runner = runner_at(RunStatus::Gating);
15296        runner.state = state_with_summary("add retries", JA_SUMMARY);
15297        if review_only {
15298            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15299        }
15300        runner.state.repo = dir.to_owned();
15301        runner.state.candidates[0].worktree = dir.to_owned();
15302        runner.state.config.agents = agents;
15303        seed_public_summary(&mut runner);
15304        runner
15305    }
15306
15307    fn gate_questions(runner: &Runner) -> usize {
15308        ask::Questions::open()
15309            .list()
15310            .into_iter()
15311            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15312            .count()
15313    }
15314
15315    #[tokio::test]
15316    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15317        crate::run::pin_test_home();
15318        let dir = tempfile::tempdir().unwrap();
15319        let mut runner = rewrite_runner(
15320            dir.path(),
15321            true,
15322            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15323        );
15324        runner.state.config.roles.language_judge =
15325            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15326        let winner = runner.state.candidates[0].clone();
15327        let msg = runner
15328            .guarded_pr_message(&winner, None, true)
15329            .await
15330            .unwrap()
15331            .expect("an English rewrite posts without asking");
15332        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15333        assert!(msg.body.contains("<details>kept</details>"));
15334        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15335        assert_eq!(gate_questions(&runner), 0);
15336        assert!(runner.state.github_text.is_none());
15337        assert!(!runner.state.seats.contains_key("impl-A"));
15338        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15339    }
15340
15341    #[tokio::test]
15342    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15343        crate::run::pin_test_home();
15344        let dir = tempfile::tempdir().unwrap();
15345        let mut runner = rewrite_runner(
15346            dir.path(),
15347            false,
15348            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15349        );
15350        runner.state.config.roles.language_judge =
15351            Some(crate::config::AgentChoice::One("beta".into()));
15352        runner.state.seats.insert(
15353            "impl-A".into(),
15354            SeatState::new("impl-A", "alpha", runner.state.seed),
15355        );
15356        let winner = runner.state.candidates[0].clone();
15357        let msg = runner
15358            .guarded_pr_message(&winner, None, true)
15359            .await
15360            .unwrap()
15361            .expect("rewritten");
15362        assert!(msg.body.contains("<details>kept</details>"));
15363        assert_eq!(runner.state.seats["impl-A"].turns, 0);
15364        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15365        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15366    }
15367
15368    #[tokio::test]
15369    async fn rewrite_reuses_a_resumable_implementer_session() {
15370        crate::run::pin_test_home();
15371        let dir = tempfile::tempdir().unwrap();
15372        let mut runner = rewrite_runner(
15373            dir.path(),
15374            false,
15375            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15376        );
15377        runner.state.config.roles.language_judge =
15378            Some(crate::config::AgentChoice::One("beta".into()));
15379        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15380        seat.turns = 1;
15381        runner.state.seats.insert("impl-A".into(), seat);
15382        let winner = runner.state.candidates[0].clone();
15383        runner
15384            .guarded_pr_message(&winner, None, true)
15385            .await
15386            .unwrap()
15387            .expect("rewritten");
15388        assert_eq!(runner.state.seats["impl-A"].turns, 2);
15389        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15390    }
15391
15392    #[tokio::test]
15393    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15394        crate::run::pin_test_home();
15395        let dir = tempfile::tempdir().unwrap();
15396        let mut broken = spec("bad");
15397        broken.command = vec!["false".into()];
15398        let mut runner = rewrite_runner(
15399            dir.path(),
15400            true,
15401            vec![
15402                broken,
15403                scripted("noformat", "Add retries."),
15404                scripted("good", EN_REWRITE),
15405            ],
15406        );
15407        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15408            "bad".into(),
15409            "noformat".into(),
15410            "good".into(),
15411        ]));
15412        let winner = runner.state.candidates[0].clone();
15413        let msg = runner
15414            .guarded_pr_message(&winner, None, true)
15415            .await
15416            .unwrap()
15417            .expect("the third agent rewrites");
15418        assert!(msg.body.contains("`src/net.rs`"));
15419        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15420        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15421    }
15422
15423    #[tokio::test]
15424    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15425        crate::run::pin_test_home();
15426        // (agents, role): still Japanese; no TITLE; nothing resolvable.
15427        for (agents, role, saved) in [
15428            (
15429                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15430                Some("beta"),
15431                true,
15432            ),
15433            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15434            (Vec::new(), None, false),
15435        ] {
15436            let dir = tempfile::tempdir().unwrap();
15437            let mut runner = rewrite_runner(dir.path(), true, agents);
15438            runner.state.config.roles.language_judge =
15439                role.map(|r| crate::config::AgentChoice::One(r.into()));
15440            let winner = runner.state.candidates[0].clone();
15441            let first = runner
15442                .guarded_pr_message(&winner, None, true)
15443                .await
15444                .unwrap();
15445            assert!(first.is_none(), "withheld text parks the run");
15446            assert!(runner.state.parked);
15447            assert_eq!(gate_questions(&runner), 1);
15448            let g = runner.state.github_text.as_ref().unwrap();
15449            assert_eq!(g.rewrite.is_some(), saved);
15450            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15451        }
15452    }
15453
15454    #[tokio::test]
15455    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15456        crate::run::pin_test_home();
15457        let dir = tempfile::tempdir().unwrap();
15458        let mut runner = runner_at(RunStatus::Gating);
15459        runner.state =
15460            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15461        runner.state.repo = dir.path().to_owned();
15462        runner.state.candidates[0].worktree = dir.path().to_owned();
15463        let mut author = spec("alpha");
15464        author.command = vec![
15465            "sh".into(),
15466            "-c".into(),
15467            "printf '%s' \"$REWRITE_REPLY\"".into(),
15468        ];
15469        author.env.insert(
15470            "REWRITE_REPLY".into(),
15471            format!(
15472                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15473                "English sentence. ".repeat(600)
15474            ),
15475        );
15476        runner.state.config.agents = vec![author];
15477        seed_public_summary(&mut runner);
15478        let winner = runner.state.candidates[0].clone();
15479        assert!(
15480            runner
15481                .guarded_pr_message(&winner, None, true)
15482                .await
15483                .unwrap()
15484                .is_none()
15485        );
15486        let record = runner.state.github_text.clone().unwrap();
15487        let rewrite = record.rewrite.clone().unwrap();
15488        assert_eq!(rewrite.failed, ["body-language"]);
15489        assert!(
15490            rewrite
15491                .body
15492                .ends_with(&format!("magi:run/{}", runner.state.id))
15493        );
15494        let store = ask::Questions::open();
15495        let q = store.get(record.question.as_ref().unwrap()).unwrap();
15496        assert!(
15497            q.choices
15498                .contains(&crate::github_text::USE_REWRITE.to_owned())
15499        );
15500        for label in [
15501            "ORIGINAL",
15502            "FALLBACK",
15503            "REWRITE",
15504            "Rules still failed: body-language",
15505            "truncated",
15506        ] {
15507            assert!(q.detail.contains(label), "{label}");
15508        }
15509        let artifact = std::fs::read_to_string(crate::run::artifact_path(
15510            &runner.state,
15511            &crate::github_text::artifact_name(&record.fingerprint),
15512        ))
15513        .unwrap();
15514        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15515        // Restart from serialized state, then retry after a bad owner title.
15516        runner.state =
15517            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15518        store
15519            .update(&q.id, |q| {
15520                q.say("まだ日本語")?;
15521                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15522            })
15523            .unwrap();
15524        assert!(
15525            runner
15526                .guarded_pr_message(&winner, None, true)
15527                .await
15528                .unwrap()
15529                .is_none()
15530        );
15531        let retry = runner.state.github_text.as_ref().unwrap();
15532        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15533        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15534        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15535        assert!(
15536            q.posting_comparison
15537                .as_ref()
15538                .unwrap()
15539                .matches_detail(&q.detail)
15540        );
15541        store
15542            .update(&q.id, |q| {
15543                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15544            })
15545            .unwrap();
15546        let message = runner
15547            .guarded_pr_message(&winner, None, true)
15548            .await
15549            .unwrap()
15550            .unwrap();
15551        assert_eq!(message.title, rewrite.title);
15552        assert_eq!(message.body, rewrite.body);
15553        assert_eq!(
15554            runner.state.github_text.as_ref().unwrap().choice,
15555            Some(GithubTextChoice::Rewrite)
15556        );
15557        let again = runner
15558            .guarded_pr_message(&winner, None, true)
15559            .await
15560            .unwrap()
15561            .unwrap();
15562        assert_eq!(again.title, message.title);
15563        assert_eq!(again.body, message.body);
15564        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15565        assert!(
15566            !runner
15567                .state
15568                .events
15569                .iter()
15570                .any(|e| e.message.contains(&rewrite.title)
15571                    || e.message.contains("English sentence."))
15572        );
15573    }
15574
15575    #[tokio::test]
15576    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15577        crate::run::pin_test_home();
15578        let dir = tempfile::tempdir().unwrap();
15579        let mut runner = runner_at(RunStatus::Gating);
15580        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15581        runner.state.repo = dir.path().to_owned();
15582        runner.state.candidates[0].worktree = dir.path().to_owned();
15583        seed_public_summary(&mut runner);
15584        let winner = runner.state.candidates[0].clone();
15585        assert!(
15586            runner
15587                .guarded_pr_message(&winner, None, true)
15588                .await
15589                .unwrap()
15590                .is_none()
15591        );
15592        let record = runner.state.github_text.as_ref().unwrap();
15593        assert!(record.rewrite.is_none());
15594        // Records written before the additive field deserialize with no rewrite.
15595        let mut old = serde_json::to_value(record).unwrap();
15596        old.as_object_mut().unwrap().remove("rewrite");
15597        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15598        assert!(old.rewrite.is_none());
15599        let id = record.question.clone().unwrap();
15600        ask::Questions::open()
15601            .update(&id, |q| {
15602                q.choices.push(crate::github_text::USE_REWRITE.into());
15603                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15604            })
15605            .unwrap();
15606        let message = runner
15607            .guarded_pr_message(&winner, None, true)
15608            .await
15609            .unwrap()
15610            .unwrap();
15611        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15612        assert_eq!(
15613            runner.state.github_text.as_ref().unwrap().choice,
15614            Some(GithubTextChoice::Fallback)
15615        );
15616    }
15617
15618    #[tokio::test]
15619    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15620        crate::run::pin_test_home();
15621        let dir = tempfile::tempdir().unwrap();
15622        let mut runner = runner_at(RunStatus::Gating);
15623        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15624        runner.state.repo = dir.path().to_owned();
15625        runner.state.candidates[0].worktree = dir.path().to_owned();
15626        seed_public_summary(&mut runner);
15627        let winner = runner.state.candidates[0].clone();
15628        assert!(
15629            runner
15630                .guarded_pr_message(&winner, None, true)
15631                .await
15632                .unwrap()
15633                .is_none()
15634        );
15635        let store = ask::Questions::open();
15636        let find = |run: &str| {
15637            store
15638                .list()
15639                .into_iter()
15640                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15641                .unwrap()
15642        };
15643        let run = runner.state.id.clone();
15644        let q = find(&run);
15645        let fp = runner
15646            .state
15647            .github_text
15648            .as_ref()
15649            .unwrap()
15650            .fingerprint
15651            .clone();
15652        let name = crate::github_text::artifact_name(&fp);
15653        let path = crate::run::artifact_path(&runner.state, &name);
15654        assert!(q.detail.contains(&path.display().to_string()));
15655        assert!(q.detail.contains("title-language: title"));
15656        let saved = std::fs::read_to_string(&path).expect("artifact written");
15657        assert!(saved.contains("日本語のタイトル"));
15658        assert!(q.cwd.is_none());
15659        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15660        // A bad replacement is refused and asked about once more.
15661        let say = |id: &str, text: &str, choice: &str| {
15662            store
15663                .update(id, |q| {
15664                    q.thread.push(ask::Turn {
15665                        who: ask::Who::Operator,
15666                        body: text.to_owned(),
15667                        at: jiff::Timestamp::now(),
15668                        note: None,
15669                    });
15670                    q.answer(ask::Answer::Choice(choice.to_owned()))
15671                })
15672                .unwrap();
15673        };
15674        say(&q.id, "まだ日本語", "use my text");
15675        assert!(
15676            runner
15677                .guarded_pr_message(&winner, None, true)
15678                .await
15679                .unwrap()
15680                .is_none()
15681        );
15682        let second = find(&run);
15683        assert_ne!(second.id, q.id);
15684        assert!(second.detail.contains("did not pass"));
15685        // The retry still compares the original text; the rejected
15686        // replacement is named by category only.
15687        assert!(second.detail.contains("日本語のタイトル"));
15688        assert!(!second.detail.contains("まだ日本語"));
15689        assert!(second.detail.contains(&path.display().to_string()));
15690        say(&second.id, "fix: retry failed requests", "use my text");
15691        let message = runner
15692            .guarded_pr_message(&winner, None, true)
15693            .await
15694            .unwrap()
15695            .expect("a vetted replacement is posted");
15696        assert_eq!(message.title, "fix: retry failed requests");
15697        assert_eq!(
15698            runner
15699                .state
15700                .github_text
15701                .as_ref()
15702                .unwrap()
15703                .chosen_title
15704                .as_deref(),
15705            Some("fix: retry failed requests")
15706        );
15707        assert!(runner.state.github_text.as_ref().unwrap().resolved);
15708        // Resumed again: the saved decision is applied, nothing is re-asked.
15709        let again = runner
15710            .guarded_pr_message(&winner, None, true)
15711            .await
15712            .unwrap()
15713            .unwrap();
15714        assert_eq!(again.title, message.title);
15715        assert_eq!(
15716            store
15717                .list()
15718                .into_iter()
15719                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15720                .count(),
15721            2
15722        );
15723    }
15724
15725    #[test]
15726    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15727        // What magi itself writes stays English under any configured language,
15728        // so a future localisation of these headings fails here. (The agents'
15729        // own text is also checked by the posting gate.)
15730        let mut state = state_with_summary(
15731            "add retries",
15732            "TITLE: fix(web): batch reads\n- reads run.json once",
15733        );
15734        state.config.graph.language = "ja".to_owned();
15735        let m = pr_message(&state, 'A');
15736        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15737        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15738
15739        // The task is the operator's own text: it goes in untouched, and the
15740        // fallback title (no summary) may be in its language too.
15741        let task = "今回やってほしいこと: results projector を直す";
15742        let mut state = state_with_summary(task, "- no title line");
15743        state.config.graph.language = "ja".to_owned();
15744        let m = pr_message(&state, 'A');
15745        assert_eq!(
15746            m.title,
15747            format!("chore: land candidate A of run {}", state.id)
15748        );
15749        assert!(
15750            m.body.contains(&format!(
15751                "<summary>Original task</summary>\n\n{task}\n\n</details>"
15752            )),
15753            "{}",
15754            m.body
15755        );
15756    }
15757
15758    #[test]
15759    fn pr_message_scrubs_home_paths_and_addresses() {
15760        let state = state_with_summary(
15761            "fix it in /Users/someone/src/x",
15762            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15763        );
15764        let m = pr_message(&state, 'A');
15765        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15766            assert!(!m.body.contains(leak), "{}", m.body);
15767        }
15768        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15769    }
15770
15771    #[test]
15772    fn pr_message_survives_a_task_that_closes_details() {
15773        let state = state_with_summary("a </details> b", "TITLE: fix: x");
15774        let m = pr_message(&state, 'A');
15775        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15776    }
15777
15778    #[test]
15779    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15780        let cmd = manual_merge_command(
15781            MergeStyle::Squash,
15782            Path::new("/repo"),
15783            "b",
15784            "fix: \"quoted\" $(x) `y`\n\nbody",
15785        );
15786        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15787    }
15788
15789    #[test]
15790    fn manual_merge_command_matches_the_configured_style() {
15791        let repo = Path::new("/repo");
15792        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15793
15794        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15795        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15796
15797        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15798        assert_eq!(
15799            squash,
15800            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15801             \"Merge magi run 0832 (candidate A)\""
15802        );
15803
15804        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15805        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15806    }
15807
15808    #[test]
15809    fn a_nudge_gets_a_quarter_of_the_budget() {
15810        // The judge and implement budgets magi ships with.
15811        assert_eq!(retry_budget(secs(1200), true), secs(300));
15812        assert_eq!(retry_budget(secs(3600), true), secs(900));
15813    }
15814
15815    #[test]
15816    fn a_resent_prompt_keeps_the_whole_budget() {
15817        // The seat kept no context, so the retry is the original job again and
15818        // shortening it would only guarantee a second failure.
15819        assert_eq!(retry_budget(secs(1200), false), secs(1200));
15820        assert_eq!(retry_budget(secs(60), false), secs(60));
15821    }
15822
15823    #[test]
15824    fn the_floor_never_exceeds_the_original_budget() {
15825        // A short configured timeout must not be *raised* by the floor: the
15826        // operator asked for a bound, and a retry may not outlast the attempt
15827        // it is retrying.
15828        assert_eq!(retry_budget(secs(60), true), secs(60));
15829        assert_eq!(retry_budget(secs(480), true), secs(120));
15830        assert_eq!(retry_budget(secs(0), true), secs(0));
15831    }
15832
15833    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15834        agent::CommandEvidence {
15835            id: "item1".to_owned(),
15836            description: "cargo test".to_owned(),
15837            exit_code,
15838            result_summary: String::new(),
15839            source: "codex".to_owned(),
15840        }
15841    }
15842
15843    #[test]
15844    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15845        // No evidence is not the same fact as unconfirmed evidence: a
15846        // backend with no adapter, or a reply that ran no commands at all,
15847        // must not be misread as carrying a dangling job.
15848        assert!(!has_unconfirmed_command(&[]));
15849    }
15850
15851    #[test]
15852    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15853        // Deliberately not a check on the exit code's *value*: a fixer
15854        // legitimately runs something that fails mid-iteration before it
15855        // succeeds, and that must never by itself reopen a valid report.
15856        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15857        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15858        assert!(!has_unconfirmed_command(&[
15859            evidence(Some(0)),
15860            evidence(Some(101))
15861        ]));
15862    }
15863
15864    #[test]
15865    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15866        assert!(has_unconfirmed_command(&[
15867            evidence(Some(0)),
15868            evidence(None)
15869        ]));
15870    }
15871
15872    #[test]
15873    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15874        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15875        assert_eq!(
15876            verified_noop_claim(true, &[], text).as_deref(),
15877            Some("already fixed by b32cfc4, on main.")
15878        );
15879    }
15880
15881    #[test]
15882    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15883        // A timeout or a bad exit code reads as the ordinary loss it is,
15884        // whatever the reply's own prose claims.
15885        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15886        assert!(verified_noop_claim(false, &[], text).is_none());
15887    }
15888
15889    #[test]
15890    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15891        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15892        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15893        // A confirmed command alongside the marker is fine.
15894        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15895    }
15896
15897    #[test]
15898    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15899        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15900    }
15901
15902    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
15903    /// pair, in order, labelled A, B, C, ...
15904    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15905        runner.state.candidates = shape
15906            .iter()
15907            .enumerate()
15908            .map(|(i, &(empty, verified))| Candidate {
15909                index: i,
15910                label: (b'A' + i as u8) as char,
15911                agent: "sonnet".to_owned(),
15912                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15913                worktree: PathBuf::from(format!("/wt/{i}")),
15914                summary: String::new(),
15915                stat: String::new(),
15916                files: 0,
15917                commits: 0,
15918                empty,
15919                failed: None,
15920                verified_noop: verified.map(str::to_owned),
15921                duration_ms: 0,
15922                folded: false,
15923            })
15924            .collect();
15925    }
15926
15927    #[test]
15928    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15929        ask_test_home();
15930        let mut runner = runner_at(RunStatus::Implementing);
15931        set_candidates(
15932            &mut runner,
15933            &[
15934                (true, Some("already on main at b32cfc4")),
15935                (true, Some("same fix, see the existing test")),
15936            ],
15937        );
15938
15939        runner
15940            .after_implement()
15941            .expect("a verified no-op is not an error");
15942
15943        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
15944    }
15945
15946    #[test]
15947    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
15948        ask_test_home();
15949        let mut runner = runner_at(RunStatus::Implementing);
15950        // Candidate A declares a verified no-op; candidate B simply wrote
15951        // nothing and said nothing about why. One candidate's claim is not
15952        // the whole run's agreement.
15953        set_candidates(
15954            &mut runner,
15955            &[(true, Some("already on main at b32cfc4")), (true, None)],
15956        );
15957
15958        let err = runner
15959            .after_implement()
15960            .expect_err("an unverified empty candidate must still fail the run");
15961
15962        assert!(
15963            err.to_string().contains("no candidate produced a change"),
15964            "{err}"
15965        );
15966        assert_eq!(runner.state.status, RunStatus::Failed);
15967    }
15968
15969    #[test]
15970    fn after_implement_still_fails_an_ordinary_all_empty_run() {
15971        ask_test_home();
15972        let mut runner = runner_at(RunStatus::Implementing);
15973        set_candidates(&mut runner, &[(true, None), (true, None)]);
15974
15975        let err = runner
15976            .after_implement()
15977            .expect_err("no candidate declared anything; this is an ordinary failure");
15978
15979        assert!(
15980            err.to_string().contains("no candidate produced a change"),
15981            "{err}"
15982        );
15983        assert_eq!(runner.state.status, RunStatus::Failed);
15984    }
15985}
15986
15987/// A graft rollback whose restore could not be confirmed (see
15988/// [`Runner::graft`]).
15989#[derive(Debug)]
15990struct GraftRestoreFailed(String);
15991
15992impl std::fmt::Display for GraftRestoreFailed {
15993    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15994        write!(
15995            f,
15996            "could not restore the winner to its pre-graft commit: {}",
15997            self.0
15998        )
15999    }
16000}
16001
16002impl std::error::Error for GraftRestoreFailed {}
16003
16004/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
16005/// not the agent: they never draw on an agent's own timeout, and running out of
16006/// them never reads as an agent failure.
16007#[cfg(test)]
16008mod resource_wait_tests {
16009    use super::*;
16010    use std::collections::BTreeMap;
16011
16012    fn sh(id: &str, script: &str) -> AgentSpec {
16013        AgentSpec {
16014            id: id.to_owned(),
16015            kind: crate::config::AgentKind::Command,
16016            model: None,
16017            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16018            extra_args: Vec::new(),
16019            env: BTreeMap::new(),
16020            prompt_delivery: None,
16021        }
16022    }
16023
16024    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16025        SeatJob {
16026            seat: SeatState::new("impl-A", &spec.id, 1),
16027            spec,
16028            cwd: dir.to_path_buf(),
16029            prompt: "do it".to_owned(),
16030            timeout,
16031            allow_write: true,
16032            sessions: false,
16033            artifacts: dir.join("art"),
16034            stem: "impl-A".to_owned(),
16035            handover: None,
16036        }
16037    }
16038
16039    fn state(resource_wait_secs: u64) -> RunState {
16040        let mut config = Config::default();
16041        config.graph.timeout_resource_wait = resource_wait_secs;
16042        RunState::new(
16043            PathBuf::from("/repo"),
16044            "main".to_owned(),
16045            "abc1234".to_owned(),
16046            "task".to_owned(),
16047            config,
16048        )
16049    }
16050
16051    fn hold(cache: &Path) -> crate::cache::Guard {
16052        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16053        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16054            crate::cache::AcquireOutcome::Acquired(g) => g,
16055            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16056        }
16057    }
16058
16059    async fn run(
16060        jobs: Vec<SeatJob>,
16061        sem: Arc<Semaphore>,
16062        cache: Option<&Path>,
16063        st: &mut RunState,
16064    ) -> Vec<(usize, SeatState, AgentOutcome)> {
16065        let prompts = Prompts::default();
16066        let ctx = WaveCtx {
16067            run: "r",
16068            node: "implement",
16069            prompts: &prompts,
16070            cache,
16071            round: None,
16072            carry_seats: false,
16073            pauses: None,
16074        };
16075        wave(jobs, sem, &ctx, st, 0).await
16076    }
16077
16078    #[tokio::test]
16079    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16080        crate::run::pin_test_home();
16081        let tmp = tempfile::tempdir().unwrap();
16082        let cache = tmp.path().join("target-a");
16083        let guard = hold(&cache);
16084        // Held for longer than the agent's whole 1s budget.
16085        tokio::spawn(async move {
16086            tokio::time::sleep(Duration::from_millis(1500)).await;
16087            guard.release();
16088        });
16089        let mut st = state(60);
16090        let results = run(
16091            vec![job(
16092                sh("alpha", "echo ok"),
16093                tmp.path(),
16094                Duration::from_secs(1),
16095            )],
16096            Arc::new(Semaphore::new(1)),
16097            Some(&cache),
16098            &mut st,
16099        )
16100        .await;
16101        assert!(
16102            matches!(results[0].2, AgentOutcome::Ok(_)),
16103            "the wait must not be charged to the agent"
16104        );
16105        assert!(st.resource_block.is_none());
16106        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16107        assert!(st.resource_wait.is_none() && st.active.is_empty());
16108    }
16109
16110    #[tokio::test]
16111    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16112        crate::run::pin_test_home();
16113        let tmp = tempfile::tempdir().unwrap();
16114        let cache = tmp.path().join("target-b");
16115        let _held = hold(&cache);
16116        let mut st = state(1);
16117        let results = run(
16118            vec![job(
16119                sh("alpha", "echo ok"),
16120                tmp.path(),
16121                Duration::from_secs(1),
16122            )],
16123            Arc::new(Semaphore::new(1)),
16124            Some(&cache),
16125            &mut st,
16126        )
16127        .await;
16128        let (_, seat, out) = &results[0];
16129        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16130        assert!(wave_blocked(&results));
16131        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16132        assert_eq!(seat.turns, 0, "nothing was asked");
16133        assert!(
16134            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16135            "no agent was spawned"
16136        );
16137        assert!(FailClass::of(out).is_none(), "not a failure class");
16138        let block = st.resource_block.as_ref().expect("recorded for the resume");
16139        assert_eq!(block.kind, "shared build cache");
16140        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16141        assert!(st.active.is_empty());
16142    }
16143
16144    #[tokio::test]
16145    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16146        crate::run::pin_test_home();
16147        let tmp = tempfile::tempdir().unwrap();
16148        let mut st = state(1);
16149        let results = run(
16150            vec![job(
16151                sh("alpha", "echo ok"),
16152                tmp.path(),
16153                Duration::from_secs(1),
16154            )],
16155            Arc::new(Semaphore::new(0)),
16156            None,
16157            &mut st,
16158        )
16159        .await;
16160        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16161        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16162        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16163    }
16164
16165    #[tokio::test]
16166    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16167        crate::run::pin_test_home();
16168        let tmp = tempfile::tempdir().unwrap();
16169        let mut st = state(60);
16170        let results = run(
16171            vec![job(
16172                sh("alpha", "sleep 30"),
16173                tmp.path(),
16174                Duration::from_secs(1),
16175            )],
16176            Arc::new(Semaphore::new(1)),
16177            None,
16178            &mut st,
16179        )
16180        .await;
16181        let out = &results[0].2;
16182        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16183        let class = FailClass::of(out).expect("a failure");
16184        assert_eq!(class, FailClass::Timeout);
16185        assert!(should_hand_over(None, &class), "existing handover policy");
16186        assert!(st.resource_block.is_none());
16187    }
16188}