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        state.status = status;
11954        Runner {
11955            state,
11956            roles: ResolvedRoles {
11957                implementers: Vec::new(),
11958                judges: Vec::new(),
11959                reviewers: Vec::new(),
11960                fixer: None,
11961                conductor: conductor(),
11962                implementer_roster: Vec::new(),
11963                judge_roster: Vec::new(),
11964                reviewer_roster: Vec::new(),
11965            },
11966            sem: Arc::new(Semaphore::new(1)),
11967            pause: Pause::new(),
11968            interrupt: Pause::new(),
11969        }
11970    }
11971
11972    fn merge_repair_request(phase: crate::run::MergeRepairPhase) -> crate::run::MergeRepair {
11973        crate::run::MergeRepair {
11974            question: "repair-question".into(),
11975            head: "old-head".into(),
11976            pr: "https://github.com/o/r/pull/42".into(),
11977            reviews: vec![review_round(false, 1, 1, 1, false, true)],
11978            gate: vec![outcome(Some(0), false)],
11979            gate_head: Some("old-head".into()),
11980            gate_fixes: Vec::new(),
11981            pre_gate: Vec::new(),
11982            phase,
11983            failure: None,
11984        }
11985    }
11986
11987    #[tokio::test]
11988    async fn merge_repair_claim_refuses_concurrent_and_obsolete_drivers() {
11989        crate::run::pin_test_home();
11990        let mut runner = runner_at(RunStatus::Landing);
11991        runner.state.merge_repairs.push(merge_repair_request(
11992            crate::run::MergeRepairPhase::Requested,
11993        ));
11994        runner.state.save().unwrap();
11995        let claim = FixClaim::acquire(&runner.state.dir()).unwrap();
11996        assert!(
11997            runner
11998                .execute()
11999                .await
12000                .unwrap_err()
12001                .to_string()
12002                .contains("already running")
12003        );
12004        assert_eq!(
12005            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12006            crate::run::MergeRepairPhase::Requested
12007        );
12008        drop(claim);
12009        let mut disk = runner.state.clone();
12010        disk.merge_repairs[0].phase = crate::run::MergeRepairPhase::Complete;
12011        disk.save().unwrap();
12012        assert!(
12013            runner
12014                .execute()
12015                .await
12016                .unwrap_err()
12017                .to_string()
12018                .contains("decision changed")
12019        );
12020        assert_eq!(
12021            RunState::load(&runner.state.id).unwrap().merge_repairs[0].phase,
12022            crate::run::MergeRepairPhase::Complete
12023        );
12024    }
12025
12026    #[test]
12027    fn merge_repair_starts_a_real_new_budget_and_preserves_old_evidence() {
12028        crate::run::pin_test_home();
12029        let mut runner = runner_at(RunStatus::Landing);
12030        let request = merge_repair_request(crate::run::MergeRepairPhase::Fixing);
12031        runner.state.reviews = request.reviews.clone();
12032        runner.state.gate = request.gate.clone();
12033        runner.state.gate_ran = true;
12034        runner.state.land_approval = Some(crate::run::LandApproval {
12035            question: "old".into(),
12036            head: "old-head".into(),
12037        });
12038        runner.state.merge_repairs.push(request);
12039        runner.begin_merge_repair_review(0).unwrap();
12040        assert_eq!(review_conclusion(&runner.state.reviews, 2), None);
12041        assert!(!runner.state.gate_status().ok());
12042        assert!(runner.state.land_approval.is_none());
12043        let disk = RunState::load(&runner.state.id).unwrap();
12044        assert_eq!(disk.merge_repairs[0].reviews[0].blocking, 1);
12045        assert!(disk.merge_repairs[0].gate[0].ok());
12046        assert_eq!(
12047            disk.merge_repairs[0].phase,
12048            crate::run::MergeRepairPhase::Reviewing
12049        );
12050        assert_eq!(disk.merge_repairs[0].pr, "https://github.com/o/r/pull/42");
12051        assert!(disk.reviews.is_empty());
12052    }
12053
12054    #[tokio::test]
12055    async fn merge_repair_interrupted_fixer_and_exhausted_generation_hold_without_repeating_work() {
12056        crate::run::pin_test_home();
12057        for phase in [
12058            crate::run::MergeRepairPhase::Fixing,
12059            crate::run::MergeRepairPhase::Requested,
12060        ] {
12061            let mut runner = runner_at(RunStatus::Landing);
12062            runner.state.config.graph.land_rounds = 0;
12063            runner.state.merge_repairs.push(merge_repair_request(phase));
12064            runner.state.save().unwrap();
12065            assert!(!runner.repair_merge_review().await.unwrap());
12066            assert_eq!(runner.state.status, RunStatus::Blocked);
12067            assert_eq!(
12068                runner.state.merge_repairs[0].phase,
12069                crate::run::MergeRepairPhase::Failed
12070            );
12071            let reason = runner.state.merge_repairs[0].failure.as_ref().unwrap();
12072            assert!(
12073                reason.contains(if phase == crate::run::MergeRepairPhase::Fixing {
12074                    "interrupted"
12075                } else {
12076                    "budget exhausted"
12077                })
12078            );
12079            assert!(runner.state.merge.is_none());
12080        }
12081    }
12082
12083    /// `park_here` folding in the reason `Pause::park_because` recorded -
12084    /// this is what lets an operator reading a run's events tell an
12085    /// interrupt-driven park from an ordinary shutdown park.
12086    #[test]
12087    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
12088        crate::run::pin_test_home();
12089        let mut runner = runner_at(RunStatus::Implementing);
12090        let interrupt = Pause::new();
12091        runner.watch_interrupt(interrupt.clone());
12092
12093        interrupt.park_because("task a1b2 asked to run first");
12094
12095        assert!(runner.park_here().expect("park_here"));
12096        assert!(runner.state.parked);
12097        let last = runner.state.events.last().expect("a park event");
12098        assert_eq!(last.node, "park");
12099        assert!(
12100            last.message.contains("task a1b2 asked to run first"),
12101            "expected the interrupt reason in {:?}",
12102            last.message
12103        );
12104    }
12105
12106    /// The cause is stored on the pass when it parks, and the first cause a
12107    /// shared pause was given is the one that stays.
12108    #[test]
12109    fn park_here_records_the_structured_cause_and_the_first_one_wins() {
12110        crate::run::pin_test_home();
12111        let mut runner = runner_at(RunStatus::Implementing);
12112        runner.state.begin_pass();
12113        let shutdown = Pause::new();
12114        runner.on_pause(shutdown.clone());
12115        let upgrade = ParkCause::Upgrade {
12116            from: "0.121.0".to_owned(),
12117            to: Some("0.121.1".to_owned()),
12118        };
12119        shutdown.park_for(upgrade.clone());
12120        shutdown.park_for(ParkCause::Operator);
12121
12122        assert!(runner.park_here().expect("park_here"));
12123        assert_eq!(
12124            runner.state.passes[0].end,
12125            Some(crate::run::PassEnd::Parked { cause: upgrade })
12126        );
12127        let last = runner.state.events.last().expect("a park event");
12128        assert!(last.message.contains("0.121.0"), "{}", last.message);
12129    }
12130
12131    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
12132    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
12133    /// cleared) must not make a *different* run - one only watching its own,
12134    /// unshared interrupt `Pause` - see itself as parked. If a future change
12135    /// ever collapsed these back into one handle, the interrupt scheduler
12136    /// would park every run for the rest of the daemon's life, not just the
12137    /// one it meant to interrupt.
12138    #[test]
12139    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
12140        crate::run::pin_test_home();
12141        let mut runner = runner_at(RunStatus::Implementing);
12142        let shutdown = Pause::new();
12143        runner.on_pause(shutdown.clone());
12144        let interrupt = Pause::new();
12145        runner.watch_interrupt(interrupt.clone());
12146
12147        // Nobody has asked for anything yet.
12148        assert!(!runner.park_here().expect("park_here"));
12149        assert!(!runner.state.parked);
12150
12151        // Only the interrupt handle fires; the shutdown handle stays clear.
12152        interrupt.park_because("test");
12153        assert!(!shutdown.parked());
12154        assert!(runner.park_here().expect("park_here"));
12155    }
12156
12157    /// The property every prior attempt at this feature failed to pin down:
12158    /// asking a run to park while one of its nodes has a real, in-flight
12159    /// async operation running (an agent call, in production) must not cut
12160    /// that operation short. `park_here` is only ever consulted *between*
12161    /// `execute`'s node calls - see its own doc - so nothing inside a node
12162    /// can observe a park request until the node itself returns. This proves
12163    /// that structurally, with real `tokio` concurrency and a channel
12164    /// handshake (never a sleep, which would only prove "usually", not
12165    /// "cannot"): the "node" below reports that it has genuinely started,
12166    /// and only then is the park requested; the node still has to be told to
12167    /// finish before `park_here` is ever called, exactly mirroring every
12168    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
12169    /// in `execute`.
12170    #[tokio::test]
12171    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
12172        crate::run::pin_test_home();
12173        let mut runner = runner_at(RunStatus::Implementing);
12174        let interrupt = Pause::new();
12175        runner.watch_interrupt(interrupt.clone());
12176
12177        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
12178        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
12179
12180        // Stands in for one node's in-flight agent call: it proves it has
12181        // genuinely started, then blocks - exactly as a spawned CLI process
12182        // does - until told to finish.
12183        let node = async move {
12184            started_tx.send(()).expect("send started");
12185            finish_rx.await.expect("recv finish");
12186            "node finished"
12187        };
12188
12189        let interrupter = async move {
12190            started_rx.await.expect("recv started");
12191            // The call is now genuinely in flight. Ask it to park.
12192            interrupt.park_because("higher-priority task waiting");
12193            // Nothing the node does can observe this yet - there is no
12194            // check inside it, by construction - so let the executor run
12195            // anything pending and then let the node finish on its own.
12196            tokio::task::yield_now().await;
12197            finish_tx.send(()).expect("send finish");
12198        };
12199
12200        let (node_result, ()) = tokio::join!(node, interrupter);
12201        assert_eq!(
12202            node_result, "node finished",
12203            "the in-flight call ran to completion"
12204        );
12205
12206        // Only now, at the boundary the real `execute` would check right
12207        // after this node, does the park take effect.
12208        assert!(runner.park_here().expect("park_here"));
12209        assert!(runner.state.parked);
12210    }
12211
12212    /// A run parked mid-competition carries every field it had accumulated
12213    /// through the exact same disk round-trip an ordinary resume uses -
12214    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
12215    /// Nothing about parking for an interrupt is a special case of that path;
12216    /// this is what proves it rather than assuming it.
12217    #[test]
12218    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
12219        crate::run::pin_test_home();
12220        let mut runner = runner_at(RunStatus::Judging);
12221        // `Runner::resume` re-resolves roles from the saved config, which
12222        // refuses an empty roster - give it the same minimal one `conductor`
12223        // itself uses.
12224        runner.state.config.agents = vec![conductor()];
12225        runner.state.candidates = vec![Candidate {
12226            index: 0,
12227            label: 'A',
12228            agent: "alpha".to_owned(),
12229            branch: "magi/x/A".to_owned(),
12230            worktree: PathBuf::from("/nonexistent/worktree"),
12231            summary: "did the thing".to_owned(),
12232            stat: "1 file changed".to_owned(),
12233            files: 1,
12234            commits: 1,
12235            empty: false,
12236            failed: None,
12237            verified_noop: None,
12238            duration_ms: 1234,
12239            folded: false,
12240        }];
12241        let run_id = runner.state.id.clone();
12242
12243        let interrupt = Pause::new();
12244        runner.watch_interrupt(interrupt.clone());
12245        interrupt.park_because("task c3d4 asked to run first");
12246        assert!(runner.park_here().expect("park_here"));
12247
12248        let resumed = Runner::resume(&run_id).expect("resume");
12249        assert_eq!(resumed.state.candidates.len(), 1);
12250        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
12251        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
12252        assert_eq!(resumed.state.status, runner.state.status);
12253        assert!(
12254            resumed.state.parked,
12255            "still parked until `execute` actually walks the graph again"
12256        );
12257        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
12258    }
12259
12260    /// A fresh open question on `run`, stored and handed back for assertions.
12261    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
12262        let mut q = ask::Question::new(
12263            run.to_owned(),
12264            "implement".to_owned(),
12265            "impl-A".to_owned(),
12266            "Which storage backend should the cache use?".to_owned(),
12267            String::new(),
12268            vec!["SQLite".to_owned(), "Redis".to_owned()],
12269        );
12270        store.put(&mut q).unwrap();
12271        q
12272    }
12273
12274    #[test]
12275    fn a_failed_runs_open_question_is_abandoned() {
12276        ask_test_home();
12277        let store = ask::Questions::open();
12278        let mut runner = runner_at(RunStatus::Failed);
12279        let run = runner.state.id.clone();
12280        let q = ask_open_question(&store, &run);
12281
12282        runner.settle_questions();
12283
12284        let back = store.get(&q.id).unwrap();
12285        assert!(
12286            !back.status.open(),
12287            "the seat that asked died with the run; nobody is left to read an answer"
12288        );
12289        assert!(
12290            back.detail.contains(&run) && back.detail.contains("failed"),
12291            "the reason names what the run became, not just that it is gone: {}",
12292            back.detail
12293        );
12294    }
12295
12296    #[test]
12297    fn a_merged_runs_open_question_is_abandoned_too() {
12298        ask_test_home();
12299        let store = ask::Questions::open();
12300        // A run that finishes cleanly still leaves nobody to read an answer -
12301        // this is not only a failure-path cleanup.
12302        for status in [RunStatus::Merged, RunStatus::Ready] {
12303            let mut runner = runner_at(status);
12304            let run = runner.state.id.clone();
12305            let q = ask_open_question(&store, &run);
12306
12307            runner.settle_questions();
12308
12309            let back = store.get(&q.id).unwrap();
12310            assert!(
12311                !back.status.open(),
12312                "{status:?} run's question must not outlive the run"
12313            );
12314        }
12315    }
12316
12317    #[test]
12318    fn a_still_resumable_runs_open_question_is_left_alone() {
12319        ask_test_home();
12320        let store = ask::Questions::open();
12321        // `Blocked` and `Stalled` can still be resumed — the candidates, the
12322        // review round and the seat sessions are all still on disk — so a
12323        // question asked mid-round may yet get a real answer from a real
12324        // resume. Sweeping it here would be exactly the failure mode this
12325        // whole feature exists to avoid on the other side.
12326        for status in [RunStatus::Blocked, RunStatus::Stalled] {
12327            let mut runner = runner_at(status);
12328            let run = runner.state.id.clone();
12329            let q = ask_open_question(&store, &run);
12330
12331            runner.settle_questions();
12332
12333            let back = store.get(&q.id).unwrap();
12334            assert!(
12335                back.status.open(),
12336                "{status:?} is still alive; the question must still be waiting"
12337            );
12338        }
12339    }
12340
12341    #[test]
12342    fn settle_questions_never_touches_an_already_answered_question() {
12343        ask_test_home();
12344        let store = ask::Questions::open();
12345        let mut runner = runner_at(RunStatus::Failed);
12346        let run = runner.state.id.clone();
12347        let mut q = ask_open_question(&store, &run);
12348        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
12349            .unwrap();
12350        store.put(&mut q).unwrap();
12351
12352        // Called twice, the way a crash-recovered daemon reclaim and the
12353        // graph's own cleanup both can for the same run — `abandon_for_run`
12354        // only ever touches what is still open, so this must be inert both
12355        // times, not merely the second.
12356        runner.settle_questions();
12357        runner.settle_questions();
12358
12359        let back = store.get(&q.id).unwrap();
12360        assert_eq!(
12361            back.status,
12362            ask::QuestionStatus::Answered,
12363            "a real answer is a decision on record, never overwritten by a sweep"
12364        );
12365    }
12366
12367    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
12368    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
12369    /// without merging, whose winner is still the operator's answer to read.
12370    /// Nothing previously called `fold_run` itself with a real `tally`, so
12371    /// this is the first test to pin down the one distinction the whole
12372    /// automatic-fold feature depends on: the winner's worktree and branch
12373    /// must survive, everything else sharing the run's worktree bay - a
12374    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
12375    /// second sweep treats every non-winner directory under the bay alike -
12376    /// must not.
12377    #[tokio::test]
12378    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
12379        crate::run::pin_test_home();
12380        let tmp = tempfile::tempdir().expect("tempdir");
12381        let repo = tmp.path().join("repo");
12382        std::fs::create_dir_all(&repo).unwrap();
12383        init_repo(&repo);
12384
12385        let mut config = Config::default();
12386        config.graph.worktree_root = Some(tmp.path().join("wt"));
12387
12388        let mut state = RunState::new(
12389            repo.clone(),
12390            "main".to_owned(),
12391            "deadbeef".to_owned(),
12392            "task".to_owned(),
12393            config,
12394        );
12395        let root = state.worktree_root();
12396        let wt_a = root.join("cand-A");
12397        let wt_b = root.join("cand-B");
12398        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
12399            .await
12400            .expect("worktree A");
12401        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
12402            .await
12403            .expect("worktree B");
12404
12405        state.candidates = vec![
12406            Candidate {
12407                index: 0,
12408                label: 'A',
12409                agent: "alpha".to_owned(),
12410                branch: "magi/x/A".to_owned(),
12411                worktree: wt_a.clone(),
12412                summary: String::new(),
12413                stat: String::new(),
12414                files: 0,
12415                commits: 0,
12416                empty: false,
12417                failed: None,
12418                verified_noop: None,
12419                duration_ms: 0,
12420                folded: false,
12421            },
12422            Candidate {
12423                index: 1,
12424                label: 'B',
12425                agent: "beta".to_owned(),
12426                branch: "magi/x/B".to_owned(),
12427                worktree: wt_b.clone(),
12428                summary: String::new(),
12429                stat: String::new(),
12430                files: 0,
12431                commits: 0,
12432                empty: false,
12433                failed: None,
12434                verified_noop: None,
12435                duration_ms: 0,
12436                folded: false,
12437            },
12438        ];
12439        state.tally = Some(Tally {
12440            first_choice: BTreeMap::from([('A', 1)]),
12441            borda: BTreeMap::new(),
12442            winner: 'A',
12443            rankings: 1,
12444            unanimous_initial: true,
12445            deliberated: false,
12446            changed_votes: 0,
12447            unanimous_final: true,
12448            tie_break: None,
12449            judges: 1,
12450            present: 1,
12451            quorum: 1,
12452            met_quorum: true,
12453            uncontested: None,
12454        });
12455        state.status = RunStatus::Ready;
12456
12457        fold_run(&mut state, false, &crate::run::home())
12458            .await
12459            .expect("fold_run");
12460
12461        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
12462        assert!(
12463            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12464            "the unmerged winner's branch survives"
12465        );
12466        assert!(
12467            !state.candidates[0].folded,
12468            "the winner is not marked folded"
12469        );
12470
12471        assert!(!wt_b.exists(), "the loser's worktree is removed");
12472        assert!(
12473            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
12474            "the loser's branch is removed"
12475        );
12476        assert!(state.candidates[1].folded, "the loser is marked folded");
12477    }
12478
12479    /// A branch handed to a later run is that run's (and its pull request's):
12480    /// folding the run that released it must not delete it.
12481    #[tokio::test]
12482    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
12483        let tmp = tempfile::tempdir().expect("tempdir");
12484        let repo = tmp.path().join("repo");
12485        std::fs::create_dir_all(&repo).unwrap();
12486        init_repo(&repo);
12487        let home = tmp.path().join("home");
12488
12489        let mut config = Config::default();
12490        config.graph.worktree_root = Some(tmp.path().join("wt"));
12491        let mut state = RunState::new(
12492            repo.clone(),
12493            "main".to_owned(),
12494            "deadbeef".to_owned(),
12495            "task".to_owned(),
12496            config,
12497        );
12498        // The worktree is already gone (released); the branch survives.
12499        git::git(&repo, &["branch", "magi/x/A", "main"])
12500            .await
12501            .expect("branch");
12502        state.candidates = vec![Candidate {
12503            index: 0,
12504            label: 'A',
12505            agent: "alpha".to_owned(),
12506            branch: "magi/x/A".to_owned(),
12507            worktree: state.worktree_root().join("cand-A"),
12508            summary: String::new(),
12509            stat: String::new(),
12510            files: 0,
12511            commits: 0,
12512            empty: false,
12513            failed: None,
12514            verified_noop: None,
12515            duration_ms: 0,
12516            folded: true,
12517        }];
12518        state.released_to = Some("20260901-000000-new1".to_owned());
12519        state.released_branches = vec!["magi/x/A".to_owned()];
12520
12521        fold_run(&mut state, true, &home).await.expect("fold_run");
12522
12523        assert!(
12524            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
12525            "the handed-over branch survives a fold"
12526        );
12527    }
12528
12529    /// A winner with nothing ahead of the base is caught before `gh` is ever
12530    /// asked for a pull request, and the message carries what the task's
12531    /// references resolved to.
12532    #[tokio::test]
12533    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
12534        let tmp = tempfile::tempdir().expect("tempdir");
12535        let repo = tmp.path().join("repo");
12536        std::fs::create_dir_all(&repo).unwrap();
12537        init_repo(&repo);
12538        let run = |args: &[&str]| {
12539            let out = std::process::Command::new("git")
12540                .quiet()
12541                .args(args)
12542                .current_dir(&repo)
12543                .output()
12544                .expect("spawn git");
12545            assert!(out.status.success(), "git {args:?}");
12546        };
12547        run(&["branch", "magi/x/A"]);
12548        run(&["checkout", "-q", "-b", "magi/x/B"]);
12549        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
12550        run(&["add", "-A"]);
12551        run(&["commit", "-q", "-m", "work"]);
12552        run(&["checkout", "-q", "main"]);
12553
12554        // A pull request is compared against the remote's base, so the
12555        // fixture needs one. Before it exists nothing can be read, and the
12556        // answer must be "not empty".
12557        let probe = RunState::new(
12558            repo.clone(),
12559            "main".to_owned(),
12560            "deadbeef".to_owned(),
12561            "task".to_owned(),
12562            Config::default(),
12563        );
12564        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
12565        let bare = tmp.path().join("origin.git");
12566        let out = std::process::Command::new("git")
12567            .quiet()
12568            .args(["init", "-q", "--bare"])
12569            .arg(&bare)
12570            .output()
12571            .expect("spawn git");
12572        assert!(out.status.success(), "git init --bare");
12573        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
12574        run(&["push", "-q", "origin", "main"]);
12575
12576        let mut state = RunState::new(
12577            repo.clone(),
12578            "main".to_owned(),
12579            "deadbeef".to_owned(),
12580            "task".to_owned(),
12581            Config::default(),
12582        );
12583        state.seeds = vec![refs::Seed {
12584            token: "magi/27b2/A".to_owned(),
12585            kind: refs::SeedKind::Unresolved,
12586            sha: String::new(),
12587            branch: true,
12588            detail: "no branch or commit named magi/27b2/A".to_owned(),
12589        }];
12590
12591        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
12592        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
12593        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
12594        let detail = empty_candidate_detail(&state, "main");
12595        assert!(detail.starts_with("empty candidate"), "{detail}");
12596        assert!(detail.contains("magi/27b2/A"), "{detail}");
12597    }
12598
12599    /// `status == Ready` used to be read as "this is the harmless
12600    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
12601    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
12602    /// run's PR was closed without merging — and reentering `merge` with
12603    /// `mode` still `Pr` does not know the difference, so it pushed and
12604    /// opened a second pull request. `mode == Local` reproduces the same
12605    /// blind spot without a network call: reentry must not attempt another
12606    /// git merge once this node has already recorded an outcome.
12607    #[tokio::test]
12608    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
12609        let tmp = tempfile::tempdir().expect("tempdir");
12610        let repo = tmp.path().join("repo");
12611        std::fs::create_dir_all(&repo).unwrap();
12612        init_repo(&repo);
12613
12614        let mut config = Config::default();
12615        config.merge.mode = MergeMode::Local;
12616
12617        let mut state = RunState::new(
12618            repo.clone(),
12619            "main".to_owned(),
12620            "deadbeef".to_owned(),
12621            "task".to_owned(),
12622            config,
12623        );
12624        state.candidates = vec![Candidate {
12625            index: 0,
12626            label: 'A',
12627            agent: "alpha".to_owned(),
12628            branch: "does-not-exist".to_owned(),
12629            worktree: repo.clone(),
12630            summary: String::new(),
12631            stat: String::new(),
12632            files: 0,
12633            commits: 0,
12634            empty: false,
12635            failed: None,
12636            verified_noop: None,
12637            duration_ms: 0,
12638            folded: false,
12639        }];
12640        state.tally = Some(Tally {
12641            first_choice: BTreeMap::from([('A', 1)]),
12642            borda: BTreeMap::new(),
12643            winner: 'A',
12644            rankings: 1,
12645            unanimous_initial: true,
12646            deliberated: false,
12647            changed_votes: 0,
12648            unanimous_final: true,
12649            tie_break: None,
12650            judges: 0,
12651            present: 0,
12652            quorum: 0,
12653            met_quorum: true,
12654            uncontested: Some("only candidate A produced a change".to_owned()),
12655        });
12656        state.reviews = vec![ReviewRound {
12657            round: 1,
12658            head: "deadbeef".to_owned(),
12659            verified_head: None,
12660            verified_at: None,
12661            reviews: Vec::new(),
12662            e2e: Vec::new(),
12663            fix: None,
12664            blocking: 0,
12665            answered: 0,
12666            expected: 0,
12667            clean: true,
12668            verify_retried: false,
12669            e2e_deferred: false,
12670            e2e_defer_reason: None,
12671            progressed: false,
12672            vote_split: false,
12673            reconsideration: Vec::new(),
12674            verdict: None,
12675        }];
12676        state.gate = vec![CommandOutcome {
12677            command: "test".to_owned(),
12678            code: Some(0),
12679            output_tail: String::new(),
12680            duration_ms: 0,
12681            resource_blocked: false,
12682        }];
12683        state.gate_ran = true;
12684        // Reached its conclusion already — e.g. `land` closing the PR without
12685        // merging it, which (like the honest `MergeMode::None` path) leaves
12686        // `status` at `Ready`. The recorded outcome is what actually marks
12687        // this node done.
12688        state.status = RunStatus::Ready;
12689        state.merge = Some(MergeOutcome {
12690            mode: MergeMode::Local,
12691            ok: false,
12692            detail: "already concluded".to_owned(),
12693            empty: false,
12694        });
12695
12696        let mut runner = Runner {
12697            state,
12698            roles: ResolvedRoles {
12699                implementers: Vec::new(),
12700                judges: Vec::new(),
12701                reviewers: Vec::new(),
12702                fixer: None,
12703                conductor: conductor(),
12704                implementer_roster: Vec::new(),
12705                judge_roster: Vec::new(),
12706                reviewer_roster: Vec::new(),
12707            },
12708            sem: Arc::new(Semaphore::new(1)),
12709            pause: Pause::new(),
12710            interrupt: Pause::new(),
12711        };
12712
12713        runner.merge().await.expect("merge");
12714
12715        assert_eq!(
12716            runner.state.status,
12717            RunStatus::Ready,
12718            "a concluded run's status must not change on reentry"
12719        );
12720        assert_eq!(
12721            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
12722            Some("already concluded"),
12723            "merge must not run again once the node already recorded an outcome"
12724        );
12725    }
12726
12727    /// `gate` leaves `state.gate_ran` false both before it has ever run and
12728    /// when its last attempt was resource-blocked (the shared build cache
12729    /// could not be acquired or confirmed fresh in time - see
12730    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
12731    /// `Vec` this also leaves behind used to read as "nothing failed" and let
12732    /// a run merge a tree the gate never actually checked - exactly the case
12733    /// a contended cache produces on every retry until it clears. `merge`
12734    /// must refuse until `gate` has actually recorded an attempt.
12735    #[tokio::test]
12736    async fn merge_refuses_a_gate_that_has_not_actually_run() {
12737        let tmp = tempfile::tempdir().expect("tempdir");
12738        let repo = tmp.path().join("repo");
12739        std::fs::create_dir_all(&repo).unwrap();
12740        init_repo(&repo);
12741
12742        let mut config = Config::default();
12743        config.merge.mode = MergeMode::Local;
12744
12745        let mut state = RunState::new(
12746            repo.clone(),
12747            "main".to_owned(),
12748            "deadbeef".to_owned(),
12749            "task".to_owned(),
12750            config,
12751        );
12752        state.candidates = vec![Candidate {
12753            index: 0,
12754            label: 'A',
12755            agent: "alpha".to_owned(),
12756            branch: "does-not-exist".to_owned(),
12757            worktree: repo.clone(),
12758            summary: String::new(),
12759            stat: String::new(),
12760            files: 0,
12761            commits: 0,
12762            empty: false,
12763            failed: None,
12764            verified_noop: None,
12765            duration_ms: 0,
12766            folded: false,
12767        }];
12768        state.tally = Some(Tally {
12769            first_choice: BTreeMap::from([('A', 1)]),
12770            borda: BTreeMap::new(),
12771            winner: 'A',
12772            rankings: 1,
12773            unanimous_initial: true,
12774            deliberated: false,
12775            changed_votes: 0,
12776            unanimous_final: true,
12777            tie_break: None,
12778            judges: 0,
12779            present: 0,
12780            quorum: 0,
12781            met_quorum: true,
12782            uncontested: Some("only candidate A produced a change".to_owned()),
12783        });
12784        state.reviews = vec![ReviewRound {
12785            round: 1,
12786            head: "deadbeef".to_owned(),
12787            verified_head: None,
12788            verified_at: None,
12789            reviews: Vec::new(),
12790            e2e: Vec::new(),
12791            fix: None,
12792            blocking: 0,
12793            answered: 0,
12794            expected: 0,
12795            clean: true,
12796            verify_retried: false,
12797            e2e_deferred: false,
12798            e2e_defer_reason: None,
12799            progressed: false,
12800            vote_split: false,
12801            reconsideration: Vec::new(),
12802            verdict: None,
12803        }];
12804        // The point: `gate` has not recorded anything yet.
12805        state.gate = Vec::new();
12806        state.gate_ran = false;
12807        state.status = RunStatus::Gating;
12808
12809        let mut runner = Runner {
12810            state,
12811            roles: ResolvedRoles {
12812                implementers: Vec::new(),
12813                judges: Vec::new(),
12814                reviewers: Vec::new(),
12815                fixer: None,
12816                conductor: conductor(),
12817                implementer_roster: Vec::new(),
12818                judge_roster: Vec::new(),
12819                reviewer_roster: Vec::new(),
12820            },
12821            sem: Arc::new(Semaphore::new(1)),
12822            pause: Pause::new(),
12823            interrupt: Pause::new(),
12824        };
12825
12826        runner.merge().await.expect("merge");
12827
12828        assert!(
12829            runner.state.merge.is_none(),
12830            "an empty gate must never be read as a passing one: {:?}",
12831            runner.state.merge
12832        );
12833    }
12834
12835    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
12836    /// commands configured and `merge.mode` is `none` (a review-only run).
12837    /// `gate` must still record a real attempt — zero commands, vacuously
12838    /// passed — rather than leaving `state.gate` empty in a way `merge`
12839    /// cannot tell apart from "never ran"; otherwise the run reaches
12840    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
12841    #[tokio::test]
12842    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
12843        let tmp = tempfile::tempdir().expect("tempdir");
12844        let repo = tmp.path().join("repo");
12845        std::fs::create_dir_all(&repo).unwrap();
12846        init_repo(&repo);
12847
12848        // Default config: `verify.gate` empty, `merge.mode` is `none`.
12849        let config = Config::default();
12850
12851        let mut state = RunState::new(
12852            repo.clone(),
12853            "main".to_owned(),
12854            "deadbeef".to_owned(),
12855            "task".to_owned(),
12856            config,
12857        );
12858        state.candidates = vec![Candidate {
12859            index: 0,
12860            label: 'A',
12861            agent: "alpha".to_owned(),
12862            branch: "does-not-exist".to_owned(),
12863            worktree: repo.clone(),
12864            summary: String::new(),
12865            stat: String::new(),
12866            files: 0,
12867            commits: 0,
12868            empty: false,
12869            failed: None,
12870            verified_noop: None,
12871            duration_ms: 0,
12872            folded: false,
12873        }];
12874        state.tally = Some(Tally {
12875            first_choice: BTreeMap::from([('A', 1)]),
12876            borda: BTreeMap::new(),
12877            winner: 'A',
12878            rankings: 1,
12879            unanimous_initial: true,
12880            deliberated: false,
12881            changed_votes: 0,
12882            unanimous_final: true,
12883            tie_break: None,
12884            judges: 0,
12885            present: 0,
12886            quorum: 0,
12887            met_quorum: true,
12888            uncontested: Some("only candidate A produced a change".to_owned()),
12889        });
12890        state.reviews = vec![ReviewRound {
12891            round: 1,
12892            head: "deadbeef".to_owned(),
12893            verified_head: None,
12894            verified_at: None,
12895            reviews: Vec::new(),
12896            e2e: Vec::new(),
12897            fix: None,
12898            blocking: 0,
12899            answered: 0,
12900            expected: 0,
12901            clean: true,
12902            verify_retried: false,
12903            e2e_deferred: false,
12904            e2e_defer_reason: None,
12905            progressed: false,
12906            vote_split: false,
12907            reconsideration: Vec::new(),
12908            verdict: None,
12909        }];
12910
12911        let mut runner = Runner {
12912            state,
12913            roles: ResolvedRoles {
12914                implementers: Vec::new(),
12915                judges: Vec::new(),
12916                reviewers: Vec::new(),
12917                fixer: None,
12918                conductor: conductor(),
12919                implementer_roster: Vec::new(),
12920                judge_roster: Vec::new(),
12921                reviewer_roster: Vec::new(),
12922            },
12923            sem: Arc::new(Semaphore::new(1)),
12924            pause: Pause::new(),
12925            interrupt: Pause::new(),
12926        };
12927
12928        runner.gate().await.expect("gate");
12929        assert!(
12930            runner.state.gate_ran,
12931            "zero configured commands is still a real attempt, not an unrun gate"
12932        );
12933        assert!(runner.state.gate.is_empty());
12934        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
12935        assert_ne!(
12936            runner.state.status,
12937            RunStatus::Blocked,
12938            "a gate with nothing to check must not read as failed"
12939        );
12940
12941        runner.merge().await.expect("merge");
12942        assert_eq!(
12943            runner.state.status,
12944            RunStatus::Ready,
12945            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
12946        );
12947    }
12948
12949    /// `Config::cache_dir` is derived from `verify.e2e` as well as
12950    /// `verify.gate` (so the e2e leg and the final gate never build against
12951    /// different directories). With zero `verify.gate` commands but a
12952    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
12953    /// that lease before discovering it had nothing to run - so a repo with
12954    /// no gate commands could come back `resource_blocked` (and therefore
12955    /// still `gate_ran == false`) on nothing but an unrelated run holding the
12956    /// cache, exactly the contention this run's own zero commands could
12957    /// never have touched. `gate` must recognise there is nothing to check
12958    /// before it ever asks for the lease.
12959    #[tokio::test]
12960    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
12961        crate::run::pin_test_home();
12962        let home = crate::run::home();
12963
12964        let tmp = tempfile::tempdir().expect("tempdir");
12965        let repo = tmp.path().join("repo");
12966        std::fs::create_dir_all(&repo).unwrap();
12967        init_repo(&repo);
12968        // Unique to this test, so holding its lease cannot collide with
12969        // another test sharing the same process-wide `home`.
12970        let cache_dir = tmp.path().join("target");
12971
12972        let mut config = Config::default();
12973        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
12974        // `verify.gate` stays empty (the default). Bounded so a regression
12975        // that does start waiting fails the test in seconds, not hangs it.
12976        config.graph.timeout_verify = Some(2);
12977
12978        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
12979        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
12980            .expect("no io error acquiring directly")
12981        {
12982            crate::cache::AcquireOutcome::Acquired(g) => g,
12983            crate::cache::AcquireOutcome::Busy(b) => {
12984                panic!("expected the direct acquire to win the lease first: {b:?}")
12985            }
12986        };
12987
12988        let mut state = RunState::new(
12989            repo.clone(),
12990            "main".to_owned(),
12991            "deadbeef".to_owned(),
12992            "task".to_owned(),
12993            config,
12994        );
12995        state.candidates = vec![Candidate {
12996            index: 0,
12997            label: 'A',
12998            agent: "alpha".to_owned(),
12999            branch: "does-not-exist".to_owned(),
13000            worktree: repo.clone(),
13001            summary: String::new(),
13002            stat: String::new(),
13003            files: 0,
13004            commits: 0,
13005            empty: false,
13006            failed: None,
13007            verified_noop: None,
13008            duration_ms: 0,
13009            folded: false,
13010        }];
13011        state.tally = Some(Tally {
13012            first_choice: BTreeMap::from([('A', 1)]),
13013            borda: BTreeMap::new(),
13014            winner: 'A',
13015            rankings: 1,
13016            unanimous_initial: true,
13017            deliberated: false,
13018            changed_votes: 0,
13019            unanimous_final: true,
13020            tie_break: None,
13021            judges: 0,
13022            present: 0,
13023            quorum: 0,
13024            met_quorum: true,
13025            uncontested: Some("only candidate A produced a change".to_owned()),
13026        });
13027        state.reviews = vec![ReviewRound {
13028            round: 1,
13029            head: "deadbeef".to_owned(),
13030            verified_head: None,
13031            verified_at: None,
13032            reviews: Vec::new(),
13033            e2e: Vec::new(),
13034            fix: None,
13035            blocking: 0,
13036            answered: 0,
13037            expected: 0,
13038            clean: true,
13039            verify_retried: false,
13040            e2e_deferred: false,
13041            e2e_defer_reason: None,
13042            progressed: false,
13043            vote_split: false,
13044            reconsideration: Vec::new(),
13045            verdict: None,
13046        }];
13047
13048        let mut runner = Runner {
13049            state,
13050            roles: ResolvedRoles {
13051                implementers: Vec::new(),
13052                judges: Vec::new(),
13053                reviewers: Vec::new(),
13054                fixer: None,
13055                conductor: conductor(),
13056                implementer_roster: Vec::new(),
13057                judge_roster: Vec::new(),
13058                reviewer_roster: Vec::new(),
13059            },
13060            sem: Arc::new(Semaphore::new(1)),
13061            pause: Pause::new(),
13062            interrupt: Pause::new(),
13063        };
13064
13065        let started = std::time::Instant::now();
13066        runner.gate().await.expect("gate");
13067        assert!(
13068            started.elapsed() < Duration::from_secs(1),
13069            "a gate with nothing to run must never wait on a lease it never needed"
13070        );
13071        assert!(
13072            runner.state.gate_ran,
13073            "zero commands is still a real, immediate attempt"
13074        );
13075        assert!(runner.state.gate.is_empty());
13076        assert_ne!(
13077            runner.state.status,
13078            RunStatus::Blocked,
13079            "must not read as resource-blocked on a lease it never asked for"
13080        );
13081    }
13082
13083    /// The addendum's second gap: a `verify.gate` command running for real
13084    /// wall-clock time had nothing at all to show for it in `active` before
13085    /// `run_commands` learned to record it — a run could sit in `Gating` for
13086    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
13087    /// what was actually happening. Proven with a genuinely still-running
13088    /// command, not just a before/after check on the final state: a poller
13089    /// task reads the same `run.json` `gate()` is writing, the same way the
13090    /// phone or `magi show` would, while the shell command is still blocked
13091    /// on its own release marker.
13092    #[tokio::test]
13093    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
13094        crate::run::pin_test_home();
13095
13096        let tmp = tempfile::tempdir().expect("tempdir");
13097        let repo = tmp.path().join("repo");
13098        std::fs::create_dir_all(&repo).unwrap();
13099        init_repo(&repo);
13100
13101        let mut config = Config::default();
13102        config.verify.gate = vec![
13103            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
13104             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
13105                .to_owned(),
13106        ];
13107
13108        let mut state = RunState::new(
13109            repo.clone(),
13110            "main".to_owned(),
13111            "deadbeef".to_owned(),
13112            "task".to_owned(),
13113            config,
13114        );
13115        let run_id = state.id.clone();
13116        state.candidates = vec![Candidate {
13117            index: 0,
13118            label: 'A',
13119            agent: "alpha".to_owned(),
13120            branch: "does-not-exist".to_owned(),
13121            worktree: repo.clone(),
13122            summary: String::new(),
13123            stat: String::new(),
13124            files: 0,
13125            commits: 0,
13126            empty: false,
13127            failed: None,
13128            verified_noop: None,
13129            duration_ms: 0,
13130            folded: false,
13131        }];
13132        state.tally = Some(Tally {
13133            first_choice: BTreeMap::from([('A', 1)]),
13134            borda: BTreeMap::new(),
13135            winner: 'A',
13136            rankings: 1,
13137            unanimous_initial: true,
13138            deliberated: false,
13139            changed_votes: 0,
13140            unanimous_final: true,
13141            tie_break: None,
13142            judges: 0,
13143            present: 0,
13144            quorum: 0,
13145            met_quorum: true,
13146            uncontested: Some("only candidate A produced a change".to_owned()),
13147        });
13148        state.reviews = vec![ReviewRound {
13149            round: 1,
13150            head: "deadbeef".to_owned(),
13151            verified_head: None,
13152            verified_at: None,
13153            reviews: Vec::new(),
13154            e2e: Vec::new(),
13155            fix: None,
13156            blocking: 0,
13157            answered: 0,
13158            expected: 0,
13159            clean: true,
13160            verify_retried: false,
13161            e2e_deferred: false,
13162            e2e_defer_reason: None,
13163            progressed: false,
13164            vote_split: false,
13165            reconsideration: Vec::new(),
13166            verdict: None,
13167        }];
13168
13169        let mut runner = Runner {
13170            state,
13171            roles: ResolvedRoles {
13172                implementers: Vec::new(),
13173                judges: Vec::new(),
13174                reviewers: Vec::new(),
13175                fixer: None,
13176                conductor: conductor(),
13177                implementer_roster: Vec::new(),
13178                judge_roster: Vec::new(),
13179                reviewer_roster: Vec::new(),
13180            },
13181            sem: Arc::new(Semaphore::new(1)),
13182            pause: Pause::new(),
13183            interrupt: Pause::new(),
13184        };
13185
13186        let started_marker = repo.join("started.marker");
13187        let release_marker = repo.join("release.marker");
13188        let poller = tokio::spawn(async move {
13189            // Bounded so a regression that never records the task entry
13190            // fails this test in seconds instead of hanging the suite —
13191            // the same shape `a_park_requested_while_a_seat_is_mid_call_
13192            // does_not_cut_it_short` uses for the same reason.
13193            for _ in 0..100 {
13194                if started_marker.exists()
13195                    && let Ok(s) = crate::run::RunState::load(&run_id)
13196                    && let Some(a) = s.active.get("gate")
13197                {
13198                    std::fs::write(&release_marker, b"go").expect("release marker");
13199                    return Some(a.clone());
13200                }
13201                tokio::time::sleep(Duration::from_millis(50)).await;
13202            }
13203            None
13204        });
13205
13206        runner.gate().await.expect("gate");
13207        let captured = poller.await.expect("poller task");
13208        let captured = captured.expect(
13209            "the poller never saw a `gate` task entry in run.json while the command was \
13210             still blocked on its own release marker",
13211        );
13212
13213        assert_eq!(captured.task.as_deref(), Some("gate"));
13214        assert_eq!(captured.node, "gate");
13215        assert_eq!(captured.index, Some(1));
13216        assert_eq!(captured.total, Some(1));
13217        assert!(
13218            captured
13219                .command
13220                .as_deref()
13221                .is_some_and(|c| c.contains("started.marker")),
13222            "{captured:?}"
13223        );
13224
13225        assert!(
13226            runner.state.active.is_empty(),
13227            "the entry must be cleared once the command actually finished: {:?}",
13228            runner.state.active
13229        );
13230        assert!(runner.state.gate_ran);
13231        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
13232    }
13233
13234    /// The hand-off over a blocking finding a reviewer rejected on leaves a
13235    /// record for `land`; one with only a Minor, or no reject, leaves none.
13236    #[tokio::test]
13237    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
13238        use crate::verdict::{Finding, ReviewVote, Severity};
13239        crate::run::pin_test_home();
13240        let tmp = tempfile::tempdir().expect("tempdir");
13241        let repo = tmp.path().join("repo");
13242        std::fs::create_dir_all(&repo).unwrap();
13243        init_repo(&repo);
13244
13245        for (severity, vote, expect) in [
13246            (Severity::Major, ReviewVote::Reject, true),
13247            (Severity::Minor, ReviewVote::Reject, false),
13248            (Severity::Major, ReviewVote::Approve, false),
13249        ] {
13250            let mut round = review_round(false, 1, 1, 1, false, true);
13251            round.reviews = vec![ReviewRecord {
13252                reviewer: 1,
13253                agent: "alpha".to_owned(),
13254                summary: String::new(),
13255                findings: vec![Finding {
13256                    id: "R1-1-1".to_owned(),
13257                    severity,
13258                    file: None,
13259                    line: None,
13260                    title: "t".to_owned(),
13261                    detail: String::new(),
13262                }],
13263                vote: Some(vote),
13264                failed: None,
13265                duration_ms: 0,
13266                attempts: 0,
13267            }];
13268            let mut state = RunState::new(
13269                repo.clone(),
13270                "main".to_owned(),
13271                "deadbeef".to_owned(),
13272                "task".to_owned(),
13273                Config::default(),
13274            );
13275            state.reviews = vec![round];
13276            let mut runner = Runner {
13277                state,
13278                roles: ResolvedRoles {
13279                    implementers: Vec::new(),
13280                    judges: Vec::new(),
13281                    reviewers: Vec::new(),
13282                    fixer: None,
13283                    conductor: conductor(),
13284                    implementer_roster: Vec::new(),
13285                    judge_roster: Vec::new(),
13286                    reviewer_roster: Vec::new(),
13287                },
13288                sem: Arc::new(Semaphore::new(1)),
13289                pause: Pause::new(),
13290                interrupt: Pause::new(),
13291            };
13292            let shell = runner.state.config.shell();
13293            runner
13294                .stop_reviewing("round budget spent", &shell, &repo)
13295                .await
13296                .expect("stop_reviewing");
13297            assert_eq!(runner.state.status, RunStatus::Gating);
13298            assert_eq!(
13299                runner.state.contested_handoff.is_some(),
13300                expect,
13301                "{severity:?} + {vote:?}"
13302            );
13303        }
13304    }
13305
13306    /// The shape the incident this whole fix responds to actually had: the
13307    /// round budget spent, the last round's own e2e blocked on the shared
13308    /// build cache (held here by a live pid — this test process — exactly
13309    /// `cache`'s own unit tests' pattern for "another owner, still alive"
13310    /// without forking a process). `stop_reviewing` must retry it — not
13311    /// silently leave the round looking untouched (the catch-up-only half of
13312    /// the bug), and not read the contention as a red `e2e` and block the
13313    /// run on it (the other half). Called directly, the same way
13314    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
13315    /// above exercises `gate`, so this never needs a real cargo build to
13316    /// reach: the lease is never released, so `with_cache_lease` never gets
13317    /// past acquiring it into anything that would need a real workspace.
13318    #[tokio::test]
13319    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
13320        crate::run::pin_test_home();
13321        let home = crate::run::home();
13322
13323        let tmp = tempfile::tempdir().expect("tempdir");
13324        let repo = tmp.path().join("repo");
13325        std::fs::create_dir_all(&repo).unwrap();
13326        init_repo(&repo);
13327        let head = crate::git::rev_parse(&repo, "HEAD")
13328            .await
13329            .expect("rev-parse");
13330        // Unique to this test, so holding its lease cannot collide with
13331        // another test sharing the same process-wide `home`.
13332        let cache_dir = tmp.path().join("target");
13333
13334        let mut config = Config::default();
13335        config.verify.e2e = vec![format!(
13336            "CARGO_TARGET_DIR='{}' test -f README.md",
13337            cache_dir.display()
13338        )];
13339        config.graph.review_rounds = 1;
13340        // Bounded so a regression that does start waiting fails the test in
13341        // seconds, not hangs it.
13342        config.graph.timeout_verify = Some(2);
13343
13344        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13345        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13346            .expect("no io error acquiring directly")
13347        {
13348            crate::cache::AcquireOutcome::Acquired(g) => g,
13349            crate::cache::AcquireOutcome::Busy(b) => {
13350                panic!("expected the direct acquire to win the lease first: {b:?}")
13351            }
13352        };
13353
13354        let mut state = RunState::new(
13355            repo.clone(),
13356            "main".to_owned(),
13357            head.clone(),
13358            "task".to_owned(),
13359            config,
13360        );
13361        state.candidates = vec![Candidate {
13362            index: 0,
13363            label: 'A',
13364            agent: "alpha".to_owned(),
13365            branch: "does-not-exist".to_owned(),
13366            worktree: repo.clone(),
13367            summary: String::new(),
13368            stat: String::new(),
13369            files: 0,
13370            commits: 0,
13371            empty: false,
13372            failed: None,
13373            verified_noop: None,
13374            duration_ms: 0,
13375            folded: false,
13376        }];
13377        state.tally = Some(Tally {
13378            first_choice: BTreeMap::from([('A', 1)]),
13379            borda: BTreeMap::new(),
13380            winner: 'A',
13381            rankings: 1,
13382            unanimous_initial: true,
13383            deliberated: false,
13384            changed_votes: 0,
13385            unanimous_final: true,
13386            tie_break: None,
13387            judges: 0,
13388            present: 0,
13389            quorum: 0,
13390            met_quorum: true,
13391            uncontested: Some("only candidate A produced a change".to_owned()),
13392        });
13393        // The round budget's last round, deferred: `needs_catchup_run`'s
13394        // other trigger. `stop_reviewing`'s retry machinery must treat this
13395        // exactly like a resource-blocked attempt once it actually runs.
13396        state.reviews = vec![ReviewRound {
13397            round: 1,
13398            head: head.clone(),
13399            verified_head: None,
13400            verified_at: None,
13401            reviews: Vec::new(),
13402            e2e: Vec::new(),
13403            fix: None,
13404            blocking: 1,
13405            answered: 1,
13406            expected: 1,
13407            clean: false,
13408            verify_retried: false,
13409            e2e_deferred: true,
13410            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
13411            progressed: false,
13412            vote_split: false,
13413            reconsideration: Vec::new(),
13414            verdict: None,
13415        }];
13416
13417        let mut runner = Runner {
13418            state,
13419            roles: ResolvedRoles {
13420                implementers: Vec::new(),
13421                judges: Vec::new(),
13422                reviewers: Vec::new(),
13423                fixer: None,
13424                conductor: conductor(),
13425                implementer_roster: Vec::new(),
13426                judge_roster: Vec::new(),
13427                reviewer_roster: Vec::new(),
13428            },
13429            sem: Arc::new(Semaphore::new(1)),
13430            pause: Pause::new(),
13431            interrupt: Pause::new(),
13432        };
13433
13434        let shell = runner.state.config.shell();
13435        runner
13436            .stop_reviewing("round budget spent", &shell, &repo)
13437            .await
13438            .expect("stop_reviewing");
13439
13440        let last = runner.state.reviews.last().expect("round record");
13441        assert_eq!(
13442            last.e2e_status(),
13443            E2eStatus::ResourceBlocked,
13444            "the shared cache is still held; the attempt must read as blocked, not deferred or \
13445             failed: {last:?}"
13446        );
13447        assert_eq!(
13448            last.verified_head.as_deref(),
13449            Some(head.as_str()),
13450            "which commit this attempt targeted is known even though nothing finished checking \
13451             it"
13452        );
13453        let first_attempt_at = last
13454            .verified_at
13455            .expect("when this attempt ran is known too");
13456        assert_ne!(
13457            runner.state.status,
13458            RunStatus::Blocked,
13459            "contention is evidence about the machine, not the patch — it must not settle the \
13460             run as blocked: {:?}",
13461            runner.state.status
13462        );
13463        assert!(
13464            !runner
13465                .state
13466                .events
13467                .iter()
13468                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
13469            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
13470            runner.state.events
13471        );
13472
13473        // The cache is still held: a later reentry must retry the same
13474        // round's verification again — not leave it looking exactly as
13475        // untouched as the first blocked attempt, which is indistinguishable
13476        // from never having tried again at all.
13477        runner
13478            .stop_reviewing("round budget spent", &shell, &repo)
13479            .await
13480            .expect("stop_reviewing retry");
13481        assert_eq!(
13482            runner.state.reviews.len(),
13483            1,
13484            "no new round was started: {:?}",
13485            runner.state.reviews
13486        );
13487        let last = runner.state.reviews.last().expect("round record");
13488        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
13489        assert!(
13490            last.verified_at.expect("still known") > first_attempt_at,
13491            "a second reentry must be a fresh attempt, not a stale copy of the first"
13492        );
13493        assert_ne!(runner.state.status, RunStatus::Blocked);
13494
13495        held.release();
13496    }
13497
13498    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
13499    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
13500    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
13501    /// own top-of-function fast path (`review_conclusion`) correctly reads
13502    /// this shape as `None` rather than guessing `Blocked`, and the loop's
13503    /// own `for` range is empty once the round budget is spent, so
13504    /// `review_loop` must retry the check itself rather than silently doing
13505    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
13506    /// above exercises directly, but through `review_loop`'s own entry point
13507    /// this time, proving the wiring between the two rather than just the
13508    /// retry logic in isolation.
13509    #[tokio::test]
13510    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
13511        crate::run::pin_test_home();
13512        let home = crate::run::home();
13513
13514        let tmp = tempfile::tempdir().expect("tempdir");
13515        let repo = tmp.path().join("repo");
13516        std::fs::create_dir_all(&repo).unwrap();
13517        init_repo(&repo);
13518        let head = crate::git::rev_parse(&repo, "HEAD")
13519            .await
13520            .expect("rev-parse");
13521        let cache_dir = tmp.path().join("target");
13522
13523        let mut config = Config::default();
13524        config.verify.e2e = vec![format!(
13525            "CARGO_TARGET_DIR='{}' test -f README.md",
13526            cache_dir.display()
13527        )];
13528        config.graph.review_rounds = 1;
13529        config.graph.timeout_verify = Some(2);
13530
13531        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
13532        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
13533            .expect("no io error acquiring directly")
13534        {
13535            crate::cache::AcquireOutcome::Acquired(g) => g,
13536            crate::cache::AcquireOutcome::Busy(b) => {
13537                panic!("expected the direct acquire to win the lease first: {b:?}")
13538            }
13539        };
13540
13541        let mut state = RunState::new(
13542            repo.clone(),
13543            "main".to_owned(),
13544            head.clone(),
13545            "task".to_owned(),
13546            config,
13547        );
13548        state.candidates = vec![Candidate {
13549            index: 0,
13550            label: 'A',
13551            agent: "alpha".to_owned(),
13552            branch: "does-not-exist".to_owned(),
13553            worktree: repo.clone(),
13554            summary: String::new(),
13555            stat: String::new(),
13556            files: 0,
13557            commits: 0,
13558            empty: false,
13559            failed: None,
13560            verified_noop: None,
13561            duration_ms: 0,
13562            folded: false,
13563        }];
13564        state.tally = Some(Tally {
13565            first_choice: BTreeMap::from([('A', 1)]),
13566            borda: BTreeMap::new(),
13567            winner: 'A',
13568            rankings: 1,
13569            unanimous_initial: true,
13570            deliberated: false,
13571            changed_votes: 0,
13572            unanimous_final: true,
13573            tie_break: None,
13574            judges: 0,
13575            present: 0,
13576            quorum: 0,
13577            met_quorum: true,
13578            uncontested: Some("only candidate A produced a change".to_owned()),
13579        });
13580        // The exact shape a prior process's `stop_reviewing` would have left
13581        // on disk: the round budget's last round, a real attempt already
13582        // made and already resource-blocked.
13583        state.reviews = vec![ReviewRound {
13584            round: 1,
13585            head: head.clone(),
13586            verified_head: Some(head.clone()),
13587            verified_at: Some(jiff::Timestamp::now()),
13588            reviews: Vec::new(),
13589            e2e: vec![CommandOutcome {
13590                command: format!(
13591                    "CARGO_TARGET_DIR='{}' test -f README.md",
13592                    cache_dir.display()
13593                ),
13594                code: None,
13595                output_tail: "waiting for the shared build cache".to_owned(),
13596                duration_ms: 0,
13597                resource_blocked: true,
13598            }],
13599            fix: None,
13600            blocking: 1,
13601            answered: 1,
13602            expected: 1,
13603            clean: false,
13604            verify_retried: false,
13605            e2e_deferred: false,
13606            e2e_defer_reason: None,
13607            progressed: false,
13608            vote_split: false,
13609            reconsideration: Vec::new(),
13610            verdict: None,
13611        }];
13612
13613        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
13614        let mut runner = Runner {
13615            state,
13616            roles: ResolvedRoles {
13617                implementers: Vec::new(),
13618                judges: Vec::new(),
13619                reviewers: Vec::new(),
13620                fixer: None,
13621                conductor: conductor(),
13622                implementer_roster: Vec::new(),
13623                judge_roster: Vec::new(),
13624                reviewer_roster: Vec::new(),
13625            },
13626            sem: Arc::new(Semaphore::new(1)),
13627            pause: Pause::new(),
13628            interrupt: Pause::new(),
13629        };
13630
13631        // The lease is still held throughout, so this reentry's own retry is
13632        // also contended — proving `review_loop` actually tried again (not
13633        // that it happened to succeed) is what the timestamp comparison
13634        // below is for.
13635        runner.review_loop().await.expect("review_loop");
13636
13637        assert_eq!(
13638            runner.state.reviews.len(),
13639            1,
13640            "no new round was started on top of the unresolved one: {:?}",
13641            runner.state.reviews
13642        );
13643        let last = &runner.state.reviews[0];
13644        assert_eq!(
13645            last.e2e_status(),
13646            E2eStatus::ResourceBlocked,
13647            "still contended: {last:?}"
13648        );
13649        assert!(
13650            last.verified_at.expect("still known") > first_attempt_at,
13651            "review_loop must have actually retried the check, not left it exactly as found"
13652        );
13653        assert_ne!(
13654            runner.state.status,
13655            RunStatus::Blocked,
13656            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
13657            runner.state.status
13658        );
13659
13660        held.release();
13661    }
13662
13663    #[tokio::test]
13664    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
13665        crate::run::pin_test_home();
13666        let tmp = tempfile::tempdir().expect("tempdir");
13667        let repo = tmp.path().join("repo");
13668        std::fs::create_dir_all(&repo).unwrap();
13669        init_repo(&repo);
13670
13671        let mut config = Config::default();
13672        config.merge.mode = MergeMode::Pr;
13673        config.graph.land = true;
13674        config.graph.land_approval = false;
13675
13676        let mut state = RunState::new(
13677            repo.clone(),
13678            "main".to_owned(),
13679            "deadbeef".to_owned(),
13680            "task".to_owned(),
13681            config,
13682        );
13683        state.candidates = vec![Candidate {
13684            index: 0,
13685            label: 'A',
13686            agent: "alpha".to_owned(),
13687            branch: "does-not-exist".to_owned(),
13688            worktree: repo.clone(),
13689            summary: String::new(),
13690            stat: String::new(),
13691            files: 0,
13692            commits: 0,
13693            empty: false,
13694            failed: None,
13695            verified_noop: None,
13696            duration_ms: 0,
13697            folded: false,
13698        }];
13699        state.tally = Some(Tally {
13700            first_choice: BTreeMap::from([('A', 1)]),
13701            borda: BTreeMap::new(),
13702            winner: 'A',
13703            rankings: 1,
13704            unanimous_initial: true,
13705            deliberated: false,
13706            changed_votes: 0,
13707            unanimous_final: true,
13708            tie_break: None,
13709            judges: 0,
13710            present: 0,
13711            quorum: 0,
13712            met_quorum: true,
13713            uncontested: Some("only candidate A produced a change".to_owned()),
13714        });
13715        state.reviews = vec![ReviewRound {
13716            round: 1,
13717            head: "deadbeef".to_owned(),
13718            verified_head: None,
13719            verified_at: None,
13720            reviews: Vec::new(),
13721            e2e: Vec::new(),
13722            fix: None,
13723            blocking: 0,
13724            answered: 0,
13725            expected: 0,
13726            clean: true,
13727            verify_retried: false,
13728            e2e_deferred: false,
13729            e2e_defer_reason: None,
13730            progressed: false,
13731            vote_split: false,
13732            reconsideration: Vec::new(),
13733            verdict: None,
13734        }];
13735        state.gate = vec![CommandOutcome {
13736            command: "test".to_owned(),
13737            code: Some(0),
13738            output_tail: String::new(),
13739            duration_ms: 0,
13740            resource_blocked: false,
13741        }];
13742        state.gate_ran = true;
13743        // A first pass through `merge` already pushed and opened this pull
13744        // request; `status` is `Landing` because a previous call into `land`
13745        // parked or was interrupted before it reached a terminal outcome.
13746        state.status = RunStatus::Landing;
13747        state.merge = Some(MergeOutcome {
13748            mode: MergeMode::Pr,
13749            ok: true,
13750            detail: "https://example.invalid/x/y/pull/1".to_owned(),
13751            empty: false,
13752        });
13753
13754        // The Landing-resume shortcut calls `run_land` directly rather than
13755        // through `merge`, which is exactly the call site that used to skip
13756        // `settle_questions` - see the fixture below.
13757        ask_test_home();
13758        // A real resume loads a persisted run; driver ownership is checked
13759        // against that record before the Landing shortcut is entered.
13760        state.save().expect("save landing run");
13761        let store = ask::Questions::open();
13762        let q = ask_open_question(&store, &state.id);
13763
13764        let mut runner = Runner {
13765            state,
13766            roles: ResolvedRoles {
13767                implementers: Vec::new(),
13768                judges: Vec::new(),
13769                reviewers: Vec::new(),
13770                fixer: None,
13771                conductor: conductor(),
13772                implementer_roster: Vec::new(),
13773                judge_roster: Vec::new(),
13774                reviewer_roster: Vec::new(),
13775            },
13776            sem: Arc::new(Semaphore::new(1)),
13777            pause: Pause::new(),
13778            interrupt: Pause::new(),
13779        };
13780
13781        // `execute`, not `merge` directly: the Landing-resume shortcut lives
13782        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
13783        // exactly because `review_loop` would otherwise clobber the marker
13784        // first.
13785        runner.execute().await.expect("execute");
13786
13787        assert_eq!(
13788            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
13789            Some("https://example.invalid/x/y/pull/1"),
13790            "reentry must not push again or open a second pull request over the \
13791             one `land` is already watching"
13792        );
13793        assert_ne!(
13794            runner.state.status,
13795            RunStatus::Landing,
13796            "land could not actually reach the fake pull request, so it must \
13797             have given up rather than left the run silently parked forever"
13798        );
13799        // `land` could not reach the fake pull request, so it gave up into
13800        // `Blocked` - still resumable, so the question must not have been
13801        // swept just because this branch now also calls `settle_questions`.
13802        assert_eq!(runner.state.status, RunStatus::Blocked);
13803        assert!(
13804            store.get(&q.id).unwrap().status.open(),
13805            "Blocked is still alive; settle_questions must have been a no-op here"
13806        );
13807    }
13808
13809    fn state_with_round(round: ReviewRound) -> RunState {
13810        let mut s = RunState::new(
13811            PathBuf::from("/repo"),
13812            "main".to_owned(),
13813            "abc1234".to_owned(),
13814            "add retries".to_owned(),
13815            Config::default(),
13816        );
13817        s.reviews = vec![round];
13818        s
13819    }
13820
13821    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
13822        crate::verdict::Finding {
13823            id: id.to_owned(),
13824            severity,
13825            file: None,
13826            line: None,
13827            title: title.to_owned(),
13828            detail: String::new(),
13829        }
13830    }
13831
13832    #[test]
13833    fn pr_body_names_open_findings_and_declined_ones() {
13834        let round = ReviewRound {
13835            round: 2,
13836            head: "deadbee".to_owned(),
13837            verified_head: None,
13838            verified_at: None,
13839            reviews: vec![ReviewRecord {
13840                attempts: 0,
13841                reviewer: 1,
13842                agent: "alpha".to_owned(),
13843                summary: String::new(),
13844                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
13845                vote: None,
13846                failed: None,
13847                duration_ms: 0,
13848            }],
13849            e2e: vec![CommandOutcome {
13850                command: "cargo test".to_owned(),
13851                code: Some(0),
13852                output_tail: String::new(),
13853                duration_ms: 0,
13854                resource_blocked: false,
13855            }],
13856            verify_retried: false,
13857            e2e_deferred: false,
13858            e2e_defer_reason: None,
13859            fix: Some(FixRecord {
13860                agent: "alpha".to_owned(),
13861                addressed: Vec::new(),
13862                rejected: vec![crate::verdict::Rejection {
13863                    id: "R1-1-1".to_owned(),
13864                    why: "not reachable from any caller".to_owned(),
13865                }],
13866                notes: String::new(),
13867                committed: true,
13868                failed: None,
13869                duration_ms: 0,
13870                continuation: None,
13871            }),
13872            blocking: 0,
13873            answered: 1,
13874            expected: 1,
13875            clean: false,
13876            progressed: true,
13877            vote_split: false,
13878            reconsideration: Vec::new(),
13879            verdict: None,
13880        };
13881        let state = state_with_round(round);
13882        let body = pr_message(&state, 'A').body;
13883
13884        assert!(body.contains("add retries"), "the task must still be there");
13885        assert!(body.contains("R2-1-1"), "{body}");
13886        assert!(!body.contains("unused import"), "{body}");
13887        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
13888        assert!(
13889            !body.contains("not reachable from any caller"),
13890            "the reason it was declined: {body}"
13891        );
13892    }
13893
13894    #[test]
13895    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
13896        let round = ReviewRound {
13897            round: 1,
13898            head: "deadbee".to_owned(),
13899            verified_head: None,
13900            verified_at: None,
13901            reviews: vec![ReviewRecord {
13902                attempts: 0,
13903                reviewer: 1,
13904                agent: "alpha".to_owned(),
13905                summary: String::new(),
13906                findings: Vec::new(),
13907                vote: None,
13908                failed: None,
13909                duration_ms: 0,
13910            }],
13911            e2e: Vec::new(),
13912            verify_retried: false,
13913            e2e_deferred: false,
13914            e2e_defer_reason: None,
13915            fix: None,
13916            blocking: 0,
13917            answered: 1,
13918            expected: 1,
13919            clean: true,
13920            progressed: false,
13921            vote_split: false,
13922            reconsideration: Vec::new(),
13923            verdict: None,
13924        };
13925        let state = state_with_round(round);
13926        let body = pr_message(&state, 'A').body;
13927        assert!(!body.contains("Open review findings"), "{body}");
13928        assert!(!body.contains("Declined"), "{body}");
13929    }
13930
13931    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
13932        let mut state = RunState::new(
13933            PathBuf::from("/repo"),
13934            "main".to_owned(),
13935            "abc1234".to_owned(),
13936            instruction.to_owned(),
13937            Config::default(),
13938        );
13939        state.candidates.push(Candidate {
13940            index: 0,
13941            label: 'A',
13942            agent: "alpha".to_owned(),
13943            branch: "magi/x/A".to_owned(),
13944            worktree: PathBuf::from("/wt"),
13945            summary: summary.to_owned(),
13946            stat: String::new(),
13947            files: 1,
13948            commits: 1,
13949            empty: false,
13950            failed: None,
13951            verified_noop: None,
13952            folded: false,
13953            duration_ms: 0,
13954        });
13955        state
13956    }
13957
13958    fn review_state(subjects: &[&str]) -> RunState {
13959        let mut state = state_with_summary(
13960            "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",
13961            "",
13962        );
13963        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
13964        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
13965        state
13966    }
13967
13968    /// A branch rebased onto a main that moved past the recorded
13969    /// `base_commit` is titled from its own first commit, never main's.
13970    #[tokio::test]
13971    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
13972        ask_test_home();
13973        let tmp = tempfile::tempdir().unwrap();
13974        let repo = tmp.path().join("repo");
13975        std::fs::create_dir_all(&repo).unwrap();
13976        init_repo(&repo);
13977        let origin = tmp.path().join("origin.git");
13978        let g = |dir: &Path, args: &[&str]| {
13979            let out = std::process::Command::new("git")
13980                .args(args)
13981                .current_dir(dir)
13982                .quiet()
13983                .output()
13984                .expect("spawn git");
13985            assert!(
13986                out.status.success(),
13987                "git {args:?}: {}",
13988                String::from_utf8_lossy(&out.stderr)
13989            );
13990        };
13991        g(
13992            tmp.path(),
13993            &[
13994                "clone",
13995                "--bare",
13996                "-q",
13997                repo.to_str().unwrap(),
13998                origin.to_str().unwrap(),
13999            ],
14000        );
14001        g(
14002            &repo,
14003            &["remote", "add", "origin", origin.to_str().unwrap()],
14004        );
14005        let c1 = git::rev_parse(&repo, "main").await.unwrap();
14006
14007        // Main moves on; the branch is built on top of the new main.
14008        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
14009        g(&repo, &["add", "-A"]);
14010        g(
14011            &repo,
14012            &["commit", "-q", "-m", "chore(deps): update a crate"],
14013        );
14014        g(&repo, &["push", "-q", "origin", "main"]);
14015        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
14016        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14017        g(&repo, &["add", "-A"]);
14018        g(
14019            &repo,
14020            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
14021        );
14022        g(&repo, &["checkout", "-q", "main"]);
14023
14024        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
14025        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
14026        // Without a readable tracking ref the recorded base's merge base is used.
14027        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
14028        assert_eq!(fallback, c1);
14029
14030        let mut state = review_state(&[
14031            "chore(deps): update a crate",
14032            "fix(daemon): apply a chosen action",
14033        ]);
14034        state.repo = repo.clone();
14035        state.base_branch = "main".to_owned();
14036        state.base_commit = c1;
14037        refresh_reviewed_commits(&mut state, "feat/own").await;
14038        assert_eq!(
14039            state.reviewed_commits,
14040            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
14041        );
14042        assert_eq!(
14043            review_title(&state).as_deref(),
14044            Some("fix(daemon): apply a chosen action")
14045        );
14046        assert_eq!(
14047            leaked_subjects(&state, "feat/own").await,
14048            Some(vec!["chore(deps): update a crate".to_owned()])
14049        );
14050        // A stale tracking ref is still used when the fetch fails, but the
14051        // leak list is withheld.
14052        state.config.merge.remote = "nowhere".to_owned();
14053        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
14054    }
14055
14056    /// A review-only run whose winner is `feat/own`, checked out in `repo`,
14057    /// with `origin/main` one commit behind it, and a summarizing agent that
14058    /// prints whatever `reply` holds.
14059    struct SummaryFixture {
14060        _tmp: tempfile::TempDir,
14061        repo: PathBuf,
14062        reply: PathBuf,
14063        runner: Runner,
14064    }
14065
14066    fn summary_fixture() -> SummaryFixture {
14067        ask_test_home();
14068        let tmp = tempfile::tempdir().unwrap();
14069        let repo = tmp.path().join("repo");
14070        std::fs::create_dir_all(&repo).unwrap();
14071        init_repo(&repo);
14072        let origin = tmp.path().join("origin.git");
14073        let g = |args: &[&str]| {
14074            let out = std::process::Command::new("git")
14075                .args(args)
14076                .current_dir(&repo)
14077                .quiet()
14078                .output()
14079                .expect("spawn git");
14080            assert!(
14081                out.status.success(),
14082                "git {args:?}: {}",
14083                String::from_utf8_lossy(&out.stderr)
14084            );
14085        };
14086        g(&["init", "-q", "--bare", origin.to_str().unwrap()]);
14087        g(&["remote", "add", "origin", origin.to_str().unwrap()]);
14088        g(&["push", "-q", "origin", "main"]);
14089        g(&["checkout", "-q", "-b", "feat/own"]);
14090        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
14091        g(&["add", "-A"]);
14092        g(&["commit", "-q", "-m", "feat(own): add the own file"]);
14093        let reply = tmp.path().join("reply.txt");
14094        std::fs::write(
14095            &reply,
14096            "TITLE: feat(own): add the own file\nAdds `own.txt` so the first summary is read.",
14097        )
14098        .unwrap();
14099        let mut runner = runner_at(RunStatus::Gating);
14100        let mut state = review_state(&["feat(own): add the own file"]);
14101        state.repo = repo.clone();
14102        state.base_branch = "main".to_owned();
14103        state.candidates[0].branch = "feat/own".to_owned();
14104        state.candidates[0].worktree = repo.clone();
14105        let mut author = spec("alpha");
14106        author.command = vec!["sh".into(), "-c".into(), "cat \"$REPLY_FILE\"".into()];
14107        author
14108            .env
14109            .insert("REPLY_FILE".into(), reply.to_string_lossy().into_owned());
14110        state.config.agents = vec![author];
14111        runner.state = state;
14112        SummaryFixture {
14113            _tmp: tmp,
14114            repo,
14115            reply,
14116            runner,
14117        }
14118    }
14119
14120    async fn posted(f: &mut SummaryFixture) -> PrMessage {
14121        let winner = f.runner.state.candidates[0].clone();
14122        f.runner
14123            .guarded_pr_message(&winner, None, true)
14124            .await
14125            .unwrap()
14126            .expect("an English description never parks")
14127    }
14128
14129    fn clean_round_with_minor_findings() -> ReviewRound {
14130        let mut round = passing_round(2, None);
14131        round.verdict = Some(crate::verdict::ReviewVote::ApproveWithFindings);
14132        round.reviews = vec![ReviewRecord {
14133            attempts: 0,
14134            reviewer: 1,
14135            agent: "alpha".into(),
14136            summary: "内部レビュー".into(),
14137            findings: vec![
14138                finding("R2-1-1", Severity::Minor, "未修正の懸念その一"),
14139                finding("R2-1-2", Severity::Minor, "未修正の懸念その二"),
14140            ],
14141            vote: Some(crate::verdict::ReviewVote::ApproveWithFindings),
14142            failed: None,
14143            duration_ms: 0,
14144        }];
14145        round
14146    }
14147
14148    #[tokio::test]
14149    async fn public_descriptions_cover_minor_findings_from_clean_rounds_in_both_modes() {
14150        for review_only in [false, true] {
14151            let mut f = summary_fixture();
14152            if !review_only {
14153                f.runner.state.candidates[0].agent = "alpha".into();
14154            }
14155            f.runner.state.reviews = vec![clean_round_with_minor_findings()];
14156            assert!(f.runner.state.reviews[0].clean);
14157            assert!(f.runner.state.reviews[0].fix.is_none());
14158            assert!(f.runner.state.open_findings().is_empty());
14159            assert_eq!(
14160                crate::pr_outcome::required_finding_ids(&f.runner.state),
14161                ["R2-1-1", "R2-1-2"]
14162            );
14163            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14164            let evidence = crate::pr_outcome::summary_evidence(
14165                &f.runner.state,
14166                crate::pr_outcome::FinalHead::At(&head),
14167            );
14168            assert!(
14169                evidence.contains("未修正の懸念その一") && evidence.contains("未修正の懸念その二")
14170            );
14171            // The ordinary reply omits both IDs: no cached description is adopted.
14172            let omitted = posted(&mut f).await;
14173            assert!(omitted.body.contains("No description"));
14174            assert!(omitted.body.contains("`R2-1-1` [Minor]"));
14175            assert!(omitted.body.contains("`R2-1-2` [Minor]"));
14176            assert!(f.runner.state.change_summary.is_none());
14177            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();
14178            let described = posted(&mut f).await;
14179            assert!(described.body.contains("improve documentation for callers"));
14180            assert!(described.body.contains("remaining edge case"));
14181            assert!(described.body.contains("## Open review findings"));
14182            assert!(!described.body.contains("未修正"));
14183        }
14184    }
14185
14186    async fn rewrite_fixture_with_findings(review_only: bool, reply: &str) -> SummaryFixture {
14187        let mut f = summary_fixture();
14188        if !review_only {
14189            f.runner.state.candidates[0].agent = "alpha".into();
14190        }
14191        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14192        let mut fixed = round_with_notes(1, Some("日本語の内部修正報告"));
14193        fixed
14194            .fix
14195            .as_mut()
14196            .unwrap()
14197            .rejected
14198            .push(crate::verdict::Rejection {
14199                id: "R1-1-1".into(),
14200                why: "呼び出されないコードのため辞退".into(),
14201            });
14202        let mut clean = clean_round_with_minor_findings();
14203        clean.verified_head = Some(head.clone());
14204        f.runner.state.reviews = vec![fixed, clean];
14205        ok_gate(&mut f.runner.state, Some(&head));
14206        std::fs::write(&f.reply, "TITLE: fix: public description\n日本語の公開用説明です。R1-1-1 は辞退。R2-1-1 と R2-1-2 の懸念が残ります。").unwrap();
14207        f.runner.state.config.agents.push(scripted("beta", reply));
14208        f.runner.state.config.roles.language_judge =
14209            Some(crate::config::AgentChoice::One("beta".into()));
14210        f
14211    }
14212
14213    #[tokio::test]
14214    async fn posting_rewrite_retains_structured_evidence_and_finding_explanations() {
14215        for review_only in [false, true] {
14216            let mut f = rewrite_fixture_with_findings(review_only,
14217                "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;
14218            let m = posted(&mut f).await;
14219            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14220            let expected =
14221                public_pr_evidence(&f.runner.state, crate::pr_outcome::FinalHead::At(&head));
14222            assert!(m.body.contains(&expected));
14223            assert!(m.body.contains("## Review rounds"));
14224            assert!(
14225                m.body
14226                    .contains("Gate (`verify.gate`): passed on the final commit")
14227            );
14228            assert!(
14229                m.body
14230                    .contains("Tests (`verify.e2e`): passed on the final commit")
14231            );
14232            let c = f.runner.state.change_summary.as_ref().unwrap();
14233            assert!(
14234                m.body
14235                    .contains(&format!("Evidence: final diff `{}`..`{}`", c.base, head))
14236            );
14237            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14238            assert!(crate::pr_outcome::covers_required_findings(
14239                &f.runner.state,
14240                &m.body
14241            ));
14242            assert!(m.body.contains("documentation remains incomplete"));
14243            assert!(!m.body.contains("内部") && !m.body.contains("辞退"));
14244            assert_eq!(f.runner.state.seats["github-text-rewrite"].turns, 1);
14245            assert!(f.runner.state.github_text.is_none());
14246        }
14247    }
14248
14249    #[tokio::test]
14250    async fn posting_rewrite_missing_required_finding_context_is_not_offered_or_published() {
14251        for review_only in [false, true] {
14252            for reply in [
14253                "TITLE: fix: public description\nA short English description.",
14254                "TITLE: fix: public description\nR1-1-1 and R2-1-2 remain; R2-1-1-extra is a different ID.",
14255            ] {
14256                let mut f = rewrite_fixture_with_findings(review_only, reply).await;
14257                let winner = f.runner.state.candidates[0].clone();
14258                let posted = f
14259                    .runner
14260                    .guarded_pr_message(&winner, None, true)
14261                    .await
14262                    .unwrap();
14263                assert!(posted.is_none());
14264                assert!(f.runner.state.parked);
14265                let withheld = f.runner.state.github_text.as_ref().unwrap();
14266                assert!(
14267                    withheld.rewrite.is_none(),
14268                    "an incomplete rewrite must not be selectable"
14269                );
14270                let q = ask::Questions::open()
14271                    .get(withheld.question.as_ref().unwrap())
14272                    .unwrap();
14273                assert!(
14274                    !q.choices
14275                        .contains(&crate::github_text::USE_REWRITE.to_owned())
14276                );
14277                assert!(
14278                    q.detail.contains("R1-1-1")
14279                        && q.detail.contains("R2-1-1")
14280                        && q.detail.contains("R2-1-2")
14281                );
14282            }
14283        }
14284    }
14285
14286    #[tokio::test]
14287    async fn japanese_reports_stay_local_in_both_public_run_modes() {
14288        for review_only in [false, true] {
14289            let mut f = summary_fixture();
14290            if !review_only {
14291                f.runner.state.candidates[0].agent = "alpha".into();
14292            }
14293            f.runner.state.instruction = "内部タスクの説明".into();
14294            f.runner.state.candidates[0].summary = "内部の候補報告".into();
14295            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14296            f.runner.state.reviews = vec![
14297                round_with_notes(1, Some("コミットできません。テストは実行できません。")),
14298                round_with_notes(2, Some("二回目の内部修正報告です。")),
14299                passing_round(3, Some(&head)),
14300            ];
14301            f.runner.state.reviews[0].fix.as_mut().unwrap().addressed = vec!["R1-1-1".into()];
14302            ok_gate(&mut f.runner.state, Some(&head));
14303            let captured = f.repo.join("public-prompt.txt");
14304            let author = &mut f.runner.state.config.agents[0];
14305            author.command = vec![
14306                "sh".into(),
14307                "-c".into(),
14308                "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14309            ];
14310            author.env.insert(
14311                "PROMPT_FILE".into(),
14312                captured.to_string_lossy().into_owned(),
14313            );
14314            let m = posted(&mut f).await;
14315            assert!(m.body.contains("Adds `own.txt`"));
14316            assert!(m.body.contains("Round 1") && m.body.contains("Round 2"));
14317            assert!(m.body.contains("R1-1-1"));
14318            assert!(
14319                m.body
14320                    .contains("Gate (`verify.gate`): passed on the final commit")
14321            );
14322            assert!(
14323                m.body
14324                    .contains("Tests (`verify.e2e`): passed on the final commit")
14325            );
14326            assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14327            assert!(!m.body.contains("内部") && !m.body.contains("できません"));
14328            let prompt = std::fs::read_to_string(captured).unwrap();
14329            assert!(prompt.contains(&format!(
14330                "git diff {} {head}",
14331                f.runner.state.change_summary.as_ref().unwrap().base
14332            )));
14333            assert!(!prompt.contains("二回目") && !prompt.contains("コミットできません"));
14334            assert_eq!(
14335                f.runner.state.reviews[1].fix.as_ref().unwrap().notes,
14336                "二回目の内部修正報告です。"
14337            );
14338        }
14339    }
14340
14341    #[tokio::test]
14342    async fn public_generation_failure_never_falls_back_to_internal_notes_and_resume_retries() {
14343        for review_only in [false, true] {
14344            let mut f = summary_fixture();
14345            if !review_only {
14346                f.runner.state.candidates[0].agent = "alpha".into();
14347            }
14348            f.runner.state.candidates[0].summary = "内部報告を公開しない".into();
14349            f.runner.state.reviews = vec![round_with_notes(1, Some("日本語の修正報告"))];
14350            std::fs::write(&f.reply, "no usable description").unwrap();
14351            let failed = posted(&mut f).await;
14352            assert!(
14353                failed
14354                    .body
14355                    .contains("No description of what this change does could be generated")
14356            );
14357            assert!(failed.body.is_ascii());
14358            assert!(f.runner.state.github_text.is_none());
14359            assert!(f.runner.state.change_summary.is_none());
14360            let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14361            let evidence = crate::pr_outcome::summary_evidence(
14362                &f.runner.state,
14363                crate::pr_outcome::FinalHead::At(&head),
14364            );
14365            let fp = crate::pr_outcome::summary_fingerprint(&f.runner.state, &evidence);
14366            assert!(
14367                crate::run::artifact_path(&f.runner.state, &format!("public-description-{fp}.md"))
14368                    .exists()
14369            );
14370            f.runner.state =
14371                serde_json::from_str(&serde_json::to_string(&f.runner.state).unwrap()).unwrap();
14372            std::fs::write(
14373                &f.reply,
14374                "TITLE: feat(own): add the own file\nAdds the committed own file.",
14375            )
14376            .unwrap();
14377            let retried = posted(&mut f).await;
14378            assert!(retried.body.contains("Adds the committed own file."));
14379            assert_eq!(
14380                f.runner.state.reviews[0].fix.as_ref().unwrap().notes,
14381                "日本語の修正報告"
14382            );
14383        }
14384    }
14385
14386    #[tokio::test]
14387    async fn same_head_verification_and_findings_invalidate_public_prose() {
14388        let mut f = summary_fixture();
14389        posted(&mut f).await;
14390        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14391        let original = f
14392            .runner
14393            .state
14394            .change_summary
14395            .as_ref()
14396            .unwrap()
14397            .evidence
14398            .clone();
14399        ok_gate(&mut f.runner.state, Some(&head));
14400        std::fs::write(
14401            &f.reply,
14402            "TITLE: feat(own): add the own file\nDescribes the now verified final change.",
14403        )
14404        .unwrap();
14405        let verified = posted(&mut f).await;
14406        assert!(verified.body.contains("now verified"));
14407        assert_ne!(
14408            f.runner.state.change_summary.as_ref().unwrap().evidence,
14409            original
14410        );
14411        let mut round = round_with_notes(1, Some("内部報告"));
14412        round.reviews.push(ReviewRecord {
14413            attempts: 0,
14414            reviewer: 1,
14415            agent: "alpha".into(),
14416            summary: "内部レビュー".into(),
14417            findings: vec![finding("R1-1-1", Severity::Minor, "残っている懸念")],
14418            vote: None,
14419            failed: None,
14420            duration_ms: 0,
14421        });
14422        round
14423            .fix
14424            .as_mut()
14425            .unwrap()
14426            .rejected
14427            .push(crate::verdict::Rejection {
14428                id: "R1-1-1".into(),
14429                why: "呼び出し元から到達できない".into(),
14430            });
14431        f.runner.state.reviews = vec![round];
14432        // A description omitting the required ID is unusable, even with a title.
14433        let omitted = posted(&mut f).await;
14434        assert!(omitted.body.contains("No description"));
14435        assert!(omitted.body.contains("R1-1-1"));
14436        assert!(!omitted.body.contains("到達"));
14437        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();
14438        let described = posted(&mut f).await;
14439        assert!(described.body.contains("unreachable from callers"));
14440        assert!(described.body.contains("R1-1-1"));
14441        assert!(!described.body.contains("到達"));
14442        let evidence = f
14443            .runner
14444            .state
14445            .change_summary
14446            .as_ref()
14447            .unwrap()
14448            .evidence
14449            .clone();
14450        f.runner.state.reviews[0].fix.as_mut().unwrap().rejected[0].why = "別の根拠".into();
14451        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: decline rationale updated after review.").unwrap();
14452        assert!(posted(&mut f).await.body.contains("rationale updated"));
14453        assert_ne!(
14454            f.runner.state.change_summary.as_ref().unwrap().evidence,
14455            evidence
14456        );
14457        // Old serialized summaries have no evidence fingerprint and must regenerate.
14458        f.runner
14459            .state
14460            .change_summary
14461            .as_mut()
14462            .unwrap()
14463            .evidence
14464            .clear();
14465        std::fs::write(&f.reply, "TITLE: feat(own): add the own file\nR1-1-1 [Minor]: regenerated under the current specification.").unwrap();
14466        assert!(posted(&mut f).await.body.contains("current specification"));
14467    }
14468
14469    #[tokio::test]
14470    async fn a_review_only_run_with_no_fixer_gets_a_summary_of_the_final_diff() {
14471        let mut f = summary_fixture();
14472        let m = posted(&mut f).await;
14473        assert_eq!(m.title, "feat(own): add the own file");
14474        assert!(
14475            m.body
14476                .contains("## Summary\n\nAdds `own.txt` so the first summary is read."),
14477            "{}",
14478            m.body
14479        );
14480        assert!(!m.body.contains("## Review fixes"), "{}", m.body);
14481        assert!(m.body.contains("Evidence: final diff"), "{}", m.body);
14482        assert!(m.body.contains(&format!("magi:run/{}", f.runner.state.id)));
14483        let head = git::rev_parse(&f.repo, "HEAD").await.unwrap();
14484        let base = git::rev_parse(&f.repo, "origin/main").await.unwrap();
14485        let kept = f.runner.state.change_summary.as_ref().unwrap();
14486        assert_eq!(
14487            (kept.head.as_str(), kept.base.as_str()),
14488            (head.as_str(), base.as_str())
14489        );
14490        assert!(!f.runner.state.seats["change-summary"].key.is_empty());
14491    }
14492
14493    #[tokio::test]
14494    async fn review_only_summary_receives_the_configured_github_language() {
14495        let mut f = summary_fixture();
14496        f.runner.state.config.graph.github_language = "ja".into();
14497        let captured = f.repo.join("summary-prompt.txt");
14498        let author = &mut f.runner.state.config.agents[0];
14499        author.command = vec![
14500            "sh".into(),
14501            "-c".into(),
14502            "cat > \"$PROMPT_FILE\"; cat \"$REPLY_FILE\"".into(),
14503        ];
14504        author.env.insert(
14505            "PROMPT_FILE".into(),
14506            captured.to_string_lossy().into_owned(),
14507        );
14508        std::fs::write(
14509            &f.reply,
14510            "TITLE: feat(own): ファイルを追加\nown.txt を追加します。",
14511        )
14512        .unwrap();
14513        let m = posted(&mut f).await;
14514        let prompt = std::fs::read_to_string(captured).unwrap();
14515        assert!(prompt.contains("Write the TITLE line and the Markdown description in Japanese."));
14516        assert!(!prompt.contains("in English"));
14517        assert_eq!(m.title, "feat(own): ファイルを追加");
14518        assert!(m.body.contains("own.txt を追加します。"));
14519    }
14520
14521    #[tokio::test]
14522    async fn review_fixes_come_after_the_whole_change_summary() {
14523        let mut f = summary_fixture();
14524        f.runner.state.reviews = vec![
14525            round_with_notes(1, Some("handled the PATH case")),
14526            round_with_notes(2, None),
14527        ];
14528        let m = posted(&mut f).await;
14529        let summary = m.body.find("## Summary").expect("summary");
14530        let fixes = m.body.find("## Review rounds").expect("review rounds");
14531        assert!(summary < fixes, "{}", m.body);
14532        assert!(!m.body.contains("handled the PATH case"), "{}", m.body);
14533        assert!(m.body.contains("## Final verification"), "{}", m.body);
14534    }
14535
14536    #[tokio::test]
14537    async fn a_changed_final_diff_invalidates_the_earlier_summary() {
14538        let mut f = summary_fixture();
14539        let first = posted(&mut f).await;
14540        let first_head = f.runner.state.change_summary.clone().unwrap().head;
14541        // The same head reuses the record: a different reply is not read.
14542        std::fs::write(&f.reply, "TITLE: feat(own): other\nNot read yet.").unwrap();
14543        let again = posted(&mut f).await;
14544        assert_eq!((again.title, again.body), (first.title, first.body));
14545
14546        let g = |args: &[&str]| {
14547            let out = std::process::Command::new("git")
14548                .args(args)
14549                .current_dir(&f.repo)
14550                .quiet()
14551                .output()
14552                .expect("spawn git");
14553            assert!(out.status.success(), "git {args:?}");
14554        };
14555        std::fs::write(f.repo.join("more.txt"), "more\n").unwrap();
14556        g(&["add", "-A"]);
14557        g(&["commit", "-q", "-m", "fix(own): handle more"]);
14558        let second = posted(&mut f).await;
14559        let kept = f.runner.state.change_summary.clone().unwrap();
14560        assert_ne!(kept.head, first_head);
14561        assert!(second.body.contains("Not read yet."), "{}", second.body);
14562        assert!(!second.body.contains("so the first summary is read"));
14563        assert_eq!(second.title, "feat(own): other");
14564    }
14565
14566    #[tokio::test]
14567    async fn an_unavailable_summary_is_stated_and_never_invented() {
14568        let mut f = summary_fixture();
14569        std::fs::write(&f.reply, "no title line here").unwrap();
14570        let m = posted(&mut f).await;
14571        assert!(
14572            m.body
14573                .contains("No description of what this change does could be generated"),
14574            "{}",
14575            m.body
14576        );
14577        assert!(m.body.contains("internal reports remain in local records"));
14578        assert!(f.runner.state.change_summary.is_none());
14579        assert!(!m.body.contains("## Commits under review"), "{}", m.body);
14580        // A preview asks nobody and claims nothing.
14581        let winner = f.runner.state.candidates[0].clone();
14582        let preview = f
14583            .runner
14584            .guarded_pr_message(&winner, None, false)
14585            .await
14586            .unwrap()
14587            .unwrap();
14588        assert!(!preview.body.contains("## Summary"), "{}", preview.body);
14589    }
14590
14591    #[test]
14592    fn pr_message_review_single_commit_uses_its_subject() {
14593        let state = review_state(&["feat(nats): per-role user"]);
14594        let m = pr_message(&state, 'A');
14595        assert_eq!(m.title, "feat(nats): per-role user");
14596        assert!(!m.body.contains("Review the work already"), "{}", m.body);
14597        assert!(m.body.contains("## Commits under review"), "{}", m.body);
14598    }
14599
14600    #[test]
14601    fn pr_message_review_multi_commit_takes_the_oldest() {
14602        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
14603        let m = pr_message(&state, 'A');
14604        assert_eq!(m.title, "feat: the change");
14605        for s in ["feat: the change", "fix: typo", "fix: again"] {
14606            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
14607        }
14608    }
14609
14610    #[test]
14611    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
14612        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
14613            let mut state = review_state(&[first, "fix: later fixup"]);
14614            state.candidates[0].branch = "機能/ログイン".to_owned();
14615            let m = pr_message(&state, 'A');
14616            assert!(
14617                m.title.starts_with("chore: land candidate A of run"),
14618                "{}",
14619                m.title
14620            );
14621        }
14622    }
14623
14624    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
14625        BranchFacts {
14626            commits: commits
14627                .iter()
14628                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
14629                .collect(),
14630            stat: stat.to_owned(),
14631        }
14632    }
14633
14634    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
14635        let mut r = review_round(true, 0, 1, 1, true, true);
14636        r.round = round;
14637        r.fix = notes.map(|n| FixRecord {
14638            agent: "fixer".to_owned(),
14639            addressed: Vec::new(),
14640            rejected: Vec::new(),
14641            notes: n.to_owned(),
14642            committed: true,
14643            failed: None,
14644            duration_ms: 0,
14645            continuation: None,
14646        });
14647        r
14648    }
14649
14650    #[test]
14651    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
14652        let state = review_state(&["ignored"]);
14653        let f = facts(
14654            &[
14655                (
14656                    "fix(login): resolve PATH on macOS",
14657                    "Login shells skip rc files.",
14658                ),
14659                ("fix: address review", ""),
14660            ],
14661            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
14662        );
14663        let m = pr_message_with(&state, 'A', Some(&f));
14664        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
14665        assert!(
14666            m.body
14667                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
14668            "{}",
14669            m.body
14670        );
14671        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
14672        assert!(m.body.contains("## Diff stat"), "{}", m.body);
14673        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
14674        for banned in [
14675            "Review the work already",
14676            "no task statement",
14677            "Original task",
14678        ] {
14679            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
14680        }
14681        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
14682    }
14683
14684    #[test]
14685    fn pr_message_review_truncates_a_huge_first_commit_body() {
14686        let state = review_state(&["ignored"]);
14687        let f = facts(
14688            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
14689            "s",
14690        );
14691        let m = pr_message_with(&state, 'A', Some(&f));
14692        assert!(m.body.len() < 30_000, "{}", m.body.len());
14693        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
14694        assert!(
14695            m.body.contains("- ... 1 more commit(s)"),
14696            "{}",
14697            m.body.len()
14698        );
14699        assert!(m.body.ends_with("magi:candidate-a\n"));
14700    }
14701
14702    #[test]
14703    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
14704        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
14705        assert_eq!(m.title, "feat: x");
14706        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
14707        assert!(!m.body.contains("Diff stat"), "{}", m.body);
14708        assert!(!m.body.contains("no task statement"), "{}", m.body);
14709    }
14710
14711    #[test]
14712    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
14713        let mut state = review_state(&["日本語の件名"]);
14714        state.candidates[0].branch = "fix/macos-login-path".to_owned();
14715        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
14716        state.candidates[0].branch = "機能/ログイン".to_owned();
14717        assert!(
14718            pr_message(&state, 'A')
14719                .title
14720                .starts_with("chore: land candidate A")
14721        );
14722    }
14723
14724    #[test]
14725    fn pr_message_review_fixes_survive_a_clean_final_round() {
14726        let mut state = review_state(&["feat: x"]);
14727        state.reviews = vec![
14728            round_with_notes(1, Some("handled the PATH case")),
14729            round_with_notes(2, None),
14730        ];
14731        let body = pr_message(&state, 'A').body;
14732        assert!(
14733            body.contains("## Review rounds") && !body.contains("handled the PATH case"),
14734            "{body}"
14735        );
14736        assert!(body.contains("### Round 1"), "{body}");
14737
14738        state.reviews = vec![
14739            round_with_notes(1, Some("first fix")),
14740            round_with_notes(2, Some("")),
14741            round_with_notes(3, Some("second fix")),
14742            round_with_notes(4, None),
14743        ];
14744        let body = pr_message(&state, 'A').body;
14745        assert!(
14746            body.contains("### Round 1") && !body.contains("first fix"),
14747            "{body}"
14748        );
14749        assert!(
14750            body.contains("### Round 3") && !body.contains("second fix"),
14751            "{body}"
14752        );
14753        assert!(body.contains("### Round 2"), "{body}");
14754    }
14755
14756    const FINAL: &str = "abcdef1234567890";
14757
14758    fn passing_round(round: usize, verified: Option<&str>) -> ReviewRound {
14759        let mut r = round_with_notes(round, None);
14760        r.verified_head = verified.map(str::to_owned);
14761        r
14762    }
14763
14764    fn ok_gate(state: &mut RunState, head: Option<&str>) {
14765        state.gate = vec![CommandOutcome {
14766            command: "gate".to_owned(),
14767            code: Some(0),
14768            output_tail: String::new(),
14769            duration_ms: 0,
14770            resource_blocked: false,
14771        }];
14772        state.gate_ran = true;
14773        state.gate_head = head.map(str::to_owned);
14774    }
14775
14776    fn body_at(state: &RunState) -> String {
14777        pr_message_raw(
14778            state,
14779            'A',
14780            None,
14781            crate::pr_outcome::FinalHead::At(FINAL),
14782            ChangeNote::NotAsked,
14783        )
14784        .body
14785    }
14786
14787    const SANDBOXED: &str = "Handled the PATH case.\nI could not commit because of the sandbox. HTTP tests could not run in the sandbox.";
14788
14789    #[test]
14790    fn rescued_commit_and_later_verification_override_the_fixers_limits() {
14791        let mut state = review_state(&["feat: x"]);
14792        state.reviews = vec![
14793            round_with_notes(1, Some(SANDBOXED)),
14794            passing_round(2, Some(FINAL)),
14795        ];
14796        ok_gate(&mut state, Some(FINAL));
14797        let body = body_at(&state);
14798        assert!(!body.contains("Handled the PATH case."), "{body}");
14799        assert!(!body.contains("could not commit"), "{body}");
14800        assert!(!body.contains("could not run"), "{body}");
14801        assert!(
14802            body.contains("The fix is committed on the branch"),
14803            "{body}"
14804        );
14805        assert!(!body.contains("sandbox"), "{body}");
14806        assert!(
14807            body.contains("Tests (`verify.e2e`): passed on the final commit"),
14808            "{body}"
14809        );
14810        assert!(
14811            body.contains("Gate (`verify.gate`): passed on the final commit"),
14812            "{body}"
14813        );
14814        // The raw report is untouched in the record.
14815        assert!(
14816            state.reviews[0]
14817                .fix
14818                .as_ref()
14819                .unwrap()
14820                .notes
14821                .contains("could not commit")
14822        );
14823    }
14824
14825    #[test]
14826    fn unresolved_commit_and_test_limits_stay_and_nothing_claims_success() {
14827        let mut state = review_state(&["feat: x"]);
14828        let mut r1 = round_with_notes(1, Some(SANDBOXED));
14829        r1.fix.as_mut().unwrap().committed = false;
14830        let mut r2 = passing_round(2, Some(FINAL));
14831        r2.e2e[0].code = Some(1);
14832        state.reviews = vec![r1, r2];
14833        let body = body_at(&state);
14834        assert!(!body.contains("could not commit"), "{body}");
14835        assert!(!body.contains("could not run"), "{body}");
14836        assert!(body.contains("This round produced no commit"), "{body}");
14837        assert!(body.contains("Tests (`verify.e2e`): FAILED"), "{body}");
14838        assert!(body.contains("Gate (`verify.gate`): not run"), "{body}");
14839        assert!(!body.contains("passed"), "{body}");
14840    }
14841
14842    #[test]
14843    fn verification_on_an_earlier_commit_does_not_supersede_test_limits() {
14844        let mut state = review_state(&["feat: x"]);
14845        state.reviews = vec![
14846            round_with_notes(1, Some(SANDBOXED)),
14847            passing_round(2, Some("0123456789abcdef")),
14848        ];
14849        ok_gate(&mut state, Some("0123456789abcdef"));
14850        let body = body_at(&state);
14851        assert!(!body.contains("HTTP tests could not run"), "{body}");
14852        // The commit claim is still settled by the structured record.
14853        assert!(!body.contains("could not commit"), "{body}");
14854        assert!(
14855            body.contains("passed on an earlier commit (`0123456`)"),
14856            "{body}"
14857        );
14858        assert!(!body.contains("passed on the final commit"), "{body}");
14859
14860        // A gate recorded before `gate_head` existed is "not recorded".
14861        ok_gate(&mut state, None);
14862        let body = body_at(&state);
14863        assert!(
14864            body.contains("passed on a commit that was not recorded"),
14865            "{body}"
14866        );
14867    }
14868
14869    #[test]
14870    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
14871        let state = state_with_summary(
14872            "add retries to the client",
14873            "TITLE: feat: retries\n\nDid it.",
14874        );
14875        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
14876        assert_eq!(m.title, "feat: retries");
14877        assert!(
14878            m.body.contains("<summary>Original task</summary>"),
14879            "{}",
14880            m.body
14881        );
14882        assert!(!m.body.contains("Commits under review"), "{}", m.body);
14883        assert!(
14884            m.body
14885                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
14886            "{}",
14887            m.body
14888        );
14889    }
14890
14891    #[test]
14892    fn pr_message_review_bounds_a_long_english_subject() {
14893        let long = format!("feat: {}", "word ".repeat(100));
14894        let m = pr_message(&review_state(&[&long]), 'A');
14895        assert!(m.title.starts_with("feat: word"), "{}", m.title);
14896        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
14897    }
14898
14899    #[test]
14900    fn pr_message_implementation_run_is_unchanged_by_review_support() {
14901        let state = state_with_summary("add retries\n\ndetails", "- did some things");
14902        let m = pr_message(&state, 'A');
14903        assert_eq!(m.title, "add retries");
14904        assert!(m.body.contains("<summary>Original task</summary>"));
14905        assert!(!m.body.contains("Commits under review"));
14906        assert_eq!(landing_subject_source(&state), state.instruction);
14907    }
14908
14909    #[test]
14910    fn review_run_squash_subject_is_the_change_not_the_prompt() {
14911        let state = review_state(&["feat: the change", "fix: typo"]);
14912        let source = landing_subject_source(&state);
14913        assert_eq!(land::merge_subject("", &source), "feat: the change");
14914        assert_eq!(
14915            land::merge_subject("magi: candidate A (uncommitted work)", &source),
14916            "feat: the change"
14917        );
14918        // An operator's rename still wins.
14919        assert_eq!(
14920            land::merge_subject("feat: renamed by hand", &source),
14921            "feat: renamed by hand"
14922        );
14923        let blank = review_state(&["日本語"]);
14924        assert!(
14925            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
14926        );
14927    }
14928
14929    #[test]
14930    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
14931        let state = review_state(&["feat: the change"]);
14932        let source = landing_subject_source(&state);
14933        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
14934        assert_eq!(
14935            land::merge_subject(landing_title(&state, old), &source),
14936            "feat: the change"
14937        );
14938        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
14939        let task = state_with_summary("add retries", "");
14940        assert_eq!(landing_title(&task, old), old);
14941    }
14942
14943    #[test]
14944    fn pr_message_describes_the_change_not_the_task() {
14945        let state = state_with_summary(
14946            "今回やってほしいこと: results projector を直す",
14947            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
14948        );
14949        let m = pr_message(&state, 'A');
14950        assert_eq!(m.title, "fix(web): batch the runs list reads");
14951        assert!(
14952            m.body.starts_with("## Summary\n\n- reads run.json once"),
14953            "{}",
14954            m.body
14955        );
14956        assert!(!m.body.contains("TITLE:"), "{}", m.body);
14957        let task_at = m.body.find("今回やってほしいこと").unwrap();
14958        let details_at = m.body.find("<details>").unwrap();
14959        assert!(
14960            details_at < task_at,
14961            "the task lives inside <details>: {}",
14962            m.body
14963        );
14964        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
14965        assert!(m.body.contains("magi:candidate-a"));
14966    }
14967
14968    #[test]
14969    fn pr_message_falls_back_to_the_task_without_a_title_line() {
14970        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
14971        let m = pr_message(&state, 'A');
14972        assert_eq!(m.title, "add retries");
14973        assert!(
14974            m.body.contains("## Summary\n\n- did some things"),
14975            "{}",
14976            m.body
14977        );
14978
14979        let none = RunState::new(
14980            PathBuf::from("/repo"),
14981            "main".to_owned(),
14982            "abc1234".to_owned(),
14983            "add retries".to_owned(),
14984            Config::default(),
14985        );
14986        let m = pr_message(&none, 'A');
14987        assert_eq!(m.title, "add retries");
14988        assert!(!m.body.contains("## Summary"), "{}", m.body);
14989    }
14990
14991    #[test]
14992    fn pr_message_refuses_the_candidate_commit_subject() {
14993        for bad in [
14994            "TITLE: magi: candidate A (uncommitted work)",
14995            "TITLE: chore: stuff (uncommitted work)",
14996            "TITLE:   ",
14997        ] {
14998            let state = state_with_summary("add retries", bad);
14999            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
15000        }
15001    }
15002
15003    #[test]
15004    fn pr_message_bounds_a_very_long_task_and_title() {
15005        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
15006        let state = state_with_summary(&long, "- nothing");
15007        let m = pr_message(&state, 'A');
15008        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15009        assert!(!m.title.contains('\n'));
15010
15011        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
15012        let m = pr_message(&state, 'A');
15013        assert!(m.title.starts_with("feat: "));
15014        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
15015        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
15016    }
15017
15018    fn long_title_of(instruction: &str) -> String {
15019        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
15020    }
15021
15022    #[test]
15023    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
15024        let first = "Make the landing path keep a readable title for long tasks";
15025        let line = format!(
15026            "{first}. {}",
15027            "And then keep going with more words ".repeat(20)
15028        );
15029        let t = long_title_of(&line);
15030        assert_eq!(t, first);
15031        assert!(!t.starts_with("chore: land"));
15032    }
15033
15034    #[test]
15035    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
15036        let line = "word ".repeat(200);
15037        let t = long_title_of(&line);
15038        assert!(t.ends_with("word..."), "{t}");
15039        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
15040    }
15041
15042    #[test]
15043    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
15044        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
15045            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
15046        }
15047    }
15048
15049    #[test]
15050    fn pr_message_keeps_a_japanese_title_when_github_language_is_japanese() {
15051        let mut state = state_with_summary("再試行を追加する", "- nothing");
15052        assert!(pr_message(&state, 'A').title.starts_with("chore: land"));
15053        state.config.graph.github_language = "ja".into();
15054        assert_eq!(pr_message(&state, 'A').title, "再試行を追加する");
15055    }
15056
15057    #[test]
15058    fn pr_message_title_limit_is_exact() {
15059        let at = "a".repeat(PR_TITLE_MAX);
15060        assert_eq!(long_title_of(&at), at);
15061        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
15062        assert!(over.ends_with("..."), "{over}");
15063        assert_eq!(over.chars().count(), PR_TITLE_MAX);
15064    }
15065
15066    #[test]
15067    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
15068        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
15069        let t = long_title_of(&line);
15070        assert!(t.ends_with("..."), "{t}");
15071        assert!(t.is_ascii(), "{t}");
15072    }
15073
15074    #[test]
15075    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
15076        let line = format!(
15077            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
15078            "plus more filler words ".repeat(20)
15079        );
15080        let t = long_title_of(&line);
15081        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
15082    }
15083
15084    #[test]
15085    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
15086        let line = format!("\n\n# {}", "title words ".repeat(40));
15087        let state = state_with_summary(&line, "- nothing");
15088        let m = pr_message(&state, 'A');
15089        assert!(m.title.starts_with("title words"), "{}", m.title);
15090        assert_eq!(
15091            land::merge_subject(&m.title, &landing_subject_source(&state)),
15092            m.title
15093        );
15094        // An operator's rename wins untouched.
15095        assert_eq!(
15096            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
15097            "feat: renamed by hand"
15098        );
15099    }
15100
15101    #[tokio::test]
15102    async fn github_text_rewrite_is_bounded_and_falls_back() {
15103        crate::run::pin_test_home();
15104        for (reply, accepted) in [
15105            (
15106                "TITLE: fix: retries\nAdd retries for failed requests.",
15107                true,
15108            ),
15109            (
15110                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
15111                false,
15112            ),
15113            (
15114                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
15115                true,
15116            ),
15117            (
15118                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
15119                true,
15120            ),
15121            (
15122                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
15123                true,
15124            ),
15125            (
15126                "TITLE: fix: retries\n日本語の説明です。 token=secret",
15127                false,
15128            ),
15129            ("TITLE: fix: retries\n", false),
15130        ] {
15131            let dir = tempfile::tempdir().unwrap();
15132            let mut runner = runner_at(RunStatus::Gating);
15133            runner.state = state_with_summary(
15134                "add retries",
15135                "TITLE: fix: retries\n日本語の説明を書きます。",
15136            );
15137            runner.state.repo = dir.path().to_owned();
15138            runner.state.candidates[0].worktree = dir.path().to_owned();
15139            let mut author = spec("alpha");
15140            author.command = vec![
15141                "sh".into(),
15142                "-c".into(),
15143                "printf '%s' \"$REWRITE_REPLY\"".into(),
15144            ];
15145            author.env.insert("REWRITE_REPLY".into(), reply.into());
15146            runner.state.config.agents = vec![author];
15147            seed_public_summary(&mut runner);
15148            let winner = runner.state.candidates[0].clone();
15149            let first = runner
15150                .guarded_pr_message(&winner, None, true)
15151                .await
15152                .unwrap();
15153            let message = if accepted {
15154                first.expect("a passing rewrite never asks")
15155            } else {
15156                // Withheld: the run parks on a question and posts nothing yet.
15157                assert!(first.is_none());
15158                assert!(runner.state.parked);
15159                let store = ask::Questions::open();
15160                let asked: Vec<_> = store
15161                    .list()
15162                    .into_iter()
15163                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15164                    .collect();
15165                assert_eq!(asked.len(), 1);
15166                assert!(!asked[0].detail.contains("secret"));
15167                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
15168                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
15169                assert_eq!(
15170                    asked[0]
15171                        .choices
15172                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
15173                    rewrite.is_some()
15174                );
15175                // Silence: the question lapses and the neutral text is used,
15176                // without a second rewrite call or a second question.
15177                store
15178                    .update(&asked[0].id, |q| {
15179                        q.abandon("test");
15180                        Ok(())
15181                    })
15182                    .unwrap();
15183                let again = runner
15184                    .guarded_pr_message(&winner, None, true)
15185                    .await
15186                    .unwrap()
15187                    .expect("an abandoned question falls back");
15188                let n = store
15189                    .list()
15190                    .into_iter()
15191                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15192                    .count();
15193                assert_eq!(n, 1, "asked once per rejected text");
15194                again
15195            };
15196            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
15197            assert!(!message.body.contains("secret"));
15198            assert!(!message.body.contains("prefix"));
15199            assert!(!message.title.contains("secret"));
15200            assert_eq!(
15201                message.body.contains("Add retries for failed requests."),
15202                accepted
15203            );
15204            assert_eq!(
15205                runner.state.seats["github-text-rewrite"].turns, 1,
15206                "only one rewrite invocation"
15207            );
15208            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
15209            if accepted {
15210                assert!(runner.state.github_text.is_none());
15211                assert!(!runner.state.parked);
15212                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
15213                assert!(message.body.contains("## Final verification"));
15214                assert!(message.body.contains("Evidence: final diff"));
15215                assert!(
15216                    message
15217                        .body
15218                        .contains(&format!("magi:run/{}", runner.state.id))
15219                );
15220            }
15221            if !accepted {
15222                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15223                assert!(
15224                    message
15225                        .body
15226                        .contains(&format!("magi:run/{}", runner.state.id))
15227                );
15228            }
15229        }
15230    }
15231
15232    fn scripted(id: &str, reply: &str) -> AgentSpec {
15233        let mut a = spec(id);
15234        a.command = vec![
15235            "sh".into(),
15236            "-c".into(),
15237            "printf '%s' \"$REWRITE_REPLY\"".into(),
15238        ];
15239        a.env.insert("REWRITE_REPLY".into(), reply.into());
15240        a
15241    }
15242
15243    const JA_SUMMARY: &str = "TITLE: fix: retries\n日本語の説明を書きます。";
15244    const EN_REWRITE: &str = "TITLE: fix: retries\nAdd retries in `src/net.rs` for 3 failed requests.\n\n<details>kept</details>";
15245
15246    /// Gate tests inject generated public output, not internal reports.
15247    fn seed_public_summary(runner: &mut Runner) {
15248        let repo = &runner.state.repo;
15249        let g = |args: &[&str]| {
15250            let out = std::process::Command::new("git")
15251                .args(args)
15252                .current_dir(repo)
15253                .quiet()
15254                .output()
15255                .unwrap();
15256            assert!(out.status.success(), "git {args:?}");
15257            String::from_utf8_lossy(&out.stdout).trim().to_owned()
15258        };
15259        g(&["init", "-q", "-b", "main"]);
15260        g(&[
15261            "-c",
15262            "user.name=Test",
15263            "-c",
15264            "user.email=test@example.invalid",
15265            "commit",
15266            "-q",
15267            "--allow-empty",
15268            "-m",
15269            "feat: fixture",
15270        ]);
15271        let head = g(&["rev-parse", "HEAD"]);
15272        g(&["update-ref", "refs/remotes/origin/main", &head]);
15273        let evidence = crate::pr_outcome::summary_evidence(
15274            &runner.state,
15275            crate::pr_outcome::FinalHead::At(&head),
15276        );
15277        let generated = &runner.state.candidates[0].summary;
15278        runner.state.change_summary = Some(crate::run::ChangeSummary {
15279            base: head.clone(),
15280            head,
15281            evidence: crate::pr_outcome::summary_fingerprint(&runner.state, &evidence),
15282            title: summary_title(generated).unwrap(),
15283            body: summary_without_title(generated),
15284        });
15285    }
15286
15287    fn rewrite_runner(dir: &Path, review_only: bool, agents: Vec<AgentSpec>) -> Runner {
15288        let mut runner = runner_at(RunStatus::Gating);
15289        runner.state = state_with_summary("add retries", JA_SUMMARY);
15290        if review_only {
15291            runner.state.candidates[0].agent = EXISTING_BRANCH.to_owned();
15292        }
15293        runner.state.repo = dir.to_owned();
15294        runner.state.candidates[0].worktree = dir.to_owned();
15295        runner.state.config.agents = agents;
15296        seed_public_summary(&mut runner);
15297        runner
15298    }
15299
15300    fn gate_questions(runner: &Runner) -> usize {
15301        ask::Questions::open()
15302            .list()
15303            .into_iter()
15304            .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
15305            .count()
15306    }
15307
15308    #[tokio::test]
15309    async fn review_only_description_is_rewritten_by_the_language_judge_role() {
15310        crate::run::pin_test_home();
15311        let dir = tempfile::tempdir().unwrap();
15312        let mut runner = rewrite_runner(
15313            dir.path(),
15314            true,
15315            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15316        );
15317        runner.state.config.roles.language_judge =
15318            Some(crate::config::AgentChoice::Chain(vec!["beta".into()]));
15319        let winner = runner.state.candidates[0].clone();
15320        let msg = runner
15321            .guarded_pr_message(&winner, None, true)
15322            .await
15323            .unwrap()
15324            .expect("an English rewrite posts without asking");
15325        assert!(msg.body.contains("`src/net.rs` for 3 failed requests"));
15326        assert!(msg.body.contains("<details>kept</details>"));
15327        assert!(msg.body.contains(&format!("magi:run/{}", runner.state.id)));
15328        assert_eq!(gate_questions(&runner), 0);
15329        assert!(runner.state.github_text.is_none());
15330        assert!(!runner.state.seats.contains_key("impl-A"));
15331        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15332    }
15333
15334    #[tokio::test]
15335    async fn rewrite_without_an_implementer_session_uses_the_role_not_the_seat() {
15336        crate::run::pin_test_home();
15337        let dir = tempfile::tempdir().unwrap();
15338        let mut runner = rewrite_runner(
15339            dir.path(),
15340            false,
15341            vec![scripted("alpha", "nope"), scripted("beta", EN_REWRITE)],
15342        );
15343        runner.state.config.roles.language_judge =
15344            Some(crate::config::AgentChoice::One("beta".into()));
15345        runner.state.seats.insert(
15346            "impl-A".into(),
15347            SeatState::new("impl-A", "alpha", runner.state.seed),
15348        );
15349        let winner = runner.state.candidates[0].clone();
15350        let msg = runner
15351            .guarded_pr_message(&winner, None, true)
15352            .await
15353            .unwrap()
15354            .expect("rewritten");
15355        assert!(msg.body.contains("<details>kept</details>"));
15356        assert_eq!(runner.state.seats["impl-A"].turns, 0);
15357        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15358        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "beta");
15359    }
15360
15361    #[tokio::test]
15362    async fn rewrite_reuses_a_resumable_implementer_session() {
15363        crate::run::pin_test_home();
15364        let dir = tempfile::tempdir().unwrap();
15365        let mut runner = rewrite_runner(
15366            dir.path(),
15367            false,
15368            vec![scripted("alpha", EN_REWRITE), scripted("beta", "nope")],
15369        );
15370        runner.state.config.roles.language_judge =
15371            Some(crate::config::AgentChoice::One("beta".into()));
15372        let mut seat = SeatState::new("impl-A", "alpha", runner.state.seed);
15373        seat.turns = 1;
15374        runner.state.seats.insert("impl-A".into(), seat);
15375        let winner = runner.state.candidates[0].clone();
15376        runner
15377            .guarded_pr_message(&winner, None, true)
15378            .await
15379            .unwrap()
15380            .expect("rewritten");
15381        assert_eq!(runner.state.seats["impl-A"].turns, 2);
15382        assert!(!runner.state.seats.contains_key("github-text-rewrite"));
15383    }
15384
15385    #[tokio::test]
15386    async fn rewrite_chain_advances_past_a_failing_or_unparsable_agent() {
15387        crate::run::pin_test_home();
15388        let dir = tempfile::tempdir().unwrap();
15389        let mut broken = spec("bad");
15390        broken.command = vec!["false".into()];
15391        let mut runner = rewrite_runner(
15392            dir.path(),
15393            true,
15394            vec![
15395                broken,
15396                scripted("noformat", "Add retries."),
15397                scripted("good", EN_REWRITE),
15398            ],
15399        );
15400        runner.state.config.roles.language_judge = Some(crate::config::AgentChoice::Chain(vec![
15401            "bad".into(),
15402            "noformat".into(),
15403            "good".into(),
15404        ]));
15405        let winner = runner.state.candidates[0].clone();
15406        let msg = runner
15407            .guarded_pr_message(&winner, None, true)
15408            .await
15409            .unwrap()
15410            .expect("the third agent rewrites");
15411        assert!(msg.body.contains("`src/net.rs`"));
15412        assert_eq!(runner.state.seats["github-text-rewrite"].agent, "good");
15413        assert!(runner.state.handovers.is_empty(), "no handover is recorded");
15414    }
15415
15416    #[tokio::test]
15417    async fn unsuccessful_review_only_rewrites_ask_the_owner_and_never_post_the_original() {
15418        crate::run::pin_test_home();
15419        // (agents, role): still Japanese; no TITLE; nothing resolvable.
15420        for (agents, role, saved) in [
15421            (
15422                vec![scripted("beta", "TITLE: fix: retries\n日本語の説明です。")],
15423                Some("beta"),
15424                true,
15425            ),
15426            (vec![scripted("beta", "Add retries.")], Some("beta"), false),
15427            (Vec::new(), None, false),
15428        ] {
15429            let dir = tempfile::tempdir().unwrap();
15430            let mut runner = rewrite_runner(dir.path(), true, agents);
15431            runner.state.config.roles.language_judge =
15432                role.map(|r| crate::config::AgentChoice::One(r.into()));
15433            let winner = runner.state.candidates[0].clone();
15434            let first = runner
15435                .guarded_pr_message(&winner, None, true)
15436                .await
15437                .unwrap();
15438            assert!(first.is_none(), "withheld text parks the run");
15439            assert!(runner.state.parked);
15440            assert_eq!(gate_questions(&runner), 1);
15441            let g = runner.state.github_text.as_ref().unwrap();
15442            assert_eq!(g.rewrite.is_some(), saved);
15443            assert!(g.choice.is_none() && !g.resolved, "nothing posts unasked");
15444        }
15445    }
15446
15447    #[tokio::test]
15448    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
15449        crate::run::pin_test_home();
15450        let dir = tempfile::tempdir().unwrap();
15451        let mut runner = runner_at(RunStatus::Gating);
15452        runner.state =
15453            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
15454        runner.state.repo = dir.path().to_owned();
15455        runner.state.candidates[0].worktree = dir.path().to_owned();
15456        let mut author = spec("alpha");
15457        author.command = vec![
15458            "sh".into(),
15459            "-c".into(),
15460            "printf '%s' \"$REWRITE_REPLY\"".into(),
15461        ];
15462        author.env.insert(
15463            "REWRITE_REPLY".into(),
15464            format!(
15465                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
15466                "English sentence. ".repeat(600)
15467            ),
15468        );
15469        runner.state.config.agents = vec![author];
15470        seed_public_summary(&mut runner);
15471        let winner = runner.state.candidates[0].clone();
15472        assert!(
15473            runner
15474                .guarded_pr_message(&winner, None, true)
15475                .await
15476                .unwrap()
15477                .is_none()
15478        );
15479        let record = runner.state.github_text.clone().unwrap();
15480        let rewrite = record.rewrite.clone().unwrap();
15481        assert_eq!(rewrite.failed, ["body-language"]);
15482        assert!(
15483            rewrite
15484                .body
15485                .ends_with(&format!("magi:run/{}", runner.state.id))
15486        );
15487        let store = ask::Questions::open();
15488        let q = store.get(record.question.as_ref().unwrap()).unwrap();
15489        assert!(
15490            q.choices
15491                .contains(&crate::github_text::USE_REWRITE.to_owned())
15492        );
15493        for label in [
15494            "ORIGINAL",
15495            "FALLBACK",
15496            "REWRITE",
15497            "Rules still failed: body-language",
15498            "truncated",
15499        ] {
15500            assert!(q.detail.contains(label), "{label}");
15501        }
15502        let artifact = std::fs::read_to_string(crate::run::artifact_path(
15503            &runner.state,
15504            &crate::github_text::artifact_name(&record.fingerprint),
15505        ))
15506        .unwrap();
15507        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
15508        // Restart from serialized state, then retry after a bad owner title.
15509        runner.state =
15510            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
15511        store
15512            .update(&q.id, |q| {
15513                q.say("まだ日本語")?;
15514                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
15515            })
15516            .unwrap();
15517        assert!(
15518            runner
15519                .guarded_pr_message(&winner, None, true)
15520                .await
15521                .unwrap()
15522                .is_none()
15523        );
15524        let retry = runner.state.github_text.as_ref().unwrap();
15525        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
15526        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
15527        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
15528        assert!(
15529            q.posting_comparison
15530                .as_ref()
15531                .unwrap()
15532                .matches_detail(&q.detail)
15533        );
15534        store
15535            .update(&q.id, |q| {
15536                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15537            })
15538            .unwrap();
15539        let message = runner
15540            .guarded_pr_message(&winner, None, true)
15541            .await
15542            .unwrap()
15543            .unwrap();
15544        assert_eq!(message.title, rewrite.title);
15545        assert_eq!(message.body, rewrite.body);
15546        assert_eq!(
15547            runner.state.github_text.as_ref().unwrap().choice,
15548            Some(GithubTextChoice::Rewrite)
15549        );
15550        let again = runner
15551            .guarded_pr_message(&winner, None, true)
15552            .await
15553            .unwrap()
15554            .unwrap();
15555        assert_eq!(again.title, message.title);
15556        assert_eq!(again.body, message.body);
15557        assert_eq!(runner.state.seats["github-text-rewrite"].turns, 1);
15558        assert!(
15559            !runner
15560                .state
15561                .events
15562                .iter()
15563                .any(|e| e.message.contains(&rewrite.title)
15564                    || e.message.contains("English sentence."))
15565        );
15566    }
15567
15568    #[tokio::test]
15569    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
15570        crate::run::pin_test_home();
15571        let dir = tempfile::tempdir().unwrap();
15572        let mut runner = runner_at(RunStatus::Gating);
15573        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
15574        runner.state.repo = dir.path().to_owned();
15575        runner.state.candidates[0].worktree = dir.path().to_owned();
15576        seed_public_summary(&mut runner);
15577        let winner = runner.state.candidates[0].clone();
15578        assert!(
15579            runner
15580                .guarded_pr_message(&winner, None, true)
15581                .await
15582                .unwrap()
15583                .is_none()
15584        );
15585        let record = runner.state.github_text.as_ref().unwrap();
15586        assert!(record.rewrite.is_none());
15587        // Records written before the additive field deserialize with no rewrite.
15588        let mut old = serde_json::to_value(record).unwrap();
15589        old.as_object_mut().unwrap().remove("rewrite");
15590        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
15591        assert!(old.rewrite.is_none());
15592        let id = record.question.clone().unwrap();
15593        ask::Questions::open()
15594            .update(&id, |q| {
15595                q.choices.push(crate::github_text::USE_REWRITE.into());
15596                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
15597            })
15598            .unwrap();
15599        let message = runner
15600            .guarded_pr_message(&winner, None, true)
15601            .await
15602            .unwrap()
15603            .unwrap();
15604        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
15605        assert_eq!(
15606            runner.state.github_text.as_ref().unwrap().choice,
15607            Some(GithubTextChoice::Fallback)
15608        );
15609    }
15610
15611    #[tokio::test]
15612    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
15613        crate::run::pin_test_home();
15614        let dir = tempfile::tempdir().unwrap();
15615        let mut runner = runner_at(RunStatus::Gating);
15616        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
15617        runner.state.repo = dir.path().to_owned();
15618        runner.state.candidates[0].worktree = dir.path().to_owned();
15619        seed_public_summary(&mut runner);
15620        let winner = runner.state.candidates[0].clone();
15621        assert!(
15622            runner
15623                .guarded_pr_message(&winner, None, true)
15624                .await
15625                .unwrap()
15626                .is_none()
15627        );
15628        let store = ask::Questions::open();
15629        let find = |run: &str| {
15630            store
15631                .list()
15632                .into_iter()
15633                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
15634                .unwrap()
15635        };
15636        let run = runner.state.id.clone();
15637        let q = find(&run);
15638        let fp = runner
15639            .state
15640            .github_text
15641            .as_ref()
15642            .unwrap()
15643            .fingerprint
15644            .clone();
15645        let name = crate::github_text::artifact_name(&fp);
15646        let path = crate::run::artifact_path(&runner.state, &name);
15647        assert!(q.detail.contains(&path.display().to_string()));
15648        assert!(q.detail.contains("title-language: title"));
15649        let saved = std::fs::read_to_string(&path).expect("artifact written");
15650        assert!(saved.contains("日本語のタイトル"));
15651        assert!(q.cwd.is_none());
15652        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
15653        // A bad replacement is refused and asked about once more.
15654        let say = |id: &str, text: &str, choice: &str| {
15655            store
15656                .update(id, |q| {
15657                    q.thread.push(ask::Turn {
15658                        who: ask::Who::Operator,
15659                        body: text.to_owned(),
15660                        at: jiff::Timestamp::now(),
15661                        note: None,
15662                    });
15663                    q.answer(ask::Answer::Choice(choice.to_owned()))
15664                })
15665                .unwrap();
15666        };
15667        say(&q.id, "まだ日本語", "use my text");
15668        assert!(
15669            runner
15670                .guarded_pr_message(&winner, None, true)
15671                .await
15672                .unwrap()
15673                .is_none()
15674        );
15675        let second = find(&run);
15676        assert_ne!(second.id, q.id);
15677        assert!(second.detail.contains("did not pass"));
15678        // The retry still compares the original text; the rejected
15679        // replacement is named by category only.
15680        assert!(second.detail.contains("日本語のタイトル"));
15681        assert!(!second.detail.contains("まだ日本語"));
15682        assert!(second.detail.contains(&path.display().to_string()));
15683        say(&second.id, "fix: retry failed requests", "use my text");
15684        let message = runner
15685            .guarded_pr_message(&winner, None, true)
15686            .await
15687            .unwrap()
15688            .expect("a vetted replacement is posted");
15689        assert_eq!(message.title, "fix: retry failed requests");
15690        assert_eq!(
15691            runner
15692                .state
15693                .github_text
15694                .as_ref()
15695                .unwrap()
15696                .chosen_title
15697                .as_deref(),
15698            Some("fix: retry failed requests")
15699        );
15700        assert!(runner.state.github_text.as_ref().unwrap().resolved);
15701        // Resumed again: the saved decision is applied, nothing is re-asked.
15702        let again = runner
15703            .guarded_pr_message(&winner, None, true)
15704            .await
15705            .unwrap()
15706            .unwrap();
15707        assert_eq!(again.title, message.title);
15708        assert_eq!(
15709            store
15710                .list()
15711                .into_iter()
15712                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
15713                .count(),
15714            2
15715        );
15716    }
15717
15718    #[test]
15719    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
15720        // What magi itself writes stays English under any configured language,
15721        // so a future localisation of these headings fails here. (The agents'
15722        // own text is also checked by the posting gate.)
15723        let mut state = state_with_summary(
15724            "add retries",
15725            "TITLE: fix(web): batch reads\n- reads run.json once",
15726        );
15727        state.config.graph.language = "ja".to_owned();
15728        let m = pr_message(&state, 'A');
15729        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
15730        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
15731
15732        // The task is the operator's own text: it goes in untouched, and the
15733        // fallback title (no summary) may be in its language too.
15734        let task = "今回やってほしいこと: results projector を直す";
15735        let mut state = state_with_summary(task, "- no title line");
15736        state.config.graph.language = "ja".to_owned();
15737        let m = pr_message(&state, 'A');
15738        assert_eq!(
15739            m.title,
15740            format!("chore: land candidate A of run {}", state.id)
15741        );
15742        assert!(
15743            m.body.contains(&format!(
15744                "<summary>Original task</summary>\n\n{task}\n\n</details>"
15745            )),
15746            "{}",
15747            m.body
15748        );
15749    }
15750
15751    #[test]
15752    fn pr_message_scrubs_home_paths_and_addresses() {
15753        let state = state_with_summary(
15754            "fix it in /Users/someone/src/x",
15755            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
15756        );
15757        let m = pr_message(&state, 'A');
15758        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
15759            assert!(!m.body.contains(leak), "{}", m.body);
15760        }
15761        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
15762    }
15763
15764    #[test]
15765    fn pr_message_survives_a_task_that_closes_details() {
15766        let state = state_with_summary("a </details> b", "TITLE: fix: x");
15767        let m = pr_message(&state, 'A');
15768        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
15769    }
15770
15771    #[test]
15772    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
15773        let cmd = manual_merge_command(
15774            MergeStyle::Squash,
15775            Path::new("/repo"),
15776            "b",
15777            "fix: \"quoted\" $(x) `y`\n\nbody",
15778        );
15779        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
15780    }
15781
15782    #[test]
15783    fn manual_merge_command_matches_the_configured_style() {
15784        let repo = Path::new("/repo");
15785        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
15786
15787        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
15788        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
15789
15790        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
15791        assert_eq!(
15792            squash,
15793            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
15794             \"Merge magi run 0832 (candidate A)\""
15795        );
15796
15797        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
15798        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
15799    }
15800
15801    #[test]
15802    fn a_nudge_gets_a_quarter_of_the_budget() {
15803        // The judge and implement budgets magi ships with.
15804        assert_eq!(retry_budget(secs(1200), true), secs(300));
15805        assert_eq!(retry_budget(secs(3600), true), secs(900));
15806    }
15807
15808    #[test]
15809    fn a_resent_prompt_keeps_the_whole_budget() {
15810        // The seat kept no context, so the retry is the original job again and
15811        // shortening it would only guarantee a second failure.
15812        assert_eq!(retry_budget(secs(1200), false), secs(1200));
15813        assert_eq!(retry_budget(secs(60), false), secs(60));
15814    }
15815
15816    #[test]
15817    fn the_floor_never_exceeds_the_original_budget() {
15818        // A short configured timeout must not be *raised* by the floor: the
15819        // operator asked for a bound, and a retry may not outlast the attempt
15820        // it is retrying.
15821        assert_eq!(retry_budget(secs(60), true), secs(60));
15822        assert_eq!(retry_budget(secs(480), true), secs(120));
15823        assert_eq!(retry_budget(secs(0), true), secs(0));
15824    }
15825
15826    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
15827        agent::CommandEvidence {
15828            id: "item1".to_owned(),
15829            description: "cargo test".to_owned(),
15830            exit_code,
15831            result_summary: String::new(),
15832            source: "codex".to_owned(),
15833        }
15834    }
15835
15836    #[test]
15837    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
15838        // No evidence is not the same fact as unconfirmed evidence: a
15839        // backend with no adapter, or a reply that ran no commands at all,
15840        // must not be misread as carrying a dangling job.
15841        assert!(!has_unconfirmed_command(&[]));
15842    }
15843
15844    #[test]
15845    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
15846        // Deliberately not a check on the exit code's *value*: a fixer
15847        // legitimately runs something that fails mid-iteration before it
15848        // succeeds, and that must never by itself reopen a valid report.
15849        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
15850        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
15851        assert!(!has_unconfirmed_command(&[
15852            evidence(Some(0)),
15853            evidence(Some(101))
15854        ]));
15855    }
15856
15857    #[test]
15858    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
15859        assert!(has_unconfirmed_command(&[
15860            evidence(Some(0)),
15861            evidence(None)
15862        ]));
15863    }
15864
15865    #[test]
15866    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
15867        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15868        assert_eq!(
15869            verified_noop_claim(true, &[], text).as_deref(),
15870            Some("already fixed by b32cfc4, on main.")
15871        );
15872    }
15873
15874    #[test]
15875    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
15876        // A timeout or a bad exit code reads as the ordinary loss it is,
15877        // whatever the reply's own prose claims.
15878        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15879        assert!(verified_noop_claim(false, &[], text).is_none());
15880    }
15881
15882    #[test]
15883    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
15884        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
15885        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
15886        // A confirmed command alongside the marker is fine.
15887        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
15888    }
15889
15890    #[test]
15891    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
15892        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
15893    }
15894
15895    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
15896    /// pair, in order, labelled A, B, C, ...
15897    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
15898        runner.state.candidates = shape
15899            .iter()
15900            .enumerate()
15901            .map(|(i, &(empty, verified))| Candidate {
15902                index: i,
15903                label: (b'A' + i as u8) as char,
15904                agent: "sonnet".to_owned(),
15905                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
15906                worktree: PathBuf::from(format!("/wt/{i}")),
15907                summary: String::new(),
15908                stat: String::new(),
15909                files: 0,
15910                commits: 0,
15911                empty,
15912                failed: None,
15913                verified_noop: verified.map(str::to_owned),
15914                duration_ms: 0,
15915                folded: false,
15916            })
15917            .collect();
15918    }
15919
15920    #[test]
15921    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
15922        ask_test_home();
15923        let mut runner = runner_at(RunStatus::Implementing);
15924        set_candidates(
15925            &mut runner,
15926            &[
15927                (true, Some("already on main at b32cfc4")),
15928                (true, Some("same fix, see the existing test")),
15929            ],
15930        );
15931
15932        runner
15933            .after_implement()
15934            .expect("a verified no-op is not an error");
15935
15936        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
15937    }
15938
15939    #[test]
15940    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
15941        ask_test_home();
15942        let mut runner = runner_at(RunStatus::Implementing);
15943        // Candidate A declares a verified no-op; candidate B simply wrote
15944        // nothing and said nothing about why. One candidate's claim is not
15945        // the whole run's agreement.
15946        set_candidates(
15947            &mut runner,
15948            &[(true, Some("already on main at b32cfc4")), (true, None)],
15949        );
15950
15951        let err = runner
15952            .after_implement()
15953            .expect_err("an unverified empty candidate must still fail the run");
15954
15955        assert!(
15956            err.to_string().contains("no candidate produced a change"),
15957            "{err}"
15958        );
15959        assert_eq!(runner.state.status, RunStatus::Failed);
15960    }
15961
15962    #[test]
15963    fn after_implement_still_fails_an_ordinary_all_empty_run() {
15964        ask_test_home();
15965        let mut runner = runner_at(RunStatus::Implementing);
15966        set_candidates(&mut runner, &[(true, None), (true, None)]);
15967
15968        let err = runner
15969            .after_implement()
15970            .expect_err("no candidate declared anything; this is an ordinary failure");
15971
15972        assert!(
15973            err.to_string().contains("no candidate produced a change"),
15974            "{err}"
15975        );
15976        assert_eq!(runner.state.status, RunStatus::Failed);
15977    }
15978}
15979
15980/// A graft rollback whose restore could not be confirmed (see
15981/// [`Runner::graft`]).
15982#[derive(Debug)]
15983struct GraftRestoreFailed(String);
15984
15985impl std::fmt::Display for GraftRestoreFailed {
15986    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15987        write!(
15988            f,
15989            "could not restore the winner to its pre-graft commit: {}",
15990            self.0
15991        )
15992    }
15993}
15994
15995impl std::error::Error for GraftRestoreFailed {}
15996
15997/// The pre-spawn waits (shared build cache, parallelism slot) are infrastructure,
15998/// not the agent: they never draw on an agent's own timeout, and running out of
15999/// them never reads as an agent failure.
16000#[cfg(test)]
16001mod resource_wait_tests {
16002    use super::*;
16003    use std::collections::BTreeMap;
16004
16005    fn sh(id: &str, script: &str) -> AgentSpec {
16006        AgentSpec {
16007            id: id.to_owned(),
16008            kind: crate::config::AgentKind::Command,
16009            model: None,
16010            command: vec!["sh".to_owned(), "-c".to_owned(), script.to_owned()],
16011            extra_args: Vec::new(),
16012            env: BTreeMap::new(),
16013            prompt_delivery: None,
16014        }
16015    }
16016
16017    fn job(spec: AgentSpec, dir: &Path, timeout: Duration) -> SeatJob {
16018        SeatJob {
16019            seat: SeatState::new("impl-A", &spec.id, 1),
16020            spec,
16021            cwd: dir.to_path_buf(),
16022            prompt: "do it".to_owned(),
16023            timeout,
16024            allow_write: true,
16025            sessions: false,
16026            artifacts: dir.join("art"),
16027            stem: "impl-A".to_owned(),
16028            handover: None,
16029        }
16030    }
16031
16032    fn state(resource_wait_secs: u64) -> RunState {
16033        let mut config = Config::default();
16034        config.graph.timeout_resource_wait = resource_wait_secs;
16035        RunState::new(
16036            PathBuf::from("/repo"),
16037            "main".to_owned(),
16038            "abc1234".to_owned(),
16039            "task".to_owned(),
16040            config,
16041        )
16042    }
16043
16044    fn hold(cache: &Path) -> crate::cache::Guard {
16045        let owner = crate::cache::Owner::here("other-run", "e2e", "e2e", cache, "deadbeef");
16046        match crate::cache::try_acquire(&crate::run::home(), cache, &owner).expect("io") {
16047            crate::cache::AcquireOutcome::Acquired(g) => g,
16048            crate::cache::AcquireOutcome::Busy(b) => panic!("lease was not free: {b:?}"),
16049        }
16050    }
16051
16052    async fn run(
16053        jobs: Vec<SeatJob>,
16054        sem: Arc<Semaphore>,
16055        cache: Option<&Path>,
16056        st: &mut RunState,
16057    ) -> Vec<(usize, SeatState, AgentOutcome)> {
16058        let prompts = Prompts::default();
16059        let ctx = WaveCtx {
16060            run: "r",
16061            node: "implement",
16062            prompts: &prompts,
16063            cache,
16064            round: None,
16065            carry_seats: false,
16066            pauses: None,
16067        };
16068        wave(jobs, sem, &ctx, st, 0).await
16069    }
16070
16071    #[tokio::test]
16072    async fn a_cache_wait_longer_than_the_agent_budget_leaves_the_budget_whole() {
16073        crate::run::pin_test_home();
16074        let tmp = tempfile::tempdir().unwrap();
16075        let cache = tmp.path().join("target-a");
16076        let guard = hold(&cache);
16077        // Held for longer than the agent's whole 1s budget.
16078        tokio::spawn(async move {
16079            tokio::time::sleep(Duration::from_millis(1500)).await;
16080            guard.release();
16081        });
16082        let mut st = state(60);
16083        let results = run(
16084            vec![job(
16085                sh("alpha", "echo ok"),
16086                tmp.path(),
16087                Duration::from_secs(1),
16088            )],
16089            Arc::new(Semaphore::new(1)),
16090            Some(&cache),
16091            &mut st,
16092        )
16093        .await;
16094        assert!(
16095            matches!(results[0].2, AgentOutcome::Ok(_)),
16096            "the wait must not be charged to the agent"
16097        );
16098        assert!(st.resource_block.is_none());
16099        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16100        assert!(st.resource_wait.is_none() && st.active.is_empty());
16101    }
16102
16103    #[tokio::test]
16104    async fn an_expired_cache_wait_blocks_without_asking_or_rotating_the_agent() {
16105        crate::run::pin_test_home();
16106        let tmp = tempfile::tempdir().unwrap();
16107        let cache = tmp.path().join("target-b");
16108        let _held = hold(&cache);
16109        let mut st = state(1);
16110        let results = run(
16111            vec![job(
16112                sh("alpha", "echo ok"),
16113                tmp.path(),
16114                Duration::from_secs(1),
16115            )],
16116            Arc::new(Semaphore::new(1)),
16117            Some(&cache),
16118            &mut st,
16119        )
16120        .await;
16121        let (_, seat, out) = &results[0];
16122        assert!(matches!(out, AgentOutcome::ResourceBlocked(_)));
16123        assert!(wave_blocked(&results));
16124        assert_eq!(seat.agent, "alpha", "the chosen agent is kept");
16125        assert_eq!(seat.turns, 0, "nothing was asked");
16126        assert!(
16127            !tmp.path().join("art").join("impl-A.prompt.md").exists(),
16128            "no agent was spawned"
16129        );
16130        assert!(FailClass::of(out).is_none(), "not a failure class");
16131        let block = st.resource_block.as_ref().expect("recorded for the resume");
16132        assert_eq!(block.kind, "shared build cache");
16133        assert!(st.handovers.is_empty() && st.seat_history.is_empty() && st.quota.is_empty());
16134        assert!(st.active.is_empty());
16135    }
16136
16137    #[tokio::test]
16138    async fn a_semaphore_with_no_permits_ends_as_a_bounded_resource_block() {
16139        crate::run::pin_test_home();
16140        let tmp = tempfile::tempdir().unwrap();
16141        let mut st = state(1);
16142        let results = run(
16143            vec![job(
16144                sh("alpha", "echo ok"),
16145                tmp.path(),
16146                Duration::from_secs(1),
16147            )],
16148            Arc::new(Semaphore::new(0)),
16149            None,
16150            &mut st,
16151        )
16152        .await;
16153        assert!(matches!(results[0].2, AgentOutcome::ResourceBlocked(_)));
16154        assert_eq!(st.resource_block.as_ref().unwrap().kind, "parallel slot");
16155        assert!(!tmp.path().join("art").join("impl-A.prompt.md").exists());
16156    }
16157
16158    #[tokio::test]
16159    async fn an_agent_that_really_hangs_still_times_out_and_hands_over() {
16160        crate::run::pin_test_home();
16161        let tmp = tempfile::tempdir().unwrap();
16162        let mut st = state(60);
16163        let results = run(
16164            vec![job(
16165                sh("alpha", "sleep 30"),
16166                tmp.path(),
16167                Duration::from_secs(1),
16168            )],
16169            Arc::new(Semaphore::new(1)),
16170            None,
16171            &mut st,
16172        )
16173        .await;
16174        let out = &results[0].2;
16175        assert!(matches!(out, AgentOutcome::Failed(e) if e == TIMED_OUT));
16176        let class = FailClass::of(out).expect("a failure");
16177        assert_eq!(class, FailClass::Timeout);
16178        assert!(should_hand_over(None, &class), "existing handover policy");
16179        assert!(st.resource_block.is_none());
16180    }
16181}