Skip to main content

magi/
daemon.rs

1//! The unattended scheduler and independently leased task workers.
2//!
3//! The scheduler chooses tasks and execution lanes; a detached worker owns each
4//! queue claim, graph driver and outcome settlement. Replacing the scheduler
5//! reconstructs occupied slots from live worker jobs, without touching their
6//! attempts, passes or paid agent calls. Finite maintenance jobs follow the same
7//! lifetime rule. Explicit parking is durable control, never an upgrade step.
8//!
9//! # A crashed worker is legible
10//!
11//! A task is written `Running`, with its run id, before graph execution. The
12//! worker keeps its claim until the outcome is recorded. Recovery requires both
13//! no live worker reservation and an obtainable task claim, and settles the
14//! saved run through the same policy as a live finish. A scheduler's missing
15//! heartbeat alone is never evidence that a run died.
16//!
17//! # Retries are bounded
18//!
19//! Every attempt at a task consumes one of [`Opts::max_attempts`], after which
20//! the task is [`crate::queue::TaskStatus::Held`] for a human. The one
21//! exception is a run that ended `Stalled`: the panel collapsed because the
22//! agent CLIs hit their quota, which is a fact about the machine and not about
23//! the task, so it must not spend an attempt. Without that exception a quota
24//! outage would quietly hold the entire backlog, and the operator would come
25//! back to a reset quota and nothing left that the loop is willing to run.
26
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::{Mutex, MutexGuard};
31use std::time::Duration;
32
33use anyhow::{Context, Result, bail};
34use jiff::Timestamp;
35use serde::{Deserialize, Serialize};
36use tokio::sync::Notify;
37
38use crate::ask::{self, Questions};
39use crate::clean;
40use crate::conduct::Conductor;
41use crate::config::{Config, MergeMode};
42use crate::graph::Runner;
43use crate::land;
44use crate::notices::{self, Link, Notice};
45use crate::queue::{Queue, Task, TaskStatus};
46use crate::run::{Liveness, QuotaLoss, RunState, RunStatus};
47use crate::triage;
48
49/// On-disk format for [`Status`]. Bumped when a field's meaning changes.
50pub const SCHEMA: u32 = 1;
51
52/// How often the status file is refreshed. A reader treats a status file older
53/// than [`STALE_SECS`] as "no daemon", so the heartbeat has to be brisk enough
54/// that a busy daemon is never mistaken for a dead one.
55pub const HEARTBEAT: Duration = Duration::from_secs(5);
56
57/// How old a heartbeat may be before a reader calls the daemon dead. Six
58/// missed beats: long enough to survive a slow filesystem, short enough that
59/// a crashed daemon is not still reported as running a task.
60///
61/// The single threshold every reader shares — the web UI's `/api/health` and
62/// `magi doctor` both call [`Reading::running`] rather than each comparing
63/// against their own copy of this number, so a crashed daemon cannot look
64/// alive on one screen and dead on another.
65pub const STALE_SECS: i64 = 30;
66
67/// Default queue poll interval.
68pub const POLL: Duration = Duration::from_secs(5);
69
70/// How old a claim has to be before startup sweeps it. Longer than any run
71/// this graph plausibly takes, so a sweep cannot pull a task out from under a
72/// daemon that is merely slow.
73pub const STALE_CLAIM: Duration = Duration::from_secs(6 * 60 * 60);
74
75/// How long a task may sit [`TaskStatus::Running`] with no live daemon's
76/// heartbeat naming it before [`crate::conduct`] is shown it as stalled.
77///
78/// [`reclaim_orphaned_running`] settles most crashes immediately, on every
79/// poll, by attempting the task's own claim: a dead pid is proof enough for
80/// [`sweep_stale_claims`] to drop the lock the same tick, and the very next
81/// claim attempt succeeds. But a lock whose pid cannot be parsed at all — an
82/// empty or corrupt `.lock` file — falls back to [`STALE_CLAIM`]'s six-hour
83/// age instead, since there is nothing else to check (see
84/// [`sweep_stale_claims`]'s own doc). For as long as that lock survives, the
85/// claim keeps failing and `reclaim_orphaned_running` correctly leaves the
86/// task `running` — see
87/// `stalled_tasks_still_reaches_a_task_reclaim_could_not_claim_yet` for
88/// exactly this ordering. `stalled_tasks` is what surfaces that task to the
89/// conductor well before the mechanical six-hour sweep would, and thirty
90/// minutes is comfortably below `STALE_CLAIM` while still being generous
91/// enough that a task merely late to publish its first [`HEARTBEAT`] is
92/// never mistaken for abandoned.
93pub const STALLED_RUNNING: Duration = Duration::from_secs(30 * 60);
94
95/// What the loop is working on, for the status file.
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(default)]
98pub struct Current {
99    /// Worker reservation that produced this claim; absent on legacy drivers.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub worker: Option<String>,
102    /// Task id being run.
103    pub task: String,
104    /// Run id the task produced.
105    pub run: String,
106}
107
108/// The daemon's liveness, published to `<home>/daemon.json`.
109///
110/// This is the only interface between the loop and the web UI, which is why it
111/// carries `updated_at` as well as `started_at`: a reader cannot tell a
112/// running daemon from a `SIGKILL`ed one by the file's existence alone, but it
113/// can compare the heartbeat against the clock.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct Status {
116    /// On-disk format version.
117    pub schema: u32,
118    /// Process id, so a human can find or kill the daemon.
119    pub pid: u32,
120    /// When this process started.
121    pub started_at: Timestamp,
122    /// Last heartbeat.
123    pub updated_at: Timestamp,
124    /// True when the queue has nothing runnable.
125    pub idle: bool,
126    /// Every task and run currently in flight. More than one entry means the
127    /// loop is driving more than one run at once — see
128    /// [`crate::config::Daemon::max_concurrent_runs`]. Empty, not absent, when
129    /// nothing is running, so a reader never has to treat "no field" and "an
130    /// empty list" as two different kinds of idle.
131    pub current: Vec<Current>,
132    /// Tasks that reached a terminal status in this process.
133    pub completed: usize,
134    /// Queue polls since start, so a wedged loop shows up as a frozen count.
135    pub polls: u64,
136    /// Effective startup capabilities of the question recovery loops.
137    #[serde(default)]
138    pub question_recovery: Option<crate::question_delivery::Recovery>,
139    /// Independent-worker control protocol supported by this scheduler.
140    #[serde(default)]
141    pub worker_protocol: Option<u32>,
142}
143
144impl Status {
145    /// A fresh, idle status for this process.
146    #[must_use]
147    pub fn new() -> Self {
148        let now = Timestamp::now();
149        Self {
150            schema: SCHEMA,
151            pid: std::process::id(),
152            started_at: now,
153            updated_at: now,
154            idle: true,
155            current: Vec::new(),
156            completed: 0,
157            polls: 0,
158            question_recovery: None,
159            worker_protocol: Some(1),
160        }
161    }
162}
163
164impl Default for Status {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170/// How the loop should behave.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(default)]
173pub struct Opts {
174    /// Repository used by tasks that name none.
175    pub repo: PathBuf,
176    /// Explicit `magi.toml`, instead of the discovered layer stack.
177    pub config: Option<PathBuf>,
178    /// Queue poll interval.
179    pub poll: Duration,
180    /// Attempts a task gets before it is held for a human.
181    pub max_attempts: usize,
182    /// Drain what is runnable now, then return, instead of waiting for more.
183    pub once: bool,
184    /// Merge mode override (`none`, `local`, `pr`); `None` keeps the config's.
185    pub merge: Option<String>,
186    /// Where the janitor's [`crate::clean::fold_orphaned_worktrees`] and
187    /// [`crate::git::worktree_prune`] look for and reclaim worktrees.
188    /// `None` resolves to [`crate::run::default_worktree_root`] - the
189    /// operator's real `~/wt/<repo>` - the same way a run with no
190    /// [`crate::config::Graph::worktree_root`] resolves its own. A caller
191    /// that does not own that directory (a test, an embedding that manages
192    /// worktrees itself) must set this, or every idle tick reclaims worktrees
193    /// out from under whoever actually does.
194    pub worktrees_root: Option<PathBuf>,
195}
196
197impl Default for Opts {
198    fn default() -> Self {
199        Self {
200            repo: PathBuf::from("."),
201            config: None,
202            poll: POLL,
203            max_attempts: 2,
204            once: false,
205            merge: None,
206            worktrees_root: None,
207        }
208    }
209}
210
211impl Opts {
212    fn absolute(&self) -> Result<Self> {
213        let mut opts = self.clone();
214        opts.repo = std::path::absolute(&opts.repo)?;
215        opts.config = opts
216            .config
217            .as_deref()
218            .map(std::path::absolute)
219            .transpose()?;
220        opts.worktrees_root = opts
221            .worktrees_root
222            .as_deref()
223            .map(std::path::absolute)
224            .transpose()?;
225        Ok(opts)
226    }
227}
228
229/// How many runs a plain `usize` from config may drive concurrently, floored
230/// at one. A `0` in a config file would otherwise stall the loop entirely -
231/// no runnable task could ever start - which is never what an operator who
232/// wrote `0` meant.
233fn max_concurrent(n: usize) -> usize {
234    n.max(1)
235}
236
237/// Where the status file lives.
238#[must_use]
239pub fn status_path() -> PathBuf {
240    crate::run::home().join("daemon.json")
241}
242
243/// Publish the status file for this process.
244pub fn write_status(status: &Status) -> Result<()> {
245    write_status_to(&status_path(), status)
246}
247
248/// Publish a status to an explicit path.
249///
250/// Written to a sibling `.tmp` and renamed, because the web UI reads this file
251/// on every health poll and must never see a half-written one.
252pub fn write_status_to(path: &Path, status: &Status) -> Result<()> {
253    if let Some(parent) = path.parent() {
254        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
255    }
256    let body = serde_json::to_string_pretty(status).context("serialize daemon status")?;
257    let tmp = path.with_extension("json.tmp");
258    std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
259    std::fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?;
260    Ok(())
261}
262
263/// Delete the status file. Called on the way out so a clean exit reads as
264/// "no daemon" rather than as a daemon whose heartbeat merely stopped.
265pub fn clear_status() {
266    clear_status_at(&status_path());
267}
268
269/// Delete a status file at an explicit path, so the loop's teardown and
270/// [`clear_status`] cannot drift apart: the loop is handed the path it
271/// published to, and a test can watch a temp file disappear.
272fn clear_status_at(path: &Path) {
273    let _ = std::fs::remove_file(path);
274}
275
276/// A cooperative stop, shared with whoever asked the loop to run.
277///
278/// Cloning is how the request travels: [`serve_until`] keeps one handle, the
279/// Ctrl-C listener and the web UI keep others, and every clone points at the
280/// same flag. There is no channel because there is nothing to send — the only
281/// message is "stop", it is idempotent, and a flag cannot be missed by a
282/// receiver that was not listening yet.
283///
284/// A stop ends scheduler dispatch promptly. Its busy snapshot is observational:
285/// detached task workers keep their own lifetimes and ownership after this
286/// handle's scheduler exits.
287#[derive(Debug, Clone, Default)]
288pub struct Stop {
289    /// Set once, never cleared: a stop is not something an operator takes back
290    /// half way through, and a clearable flag would let a start racing a stop
291    /// resurrect a loop that is already unwinding.
292    stopped: Arc<AtomicBool>,
293    /// Live task workers observed by the scheduler's latest dispatch snapshot.
294    busy: Arc<std::sync::atomic::AtomicUsize>,
295    /// Wakes the idle wait. Without this a stop would not be seen until the
296    /// poll interval elapsed, and an operator tapping stop on a phone would
297    /// watch a button do nothing for five seconds.
298    wake: Arc<Notify>,
299    /// Handed to the run in flight, so a stop can also mean "park at the next
300    /// node boundary" instead of "finish the whole competition first".
301    pause: crate::graph::Pause,
302}
303
304impl Stop {
305    /// A stop nobody has asked for yet.
306    #[must_use]
307    pub fn new() -> Self {
308        Self::default()
309    }
310
311    /// Ask the loop to stop. Idempotent, and safe to call before the loop
312    /// starts: the flag is checked before the first poll.
313    pub fn stop(&self) {
314        self.stopped.store(true, Ordering::SeqCst);
315        // `notify_one` rather than `notify_waiters` because the loop may not be
316        // parked yet: this stores a permit, so a wait that registers a moment
317        // later returns at once instead of sleeping out the whole interval.
318        self.wake.notify_one();
319    }
320
321    /// Has a stop been asked for?
322    #[must_use]
323    pub fn stopped(&self) -> bool {
324        self.stopped.load(Ordering::SeqCst)
325    }
326
327    /// Whether a requested scheduler retirement still has an active snapshot.
328    /// Workers continue independently after the scheduler has retired.
329    #[must_use]
330    pub fn finishing(&self) -> bool {
331        self.stopped() && self.busy_now()
332    }
333
334    /// Ask the loop to stop *and* the run in flight to park at its next node
335    /// boundary.
336    ///
337    /// Unlike plain stop, explicit park asks workers to checkpoint at their
338    /// existing safe boundaries. Binary replacement never needs a park.
339    pub fn park(&self, cause: crate::run::ParkCause) {
340        self.pause.park_for(cause);
341        self.stop();
342    }
343
344    /// Has a park been asked for?
345    #[must_use]
346    pub fn parking(&self) -> bool {
347        self.pause.parked()
348    }
349
350    /// The pause handle to give a runner.
351    #[must_use]
352    pub fn pause(&self) -> crate::graph::Pause {
353        self.pause.clone()
354    }
355
356    /// Whether the scheduler's latest snapshot contains active task workers.
357    #[must_use]
358    pub fn busy_now(&self) -> bool {
359        self.busy.load(Ordering::SeqCst) > 0
360    }
361
362    /// Wait out one poll interval, returning early once a stop is asked for.
363    async fn idle(&self, poll: Duration) {
364        tokio::select! {
365            () = tokio::time::sleep(poll) => {}
366            () = self.wake.notified() => {}
367        }
368    }
369}
370
371/// The daemon's published state, read permissively.
372///
373/// This mirrors [`Status`], but is a separate declaration on purpose: every
374/// field defaults, so a status file from an older or newer magi still yields
375/// a usable reading — one this build has never heard of — instead of a parse
376/// error that hides the daemon entirely.
377#[derive(Debug, Clone, Default, Deserialize)]
378#[serde(default)]
379pub struct Reading {
380    /// Format version the daemon claims.
381    pub schema: u32,
382    /// Daemon process id, for an operator who wants to stop it.
383    pub pid: Option<u32>,
384    /// When that process started.
385    pub started_at: Option<Timestamp>,
386    /// Last heartbeat. Absent means the file is unusable, hence not running.
387    pub updated_at: Option<Timestamp>,
388    /// True when the queue had nothing runnable at the last poll.
389    pub idle: bool,
390    /// What the daemon is working on. Empty means idle; more than one entry
391    /// means more than one run is in flight at once.
392    ///
393    /// `deserialize_with` rather than the plain derive: a daemon started
394    /// before this field became a list is still out there writing the old
395    /// shape — a single `{"task":...,"run":...}` object, or its absence —
396    /// on every heartbeat until it is restarted, and a live process reading
397    /// that file during the rollout must still see it as running rather than
398    /// as absent. A bare type change here would fail the whole struct's
399    /// deserialization on a type mismatch, defeating the permissiveness this
400    /// type exists for.
401    #[serde(deserialize_with = "de_current")]
402    pub current: Vec<Current>,
403    /// Tasks this daemon process has finished.
404    pub completed: u64,
405    /// Queue polls this daemon process has made.
406    pub polls: u64,
407    /// Effective startup capabilities of the question recovery loops.
408    #[serde(default)]
409    pub question_recovery: Option<crate::question_delivery::Recovery>,
410    /// Independent-worker control protocol, absent on legacy embedded loops.
411    pub worker_protocol: Option<u32>,
412}
413
414/// Accept the old single-`Current`-or-absent shape as well as the current
415/// list, so a reader never has to know which build wrote the file.
416fn de_current<'de, D>(deserializer: D) -> std::result::Result<Vec<Current>, D::Error>
417where
418    D: serde::Deserializer<'de>,
419{
420    #[derive(Deserialize)]
421    #[serde(untagged)]
422    enum Shape {
423        Many(Vec<Current>),
424        One(Current),
425    }
426    Ok(
427        Option::<Shape>::deserialize(deserializer)?.map_or_else(Vec::new, |shape| match shape {
428            Shape::Many(v) => v,
429            Shape::One(c) => vec![c],
430        }),
431    )
432}
433
434impl Reading {
435    /// Seconds since the last heartbeat, or `None` when there has never been
436    /// one.
437    #[must_use]
438    pub fn age_secs(&self, now: Timestamp) -> Option<i64> {
439        self.updated_at
440            .map(|at| (now.as_second() - at.as_second()).max(0))
441    }
442
443    /// Whether the loop counts as running: a heartbeat no older than
444    /// [`STALE_SECS`]. The alternative is a reader that claims a task is in
445    /// progress hours after the daemon that owned it was killed.
446    #[must_use]
447    pub fn running(&self, now: Timestamp) -> bool {
448        self.age_secs(now).is_some_and(|secs| secs <= STALE_SECS)
449    }
450}
451
452/// Read `<home>/daemon.json` permissively, or `None` when there is nothing
453/// usable there.
454///
455/// Missing, half-written and unparseable all collapse to `None`, because the
456/// only question a reader asks is whether a daemon is alive, and a file it
457/// cannot read is not evidence that one is.
458#[must_use]
459pub fn read_status(home: &Path) -> Option<Reading> {
460    let body = std::fs::read_to_string(home.join("daemon.json")).ok()?;
461    serde_json::from_str(&body).ok()
462}
463
464/// Read-only evidence of runs owned by a live daemon or independent worker.
465///
466/// CLI, sandboxed conversation controls and Web cleanup share this observation.
467/// A stale daemon heartbeat does not invalidate a worker's OS-held lease.
468/// Starting reservations and unknown worker state remain protected; observing
469/// them never creates, reconciles or archives files in the worker store.
470/// Callers interested in one identity use [`is_working_on`] or
471/// [`is_working_on_task`].
472#[must_use]
473pub fn current_work(home: &Path, now: Timestamp) -> Vec<Current> {
474    let mut current = read_status(home)
475        .filter(|reading| reading.running(now))
476        .map(|reading| reading.current)
477        .unwrap_or_default();
478    let workers = crate::worker::observe_active(home).and_then(|jobs| {
479        jobs.into_iter()
480            .filter(|job| job.kind == "task")
481            .map(|job| {
482                let request: TaskWorker = serde_json::from_value(job.payload)?;
483                let run = Queue::at(request.queue)
484                    .get(&request.task)
485                    .ok()
486                    .and_then(|task| task.runs.last().cloned())
487                    .unwrap_or_default();
488                Ok(Current {
489                    worker: Some(job.id),
490                    task: request.task,
491                    run,
492                })
493            })
494            .collect::<Result<Vec<_>>>()
495    });
496    match workers {
497        Ok(workers) => {
498            for worker in workers {
499                if !current.iter().any(|claim| {
500                    claim.task == worker.task
501                        && claim.run == worker.run
502                        && claim.worker == worker.worker
503                }) {
504                    current.push(worker);
505                }
506            }
507        }
508        Err(error) => {
509            // This API gates destructive cleanup. Unknown worker ownership
510            // protects every known task/run instead of pretending it is idle.
511            tracing::error!("cannot determine active workers: {error:#}");
512            current.extend(
513                Queue::at(home.join("queue"))
514                    .list()
515                    .into_iter()
516                    .map(|task| Current {
517                        worker: None,
518                        task: task.id,
519                        run: task.runs.last().cloned().unwrap_or_default(),
520                    }),
521            );
522            current.extend(
523                crate::run::list_ids_in(&home.join("runs"))
524                    .into_iter()
525                    .map(|run| Current {
526                        worker: None,
527                        task: String::new(),
528                        run,
529                    }),
530            );
531        }
532    }
533    current
534}
535
536/// Whether a live daemon is working on this run at this moment.
537#[must_use]
538pub fn is_working_on(home: &Path, run: &str, now: Timestamp) -> bool {
539    current_work(home, now).iter().any(|c| c.run == run)
540}
541
542/// Admission may ignore only this process's confirmed worker reservation.
543/// Keep anonymous/legacy daemon claims and every other worker, even for the
544/// same task. The run's execution lease and process identity are checked by
545/// the caller under the owner lock as well.
546pub(crate) fn is_working_on_other(home: &Path, run: &str, now: Timestamp) -> Result<bool> {
547    // A helper or test may inherit an identity from a different run store.
548    // That identity grants no exception here; retain all ownership evidence.
549    let same_home = std::env::var_os("MAGI_WORKER_HOME")
550        .is_some_and(|worker_home| Path::new(&worker_home) == home);
551    let caller = if same_home {
552        crate::worker::current_job(home)?
553    } else {
554        None
555    }
556    .filter(|job| {
557        job.kind == "task"
558            && job.status == crate::worker::Status::Running
559            && job.pid == Some(std::process::id())
560    });
561    Ok(other_claims_run(
562        &current_work(home, now),
563        run,
564        caller.as_ref().map(|job| job.id.as_str()),
565    ))
566}
567
568fn other_claims_run(claims: &[Current], run: &str, caller: Option<&str>) -> bool {
569    claims
570        .iter()
571        .any(|claim| claim.run == run && !(caller.is_some() && claim.worker.as_deref() == caller))
572}
573
574/// Whether a live daemon is working on a run whose short id is this one.
575///
576/// For a worktree that has no run record to compare against at all -
577/// [`crate::clean::fold_orphaned_worktrees`]'s whole reason to exist - a full
578/// id is not available to hand to [`is_working_on`]. The short id is: a run's
579/// worktree bay is named after it (see [`crate::run::RunState::worktree_root`]),
580/// and it is exactly the gap between the daemon claiming a task and
581/// `RunState::new` saving the first `run.json` that this exists to protect -
582/// a run genuinely in flight but invisible to a scan of `runs/`.
583#[must_use]
584pub fn is_working_on_short(home: &Path, short: &str, now: Timestamp) -> bool {
585    current_work(home, now)
586        .iter()
587        .any(|c| crate::run::short_of(&c.run) == short)
588}
589
590/// Whether a live daemon is working on this task at this moment.
591#[must_use]
592pub fn is_working_on_task(home: &Path, task: &str, now: Timestamp) -> bool {
593    current_work(home, now).iter().any(|c| c.task == task)
594}
595
596/// Remove claim files whose owner is provably dead, or that have simply
597/// outlived `older_than`, and return the task ids swept.
598///
599/// A daemon killed with `SIGKILL` never runs [`crate::queue::Claim`]'s
600/// destructor, and the orphaned `.lock` file would make its task permanently
601/// unclaimable — the backlog would stop for good at exactly the task that was
602/// in flight when the machine went down.
603///
604/// The pid recorded in the lock is the authority whenever it can be read at
605/// all; age is only a fallback for when it cannot be.
606///
607/// - **A parseable pid wins outright.** [`crate::proc::pid_alive`] decides,
608///   full stop — dead sweeps the lock immediately, regardless of age; alive
609///   protects it, regardless of age. This is what lets a lock be reclaimed in
610///   seconds instead of waiting out [`STALE_CLAIM`]: a lock made 33 minutes
611///   before this daemon even started, next to a `queued` task, no longer has
612///   to sit for six hours before anything notices its owner is gone.
613/// - **A pid that cannot be parsed at all** — an empty or corrupt lock file —
614///   falls back to `older_than`, since there is nothing else to check.
615///
616/// Age must never override a *positive* liveness confirmation. `sweep`
617/// polls concurrently with independently running task workers. A worker may
618/// legitimately run longer than `older_than`; its live pid must still protect
619/// its claim. Age alone must never turn that claim into a second task attempt,
620/// which [`reclaim_orphaned_running`] would then read as abandoned and hand
621/// to a second attempt — two `Runner`s writing the same `run.json` and the
622/// same worktree at once. `pid_alive` answering "alive" for anything it
623/// cannot determine (a live process, a pid this build cannot check, one
624/// under another account) is exactly what keeps that path from ever
625/// firing on a guess.
626///
627/// [`STALE_CLAIM`] itself stays large: a helper program missing or its
628/// output unreadable must not be license to guess, and the risk of an
629/// unparseable lock outliving a genuinely dead owner is bounded by an order
630/// of magnitude above any plausible run rather than by a positive check.
631///
632/// Runs on every poll, not only at startup — a daemon up for days must keep
633/// noticing a lock some other, now-dead, daemon left behind just as readily
634/// as one it trips over on the way up.
635pub fn sweep_stale_claims(queue: &Queue, older_than: Duration) -> Vec<String> {
636    sweep_stale_claims_with(queue, older_than, crate::proc::pid_alive)
637}
638
639/// [`sweep_stale_claims`] with its process-query boundary supplied by the
640/// caller. This keeps the lock policy testable where process listing is
641/// unavailable, while production still uses the platform query above.
642fn sweep_stale_claims_with<F>(queue: &Queue, older_than: Duration, pid_alive: F) -> Vec<String>
643where
644    F: Fn(u32) -> bool,
645{
646    let this_process = std::process::id();
647    let mut swept: Vec<String> = std::fs::read_dir(queue.root())
648        .into_iter()
649        .flatten()
650        .flatten()
651        .map(|e| e.path())
652        .filter(|p| p.extension().is_some_and(|x| x == "lock"))
653        .filter(|p| {
654            match std::fs::read_to_string(p)
655                .ok()
656                .and_then(|body| body.trim().parse::<u32>().ok())
657            {
658                // This process wrote it and is asking the question right
659                // now, so it is definitionally still alive - settled without
660                // spawning a helper process at all.
661                Some(pid) if pid == this_process => false,
662                Some(pid) => !pid_alive(pid),
663                None => p
664                    .metadata()
665                    .and_then(|m| m.modified())
666                    .and_then(|t| t.elapsed().map_err(std::io::Error::other))
667                    .is_ok_and(|age| age >= older_than),
668            }
669        })
670        .filter(|p| std::fs::remove_file(p).is_ok())
671        .filter_map(|p| {
672            p.file_stem()
673                .and_then(|s| s.to_str())
674                .map(std::borrow::ToOwned::to_owned)
675        })
676        .collect();
677    swept.sort_unstable();
678    swept
679}
680
681/// Is `task` stalled: [`TaskStatus::Running`], past [`STALLED_RUNNING`], with
682/// no live daemon's heartbeat naming it? Deterministic — no model call, and
683/// the exact test [`stalled_tasks`] uses to decide what `crate::conduct` is
684/// shown.
685fn is_stalled(task: &Task, home: &Path, now: Timestamp) -> bool {
686    task.status == TaskStatus::Running
687        && (now.as_second() - task.updated_at.as_second()) >= STALLED_RUNNING.as_secs() as i64
688        && !is_working_on_task(home, &task.id, now)
689}
690
691/// Every task [`is_stalled`] right now — "止まったタスク" in
692/// `crate::conduct`'s vocabulary.
693fn stalled_tasks(queue: &Queue, home: &Path, now: Timestamp) -> Vec<Task> {
694    queue
695        .list()
696        .into_iter()
697        .filter(|t| is_stalled(t, home, now))
698        .collect()
699}
700
701/// Runnable tasks a dependency can still be set on — "runnable なタスク" in
702/// `crate::conduct`'s vocabulary. Deliberately `Queued` only, not
703/// `Failed`-and-so-also-runnable: a task that already attempted and lost
704/// belongs in [`finished_tasks`], where the question is a recovery, not a
705/// dependency.
706fn queued_tasks(queue: &Queue) -> Vec<Task> {
707    queue
708        .list()
709        .into_iter()
710        .filter(|t| t.status == TaskStatus::Queued)
711        .collect()
712}
713
714/// `Failed`/`Held` tasks nobody has decided a recovery for yet — "終わった
715/// タスク" in `crate::conduct`'s vocabulary.
716fn finished_tasks(queue: &Queue) -> Vec<Task> {
717    queue
718        .list()
719        .into_iter()
720        .filter(|t| matches!(t.status, TaskStatus::Failed | TaskStatus::Held))
721        .collect()
722}
723
724/// Deterministically resolve `Task::blocked_by`: a dependency task that
725/// reached `Done`, or a question that was answered, is removed — no model
726/// involved, on every poll. An answered question's content is copied onto
727/// the task ([`Task::record_answer`]) before its id is dropped, so it
728/// reaches the next `crate::conduct` prompt and the next run's instruction
729/// (see [`instruction_for`]) rather than only clearing the block.
730///
731/// A `blocked_by` id that names neither an existing task nor an existing
732/// question - `magi task rm` (or an operator by hand) deleted it while this
733/// task was still waiting - is caught before any of that: it can never
734/// become `Done` or `Answered`, so the ordinary loop below would otherwise
735/// leave the task `blocked` forever with nothing to notice. Such a task is
736/// quarantined to a machine hold instead ([`crate::queue::missing_blocker_hold_reason`]),
737/// which puts it in front of `crate::triage::run_once`'s own walk the next
738/// time it runs - see that module's doc for why the choice is "ask a human",
739/// never "assume the missing dependency was satisfied and unblock anyway".
740fn resolve_blockers(queue: &Queue, questions: &Questions) {
741    for listed in queue.list() {
742        if listed.status != TaskStatus::Blocked || listed.blocked_by.is_empty() {
743            continue;
744        }
745        let Ok(_claim) = queue.claim(&listed.id) else {
746            continue;
747        };
748        let Ok(mut task) = queue.get(&listed.id) else {
749            continue;
750        };
751        if task.status != TaskStatus::Blocked {
752            continue;
753        }
754        // A dependency deleted on purpose is released, not held - and before
755        // the missing check, so a pass that races the delete (or a claim that
756        // made `Queue::remove` skip this task) settles it quietly.
757        let deleted = queue.apply_deleted_blockers(&mut task, questions);
758        if !deleted.is_empty() {
759            record(queue, &mut task);
760            for id in &deleted {
761                queue.note_dependency_deleted(&task, id);
762            }
763            if task.status != TaskStatus::Blocked {
764                continue;
765            }
766        }
767        let missing = crate::queue::missing_blockers(queue, questions, &task.blocked_by);
768        if !missing.is_empty() {
769            let language = language_of(&task, Path::new("."));
770            let mut cause = missing.clone();
771            cause.sort();
772            cause.dedup();
773            task.notice_cause(&format!("missing-blockers:{}", cause.join(",")), None, None);
774            task.hold_machine(Some(crate::queue::missing_blocker_hold_reason_in(
775                &task.blocked_by,
776                &missing,
777                &language,
778            )));
779            record(queue, &mut task);
780            continue;
781        }
782        // A conductor question nobody answered in time is retired by the
783        // waiter, and nothing else would ever move the task it blocks.
784        if let Some(q) = task.blocked_by.iter().find_map(|id| {
785            let crate::queue::Dependency::Question(full) =
786                crate::queue::dependency(queue, questions, id)
787            else {
788                return None;
789            };
790            questions.get(&full).ok().filter(|q| {
791                q.node == crate::conduct::NODE && q.status == ask::QuestionStatus::Abandoned
792            })
793        }) {
794            let language = language_of(&task, Path::new("."));
795            task.notice_cause(&format!("unanswered-question:{}", q.id), None, None);
796            task.hold_machine(Some(unanswered_question_hold_reason(&q, &language)));
797            record(queue, &mut task);
798            continue;
799        }
800        let mut changed = false;
801        for id in task.blocked_by.clone() {
802            let resolved = crate::queue::dependency(queue, questions, &id);
803            if let crate::queue::Dependency::Task(full) = &resolved {
804                if queue
805                    .get(full)
806                    .is_ok_and(|dep| dep.status == TaskStatus::Done)
807                {
808                    task.unblock(&id);
809                    changed = true;
810                }
811                continue;
812            }
813            if let crate::queue::Dependency::Question(full) = resolved
814                && let Ok(q) = questions.get(&full)
815                && q.status == ask::QuestionStatus::Answered
816            {
817                // The action sweep releases conductor blockers atomically
818                // with their selected operation. A fresh deputy lease must
819                // never leave an answered hold temporarily runnable.
820                if q.node == crate::conduct::NODE
821                    && q.chosen_action().is_some()
822                    && !task.action_applied(&q.id)
823                {
824                    continue;
825                }
826                let answer = match &q.answer {
827                    Some(ask::Answer::Choice(c) | ask::Answer::Text(c)) => c.clone(),
828                    None => String::new(),
829                };
830                task.record_answer(q.summary.clone(), answer);
831                task.unblock(&id);
832                changed = true;
833            }
834        }
835        if changed {
836            record(queue, &mut task);
837        }
838    }
839}
840
841/// The `hold_reason` of a task whose conductor question went unanswered.
842fn unanswered_question_hold_reason(q: &ask::Question, language: &str) -> String {
843    if crate::lang::is_japanese(language) {
844        format!(
845            "質問 {} 「{}」 に期限内の回答がなく、取り下げられました - `magi task triage` を参照",
846            q.short(),
847            q.summary
848        )
849    } else {
850        format!(
851            "question {} \"{}\" went unanswered and was abandoned - see `magi task triage`",
852            q.short(),
853            q.summary
854        )
855    }
856}
857
858/// Where `q`'s chosen action stands for `task`. `daemon_will_act` is false
859/// for several unrelated reasons, and the waiter must not read them alike.
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861pub(crate) enum ActionStanding {
862    /// The answer carries no action: the waiter delivers it as before.
863    NoAction,
864    /// The daemon will apply it (not applied, task idle, current attempt).
865    Pending,
866    /// Already applied (`Task::actions_applied`): the daemon has acted, so
867    /// delivering the word to the old seat would run it beside the new work.
868    Applied,
869    /// The question is about an earlier attempt than the task's latest.
870    Stale,
871    /// The task is running / blocked / done: the daemon defers for now.
872    Busy,
873}
874
875impl ActionStanding {
876    /// The daemon has acted or will act: the waiter must not deliver.
877    pub(crate) fn daemon_owns(self) -> bool {
878        matches!(self, Self::Pending | Self::Applied | Self::Stale)
879    }
880}
881
882/// Classify `q`'s chosen action against `task`. `Stale` is judged before
883/// `Busy`: a question about an earlier attempt must be stopped even while a
884/// newer competition is running.
885pub(crate) fn action_standing(task: &Task, q: &ask::Question) -> ActionStanding {
886    if q.chosen_action().is_none() {
887        ActionStanding::NoAction
888    } else if task.action_applied(&q.id) {
889        ActionStanding::Applied
890    } else if q.node != crate::conduct::NODE && task.runs.last() != Some(&q.run) {
891        ActionStanding::Stale
892    } else if matches!(
893        task.status,
894        TaskStatus::Running | TaskStatus::Blocked | TaskStatus::Done
895    ) {
896        ActionStanding::Busy
897    } else {
898        ActionStanding::Pending
899    }
900}
901
902/// What [`decide_action`] concluded about one answered question.
903#[derive(Debug, Clone, PartialEq, Eq)]
904enum ActionDecision {
905    /// Nothing to do now (no action, already applied, or the task is busy).
906    Skip,
907    /// Continue the named run instead of competing again.
908    Resume(String),
909    /// Requeue as a fresh competition.
910    Requeue,
911    /// Hold the task by explicit operator choice.
912    Hold,
913    /// Close the task as done.
914    Done,
915    /// The question is about an earlier attempt than the task's latest; it
916    /// is recorded as handled and changes nothing.
917    Stale,
918    /// The action cannot be carried out; hold the task and say why. Never
919    /// falls back to something else, because the operator chose this one.
920    Refuse(String),
921}
922
923/// Decide what the choice answered on `q` means for `task`. Pure: `load`
924/// reads a run's state, as with [`unfinished_run_with`].
925///
926/// `Resume` only fires on the task's *latest* run, and only while that run
927/// could actually make progress ([`RunStatus::resumable`], not released, review
928/// budget left); an answer naming anything else is refused rather than
929/// reinterpreted as a fresh competition.
930fn decide_action<F>(task: &Task, q: &ask::Question, p: &Phrases, load: F) -> ActionDecision
931where
932    F: FnOnce(&str) -> Result<RunState>,
933{
934    let Some(action) = q.chosen_action() else {
935        return ActionDecision::Skip;
936    };
937    if task.action_applied(&q.id)
938        || matches!(
939            task.status,
940            TaskStatus::Running | TaskStatus::Blocked | TaskStatus::Done
941        )
942    {
943        return ActionDecision::Skip;
944    }
945    // A question about an earlier attempt must not act on a newer one. A
946    // conductor question is keyed by the task id, not a run, so it is exempt.
947    if q.node != crate::conduct::NODE && task.runs.last() != Some(&q.run) {
948        return ActionDecision::Stale;
949    }
950    let repair = q.node == land::APPROVAL_NODE && q.resolution().as_deref() == Some(land::REPAIR);
951    let stopping = q.node == land::APPROVAL_NODE
952        && q.resolution()
953            .as_deref()
954            .is_some_and(crate::merge_decision::stops);
955    // Repair and stop actions are new decisions on an existing approval card. A
956    // manual hold before that answer must not discard it just because the
957    // card predates the hold. Keep the older policy for other choice actions.
958    let decision_at = if repair || stopping {
959        q.answered_at.unwrap_or(q.asked_at)
960    } else {
961        q.asked_at
962    };
963    if task.operator_decided_at.is_some_and(|at| at > decision_at) {
964        return if repair || stopping {
965            ActionDecision::Refuse(format!(
966                "{} answer {} was superseded by a later operator decision; the task stays held. Inspect `magi show {}`; if this action is still wanted, explicitly release the existing owner with `magi task release {}`",
967                if repair { "repair" } else { "stop" },
968                q.id,
969                q.run,
970                task.id
971            ))
972        } else {
973            ActionDecision::Stale
974        };
975    }
976    match action {
977        a if a.maintenance() => ActionDecision::Skip,
978        ask::ChoiceAction::ResumeRun { .. }
979        | ask::ChoiceAction::ReleaseTask { .. }
980        | ask::ChoiceAction::RerunChecks { .. }
981        | ask::ChoiceAction::AbandonRun { .. } => unreachable!(),
982        ask::ChoiceAction::Requeue => ActionDecision::Requeue,
983        ask::ChoiceAction::Hold => ActionDecision::Hold,
984        ask::ChoiceAction::Done => ActionDecision::Done,
985        ask::ChoiceAction::Resume { run } => {
986            if task.runs.last() != Some(run) {
987                return ActionDecision::Refuse((p.resume_not_latest)(
988                    q.short(),
989                    ask::short_id(run),
990                ));
991            }
992            match load(run) {
993                Ok(s) if stopping => {
994                    if s.merge_decision.as_ref().is_some_and(|d| d.closed)
995                        || !s
996                            .land_approval
997                            .as_ref()
998                            .is_some_and(|binding| binding.question == q.id)
999                    {
1000                        ActionDecision::Stale
1001                    } else if s.liveness(false) == Liveness::Live {
1002                        ActionDecision::Skip
1003                    } else if s.status == RunStatus::Landing
1004                        && !s.released()
1005                        && s.retired_to.is_none()
1006                    {
1007                        ActionDecision::Resume(run.clone())
1008                    } else {
1009                        ActionDecision::Refuse("Owner stop decision cannot resume its original landing execution; inspect the existing run. No replacement or merge was started.".into())
1010                    }
1011                }
1012                Ok(s)
1013                    if q.node == land::APPROVAL_NODE
1014                        && q.resolution().as_deref() == Some(land::REPAIR) =>
1015                {
1016                    if s.merge_repairs.iter().any(|r| r.question == q.id)
1017                        || s.land_approval.as_ref().is_some_and(|a| a.question != q.id)
1018                    {
1019                        // A live/manual driver already consumed or superseded
1020                        // this answer. Do not hold its fresh approval wait.
1021                        ActionDecision::Stale
1022                    } else if s.liveness(false) == Liveness::Live {
1023                        ActionDecision::Skip
1024                    } else if s.status != RunStatus::Landing
1025                        || s.released()
1026                        || s.reviews.is_empty()
1027                        || !implementers_match(task, &s)
1028                        || !s
1029                            .land_approval
1030                            .as_ref()
1031                            .is_some_and(|a| a.question == q.id && !a.head.is_empty())
1032                    {
1033                        ActionDecision::Refuse(format!(
1034                            "repair question {} no longer names a supported current approval on run {}; inspect `magi show {}` and restore the existing owner's approval before retrying; do not file another task",
1035                            q.id, run, run
1036                        ))
1037                    } else {
1038                        ActionDecision::Resume(run.clone())
1039                    }
1040                }
1041                Ok(s) if s.liveness(false) == Liveness::Live => {
1042                    ActionDecision::Refuse("run already has a live driver".into())
1043                }
1044                Ok(s) => match crate::handover::resume_refusal(&s) {
1045                    None => ActionDecision::Resume(run.clone()),
1046                    Some(reason) => ActionDecision::Refuse((p.resume_cannot_progress)(
1047                        q.short(),
1048                        ask::short_id(run),
1049                        &reason.diagnostic(&s),
1050                    )),
1051                },
1052                Err(e) => ActionDecision::Refuse((p.resume_unreadable)(
1053                    q.short(),
1054                    ask::short_id(run),
1055                    &format!("{e:#}"),
1056                )),
1057            }
1058        }
1059    }
1060}
1061
1062/// The fixed prose the daemon writes into [`Task::last_error`] and
1063/// [`Task::hold_reason`], in the language of `[graph] language`. Same shape as
1064/// `triage::Wording` and `land::Words`: only English and Japanese are
1065/// translated, anything else falls back to English.
1066///
1067/// Rendered when the reason is *generated*, so a reason keeps the language it
1068/// was written in if the setting changes later. The notification frame
1069/// (`Task <id> is held:`) is not here and stays English.
1070///
1071/// Deliberately **not** translated, because other code reads them back by
1072/// string: the disk gate prefixes (`triage::is_disk_hold`), `out of attempts`
1073/// and `gate red`. IDs, node names, paths and external error text are embedded
1074/// as they are.
1075struct Phrases {
1076    /// `{}` = status label, then the detail.
1077    graph_stopped: fn(&str, &str) -> String,
1078    quorum_lost: &'static str,
1079    /// Followed by the seats' names.
1080    quota_took_out: &'static str,
1081    /// Followed by the status label.
1082    run_ended: &'static str,
1083    /// Followed by the question's short id.
1084    waiting_for_answer: &'static str,
1085    recovered_running: &'static str,
1086    no_run_to_recover: &'static str,
1087    could_not_start: &'static str,
1088    resume_not_latest: fn(&str, &str) -> String,
1089    resume_cannot_progress: fn(&str, &str, &str) -> String,
1090    resume_unreadable: fn(&str, &str, &str) -> String,
1091    /// A takeover refusal: the operator-facing text. English is the error's own
1092    /// `Display`; a translation keeps the machine detail apart in parentheses.
1093    handover_refused: fn(&crate::handover::Refused) -> String,
1094    /// Appended to a refused handover's hold reason (leading space included).
1095    handover_hint: &'static str,
1096    /// (seats that never answered, review rounds) for the review loop's
1097    /// "refusing to call it clean" stop.
1098    reviewers_never_answered: fn(usize, usize) -> String,
1099}
1100
1101const PHRASES_EN: Phrases = Phrases {
1102    graph_stopped: |status, detail| {
1103        format!("the graph stopped at `{status}` without reaching a terminal status: {detail}")
1104    },
1105    quorum_lost: "the judging panel lost its quorum",
1106    quota_took_out: "; quota took out ",
1107    run_ended: "run ended ",
1108    waiting_for_answer: " - waiting for operator answer to question ",
1109    recovered_running: "recovered a `running` task whose daemon never recorded the outcome: ",
1110    no_run_to_recover: "task was `running` with no live daemon and no readable \
1111                        run to recover; held for a human to check what happened",
1112    could_not_start: "could not start the run: ",
1113    resume_not_latest: |q, run| {
1114        format!("question {q} asked to resume run {run}, which is not this task's latest run")
1115    },
1116    resume_cannot_progress: |q, run, reason| {
1117        format!("question {q} asked to resume run {run}, which cannot make progress: {reason}")
1118    },
1119    resume_unreadable: |q, run, e| {
1120        format!("question {q} asked to resume run {run}, which could not be read: {e}")
1121    },
1122    handover_refused: |r| r.to_string(),
1123    handover_hint: " (clean up the other worktree, then release the task from the queue)",
1124    reviewers_never_answered: |missing, rounds| {
1125        format!(
1126            "{missing} reviewer seat(s) never answered after {rounds} rounds; \
1127             refusing to call it clean"
1128        )
1129    },
1130};
1131
1132const PHRASES_JA: Phrases = Phrases {
1133    graph_stopped: |status, detail| {
1134        format!("グラフが終端状態に達しないまま `{status}` で停止しました: {detail}")
1135    },
1136    quorum_lost: "審査パネルが定足数を失いました",
1137    quota_took_out: "。クォータで脱落: ",
1138    run_ended: "run 終了: ",
1139    waiting_for_answer: " - オペレーターの回答待ち: 質問 ",
1140    recovered_running: "daemon が結果を記録しないまま `running` だったタスクを回収しました: ",
1141    no_run_to_recover: "タスクは `running` でしたが、生きた daemon も回収できる run も見つかりません。\
1142                        何が起きたか人が確認するため保留にしました",
1143    could_not_start: "run を開始できませんでした: ",
1144    resume_not_latest: |q, run| {
1145        format!(
1146            "質問 {q} は run {run} の再開を求めましたが、これはタスクの最新の run ではありません"
1147        )
1148    },
1149    resume_cannot_progress: |q, run, reason| {
1150        format!("質問 {q} は run {run} の再開を求めましたが、これは進行できません: {reason}")
1151    },
1152    resume_unreadable: |q, run, e| {
1153        format!("質問 {q} は run {run} の再開を求めましたが、読み込めませんでした: {e}")
1154    },
1155    handover_refused: |r| {
1156        use crate::handover::Refused;
1157        match r {
1158            Refused::Foreign { branch, path, why } => format!(
1159                "ブランチ `{branch}` は {path} にチェックアウトされており、magi は自動では\
1160                 削除しません。不要なら `git worktree remove` でその worktree を削除して\
1161                 から、やり直してください(詳細: {why})"
1162            ),
1163            Refused::Unsafe { branch, path, why } => format!(
1164                "ブランチ `{branch}` は {path} にチェックアウトされています。そこの作業を\
1165                 コミットか破棄したうえで `git worktree remove` で worktree を削除する\
1166                 か、run を破棄してよいと伝えてから、やり直してください(詳細: {why})"
1167            ),
1168            Refused::ReleaseFailed { branch, path, run } => format!(
1169                "ブランチ `{branch}` は {path} で run {run} がチェックアウトしており、その\
1170                 worktree の解放に失敗したか、変更が見つかりました(未コミットの変更が\
1171                 ある worktree は git が削除を拒否します)。worktree はそのまま残しました"
1172            ),
1173        }
1174    },
1175    handover_hint: "(他の worktree を片付けてから、タスクをキューから解放してください)",
1176    reviewers_never_answered: |missing, rounds| {
1177        format!(
1178            "{missing} 席のレビュアーが {rounds} ラウンドの間に一度も回答しなかったため、\
1179             クリーンとは認めません"
1180        )
1181    },
1182};
1183
1184fn phrases(language: &str) -> &'static Phrases {
1185    if crate::lang::is_japanese(language) {
1186        &PHRASES_JA
1187    } else {
1188        &PHRASES_EN
1189    }
1190}
1191
1192/// The language of the repository a task belongs to, read best-effort for the
1193/// paths that have no run (and so no config of their own) to ask. An unreadable
1194/// config is English, not an error: a hold reason must still get written.
1195fn language_of(task: &Task, fallback: &Path) -> String {
1196    crate::lang::of_repo(&repo_for(task, fallback))
1197}
1198
1199/// The task an answered question speaks for: the one whose runs include the
1200/// question's run, or - for a conductor question, filed under the task's own
1201/// id - the task with that id.
1202pub(crate) fn task_of_question<'a>(tasks: &'a [Task], q: &ask::Question) -> Option<&'a Task> {
1203    if q.node == crate::conduct::NODE {
1204        return tasks.iter().find(|t| t.id == q.run);
1205    }
1206    tasks.iter().find(|t| t.runs.contains(&q.run))
1207}
1208
1209/// Legacy conductor prose never becomes a maintenance command by inference.
1210/// Replace unsupported open labels under the question lock before dispatch.
1211fn repair_choice_offers(questions: &Questions) {
1212    for q in questions.list().into_iter().filter(|q| {
1213        q.status.open()
1214            && q.node == crate::conduct::NODE
1215            && q.choices
1216                .iter()
1217                .any(|c| crate::choice_effect::describe(q, c).is_none())
1218    }) {
1219        let _ = questions.update(&q.id, |current| {
1220            if current.status.open() {
1221                crate::choice_effect::executable_conductor(current);
1222                if let Some(deputy) = &mut current.deputy {
1223                    deputy.brief = crate::deputy::brief(
1224                        &current.run,
1225                        &current.detail,
1226                        &current.choices,
1227                        &current.actions,
1228                    );
1229                }
1230            }
1231            Ok(())
1232        });
1233    }
1234}
1235
1236/// Apply answered typed choices. Only the daemon calls this in production;
1237/// deputies settle the question and never execute its actions.
1238pub async fn apply_choice_actions(queue: &Queue, questions: &Questions, home: &Path) {
1239    apply_maintenance_choices(queue, questions, home).await;
1240    apply_task_choice_actions(queue, questions, home);
1241}
1242
1243/// Apply bounded maintenance effects independently of question ownership: a
1244/// release watcher need not have a queue task, and a predecessor is another run.
1245async fn apply_maintenance_choices(queue: &Queue, questions: &Questions, home: &Path) {
1246    for q in questions.list() {
1247        let Some(action) = q.chosen_action().filter(|a| a.maintenance()) else {
1248            continue;
1249        };
1250        // Receipt paths use question IDs, whose format is independent of run
1251        // IDs. Apply the question store's filename safety rule to both eras.
1252        if asker_may_still_read(questions, &q, home) || !ask::valid_asset_name(&q.id) {
1253            continue;
1254        }
1255        let dir = home.join("choice-actions");
1256        if std::fs::create_dir_all(&dir).is_err() {
1257            continue;
1258        }
1259        let Ok(lock) = std::fs::OpenOptions::new()
1260            .create(true)
1261            .truncate(false)
1262            .write(true)
1263            .open(dir.join(format!("{}.lock", q.id)))
1264        else {
1265            continue;
1266        };
1267        if crate::pr_head::try_lock_file(&lock).is_err() {
1268            continue;
1269        }
1270        let fresh = !home
1271            .join("choice-actions")
1272            .join(format!("{}.json", q.id))
1273            .exists();
1274        let result = async {
1275            let receipt = dir.join(format!("{}.json", q.id));
1276            if receipt.exists() {
1277                let prior: serde_json::Value = serde_json::from_slice(&std::fs::read(&receipt)?)?;
1278                anyhow::ensure!(
1279                    prior.get("status").and_then(|s| s.as_str()) == Some("applied"),
1280                    "previous maintenance attempt did not confirm completion; inspect its receipt"
1281                );
1282                return Ok::<_, anyhow::Error>(());
1283            }
1284            // Never retry an uncertain external effect. The receipt is written
1285            // before calling gh, exactly like release_watch's rerun record.
1286            let record = |status: &str| -> anyhow::Result<()> {
1287                let tmp = receipt.with_extension("tmp");
1288                std::fs::write(
1289                    &tmp,
1290                    serde_json::to_vec(&serde_json::json!({
1291                        "action": action, "status": status,
1292                    }))?,
1293                )?;
1294                std::fs::rename(tmp, &receipt)?;
1295                Ok(())
1296            };
1297            let applied = async {
1298                match action {
1299                    ask::ChoiceAction::ResumeRun { run } => {
1300                        crate::resume::validate(home, run)?;
1301                        record("started")?;
1302                        if !crate::resume::pin_queued(queue, run, Some(q.asked_at), Some(&q.id))? {
1303                            crate::resume::validate_unqueued(home, run)?;
1304                            crate::resume::start(home, run.clone()).await?;
1305                        }
1306                    }
1307                    ask::ChoiceAction::ReleaseTask {
1308                        task,
1309                        held_at,
1310                        reason,
1311                    } => {
1312                        anyhow::ensure!(
1313                            queue.resolve_id(task)? == *task,
1314                            "task action requires an exact task ID"
1315                        );
1316                        let _claim = queue.claim(task)?;
1317                        let mut task = queue.get(task)?;
1318                        if task.action_applied(&q.id) {
1319                            return Ok(());
1320                        }
1321                        // Filing this conductor question temporarily wraps the
1322                        // original hold in a Blocked state. Restore only that
1323                        // exact blocker on the local copy before checking the
1324                        // hold binding; unrelated waits never authorize release.
1325                        if q.node == crate::conduct::NODE
1326                            && q.run == task.id
1327                            && task.status == TaskStatus::Blocked
1328                            && task.blocked_from == Some(TaskStatus::Held)
1329                            && task.blocked_by.as_slice() == [q.id.as_str()]
1330                        {
1331                            task.unblock(&q.id);
1332                        }
1333                        anyhow::ensure!(
1334                            task.status == TaskStatus::Held
1335                                && task.held_at == Some(*held_at)
1336                                && task.hold_reason.as_deref() == reason.as_deref()
1337                                && task.operator_decided_at.is_none_or(|at| at <= q.asked_at),
1338                            "task no longer has the hold this question names"
1339                        );
1340                        task.release_operator("choice", Some(&q.id));
1341                        task.mark_action_applied(&q.id);
1342                        queue.put(&mut task)?;
1343                    }
1344                    ask::ChoiceAction::AbandonRun { run } => {
1345                        record("started")?;
1346                        crate::driver::abandon_proven_dead(home, run)?;
1347                    }
1348                    ask::ChoiceAction::RerunChecks {
1349                        repo,
1350                        pr,
1351                        head,
1352                        runs,
1353                    } => {
1354                        rerun_checks_once(home, &q.id, repo, pr, head, runs, || record("started"))
1355                            .await?;
1356                    }
1357                    _ => unreachable!(),
1358                }
1359                Ok::<_, anyhow::Error>(())
1360            }
1361            .await;
1362            match applied {
1363                Ok(()) => record("applied"),
1364                Err(e) => {
1365                    record(&format!("refused or failed: {e:#}"))?;
1366                    Err(e)
1367                }
1368            }
1369        }
1370        .await;
1371        // Retire the originating blocker as well as the target operation.
1372        // A foreign resume leaves its issuer held instead of competing beside
1373        // the execution just started. Other successful maintenance lets the
1374        // original task retry its normal, guarded path.
1375        if home
1376            .join("choice-actions")
1377            .join(format!("{}.json", q.id))
1378            .exists()
1379            && let Some(listed) = task_of_question(&queue.list(), &q)
1380            && !listed.action_applied(&q.id)
1381            && let Ok(_claim) = queue.claim(&listed.id)
1382            && let Ok(mut origin) = queue.get(&listed.id)
1383        {
1384            if !origin.operator_held()
1385                && origin.operator_decided_at.is_none_or(|at| at <= q.asked_at)
1386                && !matches!(origin.status, TaskStatus::Running | TaskStatus::Done)
1387            {
1388                if result.is_err() || matches!(action, ask::ChoiceAction::ResumeRun { .. }) {
1389                    origin.hold_machine(Some(format!(
1390                        "Maintenance choice {} settled; inspect its target before releasing this task",
1391                        q.id
1392                    )));
1393                } else {
1394                    origin.unblock(&q.id);
1395                }
1396            }
1397            origin.mark_action_applied(&q.id);
1398            let _ = queue.put(&mut origin);
1399        }
1400        if let Err(e) = result
1401            && fresh
1402        {
1403            notices::raise_in(
1404                home,
1405                Notice::warn(
1406                    &format!("action:{}", q.id),
1407                    format!("Choice action {} was not completed: {e:#}", q.id),
1408                )
1409                .link(if q.run_names_task() {
1410                    Link::Task { id: q.run.clone() }
1411                } else {
1412                    Link::Run { id: q.run.clone() }
1413                }),
1414            );
1415        }
1416    }
1417}
1418
1419/// Rerun the named failed workflow runs of a pull request, each at most once
1420/// per `request` (a question id, or a chat operation identity). The head,
1421/// lifecycle and failed state are re-read from the forge first; `started` is
1422/// called once those checks pass and before the first external call, and the
1423/// ledger entry is written before every `gh` call, so an uncertain result is
1424/// never repeated automatically.
1425pub(crate) async fn rerun_checks_once(
1426    home: &Path,
1427    request: &str,
1428    repo: &Path,
1429    pr: &str,
1430    head: &str,
1431    runs: &[String],
1432    started: impl FnOnce() -> anyhow::Result<()>,
1433) -> anyhow::Result<()> {
1434    use crate::release_watch::{GhForge, ReleaseForge};
1435    anyhow::ensure!(
1436        !runs.is_empty()
1437            && runs.len() <= 20
1438            && runs
1439                .iter()
1440                .all(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()))
1441            && !pr.starts_with('-'),
1442        "invalid rerun targets"
1443    );
1444    let snap = GhForge.snapshot(repo, pr).await?;
1445    anyhow::ensure!(
1446        snap.head == head && snap.state == crate::land::PrLifecycle::Open,
1447        "pull request head or lifecycle changed"
1448    );
1449    anyhow::ensure!(
1450        runs.iter().all(|r| snap
1451            .checks
1452            .iter()
1453            .any(|c| c.run.as_ref() == Some(r) && crate::release_watch::is_failed(c.verdict))),
1454        "named workflow is no longer failed on this pull request"
1455    );
1456    started()?;
1457    let dir = home.join("choice-actions");
1458    std::fs::create_dir_all(&dir)?;
1459    let rerun_lock = std::fs::OpenOptions::new()
1460        .create(true)
1461        .truncate(false)
1462        .write(true)
1463        .open(dir.join("reruns.lock"))?;
1464    crate::pr_head::try_lock_file(&rerun_lock)?;
1465    let ledger = dir.join("reruns.json");
1466    let mut recorded: std::collections::BTreeSet<String> = if ledger.exists() {
1467        serde_json::from_slice(&std::fs::read(&ledger)?)?
1468    } else {
1469        Default::default()
1470    };
1471    for run in runs {
1472        let key = serde_json::to_string(&(request, repo, pr, head, run))?;
1473        if !recorded.insert(key) {
1474            continue;
1475        }
1476        let tmp = ledger.with_extension("tmp");
1477        std::fs::write(&tmp, serde_json::to_vec(&recorded)?)?;
1478        std::fs::rename(tmp, &ledger)?;
1479        GhForge.rerun(repo, run).await?;
1480    }
1481    Ok(())
1482}
1483
1484/// Carry out the [`ask::ChoiceAction`] attached to each answered choice, once.
1485///
1486/// Runs on every poll before the conductor looks at the queue. The task is
1487/// re-read under its claim, so a task somebody completed or held by hand in
1488/// the meantime is judged on what it is now. `Resume` releases the task and
1489/// records a pinned [`crate::queue::OperatorResume`], which `crate::conduct`
1490/// honours (no re-hold, no requeue) until the task runs and [`attempt`]'s
1491/// ordinary `unfinished_run` logic resumes that same run.
1492pub(crate) fn apply_task_choice_actions(queue: &Queue, questions: &Questions, home: &Path) {
1493    let tasks = queue.list();
1494    for q in questions.list() {
1495        // Dismissal is committed by the question writer, never a resume.
1496        if q.resolution().as_deref() == Some(crate::merge_decision::DISMISS) {
1497            continue;
1498        }
1499        if q.chosen_action().is_none_or(|a| a.maintenance()) {
1500            continue;
1501        }
1502        let Some(listed) = task_of_question(&tasks, &q) else {
1503            if q.node == land::APPROVAL_NODE
1504                && q.resolution().as_deref().is_some_and(|choice| {
1505                    choice == land::REPAIR || crate::merge_decision::stops(choice)
1506                })
1507            {
1508                notices::raise_in(
1509                    home,
1510                    Notice::warn(
1511                        &format!("repair-owner:{}", q.id),
1512                        format!(
1513                            "Approval action is waiting: no task owns run {}. Inspect `magi show {}` and restore its original queue owner; do not file a competing task.",
1514                            q.run, q.run
1515                        ),
1516                    ).link(Link::Run { id: q.run.clone() }),
1517                );
1518            }
1519            continue;
1520        };
1521        if listed.action_applied(&q.id) {
1522            continue;
1523        }
1524        let Ok(_claim) = queue.claim(&listed.id) else {
1525            continue;
1526        };
1527        let Ok(mut task) = queue.get(&listed.id) else {
1528            continue;
1529        };
1530        // Work only on this local copy until the action can be committed.
1531        // In particular, a fresh lease below leaves the persisted task blocked.
1532        if q.node == crate::conduct::NODE && task.status == TaskStatus::Blocked {
1533            let blockers: Vec<_> = task
1534                .blocked_by
1535                .iter()
1536                .filter(|id| {
1537                    crate::queue::dependency(queue, questions, id)
1538                        == crate::queue::Dependency::Question(q.id.clone())
1539                })
1540                .cloned()
1541                .collect();
1542            if !blockers.is_empty() {
1543                for id in blockers {
1544                    task.unblock(&id);
1545                }
1546                let answer = match &q.answer {
1547                    Some(ask::Answer::Choice(c) | ask::Answer::Text(c)) => c.clone(),
1548                    None => String::new(),
1549                };
1550                task.record_answer(q.summary.clone(), answer);
1551            }
1552        }
1553        let language = language_of(&task, Path::new("."));
1554        let decision = decide_action(&task, &q, phrases(&language), |id| {
1555            RunState::load_under(id, home)
1556        });
1557        let ran = matches!(
1558            decision,
1559            ActionDecision::Resume(_)
1560                | ActionDecision::Requeue
1561                | ActionDecision::Hold
1562                | ActionDecision::Done
1563        );
1564        if ran && asker_may_still_read(questions, &q, home) {
1565            // The asking process or its seat can still take the word; act
1566            // once it has gone quiet. Bound on the remaining window: the
1567            // check is not atomic with the write below, so an asker that
1568            // takes the word in between (at most one poll, never more than
1569            // `ask::LEASE_TTL`) only prints it - it never touches the task,
1570            // and `Task::actions_applied` under the claim keeps the action to
1571            // one application. The waiter does not deliver an answer that
1572            // carries an action (`waiter::decide_owned`), so it cannot race.
1573            continue;
1574        }
1575        let done = matches!(decision, ActionDecision::Done);
1576        match decision {
1577            ActionDecision::Skip => continue,
1578            ActionDecision::Resume(run) => {
1579                task.release_operator("choice", Some(&q.id));
1580                task.resume_override = Some(crate::queue::OperatorResume {
1581                    question_id: q.id.clone(),
1582                    at: Timestamp::now(),
1583                    conductor_rehold: None,
1584                    forced: true,
1585                    pinned_run: Some(run),
1586                });
1587            }
1588            ActionDecision::Stale => {}
1589            ActionDecision::Requeue => {
1590                task.release_operator("choice-requeue", Some(&q.id));
1591                task.requeue();
1592            }
1593            ActionDecision::Hold => task.hold_manual(Some(q.summary.clone())),
1594            ActionDecision::Done => task.succeed(),
1595            ActionDecision::Refuse(why) => {
1596                if task.operator_held() {
1597                    // A rejected answer does not release the operator's hold
1598                    // or reclassify it as recoverable by the conductor.
1599                    task.hold_reason = Some(match task.hold_reason.take() {
1600                        Some(reason) => format!("{reason}\n\n{why}"),
1601                        None => why,
1602                    });
1603                } else {
1604                    task.notice_cause(&format!("resume-refused:{}", q.id), None, None);
1605                    task.hold_machine(Some(why));
1606                }
1607                notices::raise(
1608                    Notice::warn(
1609                        &format!("action:{}", q.id),
1610                        "An answer asked the daemon to resume a run that cannot be resumed; the task stays held.",
1611                    )
1612                    .link(Link::Task {
1613                        id: task.id.clone(),
1614                    }),
1615                );
1616            }
1617        }
1618        task.mark_action_applied(&q.id);
1619        // The applied id travels with the transition, so a failed save loses
1620        // neither and the answer is acted on at the next poll.
1621        record(queue, &mut task);
1622        if done && queue.get(&task.id).is_ok_and(|t| t.action_applied(&q.id)) {
1623            supersede_prior_runs(&task, home);
1624        }
1625        // Delivery is only a note of who has read the answer; the task's own
1626        // mark above is the authority. Best effort.
1627        let _ = questions.update(&q.id, |r| {
1628            r.answer_delivered = true;
1629            Ok(())
1630        });
1631    }
1632}
1633
1634/// Whether something may still read the answer to `q`: a fresh lease, or the
1635/// asking seat still listed as active in its run (its CLI can outlive the
1636/// `magi ask` that was killed).
1637fn asker_may_still_read(questions: &Questions, q: &ask::Question, home: &Path) -> bool {
1638    let now = Timestamp::now();
1639    if questions.read_lease(&q.id).is_some_and(|l| l.fresh(now)) {
1640        return true;
1641    }
1642    q.node != crate::conduct::NODE
1643        && RunState::load_under(&q.run, home).is_ok_and(|s| {
1644            s.seats_active()
1645                .any(|(k, a)| *k == q.seat && a.remaining_secs(now) > 0)
1646        })
1647}
1648
1649/// Retire an unanswered conductor question after its task no longer refers to
1650/// it. Conductor questions use the task id in `Question::run`, so run-based
1651/// cleanup cannot observe a manual release or completion.
1652///
1653/// Restricted to `Question::node == crate::conduct::NODE`: an ordinary run's
1654/// own question also carries a `run`, and a run id that happens to collide
1655/// with some task's id is not this loop's business — only a conductor
1656/// question actually uses the task id that way. One `Questions::list()` scan
1657/// is taken up front and matched against the in-memory task set, rather than
1658/// calling `Questions::open_for` (a full disk scan on its own) once per task.
1659fn reconcile_task_questions(queue: &Queue, questions: &Questions) {
1660    let tasks = queue.list();
1661    let by_id: std::collections::BTreeMap<&str, &Task> =
1662        tasks.iter().map(|t| (t.id.as_str(), t)).collect();
1663    let referenced: Vec<&str> = tasks
1664        .iter()
1665        .flat_map(|task| task.blocked_by.iter().map(String::as_str))
1666        .collect();
1667
1668    for mut question in questions.list() {
1669        if !question.status.open() || question.node != crate::conduct::NODE {
1670            continue;
1671        }
1672        // Keep questions a task still names, including when the reference
1673        // moved to a dependent task.
1674        // Preserve even ambiguous references: retiring a possibly referenced
1675        // question is unsafe until the operator disambiguates it.
1676        if referenced
1677            .iter()
1678            .any(|id| question.id.starts_with(id) || question.id.ends_with(id))
1679        {
1680            continue;
1681        }
1682        let Some(task) = by_id.get(question.run.as_str()) else {
1683            continue;
1684        };
1685        question.abandon(format!(
1686            "task {} no longer waits for this answer",
1687            task.short()
1688        ));
1689        if let Err(e) = questions.put(&mut question) {
1690            tracing::warn!(
1691                "could not retire question {} for task {}: {e:#}",
1692                question.short(),
1693                task.short()
1694            );
1695        }
1696    }
1697}
1698
1699/// What a finished run tells the queue about the task it came from.
1700///
1701/// A struct rather than a fourth and fifth boolean argument: the two flags
1702/// answer different questions about the same run, and a call site passing
1703/// `(…, true, false)` is one transposition away from refunding attempts
1704/// forever.
1705#[derive(Debug, Clone, Copy)]
1706pub struct Verdict {
1707    /// Where the graph stopped.
1708    pub status: RunStatus,
1709    /// The run opened a pull request.
1710    pub left_pr: bool,
1711    /// At least one seat was lost to a rate limit.
1712    pub quota_hit: bool,
1713    /// The run parked at a node boundary because it was asked to.
1714    pub parked: bool,
1715    /// The run never produced a single candidate a judge could look at.
1716    ///
1717    /// Distinct from `quota_hit`: a run can lose a seat to a rate limit and
1718    /// still have another candidate worth judging, in which case the loss was
1719    /// not the reason nothing came of the run. This is `true` only when the
1720    /// implement wave ended with nothing viable at all.
1721    pub no_viable_candidates: bool,
1722}
1723
1724/// Record a finished run against the task it came from.
1725///
1726/// Kept pure and separate from the loop because this mapping *is* the retry
1727/// policy, and a policy that can only be exercised by spawning a graph is a
1728/// policy nobody checks. The table:
1729///
1730/// | run status                           | task becomes        | attempt spent |
1731/// |---------------------------------------|---------------------|---------------|
1732/// | parked at a boundary                  | `Parked` (requeued) | **no**        |
1733/// | `Merged`, `Ready`                      | `Done`               | yes          |
1734/// | `AlreadyInBase`                        | `Done`, with a note  | **no**        |
1735/// | `Stalled`, quota hit                   | `Failed` (requeued) | **no**        |
1736/// | `Failed`, quota hit, no viable cand.   | `Failed` (requeued) | **no**        |
1737/// | `Stalled`, no quota                    | `Failed`, or `Held`  | yes          |
1738/// | `Blocked` with a PR                    | `Held`               | yes          |
1739/// | `Blocked`, `Failed` otherwise          | `Failed`, or `Held`  | yes          |
1740/// | `VerifiedNoop`                          | `Held`               | yes          |
1741/// | anything non-terminal                  | `Failed`, or `Held`  | yes          |
1742///
1743/// The `VerifiedNoop` row is independent of the `Failed`-quota row above it,
1744/// deliberately: every candidate agreeing there is nothing to write is not a
1745/// machine fact about a rate limit, it is an unverified claim about the
1746/// *task* that a human still has to check — see [`RunStatus::VerifiedNoop`]'s
1747/// own doc and [`Task::handed_off`]. `Held` rather than `Done` on purpose: the
1748/// claim could be wrong (a misread instruction, a stale check), and closing
1749/// the task automatically on an implementer's say-so would be the exact
1750/// failure mode task 391f's own audit was raised to avoid. `attempt spent` is
1751/// `yes` here for the same reason it is on the `Blocked`-with-a-PR row just
1752/// above, which settles through the same [`Task::handed_off`]: `Held` is not
1753/// `Failed`-and-requeued, so nothing retries this task on the same unverified
1754/// claim regardless of whether the one already-spent attempt is refunded, and
1755/// [`Task::release`] resets the count to zero anyway the moment a human looks
1756/// at the evidence and lets it run again.
1757///
1758/// The `Stalled`-quota and `Failed`-quota rows are the ones worth reading
1759/// twice, together. A quorum lost to rate limits is a property of the machine
1760/// and not of the task, so the attempt is refunded and a reset quota picks
1761/// the work up where it stopped — and that is just as true when every
1762/// implement seat lost the same race and `after_implement` bails with nothing
1763/// to judge, which surfaces as `Failed` rather than `Stalled` but is the same
1764/// machine fact. The `no_viable_candidates` guard is what keeps that row
1765/// narrow: a `Failed` run that produced a real candidate which then lost for
1766/// some other reason still spends the attempt, exactly like the quorum lost
1767/// to judges that answered with the wrong shape is ordinary flakiness, and
1768/// refunding *that* takes the bound off the retry loop entirely: run e633
1769/// stalled with `quota: []` after two judges wrote unusable JSON, was
1770/// refunded, and the next attempt paid for a fresh hour-long implement wave
1771/// before it could fail the same way. `max_attempts` exists precisely so
1772/// that cannot repeat forever.
1773///
1774/// A non-terminal status means `execute` returned while the graph was still
1775/// mid-flight, which is a bug rather than a verdict; it is treated as a
1776/// failure so that a task cannot loop on it either.
1777///
1778/// `left_pr` splits the `Blocked` row, and it is the difference between a run
1779/// that failed and a run that finished into a gate. See [`Task::handed_off`].
1780pub fn settle(task: &mut Task, verdict: Verdict, detail: &str, max_attempts: usize) {
1781    settle_in(task, verdict, detail, max_attempts, &PHRASES_EN)
1782}
1783
1784/// [`settle`], with the daemon's own fixed prose in `p`'s language.
1785fn settle_in(task: &mut Task, verdict: Verdict, detail: &str, max_attempts: usize, p: &Phrases) {
1786    // A parked run is the operator's own doing, and its work is intact on
1787    // disk. The task goes back in line with its attempt refunded so the next
1788    // loop resumes the same run - which `one_task` prefers over competing
1789    // again - and so that swapping the binary a few times cannot exhaust a
1790    // budget meant for agents that actually misbehaved.
1791    if verdict.parked {
1792        task.park(detail);
1793        return;
1794    }
1795    match verdict.status {
1796        RunStatus::Merged | RunStatus::Ready
1797            if task.pr_recovery.as_ref().is_some_and(|s| {
1798                s.disposition != Some(crate::pr_recovery::Disposition::Review)
1799            }) =>
1800        {
1801            task.hold_manual(Some("Public delivery is not confirmed. Inspect the selected PR and run, then use recover-pr --disposition land with the intended source and validation configuration.".into()));
1802        }
1803        RunStatus::Merged | RunStatus::Ready => task.succeed(),
1804        RunStatus::AlreadyInBase => task.already_landed(detail),
1805        RunStatus::Stalled if verdict.quota_hit => task.stall(detail),
1806        RunStatus::Failed if verdict.quota_hit && verdict.no_viable_candidates => {
1807            task.stall(detail)
1808        }
1809        RunStatus::Stalled | RunStatus::Failed => task.fail(detail, max_attempts),
1810        RunStatus::Blocked if verdict.left_pr => task.handed_off(detail),
1811        RunStatus::Blocked => task.fail(detail, max_attempts),
1812        RunStatus::VerifiedNoop => task.handed_off(detail),
1813        other => task.fail((p.graph_stopped)(label(other), detail), max_attempts),
1814    }
1815}
1816
1817/// Rewrite this task's now-moot earlier attempts — [`Task::superseded_attempts`]
1818/// — from `Blocked`/`Stalled` to [`RunStatus::Superseded`], once the task
1819/// itself has actually finished *and* its own last attempt is provably why:
1820/// this loads that run and requires `Merged`/`Ready` before touching
1821/// anything earlier in `runs`, since `Task::succeed` can also be called
1822/// directly on a task whose last attempt never landed at all.
1823///
1824/// Called right after whatever call set `task.status` to
1825/// [`TaskStatus::Done`] — this module's own `settle`'s callers below and
1826/// `reclaim`'s, but also every other place a task can be closed by hand
1827/// without the loop ever settling it itself: `magi task done`
1828/// (`main::TaskCmd::Done`) and `POST /api/queue/{id}/done`
1829/// (`web::queue_done`). Closing a task by hand — after confirming a manual
1830/// GitHub merge, say, exactly the workflow this repository's own
1831/// "Landing a run's winner by hand" documents — is just as much "this task's
1832/// story is over" as a daemon-driven `Merged`/`Ready` is, and skipping it
1833/// there would leave every earlier attempt stuck at `Blocked`/`Stalled`
1834/// forever, which is the exact backlog this status exists to clear. `pub`
1835/// for those two out-of-module callers; everything else about this stays
1836/// internal bookkeeping.
1837///
1838/// A run something is still actually driving is left alone: a
1839/// task cannot be `Done` while one of its *own* attempts is still in flight,
1840/// so a live one at this point can only be an unrelated process — a manual
1841/// `--resume` of this old, now-moot run — and rewriting under it would just
1842/// be undone (back to `Blocked`/`Stalled`, or worse) the next time that
1843/// process itself saves. This is the same `daemon_claims` + [`RunState::liveness`]
1844/// pair `run_report`/`run_detail` use for the same question elsewhere:
1845/// `is_working_on` alone only ever proves a *daemon* claim, not a `magi run
1846/// --resume` invoked by hand outside it, which `liveness` also checks via
1847/// the run's own recorded `driver_pid`. Anything not `Blocked` or `Stalled`
1848/// (already terminal in some other way, or itself mid-run) is left untouched
1849/// too.
1850///
1851/// Best-effort, like the rest of this module's bookkeeping: a load or save
1852/// failure here is a `tracing::warn`, not a failed settle, and the next time
1853/// this same task's completion is recorded — or a daemon restart replays
1854/// `reclaim_orphaned_running` — is another chance to catch up. There is no
1855/// separate reconciliation pass; if that gap ever matters in practice, one
1856/// can be added then.
1857///
1858/// `home` is a parameter, not the process-global [`crate::run::home`], for
1859/// the same reason [`RunState::save_under`] takes one: `web::queue_done`
1860/// calls this with `ui.home`, which is `crate::run::home()` in a real
1861/// process but a fixture's own directory under test — falling through to
1862/// the global there would read and write through whichever home some other
1863/// test in the same binary happened to pin into that `OnceLock` first, not
1864/// the run this call actually means.
1865pub fn supersede_prior_runs(task: &Task, home: &Path) {
1866    let now = Timestamp::now();
1867    // `Task::superseded_attempts` cannot tell a loop-driven `Done` (always
1868    // right behind a `Merged`/`Ready` verdict) from one set directly by
1869    // `magi task done`/its web equivalent/the conductor on a task whose last
1870    // recorded attempt never actually landed - that requires reading the
1871    // run itself, which is exactly what this does before trusting anything
1872    // in `runs` is safe to relabel.
1873    let last_run_succeeded = task
1874        .runs
1875        .last()
1876        .and_then(|id| RunState::load_under(id, home).ok())
1877        .is_some_and(|s| matches!(s.status, RunStatus::Merged | RunStatus::Ready));
1878    for id in task.superseded_attempts(last_run_succeeded) {
1879        let mut state = match RunState::load_under(id, home) {
1880            Ok(s) => s,
1881            Err(e) => {
1882                tracing::warn!("could not load run {id} to mark it superseded: {e:#}");
1883                continue;
1884            }
1885        };
1886        if !matches!(state.status, RunStatus::Blocked | RunStatus::Stalled) {
1887            continue;
1888        }
1889        let daemon_claims = is_working_on(home, id, now);
1890        if state.liveness(daemon_claims) == Liveness::Live {
1891            continue;
1892        }
1893        state.status = RunStatus::Superseded;
1894        if let Err(e) = state.save_under(home) {
1895            tracing::warn!("could not mark run {id} superseded: {e:#}");
1896        }
1897    }
1898}
1899
1900/// Catch up every `Done` task [`supersede_prior_runs`] could not fully
1901/// finish the moment it was called, because one of its earlier attempts was
1902/// still live then - a `magi run --resume` invoked by hand on an old run,
1903/// most likely.
1904///
1905/// That skip is correct at the time (rewriting under a run something is
1906/// still actively saving would just be undone), but a task only ever
1907/// reaches `Done` once, so without this nothing would ever look at it
1908/// again: if that manual process later ends anywhere other than
1909/// `Merged`/`Ready` - abandoned, or landed as another `Blocked` - the
1910/// skipped attempt would sit at `Blocked`/`Stalled` forever, exactly the
1911/// backlog this status exists to clear. Called on the same idle-only
1912/// cadence as [`janitor`], next to it rather than folded into it: this
1913/// walks `queue` and `janitor` never otherwise needs one.
1914///
1915/// Simply re-running [`supersede_prior_runs`] over every `Done` task with
1916/// more than one attempt, every pass: each call is a handful of local file
1917/// reads bounded by that task's own attempt count, the same order of cost
1918/// [`clean::fold_due`] already pays scanning every run on disk on the same
1919/// cadence, and a task already fully resolved costs one failed liveness
1920/// check turned no-op the moment its lingering attempt is no longer live.
1921fn resweep_superseded_attempts(queue: &Queue, home: &Path) {
1922    for task in queue.list() {
1923        if task.status != TaskStatus::Done || task.runs.len() < 2 {
1924            continue;
1925        }
1926        supersede_prior_runs(&task, home);
1927    }
1928}
1929
1930/// [`settle`], plus attaching the run's own [`diagnostic`] excerpt once the
1931/// task ends up held.
1932///
1933/// The one place [`attempt`] (a live finish) and [`reclaim`] (recovering one a
1934/// dead daemon never got back to) share this, so the two cannot drift into
1935/// disagreeing about which held tasks get a diagnostic.
1936fn settle_and_diagnose(
1937    task: &mut Task,
1938    verdict: Verdict,
1939    detail: &str,
1940    max_attempts: usize,
1941    state: &RunState,
1942) {
1943    let p = phrases(&state.config.graph.language);
1944    // Shared by normal completion and crash recovery, under the task claim.
1945    // No new attempt can own the task until the old driver has exited.
1946    if let Some(d) = &state.merge_decision {
1947        use crate::merge_decision as md;
1948        if task.runs.last().is_some_and(|id| id != &state.id) {
1949            return;
1950        }
1951        if (d.choice == md::WITHDRAW && d.closed) || d.choice == md::DISMISS {
1952            task.succeed();
1953        } else if let Some(agent) = md::selected_agent(&d.choice).filter(|_| d.closed) {
1954            if state.liveness(false) != Liveness::Dead {
1955                task.hold_manual(Some("Previous driver is still live; replacement held until it exits. Release the task after checking the driver.".into()));
1956                return;
1957            }
1958            let replacement = md::Replacement {
1959                question: d.question.clone(),
1960                run: state.id.clone(),
1961                pr: d.target.pr.clone(),
1962                agent: agent.to_owned(),
1963                context: format!(
1964                    "Previous PR: {}. Previous run: {}. Owner requested a new implementation.\nReview evidence:\n{}",
1965                    d.target.pr,
1966                    state.id,
1967                    serde_json::to_string(&state.reviews).unwrap_or_default()
1968                ),
1969            };
1970            task.requeue();
1971            task.pr_recovery = None;
1972            task.review_of = None;
1973            task.replacement = Some(replacement);
1974        } else {
1975            task.hold_manual(Some(if d.choice.eq_ignore_ascii_case(land::HOLD) {
1976                format!("Decision held by owner; no further repair or merge work. {}", state.merge.as_ref().map_or("", |m| m.detail.as_str()))
1977            } else { format!("PR closure was not confirmed. Task held; inspect the existing run before starting replacement work. {}", state.merge.as_ref().map_or("", |m| m.detail.as_str())) }));
1978        }
1979        task.mark_action_applied(&d.question);
1980        if let Some(pass) = state.passes.last() {
1981            task.record_pass_at(
1982                &state.id,
1983                pass.index,
1984                Some(pass.started_at),
1985                task.attempts,
1986                None,
1987            );
1988        }
1989        return;
1990    }
1991    if let Some(replacement) = &task.replacement
1992        && !state.parked
1993        && state.resource_block.is_none()
1994        && (state.viable().is_empty()
1995            || (state.status == RunStatus::Blocked && state.tally.is_none()))
1996    {
1997        task.hold_manual(Some(format!("Selected implementer `{}` was unavailable, failed or hit quota before producing a viable implementation. No other implementer was dispatched. Inspect the run, restore availability, then explicitly release this task.", replacement.agent)));
1998        return;
1999    }
2000    let attempts_before = task.attempts;
2001    // A run that stopped because a shared resource stayed busy was never a
2002    // failure of the task or of an agent. Earlier nodes may have run; only
2003    // the blocked operation lacks a result. The attempt is
2004    // refunded and the task is held by the machine, under a reason `triage`
2005    // can re-check, so the same run resumes with the same agents once the
2006    // resource is free. A park request still wins (it is the operator's).
2007    if let Some(block) = state.resource_block.as_ref().filter(|_| !verdict.parked) {
2008        task.notice_cause(
2009            &format!("resource:{}:{}", block.kind, block.node),
2010            None,
2011            None,
2012        );
2013        task.last_error = Some(detail.to_owned());
2014        task.diagnostic = None;
2015        task.attempts = task.attempts.saturating_sub(1);
2016        if block.kind == "disk pressure" {
2017            task.hold_machine(Some(format!(
2018                "{}{}",
2019                crate::disk::RUNNING_PRESSURE,
2020                block.detail
2021            )));
2022        } else {
2023            task.hold_machine(Some(format!(
2024                "{}{}; no attempt was spent; resume to retry the affected operation",
2025                crate::cache::RESOURCE_HOLD,
2026                block.description()
2027            )));
2028        }
2029        if let Some(pass) = state.passes.last() {
2030            task.record_pass_at(
2031                &state.id,
2032                pass.index,
2033                Some(pass.started_at),
2034                attempts_before,
2035                None,
2036            );
2037        }
2038        return;
2039    }
2040    if let Some(hold) = state.land_unrelated.as_ref().filter(|hold| {
2041        state.parked
2042            && state.status == RunStatus::Landing
2043            && state
2044                .pr_pushed_head
2045                .as_ref()
2046                .is_none_or(|h| h == &hold.head)
2047            && task.runs.last() == Some(&state.id)
2048    }) {
2049        // Settlement and crash recovery share this idempotency key. A later
2050        // explicit resume is a new pass and may refund its own attempt once.
2051        let pass = state.passes.last().map_or(0, |p| p.index);
2052        if !task
2053            .passes
2054            .iter()
2055            .any(|p| p.run == state.id && p.pass_index == pass)
2056        {
2057            task.attempts = task.attempts.saturating_sub(1);
2058        }
2059        task.hold_machine(Some(format!(
2060            "[ci-unrelated] CI failure reproduced on base; owner question {}",
2061            hold.question
2062        )));
2063        task.last_error = Some(detail.to_owned());
2064        task.diagnostic = None;
2065        task.record_pass_at(
2066            &state.id,
2067            pass,
2068            state.passes.last().map(|p| p.started_at),
2069            attempts_before,
2070            Some(crate::run::ParkCause::Question),
2071        );
2072        return;
2073    }
2074    let unresolved = task.hold_notice_cause.clone();
2075    if !verdict.parked
2076        && task
2077            .pr_recovery
2078            .as_ref()
2079            .is_some_and(|s| s.disposition != Some(crate::pr_recovery::Disposition::Review))
2080        && matches!(
2081            verdict.status,
2082            RunStatus::Ready | RunStatus::Merged | RunStatus::AlreadyInBase
2083        )
2084    {
2085        if state.pr_recovery == task.pr_recovery && state.public_delivery_complete() {
2086            task.succeed();
2087        } else {
2088            task.hold_manual(Some("Selected PR delivery is incomplete: local review or publication is not a confirmed public merge. Inspect the run and PR; use recover-pr --disposition land with --source-head <reviewed-full-SHA> and the required verification configuration. No automatic new attempt will start.".into()));
2089        }
2090    } else if !verdict.parked
2091        && task.pr_recovery.is_some()
2092        && matches!(
2093            verdict.status,
2094            RunStatus::Blocked | RunStatus::Failed | RunStatus::Gating
2095        )
2096        && !verdict.quota_hit
2097    {
2098        task.hold_unrequested_retry(Some(format!("Selected recovery stopped: {detail}. Inspect the retained source and run; correct the cause before explicitly releasing or selecting again. No automatic replacement will start.")));
2099    } else {
2100        settle_in(task, verdict, detail, max_attempts, p);
2101    }
2102    if !verdict.parked
2103        && !verdict.quota_hit
2104        && matches!(
2105            verdict.status,
2106            RunStatus::Blocked | RunStatus::Failed | RunStatus::Stalled
2107        )
2108    {
2109        task.recovery_failed(state);
2110    }
2111    if state.prep_failure.is_some() {
2112        // Minting a run whose candidates could not be prepared is not work
2113        // on a newly approved attempt. Keep the unresolved outcome anchor.
2114        task.hold_notice_cause = unresolved;
2115        task.startup_notice_cause("startup-candidates");
2116    }
2117    // Written here, where the refund (or not) is decided, so the history is a
2118    // fact and not a later guess from the run's status.
2119    if let Some(pass) = state.passes.last() {
2120        let parked = match &pass.end {
2121            Some(crate::run::PassEnd::Parked { cause }) => Some(cause.clone()),
2122            _ => None,
2123        };
2124        task.record_pass_at(
2125            &state.id,
2126            pass.index,
2127            Some(pass.started_at),
2128            attempts_before,
2129            parked,
2130        );
2131    }
2132    if task.status == TaskStatus::Held {
2133        if state.prep_failure.is_none() {
2134            task.notice_cause(
2135                label(verdict.status),
2136                Some(&state.id),
2137                state.passes.last().map(|p| p.index),
2138            );
2139        }
2140        task.diagnostic = diagnostic(state);
2141        note_open_question(task, &state.id, p);
2142    }
2143}
2144
2145/// If `task` is held with a `magi ask` question still open on `run`, name the
2146/// question in [`Task::hold_reason`] rather than leaving the hold reading
2147/// only [`describe`]'s run summary.
2148///
2149/// This is the shape task 20260928-191407-05fd left underspecified: an
2150/// implementer settled a verified no-op and filed a question for the
2151/// operator, and the hold that followed said nothing about the question
2152/// waiting on them. Appended, not substituted - [`crate::triage`] parses the
2153/// existing text ([`is_disk_hold`](crate::triage), the answered-question
2154/// marker) and must keep seeing it unchanged.
2155///
2156/// Best-effort: an unreadable question store must not stop a run from
2157/// settling, so this is a no-op rather than a propagated error when
2158/// [`crate::run::try_home`] has nothing to offer or the store is empty.
2159fn note_open_question(task: &mut Task, run: &str, p: &Phrases) {
2160    let Some(home) = crate::run::try_home() else {
2161        return;
2162    };
2163    let open = Questions::at(home.join("questions")).open_for(run);
2164    let Some(q) = open.first() else {
2165        return;
2166    };
2167    let mut decisions: Vec<_> = open.iter().map(|q| q.id.as_str()).collect();
2168    decisions.sort_unstable();
2169    task.notice_cause(
2170        &format!("questions:{}", decisions.join(",")),
2171        Some(run),
2172        None,
2173    );
2174    let base = task.hold_reason.clone().unwrap_or_default();
2175    task.hold_reason = Some(format!("{base}{}{}", p.waiting_for_answer, q.short()));
2176}
2177
2178/// Reconcile a task left at [`TaskStatus::Running`] by a daemon that never
2179/// got back to [`settle`] for it — a crash, a `SIGKILL`, or a run carried on
2180/// by some other means entirely, like a manual `magi run` resume that
2181/// finishes the graph outside the queue's bookkeeping.
2182///
2183/// Pure and separate from [`reclaim_orphaned_running`] for the same reason
2184/// `settle` is separate from `attempt`: a task recovered this way must land
2185/// exactly where a live daemon would have put it — the same policy table,
2186/// not a second one that quietly drifts from it — and that is only checkable
2187/// without spawning a real run.
2188fn reclaim(task: &mut Task, last_run: Option<RunState>, max_attempts: usize, language: &str) {
2189    match last_run {
2190        Some(state) => {
2191            let verdict = Verdict {
2192                status: state.status,
2193                left_pr: state.pr.is_some(),
2194                quota_hit: !state.quota.is_empty(),
2195                parked: state.parked,
2196                no_viable_candidates: state.viable().is_empty(),
2197            };
2198            let detail = format!(
2199                "{}{}",
2200                phrases(&state.config.graph.language).recovered_running,
2201                describe(&state)
2202            );
2203            settle_and_diagnose(task, verdict, &detail, max_attempts, &state);
2204        }
2205        None => {
2206            // No run, so no config of its own: `language` is the task's
2207            // repository's setting, read by the caller.
2208            let why = phrases(language).no_run_to_recover;
2209            task.last_error = Some(why.to_owned());
2210            // The phone shows `hold_reason`, so a task held by the machine
2211            // says why there too and not only in `last_error`.
2212            task.notice_cause("missing-run", None, None);
2213            task.hold_machine(Some(why.to_owned()));
2214        }
2215    }
2216}
2217
2218/// Find every task left at `running` that no live process is actually
2219/// driving, and settle each one against whatever its last run became.
2220///
2221/// # Why a claim is proof, not a guess
2222///
2223/// [`poll`] takes a task's [`Queue::claim`] *before* [`Task::start`] writes
2224/// `running`, and the guard is held for the task's whole time in that status:
2225/// `attempt` does not return, and the loop does not move past the scope
2226/// holding the claim, until the run has settled. So a `running` task whose
2227/// lock is gone cannot have a live owner — this process or any other —
2228/// without needing a staleness threshold or a pid check the way
2229/// [`sweep_stale_claims`] does for the narrower case of a lock left next to a
2230/// task that never got as far as `running` at all. Taking the claim here is
2231/// the whole test: it either fails, because something really does hold it
2232/// and the task is left alone, or it succeeds, which is the proof — and it is
2233/// kept for the rest of the decision so nothing else can start a competing
2234/// run while this one is being written.
2235///
2236/// Called on every poll, not only at startup, for the reason
2237/// [`sweep_stale_claims`] now is too: a daemon that has been up for days must
2238/// keep noticing this, not only on the one morning it happened to restart.
2239fn reclaim_orphaned_running(
2240    queue: &Queue,
2241    max_attempts: usize,
2242    live_workers: &std::collections::BTreeSet<String>,
2243) -> Vec<String> {
2244    let mut reclaimed = Vec::new();
2245    for listed in queue.list() {
2246        if listed.status != TaskStatus::Running || live_workers.contains(&listed.id) {
2247            continue;
2248        }
2249        let Ok(_claim) = queue.claim(&listed.id) else {
2250            continue;
2251        };
2252        // Re-read under the claim: a release or an edit landed by a human
2253        // between the listing above and the claim just taken must not be
2254        // clobbered by a decision based on the stale copy.
2255        let Ok(mut task) = queue.get(&listed.id) else {
2256            continue;
2257        };
2258        if task.status != TaskStatus::Running {
2259            continue;
2260        }
2261        let last_run = task.runs.last().and_then(|id| RunState::load(id).ok());
2262        // `execute` normally abandons a run's own open questions the moment
2263        // `status` lands somewhere non-resumable (see `graph::Runner::settle_questions`),
2264        // but a daemon that crashed *inside* that path - mid `land`'s CI wait,
2265        // say - can leave a `run.json` already at `Merged`/`Ready`/`Failed`
2266        // with the question still `open`, because the process died before
2267        // reaching that call. `reclaim` itself stays pure on purpose (see its
2268        // own doc), so the same cleanup runs here instead, against the run
2269        // this reclaim is already reading. `settle_run` costs nothing when
2270        // `execute` already got there first.
2271        if let Some(state) = &last_run
2272            && let Err(e) = ask::Questions::open().settle_run(&state.id, state.status)
2273        {
2274            tracing::warn!("abandon questions for {}: {e:#}", state.id);
2275        }
2276        let language = if last_run.is_none() {
2277            language_of(&task, Path::new("."))
2278        } else {
2279            String::new()
2280        };
2281        reclaim(&mut task, last_run, max_attempts, &language);
2282        if task.status == TaskStatus::Done {
2283            supersede_prior_runs(&task, &crate::run::home());
2284        }
2285        record(queue, &mut task);
2286        reclaimed.push(task.id.clone());
2287    }
2288    reclaimed
2289}
2290
2291/// Find every run whose `run.json` is provably dead — every seat it still
2292/// lists as [`crate::run::RunState::active`] has overrun its own timeout, and
2293/// [`crate::run::RunState::liveness`] reads [`crate::run::Liveness::Dead`],
2294/// not merely "no daemon claims it" — and fail it, clearing the leftover
2295/// active seats so the run stops reading as `implementing` (or whichever
2296/// node) forever.
2297///
2298/// [`reclaim_orphaned_running`] settles the *task* a dead daemon left
2299/// `running`, using whatever `run.json` already says — but nothing in that
2300/// path, nor in [`reclaim`], ever writes back to the run itself (`reclaim`
2301/// stays pure on purpose, see its own doc), so a `run.json` a killed process
2302/// never got back to sits exactly where it was left: `active` full of seats
2303/// nobody will ever answer for, `status` stuck on whatever node was in
2304/// flight. `magi show` already tells an operator this in prose (`no live
2305/// daemon claims this run right now`); this is what makes that fact durable
2306/// on disk, the same way a task's own `TaskStatus::Running` does not get to
2307/// stay stuck once nothing is driving it.
2308///
2309/// Runs on every poll, not only at startup, for the reason
2310/// [`sweep_stale_claims`] and [`reclaim_orphaned_running`] already are: a
2311/// daemon up for days must keep noticing a run some other, now-dead, daemon
2312/// left behind just as readily as one it trips over on the way up.
2313///
2314/// Walks `home.join("runs")` directly and reads each `run.json` on its own,
2315/// rather than the process-global [`RunState::load`] / [`crate::run::list_ids`] —
2316/// the same reason [`crate::clean`]'s housekeeping passes take an explicit
2317/// `runs` directory instead: `home` here is a parameter precisely so a test
2318/// can point it away from the operator's real history (see [`drive`]'s own
2319/// doc), and a scan that fell through to the global home anyway would walk
2320/// whichever directory some *other* process or test pinned into that
2321/// `OnceLock` first — mutating runs this call was never handed.
2322fn reclaim_abandoned_runs(home: &Path, now: Timestamp) -> Vec<String> {
2323    reclaim_abandoned_runs_with(
2324        home,
2325        now,
2326        crate::proc::pid_status,
2327        crate::proc::process_started_at,
2328    )
2329}
2330
2331/// [`reclaim_abandoned_runs`] with its `driver_pid` liveness/identity queries
2332/// supplied by the caller — mirrors [`sweep_stale_claims_with`], which exists
2333/// for the identical reason: this sweep's real damage (wiping a run's active
2334/// seats and failing it) has to be provable against an injected answer in a
2335/// test, not just the real process table.
2336fn reclaim_abandoned_runs_with<F, G>(
2337    home: &Path,
2338    now: Timestamp,
2339    query: F,
2340    identity: G,
2341) -> Vec<String>
2342where
2343    F: Fn(u32) -> Option<bool>,
2344    G: Fn(u32) -> Option<String>,
2345{
2346    let mut abandoned = Vec::new();
2347    for entry in std::fs::read_dir(home.join("runs"))
2348        .into_iter()
2349        .flatten()
2350        .flatten()
2351    {
2352        let id = entry.file_name().to_string_lossy().into_owned();
2353        if !crate::run::is_run_id(&id) {
2354            continue;
2355        }
2356        // Serialize the proof and save with claims and takeover: a new
2357        // execution must not be overwritten after the liveness observation.
2358        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2359            continue;
2360        };
2361        // Unreadable is `clean::fold_due`'s problem, not this one's — see
2362        // that module's docs for why a run this cannot parse is left alone
2363        // rather than guessed at. A different schema number is not that: this
2364        // touches only `status` and `active`, never a field whose meaning a
2365        // schema bump changed, so an old record's values serve this exactly
2366        // as well as a current one's (see `clean::read_state`'s own doc for
2367        // the same reasoning applied to folding).
2368        let Ok(body) = std::fs::read_to_string(entry.path().join("run.json")) else {
2369            continue;
2370        };
2371        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2372            continue;
2373        };
2374        if state.driver_exited || state.status.done() || !state.active_all_overrun(now) {
2375            continue;
2376        }
2377        // Not `!is_working_on(..)` alone: that is only "no *daemon* claims
2378        // it", which is also the normal, healthy shape of a manual `magi
2379        // run` / `magi review` sharing this same home — this scan walks
2380        // every run on disk, not only ones this daemon itself started. Such
2381        // a run's active seats can legitimately sit past their own timeout
2382        // for a little while (the CLI finishing up, its result still being
2383        // collected) without the process driving it having died. `liveness`
2384        // is what actually tells the two apart, by corroborating
2385        // `driver_pid` against the process it names — see its own doc. Only
2386        // its strongest, provable answer licenses wiping this run's active
2387        // seats and failing it out from under whatever is still running it.
2388        let daemon_claims = is_working_on(home, &id, now);
2389        if state.liveness_under_with(home, daemon_claims, &query, &identity)
2390            != crate::run::Liveness::Dead
2391        {
2392            continue;
2393        }
2394        state.abandon("daemon");
2395        if let Err(e) = state.save_under(home) {
2396            tracing::warn!("could not persist abandoned run {id}: {e:#}");
2397            continue;
2398        }
2399        // This run never passes through `Runner::execute` again, so its end
2400        // is announced here.
2401        if let Some(notice) = notices::run_ended(&state) {
2402            notices::raise_in(home, notice);
2403        }
2404        // The seat that asked is gone for good now, exactly like any other
2405        // door `graph::Runner::settle_questions` closes the moment `status`
2406        // lands somewhere non-resumable - see that method's own doc. Nothing
2407        // else reaches this one before the next `janitor()` startup pass
2408        // (`clean::abandon_settled_questions`), and a daemon that stays up
2409        // for days must not leave an open question badging the operator
2410        // until it happens to restart.
2411        if let Err(e) = Questions::at(home.join("questions")).settle_run(&id, state.status) {
2412            tracing::warn!("abandon questions for {id}: {e:#}");
2413        }
2414        abandoned.push(id);
2415    }
2416    abandoned
2417}
2418
2419#[derive(Debug, Clone, Serialize, Deserialize)]
2420struct TaskWorker {
2421    opts: Opts,
2422    queue: PathBuf,
2423    task: String,
2424    #[serde(default)]
2425    lane: PermitKind,
2426    #[serde(default)]
2427    standalone: bool,
2428}
2429
2430fn task_jobs(home: &Path) -> Result<Vec<crate::worker::Job>> {
2431    Ok(crate::worker::active(home)?
2432        .into_iter()
2433        .filter(|job| job.kind == "task")
2434        .collect())
2435}
2436
2437fn owned_task_jobs(home: &Path) -> Result<Vec<crate::worker::Job>> {
2438    task_jobs(home)?
2439        .into_iter()
2440        .filter_map(
2441            |job| match serde_json::from_value::<TaskWorker>(job.payload.clone()) {
2442                Ok(request) if !request.standalone => Some(Ok(job)),
2443                Ok(_) => None,
2444                Err(error) => Some(Err(error.into())),
2445            },
2446        )
2447        .collect()
2448}
2449
2450/// Whether an independent scheduler can be controlled without its web parent.
2451#[must_use]
2452pub fn managed(home: &Path) -> bool {
2453    match crate::worker::active(home) {
2454        Err(error) => {
2455            tracing::error!("cannot determine scheduler ownership: {error:#}");
2456            true
2457        }
2458        Ok(jobs) if jobs.iter().any(|j| j.kind == "scheduler") => true,
2459        Ok(_) => {
2460            read_status(home).is_some_and(|r| r.worker_protocol == Some(1))
2461                && crate::worker::try_lease(home, "scheduler").map_or(true, |lease| lease.is_none())
2462        }
2463    }
2464}
2465
2466/// Start a detached, singleton scheduler using this executable generation.
2467pub async fn spawn_detached(home: &Path, opts: &Opts) -> Result<crate::worker::Job> {
2468    anyhow::ensure!(
2469        crate::worker::control(home, "scheduler")?.is_none() || !managed(home),
2470        "the scheduler is stopping; wait for retirement before starting it again"
2471    );
2472    crate::worker::spawn(
2473        home,
2474        "scheduler",
2475        "scheduler",
2476        serde_json::to_value(opts.absolute()?)?,
2477    )
2478    .await
2479}
2480
2481fn park_request_path(home: &Path, job: &crate::worker::Job) -> PathBuf {
2482    home.join("worker-parks").join(format!("{}.json", job.id))
2483}
2484
2485fn request_task_park(
2486    home: &Path,
2487    job: &crate::worker::Job,
2488    cause: crate::run::ParkCause,
2489) -> Result<()> {
2490    let path = park_request_path(home, job);
2491    std::fs::create_dir_all(path.parent().context("worker park directory")?)?;
2492    std::fs::write(path, serde_json::to_vec(&cause)?)?;
2493    crate::worker::request(home, &job.key, crate::worker::Control::Park)
2494}
2495
2496/// Stop dispatch immediately; only an explicit park reaches existing workers.
2497pub fn request_control(home: &Path, control: crate::worker::Control) -> Result<()> {
2498    if control == crate::worker::Control::Park {
2499        for job in owned_task_jobs(home)? {
2500            request_task_park(home, &job, crate::run::ParkCause::Operator)?;
2501        }
2502    }
2503    crate::worker::request(home, "scheduler", control)
2504}
2505
2506/// Replace the scheduler, leaving all active task and conversation jobs alone.
2507pub async fn restart_detached(home: &Path) -> Result<()> {
2508    let active = crate::worker::active(home)?;
2509    let prior = active.into_iter().find(|job| job.kind == "scheduler");
2510    if prior.is_none() && !managed(home) {
2511        return Ok(());
2512    }
2513    let opts: Opts = match prior {
2514        Some(job) => serde_json::from_value(job.payload)?,
2515        None => serde_json::from_slice(&std::fs::read(home.join("scheduler-options.json"))?)?,
2516    };
2517    request_control(home, crate::worker::Control::Stop)?;
2518    loop {
2519        let reserved = crate::worker::active(home)?
2520            .iter()
2521            .any(|job| job.kind == "scheduler");
2522        if !reserved && crate::worker::try_lease(home, "scheduler")?.is_some() {
2523            break;
2524        }
2525        tokio::time::sleep(Duration::from_millis(100)).await;
2526    }
2527    spawn_detached(home, &opts).await?;
2528    Ok(())
2529}
2530
2531/// Hidden worker dispatch entry. The worker infrastructure owns its job lease.
2532pub async fn run_scheduler_worker(home: &Path, payload: serde_json::Value) -> Result<()> {
2533    let opts: Opts = serde_json::from_value(payload)?;
2534    let _ = home;
2535    serve_until(opts, Stop::new()).await
2536}
2537
2538/// Execute and settle one task with the queue claim owned by this process.
2539pub async fn run_task_worker(home: &Path, payload: serde_json::Value) -> Result<()> {
2540    let request: TaskWorker = serde_json::from_value(payload)?;
2541    let queue = Queue::at(request.queue);
2542    let key = format!("task:{}", request.task);
2543    let job = task_jobs(home)?
2544        .into_iter()
2545        .find(|job| job.key == key)
2546        .context("task worker reservation is missing")?;
2547    let park_path = park_request_path(home, &job);
2548    // The spawning CLI/scheduler still owns the claim until spawn acknowledges
2549    // our durable reservation. Never execute before that ownership hand-off.
2550    let _claim = loop {
2551        match queue.claim(&request.task) {
2552            Ok(claim) => break claim,
2553            // Only contention (the claim file exists) is worth waiting out; an
2554            // I/O failure must end this worker instead of pinning its lane.
2555            Err(e) if e.chain().any(|c| c.is::<std::io::Error>()) => {
2556                return Err(e.context("claim the task for its worker"));
2557            }
2558            Err(_) => {
2559                sweep_stale_claims(&queue, STALE_CLAIM);
2560                tokio::time::sleep(Duration::from_millis(50)).await;
2561            }
2562        }
2563    };
2564    let mut task = queue.get(&request.task)?;
2565    if !task.status.runnable()
2566        || (!request.standalone
2567            && request.lane
2568                != permit_kind(land_resume_state(&task) == LandResume::Ready, task.urgent))
2569    {
2570        return Ok(());
2571    }
2572    let stop = Stop::new();
2573    let watcher = {
2574        let home = home.to_path_buf();
2575        let stop = stop.clone();
2576        let park_path = park_path.clone();
2577        tokio::spawn(async move {
2578            loop {
2579                if crate::worker::control(&home, &key).ok().flatten().is_some() {
2580                    let cause = std::fs::read(&park_path)
2581                        .ok()
2582                        .and_then(|body| serde_json::from_slice(&body).ok())
2583                        .unwrap_or(crate::run::ParkCause::Operator);
2584                    stop.park(cause);
2585                    return;
2586                }
2587                tokio::time::sleep(Duration::from_millis(200)).await;
2588            }
2589        })
2590    };
2591    let quota = if request.standalone {
2592        run_claimed_until(&request.opts, &queue, &mut task, &stop).await
2593    } else {
2594        let status = Arc::new(Mutex::new(Status::new()));
2595        Box::pin(attempt(
2596            &request.opts,
2597            &queue,
2598            &status,
2599            &stop,
2600            crate::graph::Pause::new(),
2601            &mut task,
2602        ))
2603        .await
2604    };
2605    watcher.abort();
2606    let _ = std::fs::remove_file(park_path);
2607    persist_cooldown(home, &quota).await?;
2608    Ok(())
2609}
2610
2611/// Launch the standalone task worker with a claim-to-reservation handoff.
2612/// Returns only after the worker owns a durable execution reservation.
2613pub async fn spawn_claimed(
2614    home: &Path,
2615    opts: &Opts,
2616    queue: &Queue,
2617    task: &Task,
2618    claim: crate::queue::Claim,
2619) -> Result<crate::worker::Job> {
2620    let request = TaskWorker {
2621        opts: opts.absolute()?,
2622        queue: std::path::absolute(queue.root())?,
2623        task: task.id.clone(),
2624        lane: permit_kind(land_resume_state(task) == LandResume::Ready, task.urgent),
2625        standalone: true,
2626    };
2627    let job = crate::worker::spawn(
2628        home,
2629        "task",
2630        &format!("task:{}", task.id),
2631        serde_json::to_value(request)?,
2632    )
2633    .await?;
2634    drop(claim);
2635    Ok(job)
2636}
2637
2638/// Transfer a standalone caller's claim only after the detached worker is live,
2639/// then follow that worker to its final (non-automatically-retrying) outcome.
2640pub async fn run_detached_claimed(
2641    opts: &Opts,
2642    queue: &Queue,
2643    task: &mut Task,
2644    claim: crate::queue::Claim,
2645) -> Result<()> {
2646    let home = crate::run::home();
2647    let job = spawn_claimed(&home, opts, queue, task, claim).await?;
2648    loop {
2649        // Reconcile execution leases even without a scheduler. A dead worker
2650        // must become a reported failure, never an infinite persisted wait.
2651        crate::worker::active(&home)?;
2652        let current = crate::worker::get(&home, &job.id)?;
2653        if matches!(
2654            current.status,
2655            crate::worker::Status::Finished | crate::worker::Status::Failed
2656        ) {
2657            *task = queue.get(&task.id)?;
2658            anyhow::ensure!(
2659                current.status != crate::worker::Status::Failed,
2660                "task worker failed: {}",
2661                current.result.as_deref().unwrap_or("unknown failure")
2662            );
2663            return Ok(());
2664        }
2665        tokio::time::sleep(Duration::from_millis(200)).await;
2666    }
2667}
2668
2669async fn persist_cooldown(home: &Path, quota: &[QuotaLoss]) -> Result<()> {
2670    if let Some(until) = cooldown_until(quota, Timestamp::now()) {
2671        let _lease = loop {
2672            if let Some(lease) = crate::worker::try_lease(home, "task-quota-cooldown")? {
2673                break lease;
2674            }
2675            tokio::time::sleep(Duration::from_millis(10)).await;
2676        };
2677        if read_cooldown(home)?.is_none_or(|previous| previous < until) {
2678            let path = home.join("task-quota-cooldown.json");
2679            let temporary = path.with_extension("json.tmp");
2680            std::fs::write(&temporary, serde_json::to_vec(&until)?)?;
2681            std::fs::rename(temporary, path)?;
2682        }
2683    }
2684    Ok(())
2685}
2686
2687fn read_cooldown(home: &Path) -> Result<Option<Timestamp>> {
2688    match std::fs::read(home.join("task-quota-cooldown.json")) {
2689        Ok(body) => Ok(Some(serde_json::from_slice(&body)?)),
2690        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2691        Err(error) => Err(error.into()),
2692    }
2693}
2694
2695/// Run the loop until Ctrl-C, or until the queue drains with [`Opts::once`].
2696///
2697/// A thin wrapper over [`serve_until`] with a stop nothing but Ctrl-C ever
2698/// sets, so there is one loop body rather than two that drift apart the first
2699/// time the retry policy changes on only one of them.
2700pub async fn serve(opts: Opts) -> Result<()> {
2701    tracing::info!(
2702        "automatic update and restart requires magi web; standalone serve does not restart"
2703    );
2704    serve_until(opts, Stop::new()).await
2705}
2706
2707/// [`serve`], but stopping when `stop` is set as well as on Ctrl-C.
2708///
2709/// A stop retires dispatch promptly, including while a worker launch is being
2710/// acknowledged. Already reserved workers own their attempts independently and
2711/// finish with their started executable. Explicit park additionally forwards
2712/// durable park requests; neither scheduler retirement nor update does so.
2713///
2714/// While the queue is empty the stop is honoured within one wakeup rather than
2715/// one poll interval: the wait is a `select!` against [`Stop`]'s notify, so a
2716/// caller that taps stop does not sit through the remainder of a sleep.
2717pub async fn serve_until(opts: Opts, stop: Stop) -> Result<()> {
2718    let opts = opts.absolute()?;
2719    let home = crate::run::home();
2720    let _scheduler = crate::worker::try_lease(&home, "scheduler")?
2721        .context("another scheduler already owns this home")?;
2722    if let Some(reading) = read_status(&home)
2723        && legacy_scheduler_running(&reading, Timestamp::now(), std::process::id())
2724    {
2725        bail!("a legacy scheduler is still running; stop it before starting independent dispatch");
2726    }
2727    if !crate::worker::active(&home)?
2728        .iter()
2729        .any(|job| job.kind == "scheduler")
2730    {
2731        crate::worker::clear_control(&home, "scheduler")?;
2732    }
2733    std::fs::write(
2734        home.join("scheduler-options.json"),
2735        serde_json::to_vec(&opts)?,
2736    )?;
2737    let control = {
2738        let home = home.clone();
2739        let stop = stop.clone();
2740        tokio::spawn(async move {
2741            loop {
2742                match crate::worker::control(&home, "scheduler").ok().flatten() {
2743                    Some(crate::worker::Control::Park) => {
2744                        let _ = request_control(&home, crate::worker::Control::Park);
2745                        stop.park(crate::run::ParkCause::Operator);
2746                        break;
2747                    }
2748                    Some(crate::worker::Control::Stop) => {
2749                        stop.stop();
2750                        break;
2751                    }
2752                    None => {}
2753                }
2754                tokio::time::sleep(Duration::from_millis(100)).await;
2755            }
2756        })
2757    };
2758    let signal = {
2759        let stop = stop.clone();
2760        tokio::spawn(async move {
2761            if tokio::signal::ctrl_c().await.is_ok() {
2762                stop.stop();
2763                tracing::info!("scheduler shutdown requested; independent workers continue");
2764            }
2765        })
2766    };
2767
2768    let worktrees_root = opts
2769        .worktrees_root
2770        .clone()
2771        .unwrap_or_else(crate::run::default_worktree_root);
2772    let outcome = drive(
2773        &opts,
2774        &Queue::open(),
2775        &status_path(),
2776        &crate::run::home(),
2777        &worktrees_root,
2778        &stop,
2779    )
2780    .await;
2781
2782    signal.abort();
2783    control.abort();
2784    outcome
2785}
2786
2787/// The loop proper: setup, poll, teardown, with the queue and the status file
2788/// supplied rather than discovered.
2789///
2790/// All three of `home`, `worktrees_root` and the queue/status paths are
2791/// parameters rather than resolved here, for the same reason:
2792/// [`crate::run::home`] is process-global and its override is a `OnceLock`,
2793/// so a unit test that pinned it would fight every other test in the binary,
2794/// and a loop that resolved its own worktree bay could only be exercised
2795/// against the operator's real `~/wt/<repo>` - publishing over a live
2796/// daemon's status file, claiming tasks out of a live backlog, and, since
2797/// [`janitor`] runs on every idle tick, reclaiming worktrees out from under
2798/// whatever the operator actually has on disk.
2799async fn drive(
2800    opts: &Opts,
2801    queue: &Queue,
2802    status_file: &Path,
2803    home: &Path,
2804    worktrees_root: &Path,
2805    stop: &Stop,
2806) -> Result<()> {
2807    // The status file is a *snapshot*, not a stream of events: a reader only
2808    // ever wants the latest values, and every tick rewrites the whole file
2809    // anyway. A shared `Mutex<Status>` therefore says exactly what is meant,
2810    // while an mpsc channel would force the loop to re-send unchanged fields on
2811    // every heartbeat — or the heartbeat to keep its own shadow copy of them —
2812    // for no gain. The lock is only ever held across a field assignment, never
2813    // across an await.
2814    let recovery_cfg = prepare(&opts.repo, opts).ok();
2815    let daemon_cfg = recovery_cfg
2816        .as_ref()
2817        .map(|c| c.daemon.clone())
2818        .unwrap_or_default();
2819    let reconciled = crate::driver::reconcile(home);
2820    if !reconciled.is_empty() {
2821        tracing::info!(runs = ?reconciled, "reconciled stopped run drivers at startup");
2822    }
2823    let mut initial_status = Status::new();
2824    let owned = owned_task_jobs(home)?;
2825    initial_status.current = current_work(home, Timestamp::now())
2826        .into_iter()
2827        .filter(|current| {
2828            owned
2829                .iter()
2830                .any(|job| job.key.strip_prefix("task:") == Some(current.task.as_str()))
2831        })
2832        .collect();
2833    initial_status.idle = initial_status.current.is_empty();
2834    initial_status.question_recovery = Some(crate::question_delivery::Recovery::new(
2835        recovery_cfg.as_ref(),
2836        daemon_cfg.max_deputies,
2837    ));
2838    let status = Arc::new(Mutex::new(initial_status));
2839    write_status_to(status_file, &lock(&status)).context("publish the daemon status file")?;
2840    let beat = tokio::spawn(heartbeat(Arc::clone(&status), status_file.to_path_buf()));
2841
2842    // Read once at startup, not per task: how many runs this loop drives at
2843    // once is a property of the machine running it, not of whichever
2844    // repository a given task happens to name - see
2845    // `Config::daemon.max_concurrent_runs`'s doc for why that is a machine
2846    // fact in the same sense the agent roster is.
2847    let concurrency = max_concurrent(daemon_cfg.max_concurrent_runs);
2848
2849    tracing::info!(
2850        "magi serve: queue {} (poll {}s, {} attempts per task, {} run(s) at once{})",
2851        queue.root().display(),
2852        opts.poll.as_secs(),
2853        opts.max_attempts,
2854        concurrency,
2855        if daemon_cfg.pause_for_interrupts {
2856            ", interrupts enabled"
2857        } else {
2858            ""
2859        }
2860    );
2861
2862    let outcome = tokio::select! {
2863        outcome = poll(opts, queue, &status, home, worktrees_root, stop, DispatchLimits {
2864            max_concurrent: concurrency,
2865            pause_for_interrupts: daemon_cfg.pause_for_interrupts,
2866            recovery_cfg,
2867        }) => outcome,
2868        () = async {
2869            while !stop.stopped() {
2870                tokio::time::sleep(Duration::from_millis(25)).await;
2871            }
2872        } => {
2873            if stop.parking() {
2874                request_control(home, crate::worker::Control::Park)?;
2875            }
2876            Ok(())
2877        }
2878    };
2879
2880    stop.busy.store(0, Ordering::SeqCst);
2881    beat.abort();
2882    clear_status_at(status_file);
2883    outcome
2884}
2885
2886/// The checkouts the release watcher covers and `[daemon] release_stall_minutes`,
2887/// re-read each lap. An unreadable config yields `0` minutes: the watcher idles.
2888pub(crate) fn release_watch_settings_from_config(
2889    home: &Path,
2890    repo: &Path,
2891    cfg: &Config,
2892) -> (Vec<PathBuf>, u64) {
2893    let mut paths: Vec<PathBuf> = crate::repos::scan(&cfg.repos.roots)
2894        .into_iter()
2895        .map(|r| r.path)
2896        .collect();
2897    // Repositories the daemon has worked in, wherever they live: a task may
2898    // name one outside `[repos] roots`, and its release PR must be watched too.
2899    let mut known: Vec<PathBuf> = Queue::at(home.join("queue"))
2900        .list()
2901        .into_iter()
2902        .map(|t| t.repo)
2903        .collect();
2904    let runs = home.join("runs");
2905    for id in crate::run::list_ids_in(&runs) {
2906        let repo = std::fs::read_to_string(runs.join(&id).join("run.json"))
2907            .ok()
2908            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
2909            .and_then(|v| v.get("repo")?.as_str().map(PathBuf::from));
2910        known.extend(repo);
2911    }
2912    for k in known {
2913        let k = k.canonicalize().unwrap_or(k);
2914        if k.join(".git").exists() && !paths.contains(&k) {
2915            paths.push(k);
2916        }
2917    }
2918    let own = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
2919    if !paths.contains(&own) {
2920        paths.push(own);
2921    }
2922    (paths, cfg.daemon.release_stall_minutes)
2923}
2924
2925/// Per-repo bound on one `git fetch`, so a dead remote cannot stall the pass.
2926const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
2927
2928/// Refresh the status file on a fixed tick.
2929///
2930/// Separate from the loop because a run takes tens of minutes: a status file
2931/// written only between tasks would look stale for the whole of every run, and
2932/// a reader would report the daemon dead exactly while it was busiest.
2933async fn heartbeat(status: Arc<Mutex<Status>>, path: PathBuf) {
2934    loop {
2935        tokio::time::sleep(HEARTBEAT).await;
2936        let snapshot = {
2937            let mut guard = lock(&status);
2938            guard.updated_at = Timestamp::now();
2939            guard.clone()
2940        };
2941        if let Err(e) = write_status_to(&path, &snapshot) {
2942            // A failed heartbeat must not take the daemon down: the loop is the
2943            // product, the status file is only the window onto it.
2944            tracing::warn!("could not refresh the daemon status file: {e:#}");
2945        }
2946    }
2947}
2948
2949/// Whether a task's last run is sitting in `land`'s merge-approval wait, and
2950/// if so, whether that wait is over.
2951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2952enum LandResume {
2953    /// The task's last run is not parked on a land approval; schedule it
2954    /// like any other candidate.
2955    NotLanding,
2956    /// Parked in `land`, waiting on a question nobody has answered yet.
2957    /// Left alone: attempting it now would only re-observe the same pull
2958    /// request and park again, spending a `gh` call on a decision that has
2959    /// not changed since the last time this was checked.
2960    StillWaiting,
2961    /// Parked in `land`, and the question is settled - answered or
2962    /// abandoned. Resuming this is the one kind of candidate that must not
2963    /// wait on a free [`Config::daemon`] concurrency slot: see [`poll`].
2964    Ready,
2965}
2966
2967/// Classify a runnable candidate by whether it is parked on a land-merge
2968/// approval. Read-only - no claim taken, nothing written - so it is cheap
2969/// enough to call on every candidate, every poll.
2970fn land_resume_state(task: &Task) -> LandResume {
2971    let Some(run_id) = task.runs.last() else {
2972        return LandResume::NotLanding;
2973    };
2974    let Ok(state) = RunState::load(run_id) else {
2975        return LandResume::NotLanding;
2976    };
2977    if !state.parked {
2978        return LandResume::NotLanding;
2979    }
2980    if state.status == RunStatus::Landing
2981        && state.land_approval.as_ref().is_some_and(|binding| {
2982            ask::Questions::open()
2983                .get(&binding.question)
2984                .is_ok_and(|q| {
2985                    q.run == state.id
2986                        && q.resolution()
2987                            .as_deref()
2988                            .is_some_and(crate::merge_decision::stops)
2989                })
2990        })
2991    {
2992        return LandResume::Ready;
2993    }
2994    if !implementers_match(task, &state) {
2995        return LandResume::NotLanding;
2996    }
2997    // Parked in `land` on the merge approval, or at `merge` on the owner's
2998    // word about a title/body the posting gate withheld. Told apart by the
2999    // run's own record, not by a status both can share.
3000    let node = if state.status == RunStatus::Landing {
3001        land::APPROVAL_NODE
3002    } else if state.github_text.as_ref().is_some_and(|g| !g.resolved) {
3003        crate::github_text::ASK_NODE
3004    } else {
3005        return LandResume::NotLanding;
3006    };
3007    let store = ask::Questions::open();
3008    let waiting = store
3009        .list()
3010        .into_iter()
3011        .filter(|q| &q.run == run_id && q.node == node)
3012        .max_by(|a, b| a.id.cmp(&b.id));
3013    let Some(mut q) = waiting else {
3014        return LandResume::Ready;
3015    };
3016    if !q.status.open() {
3017        // Repair spends agent resources and must obey concurrency and quota
3018        // waits; only a merge/hold decision may use the decision-only lane.
3019        if q.resolution().as_deref() == Some(land::REPAIR) {
3020            return if state.liveness(false) == Liveness::Live {
3021                LandResume::StillWaiting
3022            } else {
3023                LandResume::NotLanding
3024            };
3025        }
3026        return LandResume::Ready;
3027    }
3028    // `ask::ask_and_wait`'s own deadline is what used to retire a question
3029    // nobody ever answered; land's approval bypasses that wait entirely (see
3030    // `land::approval_gate`), so the same deadline has to be enforced here
3031    // instead, or `graph.answer_timeout` silently stops meaning anything for
3032    // a land approval and a run can sit `StillWaiting` forever with nobody
3033    // told to look at it.
3034    let timeout = Duration::from_secs(state.config.graph.answer_timeout);
3035    let elapsed = Timestamp::now().as_second() - q.asked_at.as_second();
3036    if elapsed >= 0 && elapsed as u64 >= timeout.as_secs() {
3037        q.abandon(format!(
3038            "no answer within {}s of asking",
3039            timeout.as_secs().max(1)
3040        ));
3041        // If this can't be persisted, do not treat the wait as settled on a
3042        // guess: fall through and try again next poll.
3043        if store.put(&mut q).is_ok() {
3044            return LandResume::Ready;
3045        }
3046    }
3047    LandResume::StillWaiting
3048}
3049
3050/// How often the loop rechecks for new work while something it already
3051/// started is still running, rather than sleeping out the whole
3052/// [`Opts::poll`] interval.
3053///
3054/// Short on purpose: this is what lets a land-merge approval that comes back
3055/// while another task is mid-competition be noticed and resumed within a
3056/// fraction of a second, not within the next multi-second poll.
3057const RECHECK_WHILE_BUSY: Duration = Duration::from_millis(200);
3058
3059/// How often [`poll`] rechecks the shared build cache against its cap at a
3060/// boundary between runs (see [`maybe_prune_cache_between_runs`]), instead of
3061/// waiting for the queue to run dry.
3062///
3063/// A queue that never empties means the `janitor` call at the bottom of this
3064/// loop's fully-idle branch can go unreached for as long as the backlog
3065/// lasts. Five minutes is far below a single gate's own 1200s timeout, so a
3066/// cache that started the day at its 10 GiB cap cannot grow anywhere near the
3067/// 81.8 GiB an idle-only check let it reach before this existed, and it is
3068/// well above the cost of a `dir_size` walk over a multi-gigabyte cache, so a
3069/// backlog of short tasks does not pay for that walk on every poll.
3070const CACHE_CHECK_INTERVAL_SECS: u64 = 5 * 60;
3071
3072/// State of [`poll`]'s own interrupt-scheduling sequence - see
3073/// [`crate::config::Daemon::pause_for_interrupts`]. Advanced once per tick by
3074/// [`advance_interrupt`] and consulted by [`interrupt_gate`], both pure and
3075/// both kept free of `Task`'s non-identity fields on purpose: every decision
3076/// here turns only on task ids and which ones are in flight, so the "never
3077/// more than one run at once" and "exactly one resume" invariants can be
3078/// pinned down with a plain `#[test]`, no `Runner`, no tokio, no fixture
3079/// queue - which is exactly the coverage this feature's first two attempts
3080/// were missing.
3081///
3082/// Persisted after every transition, so scheduler replacement preserves the
3083/// interrupt's exclusive turn and the interrupted task's priority resume.
3084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3085enum Interrupt {
3086    /// No interrupt sequence in progress. Ordinary dispatch applies.
3087    Idle,
3088    /// `interrupt_task` is runnable and exactly one other run is in flight;
3089    /// `parked` names that one task's id. Dispatch is withheld from
3090    /// everyone, including `interrupt_task` itself, until it has left
3091    /// flight - only then is `interrupt_task` let through.
3092    ///
3093    /// `parked` is a `Vec` rather than a bare id for symmetry with
3094    /// [`Interrupt::Running`] and [`Interrupt::Resuming`], but
3095    /// [`advance_interrupt`]'s own `Idle` branch only ever starts a sequence
3096    /// when exactly one run is in flight, so it is guaranteed to hold
3097    /// exactly one entry in practice - see that branch's own doc for why
3098    /// more than one is deliberately never attempted.
3099    Parking {
3100        parked: Vec<String>,
3101        interrupt_task: String,
3102    },
3103    /// `interrupt_task` is dispatched and in flight alone. Dispatch is
3104    /// withheld from everyone until it leaves flight - merged, failed, held,
3105    /// it makes no difference - at which point the sequence moves to
3106    /// [`Interrupt::Resuming`], never straight back to [`Interrupt::Idle`]:
3107    /// going straight to `Idle` would hand `parked` back to ordinary
3108    /// priority-order dispatch, where a higher-priority task filed in the
3109    /// meantime could start ahead of it.
3110    Running {
3111        parked: Vec<String>,
3112        interrupt_task: String,
3113    },
3114    /// `interrupt_task` left flight; `parked` still names the one run this
3115    /// sequence owes a resume. Dispatch is withheld from everyone except
3116    /// that task - see [`interrupt_gate`] - so the resume this feature
3117    /// promises is never raced by, or run alongside, an unrelated candidate.
3118    /// Ends the moment it is seen in flight, or - see `advance_interrupt`'s
3119    /// own doc on abandonment - the moment it is no longer runnable at all.
3120    Resuming { parked: Vec<String> },
3121}
3122
3123/// One tick of the interrupt scheduler's own state machine. Pure: `in_flight`
3124/// and `runnable` are read-only snapshots of this tick's reality, and the
3125/// only side effect the caller still owes the world is asking whichever
3126/// `Pause` handles `parked` names to actually park - see [`poll`]'s own call
3127/// site.
3128///
3129/// `runnable` only has to carry `id` and `interrupt`; the whole [`Task`] is
3130/// accepted rather than a narrower type because that is what [`poll`] already
3131/// has on hand from [`runnable`], and building a second, smaller list on
3132/// every tick just to satisfy this signature would cost more than it proves.
3133///
3134/// Abandonment: [`Interrupt::Parking`] and [`Interrupt::Resuming`] both fall
3135/// back to a task they are waiting on no longer being [`runnable`] - held,
3136/// blocked, deleted, or finished by some other means entirely, all of which
3137/// an operator can do to a task sitting in the queue with no claim on it at
3138/// all, at any moment, interrupt sequence or not. Without this check the
3139/// sequence would wait forever for a dispatch that can never come, and
3140/// `interrupt_gate` would withhold every other task in the queue right along
3141/// with it - a single `magi task hold` on the wrong id turning into a
3142/// daemon that never dispatches anything again.
3143fn advance_interrupt(state: Interrupt, in_flight: &[String], runnable: &[Task]) -> Interrupt {
3144    match state {
3145        Interrupt::Idle => {
3146            // Not just "something to interrupt": exactly one thing. More
3147            // than one run in flight only happens above the default
3148            // `max_concurrent_runs = 1`, and `parked` guarantees "exactly
3149            // one resume, never run alongside anything else" only because
3150            // it is only ever seeded with exactly one id - see
3151            // `Interrupt::Resuming`'s own doc on why releasing more than one
3152            // parked id back to ordinary dispatch cannot be made safe
3153            // against that same setting's own extra concurrency slots.
3154            // Waiting here for the herd to settle to one is the
3155            // simplification this feature's own constraints ask for rather
3156            // than a second concurrency model to reconcile with the first.
3157            if in_flight.len() != 1 {
3158                return Interrupt::Idle;
3159            }
3160            match runnable.iter().find(|t| t.interrupt) {
3161                Some(t) => Interrupt::Parking {
3162                    parked: in_flight.to_vec(),
3163                    interrupt_task: t.id.clone(),
3164                },
3165                None => Interrupt::Idle,
3166            }
3167        }
3168        Interrupt::Parking {
3169            parked,
3170            interrupt_task,
3171        } => {
3172            if in_flight.iter().any(|id| parked.contains(id)) {
3173                // Still waiting for what was in flight to actually stop.
3174                Interrupt::Parking {
3175                    parked,
3176                    interrupt_task,
3177                }
3178            } else if in_flight.contains(&interrupt_task) {
3179                Interrupt::Running {
3180                    parked,
3181                    interrupt_task,
3182                }
3183            } else if runnable.iter().any(|t| t.id == interrupt_task) {
3184                // The parked run(s) are gone, but the interrupt task has not
3185                // been dispatched yet on this tick - `interrupt_gate` is
3186                // what lets it through next.
3187                Interrupt::Parking {
3188                    parked,
3189                    interrupt_task,
3190                }
3191            } else {
3192                // The interrupt task itself is no longer runnable - see this
3193                // function's own doc on abandonment. The parked run(s) still
3194                // get their guaranteed resume; there is simply no interrupt
3195                // to run ahead of them any longer.
3196                Interrupt::Resuming { parked }
3197            }
3198        }
3199        Interrupt::Running {
3200            parked,
3201            interrupt_task,
3202        } => {
3203            if in_flight.contains(&interrupt_task) {
3204                Interrupt::Running {
3205                    parked,
3206                    interrupt_task,
3207                }
3208            } else {
3209                // The interrupt task's own run reached a terminal status,
3210                // whichever one - this is the *only* trigger that moves the
3211                // sequence on, driven straight off the same in-flight
3212                // bookkeeping `poll` already reaps every tick, not a second,
3213                // independent poll of anything.
3214                Interrupt::Resuming { parked }
3215            }
3216        }
3217        Interrupt::Resuming { parked } => {
3218            if in_flight.iter().any(|id| parked.contains(id)) {
3219                // One of the parked runs has been dispatched - the resume
3220                // this sequence owed is fulfilled. Whatever else is left in
3221                // `parked` (ordinarily nothing, at the default concurrency
3222                // of one) rejoins ordinary priority-order dispatch, same as
3223                // any other runnable task.
3224                Interrupt::Idle
3225            } else if runnable.iter().any(|t| parked.contains(&t.id)) {
3226                Interrupt::Resuming { parked }
3227            } else {
3228                // Abandonment (see this function's own doc): nothing left in
3229                // `parked` is even runnable any longer.
3230                Interrupt::Idle
3231            }
3232        }
3233    }
3234}
3235
3236/// [`Interrupt`], but with [`crate::config::Daemon::pause_for_interrupts`]
3237/// folded in: disabled, the sequence can never leave [`Interrupt::Idle`], so
3238/// a task marked [`Task::interrupt`] on a daemon that has not opted in is
3239/// indistinguishable from any other runnable task - exactly the "off does
3240/// nothing" this feature promises.
3241fn advance_interrupt_tick(
3242    enabled: bool,
3243    state: Interrupt,
3244    in_flight: &[String],
3245    runnable: &[Task],
3246) -> Interrupt {
3247    if !enabled {
3248        return Interrupt::Idle;
3249    }
3250    advance_interrupt(state, in_flight, runnable)
3251}
3252
3253/// Which of this tick's runnable candidates the interrupt sequence actually
3254/// allows to be dispatched. Pure, and separate from [`advance_interrupt`] so
3255/// each half is assertable on its own: this is the half that keeps a
3256/// competition and an interrupt from ever running at the same moment.
3257fn interrupt_gate(state: &Interrupt, in_flight: &[String], candidates: Vec<Task>) -> Vec<Task> {
3258    match state {
3259        Interrupt::Idle => candidates,
3260        Interrupt::Parking {
3261            parked,
3262            interrupt_task,
3263        } => {
3264            if in_flight.iter().any(|id| parked.contains(id)) {
3265                Vec::new()
3266            } else {
3267                candidates
3268                    .into_iter()
3269                    .filter(|t| &t.id == interrupt_task)
3270                    .collect()
3271            }
3272        }
3273        Interrupt::Running { .. } => Vec::new(),
3274        // At most one: even if `parked` names more than one id (more than
3275        // one run was in flight when the sequence began, only possible
3276        // above the default `max_concurrent_runs = 1`), only the first match
3277        // is offered. Capping this to a single candidate - not merely to
3278        // `parked`'s own ids - is what makes "exactly one resume, never two
3279        // dispatched together" true regardless of how many ordinary slots
3280        // happen to be free this tick.
3281        Interrupt::Resuming { parked } => candidates
3282            .into_iter()
3283            .find(|t| parked.contains(&t.id))
3284            .into_iter()
3285            .collect(),
3286    }
3287}
3288
3289/// The daemon-loop knobs [`poll`] needs from [`crate::config::Daemon`],
3290/// bundled into one parameter so `poll`'s own signature stays readable -
3291/// see [`drive`]'s call site for where these are actually read.
3292struct DispatchLimits {
3293    /// How many *ordinary* candidates run at once. See
3294    /// [`crate::config::Daemon::max_concurrent_runs`].
3295    max_concurrent: usize,
3296    /// See [`crate::config::Daemon::pause_for_interrupts`].
3297    pause_for_interrupts: bool,
3298    recovery_cfg: Option<Config>,
3299}
3300
3301/// Which semaphore, if any, dispatching a candidate should draw its permit
3302/// from.
3303///
3304/// Pure and separate from [`poll`]'s loop body for the same reason
3305/// [`advance_interrupt`] is: the choice between "skip the ordinary pool
3306/// entirely", "spend the one urgent slot" and "spend an ordinary slot" is
3307/// exactly the policy this feature adds, and a policy only exercisable by
3308/// running the whole loop is a policy nobody checks.
3309#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
3310enum PermitKind {
3311    /// A land-merge resume (see [`LandResume::Ready`]): bypasses every slot.
3312    /// Checked first - a task can be both a land resume and marked
3313    /// [`Task::urgent`], and the resume's own "must not queue behind
3314    /// anything" guarantee takes precedence.
3315    None,
3316    /// [`Task::urgent`]: the one extra slot in `urgent_sem`, spent instead of
3317    /// (never in addition to trying) the ordinary pool. This is what lets
3318    /// an urgent task dispatch while every ordinary slot is already checked
3319    /// out, and what stops a second urgent task from opening a third run: it
3320    /// waits on this same one-slot semaphore rather than falling through to
3321    /// the ordinary one.
3322    Urgent,
3323    /// The ordinary `max_concurrent_runs` pool - unaffected by either of the
3324    /// above.
3325    #[default]
3326    Ordinary,
3327}
3328
3329/// [`PermitKind`] for one candidate. `priority` is [`LandResume::Ready`]'s
3330/// own boolean, already computed by the caller from [`land_resume_state`].
3331fn permit_kind(priority: bool, urgent: bool) -> PermitKind {
3332    if priority {
3333        PermitKind::None
3334    } else if urgent {
3335        PermitKind::Urgent
3336    } else {
3337        PermitKind::Ordinary
3338    }
3339}
3340
3341fn available_slots(requests: &[TaskWorker], limit: usize) -> (usize, usize) {
3342    let (ordinary, urgent) = requests.iter().filter(|request| !request.standalone).fold(
3343        (0usize, 0usize),
3344        |(ordinary, urgent), request| match request.lane {
3345            PermitKind::Ordinary => (ordinary + 1, urgent),
3346            PermitKind::Urgent => (ordinary, urgent + 1),
3347            PermitKind::None => (ordinary, urgent),
3348        },
3349    );
3350    (
3351        limit.saturating_sub(ordinary),
3352        1usize.saturating_sub(urgent),
3353    )
3354}
3355
3356struct DispatchClaim {
3357    task: Task,
3358    claim: crate::queue::Claim,
3359    permit: Option<tokio::sync::OwnedSemaphorePermit>,
3360}
3361
3362/// Claim and re-read before choosing a lane: a queue listing may predate an
3363/// urgent toggle. All unsuccessful paths release the claim without an attempt.
3364fn claim_dispatch(
3365    queue: &Queue,
3366    candidate: &Task,
3367    cooling_down: bool,
3368    ordinary: &Arc<tokio::sync::Semaphore>,
3369    urgent: &Arc<tokio::sync::Semaphore>,
3370) -> Option<DispatchClaim> {
3371    let Ok(claim) = queue.claim(&candidate.id) else {
3372        tracing::info!("task {} is claimed elsewhere; skipping", candidate.short());
3373        return None;
3374    };
3375    let task = match queue.get(&candidate.id) {
3376        Ok(t) if t.status.runnable() => t,
3377        Ok(_) => return None,
3378        Err(e) => {
3379            tracing::warn!("could not re-read task {}: {e:#}", candidate.short());
3380            return None;
3381        }
3382    };
3383    let resume = land_resume_state(&task);
3384    if resume == LandResume::StillWaiting {
3385        return None;
3386    }
3387    let priority = resume == LandResume::Ready;
3388    if !priority && cooling_down {
3389        return None;
3390    }
3391    // Never borrow an ordinary slot when the single urgent slot is busy.
3392    // Keep scanning other candidates when either pool has no free slot.
3393    let permit = match permit_kind(priority, task.urgent) {
3394        PermitKind::None => None,
3395        PermitKind::Urgent => Some(Arc::clone(urgent).try_acquire_owned().ok()?),
3396        PermitKind::Ordinary => Some(Arc::clone(ordinary).try_acquire_owned().ok()?),
3397    };
3398    // Persist the dispatch order under the claim, before config discovery or
3399    // coordination waits can reorder concurrent starts.
3400    let stamp = Timestamp::now();
3401    let mut task = task;
3402    task.dispatched_at = Some(stamp);
3403    if let Err(e) = queue.modify(&task.id, |t| {
3404        t.dispatched_at = Some(stamp);
3405        true
3406    }) {
3407        tracing::warn!("could not record dispatch order of {}: {e:#}", task.short());
3408    }
3409    Some(DispatchClaim {
3410        task,
3411        claim,
3412        permit,
3413    })
3414}
3415
3416/// Poll the queue until stopped, factored out so [`drive`] owns only setup and
3417/// teardown and cannot skip the teardown on an early return.
3418///
3419/// `limits.max_concurrent` bounds how many *ordinary* candidates run at once,
3420/// see [`crate::config::Daemon::max_concurrent_runs`]. A run parked on a
3421/// land approval that has since been answered is dispatched outside that
3422/// bound the moment [`land_resume_state`] reports it [`LandResume::Ready`]:
3423/// the whole point of parking there is that it must not queue behind
3424/// whatever else the loop happens to be running, even at the default of one.
3425/// A [`Task::urgent`] candidate is exempt from the same bound the same way,
3426/// through its own one-slot `urgent_sem` (see [`permit_kind`]). Every one of
3427/// these exemptions is still subject to the interrupt gate below, urgent
3428/// included: a land-resume or urgent candidate is exactly as much "something
3429/// else running" as an ordinary one from the interrupt sequence's point of
3430/// view, and letting either slip through while a run is being parked, or
3431/// while the interrupt task itself has the floor, is precisely the second
3432/// run 75dd's own "at most one run, ever, at once" guarantee must never
3433/// allow - see [`Interrupt`]'s own doc.
3434#[derive(Serialize, Deserialize)]
3435struct ServiceWorker {
3436    opts: Opts,
3437    queue: PathBuf,
3438    worktrees_root: PathBuf,
3439    operation: String,
3440}
3441
3442fn load_conductor(home: &Path) -> Result<Conductor> {
3443    match std::fs::read(home.join("conductor-state.json")) {
3444        Ok(body) => serde_json::from_slice(&body).context("read conductor state"),
3445        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Conductor::new()),
3446        Err(error) => Err(error.into()),
3447    }
3448}
3449
3450fn conductor_work(queue: &Queue, home: &Path) -> (Vec<Task>, Vec<Task>, Vec<Task>) {
3451    let live: std::collections::BTreeSet<_> = current_work(home, Timestamp::now())
3452        .into_iter()
3453        .map(|c| c.task)
3454        .collect();
3455    let stalled = stalled_tasks(queue, home, Timestamp::now());
3456    let stalled_ids: std::collections::BTreeSet<_> = stalled.iter().map(|t| t.id.clone()).collect();
3457    let finished = finished_tasks(queue)
3458        .into_iter()
3459        .filter(|t| !live.contains(&t.id) && !stalled_ids.contains(&t.id))
3460        .filter(|t| !awaiting_resume_with(t, |id| RunState::load_under(id, home)))
3461        .collect();
3462    let queued = queued_tasks(queue)
3463        .into_iter()
3464        .filter(|t| !live.contains(&t.id))
3465        .collect();
3466    (queued, stalled, finished)
3467}
3468
3469async fn start_service(
3470    home: &Path,
3471    opts: &Opts,
3472    queue: &Queue,
3473    worktrees_root: &Path,
3474    operation: &str,
3475    cfg: Option<&Config>,
3476    busy: bool,
3477) -> Result<()> {
3478    // Fetch policy is live configuration, unlike question recovery capacity.
3479    // Reload before both cadence and eligibility, including disabled -> enabled.
3480    let fetch_cfg = if operation == "fetch" {
3481        match prepare(&opts.repo, opts) {
3482            Ok(cfg) => Some(cfg),
3483            Err(error) => {
3484                tracing::warn!("could not reload periodic fetch configuration: {error:#}");
3485                return Ok(());
3486            }
3487        }
3488    } else {
3489        None
3490    };
3491    let cfg = fetch_cfg.as_ref().or(cfg);
3492    let ticks = home.join("service-ticks");
3493    std::fs::create_dir_all(&ticks)?;
3494    let receipt = ticks.join(format!("daemon-{operation}"));
3495    let last = std::fs::read(&receipt)
3496        .ok()
3497        .and_then(|body| serde_json::from_slice::<Timestamp>(&body).ok());
3498    let interval = match operation {
3499        "disk-pressure" => 30,
3500        "cache" => CACHE_CHECK_INTERVAL_SECS,
3501        "fetch" => cfg.map_or(60, |cfg| cfg.repos.fetch_interval.max(1)),
3502        _ => opts.poll.as_secs().max(1),
3503    };
3504    let now = Timestamp::now();
3505    if !cache_check_due(last, now, interval) {
3506        return Ok(());
3507    }
3508    let key = format!("service:daemon-{operation}");
3509    if crate::worker::active(home)?
3510        .iter()
3511        .any(|job| job.key == key)
3512    {
3513        return Ok(());
3514    }
3515    let eligible = match operation {
3516        "conductor" => {
3517            let (queued, stalled, finished) = conductor_work(queue, home);
3518            !(queued.is_empty() && stalled.is_empty() && finished.is_empty())
3519                && load_conductor(home)?.worth_a_look(queue, &stalled, &finished)
3520        }
3521        "maintenance" => Questions::at(home.join("questions"))
3522            .list()
3523            .iter()
3524            .any(|q| {
3525                q.chosen_action().is_some_and(|action| action.maintenance())
3526                    && !home
3527                        .join("choice-actions")
3528                        .join(format!("{}.json", q.id))
3529                        .exists()
3530            }),
3531        "housekeeping" => !busy && runnable(queue).is_empty(),
3532        "cache" => {
3533            !busy
3534                && !runnable(queue).is_empty()
3535                && cfg.is_some_and(|cfg| cfg.disk.cache_limit_bytes > 0)
3536        }
3537        "disk-pressure" => busy,
3538        "fetch" => {
3539            cfg.is_some_and(|cfg| cfg.repos.fetch_interval > 0 && !cfg.repos.roots.is_empty())
3540        }
3541        _ => false,
3542    };
3543    if !eligible {
3544        return Ok(());
3545    }
3546    let request = ServiceWorker {
3547        opts: opts.absolute()?,
3548        queue: std::path::absolute(queue.root())?,
3549        worktrees_root: std::path::absolute(worktrees_root)?,
3550        operation: operation.to_owned(),
3551    };
3552    if let Err(error) =
3553        crate::worker::spawn(home, "daemon-service", &key, serde_json::to_value(request)?).await
3554    {
3555        tracing::warn!(operation, "could not launch daemon service: {error:#}");
3556    }
3557    std::fs::write(receipt, serde_json::to_vec(&now)?)?;
3558    Ok(())
3559}
3560
3561/// Finite maintenance jobs survive scheduler replacement without duplicating
3562/// agent calls or cancelling an operation between its effect and receipt.
3563pub async fn run_service_worker(home: &Path, payload: serde_json::Value) -> Result<()> {
3564    let request: ServiceWorker = serde_json::from_value(payload)?;
3565    let opts = &request.opts;
3566    let queue = Queue::at(request.queue);
3567    let questions = Questions::at(home.join("questions"));
3568    match request.operation.as_str() {
3569        "maintenance" => apply_maintenance_choices(&queue, &questions, home).await,
3570        "conductor" => {
3571            let path = home.join("conductor-state.json");
3572            let mut conductor = load_conductor(home)?;
3573            let (queued, stalled, finished) = conductor_work(&queue, home);
3574            if !(queued.is_empty() && stalled.is_empty() && finished.is_empty())
3575                && conductor.worth_a_look(&queue, &stalled, &finished)
3576            {
3577                let cfg = prepare(&opts.repo, opts)?;
3578                conductor
3579                    .maybe_run(
3580                        &cfg,
3581                        &opts.repo,
3582                        &queue,
3583                        &questions,
3584                        home,
3585                        &queued,
3586                        &stalled,
3587                        &finished,
3588                        opts.max_attempts,
3589                    )
3590                    .await;
3591                let temporary = path.with_extension("json.tmp");
3592                std::fs::write(&temporary, serde_json::to_vec(&conductor)?)?;
3593                std::fs::rename(temporary, path)?;
3594            }
3595        }
3596        "fetch" => {
3597            let cfg = prepare(&opts.repo, opts)?;
3598            if cfg.repos.fetch_interval > 0 && !cfg.repos.roots.is_empty() {
3599                let outcome = clean::fetch_origins(&cfg.repos.roots, FETCH_TIMEOUT, || false).await;
3600                tracing::debug!("fetch origins: {outcome:?}");
3601            }
3602        }
3603        "cache" => {
3604            if task_jobs(home)?.is_empty() {
3605                let mut checked = None;
3606                maybe_prune_cache_between_runs(
3607                    &opts.repo,
3608                    opts,
3609                    home,
3610                    &Stop::new(),
3611                    &mut checked,
3612                    Timestamp::now(),
3613                )
3614                .await;
3615            }
3616        }
3617        "disk-pressure" => {
3618            if let Ok(cfg) = prepare(&opts.repo, opts) {
3619                crate::retention::sweep(home, &cfg.disk, Timestamp::now()).await;
3620                if let Err(e) = clean::prune_cache_if_over_limit(&cfg, home) {
3621                    tracing::warn!("disk pressure cache cleanup failed: {e:#}");
3622                }
3623                for current in current_work(home, Timestamp::now()) {
3624                    if let Ok(state) = RunState::load_under(&current.run, home)
3625                        && let Some(reason) = disk_gate(&state.repo, &state.config)
3626                    {
3627                        for job in owned_task_jobs(home)?.into_iter().filter(|job| {
3628                            job.key.strip_prefix("task:") == Some(current.task.as_str())
3629                        }) {
3630                            request_task_park(
3631                                home,
3632                                &job,
3633                                crate::run::ParkCause::Interrupt {
3634                                    reason: reason.clone(),
3635                                },
3636                            )?;
3637                        }
3638                        notices::raise_in(
3639                            home,
3640                            Notice::warn(
3641                                "disk:pressure",
3642                                "Disk pressure persists after cleanup; active workers were asked to checkpoint and park. New starts and resumes wait for free space.",
3643                            ),
3644                        );
3645                    }
3646                }
3647            }
3648        }
3649        "housekeeping" => {
3650            if task_jobs(home)?.is_empty() {
3651                if let Ok(cfg) = prepare(&opts.repo, opts) {
3652                    crate::retention::sweep(home, &cfg.disk, Timestamp::now()).await;
3653                }
3654                janitor(&opts.repo, opts, home, &request.worktrees_root).await;
3655                resweep_superseded_attempts(&queue, home);
3656                triage_held(&queue, home, opts).await;
3657            }
3658        }
3659        operation => bail!("unknown daemon service {operation}"),
3660    }
3661    Ok(())
3662}
3663
3664/// Only a different request under the same key is a dispatch handoff race.
3665/// Our own unacknowledged reservation is a startup failure, not competing work:
3666/// stop dispatch without fencing it, so an already detached child may still run.
3667fn task_worker_spawn_error(home: &Path, request: &TaskWorker, error: anyhow::Error) -> Result<()> {
3668    let key = format!("task:{}", request.task);
3669    let payload = serde_json::to_value(request)?;
3670    if crate::worker::active(home)?
3671        .iter()
3672        .any(|job| job.key == key && job.kind == "task" && job.payload != payload)
3673    {
3674        tracing::warn!(
3675            "task {} acquired a competing worker reservation: {error:#}",
3676            crate::run::short_of(&request.task)
3677        );
3678        return Ok(());
3679    }
3680    tracing::error!("task worker startup stopped dispatch: {error:#}");
3681    notices::raise_in(
3682        home,
3683        Notice::error(
3684            "loop:worker-startup",
3685            // Startup diagnostics may contain private child-process details;
3686            // keep them in the local error/log rather than the chat or notifier.
3687            format!("Task {} worker could not be started or acknowledged. Scheduler dispatch stopped; already reserved workers may still complete. Inspect the local execution log and reservation worker.log for startup details.", request.task),
3688        ).link(Link::Task { id: request.task.clone() }),
3689    );
3690    Err(error).with_context(|| {
3691        format!(
3692            "cannot start task {} worker; dispatcher stopped; any unacknowledged reservation remains occupied",
3693            crate::run::short_of(&request.task)
3694        )
3695    })
3696}
3697
3698async fn poll(
3699    opts: &Opts,
3700    queue: &Queue,
3701    status: &Arc<Mutex<Status>>,
3702    home: &Path,
3703    worktrees_root: &Path,
3704    stop: &Stop,
3705    limits: DispatchLimits,
3706) -> Result<()> {
3707    let mut attempted = Vec::new();
3708    let mut settling_once_services = false;
3709    let interrupt_path = home.join("scheduler-interrupt.json");
3710    let mut interrupt = match std::fs::read(&interrupt_path) {
3711        Ok(body) => serde_json::from_slice(&body).context("read scheduler interrupt state")?,
3712        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Interrupt::Idle,
3713        Err(error) => return Err(error.into()),
3714    };
3715    let mut previous = std::collections::BTreeSet::<String>::new();
3716    while !stop.stopped() {
3717        let jobs: Vec<_> = crate::worker::active(home)?
3718            .into_iter()
3719            .filter(|job| job.kind == "task")
3720            .collect();
3721        let requests: Vec<_> = jobs
3722            .iter()
3723            .map(|job| {
3724                serde_json::from_value::<TaskWorker>(job.payload.clone())
3725                    .with_context(|| format!("read live task worker {}", job.id))
3726            })
3727            .collect::<Result<_>>()?;
3728        let live: std::collections::BTreeSet<_> = requests.iter().map(|r| r.task.clone()).collect();
3729        let owned: std::collections::BTreeSet<_> = requests
3730            .iter()
3731            .filter(|r| !r.standalone)
3732            .map(|r| r.task.clone())
3733            .collect();
3734        let current: Vec<_> = requests
3735            .iter()
3736            .zip(&jobs)
3737            .filter(|(request, _)| !request.standalone)
3738            .map(|(request, job)| Current {
3739                worker: Some(job.id.clone()),
3740                task: request.task.clone(),
3741                run: queue
3742                    .get(&request.task)
3743                    .ok()
3744                    .and_then(|task| task.runs.last().cloned())
3745                    .unwrap_or_default(),
3746            })
3747            .collect();
3748        {
3749            let mut snapshot = lock(status);
3750            snapshot.polls += 1;
3751            snapshot.completed += previous.difference(&owned).count();
3752            snapshot.current = current;
3753            snapshot.idle = owned.is_empty();
3754        }
3755        previous = owned.clone();
3756        stop.busy.store(owned.len(), Ordering::SeqCst);
3757
3758        // Slots are reconstructed from durable reservations and OS-held worker
3759        // leases, not this scheduler's lifetime or mutable task urgency.
3760        let (ordinary, urgent) = available_slots(&requests, limits.max_concurrent);
3761        let sem = Arc::new(tokio::sync::Semaphore::new(ordinary));
3762        let urgent_sem = Arc::new(tokio::sync::Semaphore::new(urgent));
3763        let mut permits = Vec::new();
3764        sweep_stale_claims(queue, STALE_CLAIM);
3765        reclaim_orphaned_running(queue, opts.max_attempts, &live);
3766        reclaim_abandoned_runs(home, Timestamp::now());
3767        let questions = Questions::at(home.join("questions"));
3768
3769        // Deterministic: no model, run before the conductor sees anything so
3770        // its input reflects the queue's current, already-resolved state.
3771        crate::task_chat::sweep(
3772            queue,
3773            &crate::talk::Talks::at(home.join("talks")),
3774            &questions,
3775            home,
3776        );
3777        repair_choice_offers(&questions);
3778        resolve_blockers(queue, &questions);
3779        apply_task_choice_actions(queue, &questions, home);
3780        triage::apply_answers(queue, &questions);
3781        reconcile_task_questions(queue, &questions);
3782        let talks = crate::talk::Talks::at(home.join("talks"));
3783        crate::chat_notify::sweep(queue, &talks, home);
3784        crate::notice_delivery::sweep(queue, &talks, home, &opts.repo);
3785        crate::chat_watch::sweep(home, &talks);
3786        let mut turns = tokio::task::JoinSet::new();
3787        crate::chat_notify::start_turns(&talks, stop.parking(), &mut turns);
3788        // start_turns dispatches detached turns; no paid work lives in this set.
3789        while turns.join_next().await.is_some() {}
3790
3791        crate::consult::start_services(
3792            home,
3793            &opts.repo,
3794            limits.recovery_cfg.as_ref(),
3795            opts.config.as_deref(),
3796        )
3797        .await?;
3798        for operation in [
3799            "conductor",
3800            "maintenance",
3801            "housekeeping",
3802            "disk-pressure",
3803            "cache",
3804            "fetch",
3805        ] {
3806            // Once an idle --once drain starts settling services, subsequent
3807            // scans only consume their effects. An expired cadence receipt
3808            // must not start another service and keep the drain alive.
3809            if settling_once_services {
3810                break;
3811            }
3812            start_service(
3813                home,
3814                opts,
3815                queue,
3816                worktrees_root,
3817                operation,
3818                limits.recovery_cfg.as_ref(),
3819                !live.is_empty(),
3820            )
3821            .await?;
3822        }
3823        let latest = crate::worker::active(home)?;
3824        if latest.iter().any(|job| job.key == "service:daemon-cache")
3825            || latest
3826                .iter()
3827                .any(|job| job.kind == "task" && !jobs.iter().any(|known| known.id == job.id))
3828        {
3829            stop.idle(RECHECK_WHILE_BUSY.min(opts.poll)).await;
3830            continue;
3831        }
3832        let candidates: Vec<Task> = runnable(queue)
3833            .into_iter()
3834            .filter(|task| !live.contains(&task.id))
3835            .filter(|task| !opts.once || !attempted.contains(&task.id))
3836            .collect();
3837        let in_flight: Vec<_> = owned.iter().cloned().collect();
3838        let prior_interrupt = std::mem::discriminant(&interrupt);
3839        interrupt = advance_interrupt_tick(
3840            limits.pause_for_interrupts,
3841            interrupt,
3842            &in_flight,
3843            &candidates,
3844        );
3845        // Same-variant transitions preserve their identities. Only an actual
3846        // transition needs a durable write, atomically before sending controls.
3847        if prior_interrupt != std::mem::discriminant(&interrupt) {
3848            let temporary = interrupt_path.with_extension("json.tmp");
3849            std::fs::write(&temporary, serde_json::to_vec(&interrupt)?)?;
3850            std::fs::rename(temporary, &interrupt_path)?;
3851        }
3852        if let Interrupt::Parking {
3853            parked,
3854            interrupt_task,
3855        } = &interrupt
3856        {
3857            for job in jobs.iter().filter(|job| {
3858                parked
3859                    .iter()
3860                    .any(|id| job.key.strip_prefix("task:") == Some(id.as_str()))
3861            }) {
3862                if crate::worker::control(home, &job.key)?.is_none() {
3863                    request_task_park(
3864                        home,
3865                        job,
3866                        crate::run::ParkCause::Interrupt {
3867                            reason: format!(
3868                                "task {} asked to run first",
3869                                crate::run::short_of(interrupt_task)
3870                            ),
3871                        },
3872                    )?;
3873                }
3874            }
3875        }
3876        let candidates = interrupt_gate(&interrupt, &in_flight, candidates);
3877        let cooling = read_cooldown(home)?.is_some_and(|until| Timestamp::now() < until);
3878        let mut started = false;
3879        let mut resync = false;
3880        for candidate in candidates {
3881            if stop.stopped() {
3882                break;
3883            }
3884            let Some(DispatchClaim {
3885                task,
3886                claim,
3887                permit,
3888            }) = claim_dispatch(queue, &candidate, cooling, &sem, &urgent_sem)
3889            else {
3890                continue;
3891            };
3892            if crate::worker::active(home)?
3893                .iter()
3894                .any(|job| job.kind == "task" && !jobs.iter().any(|known| known.id == job.id))
3895            {
3896                drop(claim);
3897                resync = true;
3898                break;
3899            }
3900            let lane = permit_kind(land_resume_state(&task) == LandResume::Ready, task.urgent);
3901            let request = TaskWorker {
3902                opts: opts.absolute()?,
3903                queue: std::path::absolute(queue.root())?,
3904                task: task.id.clone(),
3905                lane,
3906                standalone: false,
3907            };
3908            // Keep the claim until spawn acknowledges the worker's durable
3909            // reservation. The worker then claims and re-reads before attempt.
3910            if let Err(error) = crate::worker::spawn(
3911                home,
3912                "task",
3913                &format!("task:{}", task.id),
3914                serde_json::to_value(&request)?,
3915            )
3916            .await
3917            {
3918                task_worker_spawn_error(home, &request, error)?;
3919                drop(claim);
3920                resync = true;
3921                break;
3922            }
3923            drop(claim);
3924            permits.extend(permit);
3925            attempted.push(task.id);
3926            started = true;
3927        }
3928        if started || resync {
3929            continue;
3930        }
3931        if owned.is_empty() && opts.once {
3932            settling_once_services = true;
3933            // These finite services can own queue claims or publish answers.
3934            // A skipped claim is not an idle queue until they have finished;
3935            // scan again afterwards so their changes are actually scheduled.
3936            if crate::worker::active(home)?.iter().any(|job| {
3937                job.kind == "daemon-service"
3938                    && matches!(
3939                        job.key.as_str(),
3940                        "service:daemon-housekeeping"
3941                            | "service:daemon-conductor"
3942                            | "service:daemon-maintenance"
3943                    )
3944            }) {
3945                stop.idle(RECHECK_WHILE_BUSY.min(opts.poll)).await;
3946                continue;
3947            }
3948            break;
3949        }
3950        stop.idle(if live.is_empty() {
3951            opts.poll
3952        } else {
3953            RECHECK_WHILE_BUSY.min(opts.poll)
3954        })
3955        .await;
3956    }
3957    // Dropping a scheduler only drops dispatch bookkeeping. The workers own
3958    // every queue claim and Runner driver lease through outcome settlement.
3959    stop.busy.store(0, Ordering::SeqCst);
3960    Ok(())
3961}
3962
3963/// A maintenance pass may briefly own the same record while a worker starts.
3964/// Wait without blocking the executor or charging an agent attempt, then read
3965/// ownership again under the acquired lock. Other I/O failures still fail closed.
3966async fn resume_owner_lock(home: &Path, id: &str) -> Result<crate::pr_head::OwnerLock> {
3967    let busy = |error: &anyhow::Error| {
3968        error.downcast_ref::<std::io::Error>().is_some_and(|error| {
3969            error.kind() == std::io::ErrorKind::WouldBlock
3970                || cfg!(windows) && error.raw_os_error() == Some(33)
3971        })
3972    };
3973    match crate::pr_head::owner_lock(home, id) {
3974        Ok(lock) => return Ok(lock),
3975        Err(error) if busy(&error) => {}
3976        Err(error) => return Err(error),
3977    }
3978    let wait = RunState::load_under(id, home)?
3979        .config
3980        .graph
3981        .timeout_resource_wait;
3982    crate::pr_head::owner_lock_wait(home, id, Duration::from_secs(wait)).await
3983}
3984
3985/// Historical successors do not revoke a still-resumable public owner. Scan
3986/// under the same lock as publication/driver claims, and fail closed on unknown
3987/// history before spending another attempt.
3988async fn public_resume_owner(
3989    home: &Path,
3990    runs: &[String],
3991    repo: &Path,
3992) -> Result<Option<RunState>> {
3993    let mut owner = None;
3994    for id in runs.iter().collect::<std::collections::BTreeSet<_>>() {
3995        let _lock = resume_owner_lock(home, id).await?;
3996        let state = RunState::load_under(id, home)?;
3997        if state.released()
3998            || state.retired_to.is_some()
3999            || state.pr_taken_over_by.is_some()
4000            || !crate::handover::landing_checkpoint(&state)
4001        {
4002            continue;
4003        }
4004        anyhow::ensure!(
4005            crate::git::toplevel(&state.repo).await?.canonicalize()?
4006                == crate::git::toplevel(repo).await?.canonicalize()?,
4007            "public owner repository differs; inspect run lineage"
4008        );
4009        anyhow::ensure!(
4010            owner.is_none(),
4011            "multiple retained public owners; inspect run lineage"
4012        );
4013        if let Some(reason) = crate::handover::resume_refusal_under(&state, Some(home)) {
4014            anyhow::bail!(reason.diagnostic(&state));
4015        }
4016        let claimed = is_working_on_other(home, id, Timestamp::now())?;
4017        anyhow::ensure!(
4018            state.liveness_under(home, claimed) == crate::run::Liveness::Dead,
4019            "public owner's driver is live or unknown; wait before retrying"
4020        );
4021        owner = Some(state);
4022    }
4023    Ok(owner)
4024}
4025
4026/// Run one claimed task to a terminal status and record the outcome.
4027///
4028/// Every transition is flushed to the queue as it happens, so the state on disk
4029/// is what actually occurred rather than what this process still intends to
4030/// write.
4031async fn attempt(
4032    opts: &Opts,
4033    queue: &Queue,
4034    status: &Arc<Mutex<Status>>,
4035    stop: &Stop,
4036    interrupt_pause: crate::graph::Pause,
4037    task: &mut Task,
4038) -> Vec<QuotaLoss> {
4039    let repo = repo_for(task, &opts.repo);
4040    tracing::info!(
4041        "task {} — {} (repo {})",
4042        task.short(),
4043        task.title,
4044        repo.display()
4045    );
4046
4047    let public_owner = match public_resume_owner(&crate::run::home(), &task.runs, &repo).await {
4048        Ok(owner) => owner,
4049        Err(e) => {
4050            task.hold_machine(Some(format!(
4051                "Cannot verify existing public owner: {e:#}; no attempt started."
4052            )));
4053            record(queue, task);
4054            return Vec::new();
4055        }
4056    };
4057    let resume_runs = public_owner
4058        .as_ref()
4059        .map(|s| vec![s.id.clone()])
4060        .unwrap_or_else(|| task.runs.clone());
4061
4062    // A stop spends no agent resources and uses the saved landing owner.
4063    // Current roster edits, missing attachments or a failed config fetch must
4064    // never turn it into a new implementation, or prevent a PR from closing.
4065    if let Some(previous) = resume_runs.last().and_then(|id| RunState::load(id).ok())
4066        && previous
4067            .merge_decision
4068            .as_ref()
4069            .is_some_and(|decision| !decision.closed)
4070        && previous.status != RunStatus::Landing
4071        && !crate::merge_decision::resumable_hold(&previous)
4072    {
4073        task.hold_unrequested_retry(Some(format!("The existing owner stop decision remains held. Inspect run {} and its PR; request an explicit fresh review/decision before further work. No replacement or merge was started.", previous.id)));
4074        record(queue, task);
4075        return Vec::new();
4076    }
4077    let stop_owner = resume_runs
4078        .last()
4079        .and_then(|id| RunState::load(id).ok())
4080        .filter(|state| {
4081            crate::merge_decision::resumable_hold(state)
4082                || state.status == RunStatus::Landing
4083                    && state.land_approval.as_ref().is_some_and(|binding| {
4084                        Questions::open().get(&binding.question).is_ok_and(|q| {
4085                            q.run == state.id
4086                                && q.resolution()
4087                                    .as_deref()
4088                                    .is_some_and(crate::merge_decision::stops)
4089                        })
4090                    })
4091        });
4092    if let Some(owner) = stop_owner {
4093        let mut runner = match Runner::resume(&owner.id) {
4094            Ok(runner) => runner,
4095            Err(e) => {
4096                task.hold_manual(Some(format!("Cannot stop the existing landing owner: {e:#}. No replacement or merge was started.")));
4097                record(queue, task);
4098                return Vec::new();
4099            }
4100        };
4101        runner.on_pause(stop.pause());
4102        runner.watch_interrupt(interrupt_pause);
4103        let quota_before = runner.state.quota.clone();
4104        task.start(owner.id.clone());
4105        record(queue, task);
4106        lock(status).current.push(Current {
4107            worker: None,
4108            task: task.id.clone(),
4109            run: owner.id,
4110        });
4111        runner.state.next_pass_task_entry = true;
4112        let result = runner.execute().await;
4113        return finish_attempt(
4114            opts.max_attempts,
4115            queue,
4116            task,
4117            &runner.state,
4118            &quota_before,
4119            result,
4120        );
4121    }
4122
4123    if let Some(owner) = &public_owner
4124        && (task.fresh_start || !may_resume_run(owner, task))
4125    {
4126        task.hold_machine(Some(format!("Existing PR belongs to run {}; resume its authorized instructions or explicitly resolve its owner decision before starting a successor. No attempt started.", owner.id)));
4127        record(queue, task);
4128        return Vec::new();
4129    }
4130
4131    let unfinished = (!task.fresh_start)
4132        .then(|| unfinished_run(&resume_runs, task.short()))
4133        .flatten()
4134        .filter(|id| RunState::load(id).is_ok_and(|run| may_resume_run(&run, task)));
4135    let previous = resume_runs.last().and_then(|id| RunState::load(id).ok());
4136    if !task.fresh_start
4137        && task.pr_recovery.is_none()
4138        && task.review_branch.is_none()
4139        && task.resume_override.is_none()
4140        && previous
4141            .as_ref()
4142            .is_some_and(|run| run.retired_to.is_some())
4143        && unfinished.is_none()
4144    {
4145        task.hold_unrequested_retry(Some("Recorded recovery authority is unavailable, no longer resumable or no longer authorized for these instructions. Inspect the retained lineage and select a recovery source explicitly; no replacement competition was started.".into()));
4146        record(queue, task);
4147        return Vec::new();
4148    }
4149
4150    let saved_recovery = previous.as_ref().filter(|run| {
4151        task.replacement.is_none()
4152            && task.pr_recovery.as_ref().is_some_and(|selected| {
4153                selected.check_task(&task.id, &task.instruction).is_ok()
4154                    && selected_starter(selected, Some(run), unfinished.as_deref())
4155                        == Starter::Resume(run.id.clone())
4156            })
4157    });
4158
4159    // The repository's magi.toml comes from `<remote>/<base>`, fetched now. A
4160    // failure here is the network's or the operator's, not the task's: hold
4161    // without spending an attempt, never fall back to a possibly stale copy.
4162    let explicit = match &task.overrides {
4163        Some(o) if !o.inherit_daemon_options => o.config.as_deref(),
4164        Some(_) => opts.config.as_deref(),
4165        None => opts.config.as_deref(),
4166    };
4167    let (mut config, layers) = if let Some(run) = saved_recovery {
4168        // A matching resume owns its resolved configuration and evidence;
4169        // current layers and inherited overrides only configure new work.
4170        let mut saved = run.clone();
4171        if let Err(e) = saved.configure_recovery_resume() {
4172            task.hold_manual(Some(format!("[recovery] saved run {} cannot resume: {e:#}. Inspect its saved configuration and evidence before requesting a new selection.", run.id)));
4173            record(queue, task);
4174            return Vec::new();
4175        }
4176        (saved.config, Vec::new())
4177    } else {
4178        let (mut config, layers) = match Config::discover_fetched(&repo, explicit).await {
4179            Ok(loaded) => loaded,
4180            Err(e) => {
4181                let reason = format!("[config] {e:#}");
4182                record_config_failure(task, &e);
4183                task.last_error = Some(reason.clone());
4184                task.hold_machine(Some(reason));
4185                record(queue, task);
4186                return Vec::new();
4187            }
4188        };
4189        if let Err(e) = apply_options(&mut config, opts, task) {
4190            task.hold_manual(Some(format!(
4191                "[config] {e:#}; correct the merge override before releasing the task"
4192            )));
4193            record(queue, task);
4194            return Vec::new();
4195        }
4196        if let Some(selected) = &task.pr_recovery
4197            && let Err(e) = selected.configure(&mut config)
4198        {
4199            task.hold_manual(Some(format!("[recovery] {e:#}. No run started; select the PR again with an explicit disposition and corrected configuration.")));
4200            record(queue, task);
4201            return Vec::new();
4202        }
4203        (config, layers)
4204    };
4205    let config_evidence = crate::pr_recovery::ConfigEvidence {
4206        layers,
4207        overrides: task.overrides.clone(),
4208        daemon_merge: task
4209            .overrides
4210            .as_ref()
4211            .is_none_or(|o| o.inherit_daemon_options)
4212            .then(|| opts.merge.clone())
4213            .flatten(),
4214    };
4215    apply_task_implementers(&mut config, task);
4216    if let Some(replacement) = &task.replacement {
4217        let safe = RunState::load(&replacement.run).is_ok_and(|old| {
4218            old.liveness(false) == Liveness::Dead
4219                && old.merge_decision.as_ref().is_some_and(|d| {
4220                    d.closed
4221                        && d.question == replacement.question
4222                        && d.replacement_run.as_ref().is_none_or(|run| {
4223                            task.runs.last() == Some(run)
4224                                && RunState::load(run).is_ok_and(|current| {
4225                                    current.replaces.as_ref().is_some_and(|pin| {
4226                                        pin.question == replacement.question
4227                                            && pin.agent == replacement.agent
4228                                    })
4229                                })
4230                        })
4231                })
4232        });
4233        if !safe {
4234            task.hold_manual(Some("Replacement ownership cannot be confirmed, or a replacement run was already minted before a crash. Inspect the task and its linked runs; no second replacement was started.".into()));
4235            record(queue, task);
4236            return Vec::new();
4237        }
4238        match config.agent(&replacement.agent) {
4239            Ok(agent) if crate::agent::installed(agent) => {}
4240            _ => {
4241                task.hold_manual(Some(format!("Selected implementer `{}` is unavailable or no longer configured. No substitute was dispatched. Restore it and explicitly release the task.", replacement.agent)));
4242                record(queue, task);
4243                return Vec::new();
4244            }
4245        }
4246        config.graph.implementers = 1;
4247        config.graph.land_approval = true;
4248        config.merge.mode = MergeMode::Pr;
4249        config.graph.selected_implementer = Some(replacement.agent.clone());
4250    }
4251    let p = phrases(&config.graph.language);
4252    let start_failed = p.could_not_start;
4253    // Resolve once for both admission and settlement. The inputs are the
4254    // task's explicit sources (never prose), plus an explicit review source's
4255    // current head; review-only runs may rebase later, and neither fact can
4256    // refund a failed automatic generation.
4257    if let Ok(base) =
4258        crate::git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
4259            .await
4260        && let Ok(head) =
4261            crate::git::rev_parse(&repo, &format!("{}/{}", config.merge.remote, base)).await
4262    {
4263        let mut inputs = crate::queue::recovery_inputs(&task.sources);
4264        if let Some(branch) = task.review_of.as_ref().or(task.review_branch.as_ref()) {
4265            let source = crate::git::rev_parse(&repo, branch)
4266                .await
4267                .unwrap_or_default();
4268            inputs.push_str(&format!(";review:{branch}:{source}"));
4269        }
4270        if let Some(reason) = task.recovery_exhausted(
4271            &head,
4272            &inputs,
4273            task.attempt_limit
4274                .map_or(opts.max_attempts, std::num::NonZeroUsize::get),
4275        ) {
4276            task.hold_unrequested_retry(Some(reason));
4277            record(queue, task);
4278            return Vec::new();
4279        }
4280        task.recovery_admission = Some(crate::queue::RecoveryAdmission { base: head, inputs });
4281    }
4282
4283    // The free-space gate, checked *before* anything is minted: a task that
4284    // waits out a full disk costs nothing yet, and must not spend an attempt
4285    // or start a run the machine cannot finish. Held tasks stay in the list
4286    // for the human to see, and `magi task release` re-queues them when space
4287    // comes back - the same recovery as any other hold. A volume whose free
4288    // space cannot be measured closes the gate too: starting a run blind on a
4289    // disk that may be full is how the machine ends up with 6.7 GB free.
4290    if let Some(reason) = disk_gate(&repo, &config) {
4291        task.notice_cause("disk-space", None, None);
4292        task.last_error = Some(reason.clone());
4293        task.hold_machine(Some(reason.clone()));
4294        record(queue, task);
4295        tracing::warn!("holding {} for want of disk space: {reason}", task.short());
4296        // Stable wording - the free-space figures live in the task's hold
4297        // reason, and changing numbers would relight the bell every poll.
4298        notices::raise(
4299            Notice::warn(
4300                &format!("disk:{}", repo.display()),
4301                format!("Task {} held for want of free disk space: {reason}. Free some, then release it from the queue.", task.id),
4302            )
4303            .link(Link::Task {
4304                id: task.id.clone(),
4305            }),
4306        );
4307        return Vec::new();
4308    }
4309
4310    // A resumable run of this task is carried on, never re-competed. The
4311    // candidates are built and paid for, and a fresh competition races a
4312    // second implementation against them.
4313    //
4314    // Two runs paid for that lesson. Run 01c2 was blocked and the loop
4315    // started 3cbf on the same task a moment later, duplicating two and a
4316    // half hours of agent work. Then b25f stalled on a judge that timed out
4317    // and one that answered with no JSON - `quota: 0`, so nothing the machine
4318    // was to blame for - and 4043 started **one second** later, buying three
4319    // fresh implementations to reach the same panel. `RunStatus::resumable`
4320    // rather than `!done()` is what catches the second case: a stall is
4321    // terminal, and its cheap recovery re-asks only the absent seats.
4322    //
4323    // A load failure is warned about rather than silently read as "not
4324    // resumable": the alternative is exactly what let a schema mismatch on
4325    // run `eba2` fall through to a full re-competition with nobody told why.
4326    // `crate::conduct` is what actually offers a better answer than
4327    // `Runner::start` here (see `Recovery::Review`), once this task's next
4328    // failure shows it up as `held`/`failed` with the run state unreadable.
4329    // `crate::conduct` chose `Review` for this task on an earlier cycle: its
4330    // branch survived, and this reopens exactly that branch as a
4331    // review-only pass rather than resuming or competing again. Consumed
4332    // (cleared) here whichever way this goes, so it never outlives this one
4333    // attempt - see `queue::Task::review_branch`.
4334    let review_branch = take_review_branch(task, previous.as_ref());
4335    let branch_exists = match &review_branch {
4336        Some(branch) => crate::git::branch_exists(&repo, branch)
4337            .await
4338            .unwrap_or(false),
4339        None => false,
4340    };
4341    // An explicit `magi review <branch>` is reviewed whatever happens to the
4342    // branch unless an explicit implementer change requires fresh work. An
4343    // unfinished run of the same task still wins, so a retry resumes.
4344    let proposed = review_retry_starter(
4345        task,
4346        previous.as_ref(),
4347        review_branch.as_deref(),
4348        branch_exists,
4349        unfinished.as_deref(),
4350    );
4351    let retry_selected = task.replacement.is_some()
4352        && task.resumption.is_some()
4353        && !task.fresh_start
4354        && previous.as_ref().is_some_and(|run| {
4355            run.replaces.is_some()
4356                && ((run.status == RunStatus::Failed && run.viable().is_empty())
4357                    || (run.status == RunStatus::Blocked && run.tally.is_none()))
4358        });
4359    let stop_pin = task
4360        .resume_override
4361        .as_ref()
4362        .filter(|pin| {
4363            Questions::open().get(&pin.question_id).is_ok_and(|q| {
4364                q.node == land::APPROVAL_NODE
4365                    && q.resolution()
4366                        .as_deref()
4367                        .is_some_and(crate::merge_decision::stops)
4368            })
4369        })
4370        .and_then(|pin| pin.pinned_run.clone());
4371    let starter = if let Some(owner) = &public_owner {
4372        Starter::Resume(owner.id.clone())
4373    } else if let Some(run) = stop_pin {
4374        // Stop actions resume their exact owner even after seat-count edits;
4375        // they must never fall through to implementation or a takeover.
4376        Starter::Resume(run)
4377    } else if retry_selected {
4378        Starter::Resume(previous.as_ref().unwrap().id.clone())
4379    } else if let Some(selected) = &task.pr_recovery {
4380        if selected.check_task(&task.id, &task.instruction).is_ok() {
4381            selected_starter(selected, previous.as_ref(), unfinished.as_deref())
4382        } else {
4383            Starter::Review(selected.branch.clone())
4384        }
4385    } else {
4386        starter_with_implementers(task, previous.as_ref(), proposed)
4387    };
4388    // An explicit resume pin must never degrade into a new competition or
4389    // takeover, including when its run stops being eligible after the answer.
4390    if let Some(pin) = &task.resume_override
4391        && let Some(run) = &pin.pinned_run
4392        && starter != Starter::Resume(run.clone())
4393    {
4394        let run = run.clone();
4395        task.notice_cause("resume-owner", Some(&run), None);
4396        task.hold_machine(Some(format!(
4397            "pinned run cannot resume; inspect `magi show {run}` and restore the existing run before `magi task release {}`; no replacement task was started",
4398            task.id
4399        )));
4400        record(queue, task);
4401        return Vec::new();
4402    }
4403    if task.replacement.is_some()
4404        && previous.as_ref().is_some_and(|run| run.replaces.is_some())
4405        && starter != Starter::Resume(previous.as_ref().unwrap().id.clone())
4406    {
4407        task.hold_manual(Some("The selected replacement already has an attempt. It cannot be resumed safely; inspect its saved pin and restore the existing run. No additional replacement was started.".into()));
4408        record(queue, task);
4409        return Vec::new();
4410    }
4411    if let Some(reason) = retained_attempt_hold(
4412        task,
4413        &starter,
4414        config.disk.retained_attempt_limit,
4415        RunState::load,
4416    ) {
4417        // The review choice was consumed above. Preserve it across this hold
4418        // so a later release cannot silently become a fresh implementation.
4419        if let Starter::Review(branch) = &starter {
4420            task.review_branch = Some(branch.clone());
4421        }
4422        task.hold_unrequested_retry(Some(reason));
4423        record(queue, task);
4424        return Vec::new();
4425    }
4426    // Absolute paths of the files the task was filed with, re-read from the
4427    // task now so a file attached while it was held reaches a resumed run
4428    // too. A stored name whose file has gone is an error before any agent is
4429    // paid for, never a silent omission the implementer cannot notice.
4430    let attachments = match task_attachments(queue, task) {
4431        Ok(a) => a,
4432        Err(e) => {
4433            task.attempts += 1;
4434            task.fail_startup_target(
4435                "startup-attachments",
4436                e.name.clone(),
4437                format!("{start_failed}{e:#}"),
4438                opts.max_attempts,
4439            );
4440            record(queue, task);
4441            return Vec::new();
4442        }
4443    };
4444    let startup_pause = stop.pause();
4445    let startup_interrupt = interrupt_pause.clone();
4446    let wait = crate::repo_lock::WaitPolicy::new(
4447        Duration::from_secs(config.graph.timeout_resource_wait),
4448        move || startup_pause.parked() || startup_interrupt.parked(),
4449    );
4450    // Startup includes review takeover and recovery. Do not embed its large
4451    // future in both the wait scope and the surrounding attempt stack frame.
4452    let started = wait.scope(Box::pin(async { match &starter {
4453        Starter::Review(_) if task.pr_recovery.is_some() => {
4454            let selected = task.pr_recovery.clone().expect("selected recovery");
4455            match selected.check_task(&task.id, &task.instruction) {
4456                Ok(()) => Runner::recover_pr(&repo, &task.id, selected, config)
4457                    .await
4458                    .map(|mut r| {
4459                        r.state.attachments = attachments.clone();
4460                        r
4461                    }),
4462                Err(e) => Err(e),
4463            }
4464        }
4465        Starter::Review(branch) => {
4466            tracing::info!(
4467                "task {} reopens `{branch}` as a review-only pass",
4468                task.short()
4469            );
4470            // Every earlier attempt is superseded by the one being minted
4471            // now, so a branch one of them still holds may be taken over.
4472            let takeover = crate::handover::Takeover {
4473                earlier: task.earlier_attempts().to_vec(),
4474                home: crate::run::home(),
4475                choice: take_divergence_answer(branch, &config.merge.remote, task),
4476                task_instruction: Some(instruction_for(task)),
4477            };
4478            Runner::review_taking_over(
4479                &repo,
4480                branch,
4481                config,
4482                Some(takeover),
4483                crate::run::Origin::queue(&task.id),
4484            )
4485            .await
4486        }
4487        Starter::Resume(id) => {
4488            tracing::info!("resuming run {id} rather than competing again");
4489            Runner::resume(id).and_then(|mut r| {
4490                if retry_selected {
4491                    // The explicit queue release retries this same pinned
4492                    // implementation, preserving the attempt and worktrees.
4493                    for candidate in &mut r.state.candidates {
4494                        candidate.failed = None;
4495                        candidate.empty = false;
4496                        candidate.commits = 0;
4497                    }
4498                    r.state.status = RunStatus::Implementing;
4499                    r.state.event("implement", "owner released the held selected implementer; retrying the same attempt without fallback");
4500                }
4501                if r.state.prep_complete == Some(false) && r.state.candidates.is_empty() {
4502                    r.state.sources = task.sources.clone();
4503                }
4504                if r.state.prep_failure.is_some() {
4505                    let approval = task
4506                        .resumption
4507                        .as_ref()
4508                        .context("preparation retry requires an explicit task release")?;
4509                    r.retry_preparation(approval.at)?;
4510                }
4511                if let Some(instruction) =
4512                    prepare_instruction(&starter, Some(&r.state.instruction), task)
4513                {
4514                    r.state.instruction = instruction;
4515                }
4516                r.state.attachments = attachments.clone();
4517                if task.replacement.is_some() {
4518                    r.state.config.merge.mode = MergeMode::Pr;
4519                    r.state.config.graph.land_approval = true;
4520                }
4521                // Replacements publish a new PR and ask again. Selected PR
4522                // recoveries keep their saved disposition; ordinary runs can
4523                // retain the task's merge override.
4524                if task.replacement.is_none() && task.pr_recovery.is_none() && let Some(mode) = task
4525                    .overrides
4526                    .as_ref()
4527                    .and_then(|o| o.merge.as_deref())
4528                    .and_then(|m| merge_mode(m).ok())
4529                {
4530                    r.state.config.merge.mode = mode;
4531                }
4532                Ok(r)
4533            })
4534        }
4535        Starter::Start => {
4536            if let Some(branch) = &review_branch {
4537                tracing::warn!(
4538                    "conductor chose review for task {} but branch `{branch}` no longer \
4539                     exists; requeuing as a fresh competition instead",
4540                    task.short()
4541                );
4542            }
4543            let instruction = prepare_instruction(&starter, None, task)
4544                .unwrap_or_else(|| task.instruction.clone());
4545            Runner::start_naming(
4546                &repo,
4547                instruction,
4548                &task.title,
4549                config,
4550                crate::run::Origin::queue(&task.id),
4551            )
4552            .await
4553            .map(|mut r| {
4554                r.state.attachments = attachments.clone();
4555                r.state.sources = task.sources.clone();
4556                r
4557            })
4558        }
4559    }})).await;
4560    let mut runner = match started {
4561        Ok(r) => r,
4562        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
4563            task.last_error = Some(e.to_string());
4564            if stop.parking() || interrupt_pause.parked() {
4565                let attempts = task.attempts;
4566                task.park(e.to_string());
4567                // Startup has not spent an attempt to refund.
4568                task.attempts = attempts;
4569            } else {
4570                task.hold_machine(Some(format!("{}{e}", crate::cache::RESOURCE_HOLD)));
4571            }
4572            record(queue, task);
4573            return Vec::new();
4574        }
4575        // A branch an earlier attempt still holds and magi will not release
4576        // by itself is the operator's call, not a failed attempt: hold the
4577        // task with the reason, spend nothing, and say so on the bell.
4578        Err(e) if task.pr_recovery.is_some() => {
4579            task.startup_notice_cause("startup-public-head");
4580            task.attempts += 1;
4581            if let Some(partial) = e.downcast_ref::<crate::pr_recovery::StartupFailed>() {
4582                task.link_run(&partial.run);
4583                task.record_pass(&partial.run, 0, task.attempts, None);
4584            }
4585            task.last_error = Some(format!("{start_failed}{e:#}"));
4586            task.hold_unrequested_retry(Some(format!(
4587                "Selected public-head recovery could not start: {e:#}. Correct the cause and use `magi task recover-pr` with the approved public head; automatic recovery is stopped."
4588            )));
4589            record(queue, task);
4590            return Vec::new();
4591        }
4592        Err(e) if e.downcast_ref::<crate::handover::Refused>().is_some() => {
4593            let detail = match e.downcast_ref::<crate::handover::Refused>() {
4594                Some(r) => (p.handover_refused)(r),
4595                None => format!("{e:#}"),
4596            };
4597            task.startup_notice_cause("startup-handover");
4598            let reason = format!("{start_failed}{detail}");
4599            task.last_error = Some(reason.clone());
4600            // Consumed above by `take`; keep it so the released task retries
4601            // as a review of this same branch.
4602            let branch = match &starter {
4603                Starter::Review(branch) => Some(branch.clone()),
4604                _ => None,
4605            };
4606            // The hold's own `task:<id>` notice (raised by `Queue::put`) is the
4607            // one page; the guidance rides in the reason, fixed per state so a
4608            // re-put never changes the wording and relights it.
4609            task.hold_for_handover(branch, format!("{reason}{}", p.handover_hint));
4610            record(queue, task);
4611            tracing::warn!(
4612                "holding {} for a branch it cannot take over: {e:#}",
4613                task.short()
4614            );
4615            return Vec::new();
4616        }
4617        // A branch that differs between here and the remote in ways magi
4618        // cannot prove are the same change is a decision for the owner, not a
4619        // failed attempt: ask with both sides laid out, spend no attempt, and
4620        // let the answer requeue the task.
4621        Err(e) if e.downcast_ref::<crate::reconcile::Diverged>().is_some() => {
4622            let d = e
4623                .downcast_ref::<crate::reconcile::Diverged>()
4624                .expect("checked by the guard");
4625            let mut q = ask::Question::new(
4626                task.id.clone(),
4627                crate::reconcile::NODE.to_owned(),
4628                crate::reconcile::SEAT.to_owned(),
4629                d.summary(),
4630                d.detail(),
4631                d.choices(),
4632            );
4633            match Questions::open().put(&mut q) {
4634                Ok(()) => {
4635                    task.last_error = Some(format!("{e:#}"));
4636                    // Consumed above by `take`; the answer has to reach a review
4637                    // of this same branch, so keep pointing the task at it.
4638                    task.review_branch = Some(d.branch.clone());
4639                    task.review_instruction = Some(task.instruction.clone());
4640                    task.block(vec![q.id.clone()], Some(d.summary()));
4641                }
4642                Err(put) => {
4643                    tracing::warn!("could not file the divergence question: {put:#}");
4644                    task.attempts += 1;
4645                    task.fail_startup(
4646                        "startup-question-store",
4647                        format!("{start_failed}{e:#}"),
4648                        opts.max_attempts,
4649                    );
4650                }
4651            }
4652            record(queue, task);
4653            return Vec::new();
4654        }
4655        Err(e) => {
4656            // The answer was stale and nothing was applied: keep pointing at
4657            // the branch so the next attempt asks again against its new tips.
4658            // Nothing went wrong with the task, so no attempt is spent: a
4659            // budget one short of its limit must not hold it instead of
4660            // letting the question be asked again.
4661            if e.downcast_ref::<crate::reconcile::Stale>().is_some()
4662                && let Starter::Review(branch) = &starter
4663            {
4664                task.review_branch = Some(branch.clone());
4665                task.review_instruction = Some(task.instruction.clone());
4666                task.last_error = Some(format!("{start_failed}{e:#}"));
4667                task.status = crate::queue::TaskStatus::Failed;
4668                record(queue, task);
4669                return Vec::new();
4670            }
4671            task.attempts += 1;
4672            task.last_error = Some(format!("{start_failed}{e:#}"));
4673            task.startup_notice_cause("startup-source");
4674            task.hold_unrequested_retry(Some(format!(
4675                "Startup failed: {e:#}. Correct the startup source or configuration, then explicitly release the task; automatic recovery is stopped."
4676            )));
4677            record(queue, task);
4678            return Vec::new();
4679        }
4680    };
4681    if task.pr_recovery.is_some() && !matches!(starter, Starter::Resume(_)) {
4682        runner.state.recovery_config = Some(config_evidence);
4683    }
4684    if let Some(replacement) = &task.replacement {
4685        if matches!(starter, Starter::Start) {
4686            runner.state.instruction.push_str(&format!(
4687                "\n\n# Replacement review context\n{}",
4688                replacement.context
4689            ));
4690        }
4691        runner.state.replaces = Some(replacement.clone());
4692        runner.state.config.graph.selected_implementer = Some(replacement.agent.clone());
4693        if let Ok(mut old) = RunState::load(&replacement.run)
4694            && let Some(d) = &mut old.merge_decision
4695        {
4696            d.replacement_run = Some(runner.state.id.clone());
4697            old.event(
4698                "replacement",
4699                format!(
4700                    "replacement run {} owns the next attempt; old PR remains closed",
4701                    runner.state.id
4702                ),
4703            );
4704            if let Err(e) = old.save() {
4705                task.hold_manual(Some(format!(
4706                    "Could not link replacement history: {e}; no agents dispatched"
4707                )));
4708                record(queue, task);
4709                return Vec::new();
4710            }
4711        }
4712    }
4713    runner.state.task_instruction = Some(task.instruction.clone());
4714    // The follow-up depth is fixed now, while the task certainly exists:
4715    // deleting it later must not reset the cap (`crate::followup`).
4716    runner.state.followup_generation = Some(task.followup.as_ref().map_or(0, |f| f.generation));
4717    if runner.state.origin_chat.is_none() {
4718        runner.state.origin_chat = task.chat_talk().map(str::to_owned);
4719    }
4720    // A stop that means "park" reaches the graph through this handle.
4721    runner.on_pause(stop.pause());
4722    // `poll`'s interrupt scheduler reaches this one run - and no other -
4723    // through this handle. See `Pause`'s own doc for why these are never
4724    // the same one.
4725    runner.watch_interrupt(interrupt_pause);
4726
4727    // `start` has minted the run, so the task can now point at it. Persisting
4728    // `Running` before `execute` is what makes a crash mid-run legible.
4729    let run = runner.state.id.clone();
4730    task.start(run.clone());
4731    record(queue, task);
4732    lock(status).current.push(Current {
4733        worker: None,
4734        task: task.id.clone(),
4735        run,
4736    });
4737
4738    // `RunState::quota` is the run's whole history across every resume, so
4739    // only what this execution added may arm the cooldown or earn a refund.
4740    let quota_before = runner.state.quota.clone();
4741    runner.state.next_pass_task_entry = true;
4742    let result = runner.execute().await;
4743    finish_attempt(
4744        opts.max_attempts,
4745        queue,
4746        task,
4747        &runner.state,
4748        &quota_before,
4749        result,
4750    )
4751}
4752
4753/// Settle `task` after its run executed: the verdict, the diagnosis, the
4754/// supersession of earlier attempts and the save. Shared by the loop and by
4755/// `magi run --resume`, so an attempt is counted one way wherever it ran.
4756/// Returns the quota losses this execution added to the run.
4757pub fn finish_attempt(
4758    max_attempts: usize,
4759    queue: &Queue,
4760    task: &mut Task,
4761    state: &RunState,
4762    quota_before: &[QuotaLoss],
4763    result: Result<()>,
4764) -> Vec<QuotaLoss> {
4765    let detail = match result {
4766        Ok(()) => describe(state),
4767        Err(e) => format!("{e:#}"),
4768    };
4769    let fresh = losses_this_attempt(quota_before, &state.quota);
4770    let verdict = Verdict {
4771        status: state.status,
4772        // A run that opened a pull request handed its work over, whatever the
4773        // gate then decided about merging it.
4774        left_pr: state.pr.is_some(),
4775        // Only a rate limit earns the task its attempt back - and only one
4776        // suffered now: a refund justified by a previous session's loss is
4777        // the same mistake as re-arming the cooldown from it. A stalled run
4778        // resumed without hitting quota again therefore spends its attempt,
4779        // like any other failure of the task's own.
4780        quota_hit: !fresh.is_empty(),
4781        // A run that parked was asked to stop; that is not a failure and must
4782        // not spend an attempt, or replacing the binary a few times would
4783        // exhaust a task's budget without an agent ever misbehaving.
4784        parked: state.parked,
4785        // A quota loss that left nothing viable is the same machine fact as a
4786        // `Stalled` quota loss; see `settle`'s doc table.
4787        no_viable_candidates: state.viable().is_empty(),
4788    };
4789    if let Some(replacement) = &state.replaces
4790        && let Some(pr) = &state.pr
4791        && let Ok(mut old) = RunState::load(&replacement.run)
4792        && let Some(d) = &mut old.merge_decision
4793    {
4794        d.replacement_pr = Some(pr.url.clone());
4795        let _ = old.save();
4796    }
4797    settle_and_diagnose(task, verdict, &detail, max_attempts, state);
4798    if state.status == RunStatus::Blocked && state.prep_failure.is_some() {
4799        task.hold_unrequested_retry(Some(format!(
4800            "Candidate setup failed: {detail}. Correct the explicit sources with `magi task sources {}` (README, \"Use\": implementation imports), or select a complete implementation with `magi task recover-pr`, then explicitly release the task.",
4801            task.short()
4802        )));
4803    }
4804    if task.status == TaskStatus::Done {
4805        supersede_prior_runs(task, &crate::run::home());
4806    }
4807    record(queue, task);
4808    tracing::info!(
4809        "task {} is {} after run {} ({})",
4810        task.short(),
4811        task.status.as_str(),
4812        state.short(),
4813        label(state.status)
4814    );
4815    fresh
4816}
4817
4818/// A task a hand-started run leaves runnable (failed with attempts to spare,
4819/// or requeued) is **held**: nobody asked for a retry, and a loop starting
4820/// later must not spend agent calls on it. `magi task release` retries.
4821pub fn hold_if_runnable(queue: &Queue, task: &mut Task) {
4822    // Parked work (including legacy Failed records with a parked run) is a
4823    // checkpoint or owner handoff, not an unrequested retry.
4824    if matches!(task.status, TaskStatus::Queued | TaskStatus::Failed)
4825        && !awaiting_resume_with(task, RunState::load)
4826        // A run parked on the owner's word about a withheld PR title/body
4827        // resumes via `land_resume_state`, including review-only tasks that
4828        // `awaiting_resume_with` does not consider.
4829        && withheld_text_wait(task).is_none()
4830        && !(task.fresh_start && task.replacement.is_some())
4831    {
4832        let why = task.last_error.clone().map_or_else(
4833            || "the run did not finish".to_owned(),
4834            |e| format!("the run did not finish: {e}"),
4835        );
4836        task.hold_unrequested_retry(Some(format!(
4837            "{why}. It was started by hand, so it is not retried \
4838             automatically; `magi task release` retries it."
4839        )));
4840        record(queue, task);
4841    }
4842}
4843
4844/// Whether an attempt at a task whose newest run is `run` would resume that
4845/// run rather than start a fresh competition. `magi run --resume` hands a run
4846/// to a live loop only when this holds.
4847#[must_use]
4848pub fn loop_would_resume(run: &str) -> bool {
4849    unfinished_run(&[run.to_owned()], crate::run::short_of(run)).is_some()
4850}
4851
4852/// A loop in another process that is alive right now: a fresh heartbeat in
4853/// `<home>/daemon.json` published by a pid that is not `own_pid`. `Some(pid)`
4854/// carries the pid it published (`None` inside when it published none - a
4855/// fresh heartbeat is still evidence of a live loop).
4856///
4857/// The one judgement of "is somebody else serving this queue", shared by the
4858/// web UI's refusal to start a second loop and by `magi run` / `magi review`
4859/// deciding whether to hand a task to the loop. A claim file proves nothing
4860/// here: whether a claim succeeds says nothing about whether its owner lives.
4861#[must_use]
4862pub fn foreign_loop(
4863    reading: Option<&Reading>,
4864    now: Timestamp,
4865    own_pid: u32,
4866) -> Option<Option<u32>> {
4867    let reading = reading.filter(|r| r.running(now))?;
4868    match reading.pid {
4869        Some(pid) if pid == own_pid => None,
4870        pid => Some(pid),
4871    }
4872}
4873
4874fn legacy_scheduler_running(reading: &Reading, now: Timestamp, own_pid: u32) -> bool {
4875    reading.worker_protocol != Some(1)
4876        && (foreign_loop(Some(reading), now, own_pid).is_some()
4877            || reading
4878                .pid
4879                .is_some_and(|pid| pid != own_pid && crate::proc::pid_alive(pid)))
4880}
4881
4882/// Low-level claimed-task execution shared by the detached standalone worker
4883/// and focused lifecycle fixtures. The caller must retain the queue claim for
4884/// this entire call. CLI callers transfer ownership through
4885/// [`run_detached_claimed`] instead of executing inside their own runtime.
4886///
4887/// The one deliberate difference from the loop: a task the attempt leaves
4888/// runnable (failed with attempts to spare, or requeued) is **held** instead.
4889/// Nobody asked for a retry, and a daemon starting later must not spend agent
4890/// calls re-running a hand-started task behind the operator's back;
4891/// `magi task release` is the explicit retry.
4892pub async fn run_claimed(opts: &Opts, queue: &Queue, task: &mut Task) {
4893    run_claimed_until(opts, queue, task, &Stop::new()).await;
4894}
4895
4896async fn run_claimed_until(
4897    opts: &Opts,
4898    queue: &Queue,
4899    task: &mut Task,
4900    stop: &Stop,
4901) -> Vec<QuotaLoss> {
4902    let status = Arc::new(Mutex::new(Status::new()));
4903    // An attempt embeds startup and graph futures. Keep it off the standalone
4904    // caller's stack, on both the initial execution and withheld-text resume.
4905    let mut quota = Box::pin(attempt(
4906        opts,
4907        queue,
4908        &status,
4909        stop,
4910        crate::graph::Pause::new(),
4911        task,
4912    ))
4913    .await;
4914    // A run parked on the owner's word about a withheld PR title/body has no
4915    // daemon to finish it. Nobody else will enforce `answer_timeout`, so wait
4916    // here with the claim still held, and resume once the question is settled
4917    // (answered, or abandoned at its deadline -> the neutral text).
4918    // A rejected reply files one more question (the gate asks at most twice),
4919    // so after a resume the run may be waiting again: keep going, with a fresh
4920    // limit for the new question. `resumes` bounds it even if a resume parks
4921    // without asking anything new.
4922    let mut resumes = 0;
4923    while resumes < MAX_WITHHELD_RESUMES && !stop.parking() {
4924        let Some(timeout) = withheld_text_wait(task) else {
4925            break;
4926        };
4927        eprintln!(
4928            "waiting for the owner's answer on a withheld pull request text \
4929             (up to {timeout}s; Ctrl-C leaves the run parked for a later `magi serve`)"
4930        );
4931        // The deadline abandons the question itself; the extra margin only
4932        // covers an abandon that could not be saved.
4933        let limit = std::time::Instant::now() + Duration::from_secs(timeout + 60);
4934        let ready = loop {
4935            match land_resume_state(task) {
4936                LandResume::StillWaiting
4937                    if !stop.parking() && std::time::Instant::now() < limit =>
4938                {
4939                    tokio::time::sleep(WITHHELD_POLL).await;
4940                }
4941                LandResume::Ready => break true,
4942                _ => break false,
4943            }
4944        };
4945        if !ready || stop.parking() {
4946            break;
4947        }
4948        resumes += 1;
4949        quota.extend(
4950            Box::pin(attempt(
4951                opts,
4952                queue,
4953                &status,
4954                stop,
4955                crate::graph::Pause::new(),
4956                task,
4957            ))
4958            .await,
4959        );
4960    }
4961    hold_if_runnable(queue, task);
4962    quota
4963}
4964
4965/// Resumes an in-process run makes for withheld-text questions: the gate asks
4966/// at most twice, plus one for a resume that asks nothing new.
4967const MAX_WITHHELD_RESUMES: u32 = 3;
4968
4969/// How often an in-process run rechecks its withheld-text question.
4970const WITHHELD_POLL: Duration = Duration::from_secs(15);
4971
4972/// The `answer_timeout` (seconds) of the task's last run when that run is
4973/// parked on an unresolved withheld-text question; `None` otherwise.
4974fn withheld_text_wait(task: &Task) -> Option<u64> {
4975    let id = task.runs.last()?;
4976    let s = RunState::load(id).ok()?;
4977    (s.parked && s.github_text.as_ref().is_some_and(|g| !g.resolved))
4978        .then_some(s.config.graph.answer_timeout)
4979}
4980
4981/// The quota losses `after` holds that `before` did not: what one execution
4982/// suffered, as opposed to the run's history.
4983///
4984/// Compared by value rather than by length or position because
4985/// `Runner::recover_stall` drops a `QuotaLoss` when its seat ranks again, so
4986/// the vector can shrink and shift under a resume. A retried seat that hits
4987/// quota again is a `push` with a new `at`, so it shows up as new here.
4988/// `reclaim` deliberately keeps reading the whole history: after a crash there
4989/// is no attempt boundary to diff against.
4990fn losses_this_attempt(before: &[QuotaLoss], after: &[QuotaLoss]) -> Vec<QuotaLoss> {
4991    after
4992        .iter()
4993        .filter(|q| !before.contains(q))
4994        .cloned()
4995        .collect()
4996}
4997
4998/// When the loop-wide quota cooldown should end, given this attempt's losses;
4999/// `None` when there were none. Reset-hint parsing and the cap live here.
5000fn cooldown_until(quota: &[QuotaLoss], now: Timestamp) -> Option<Timestamp> {
5001    if quota.is_empty() {
5002        return None;
5003    }
5004    let with_hint = quota.iter().find(|q| q.reset.is_some());
5005    let reset_at = with_hint.and_then(|q| parse_reset_hint(q.reset.as_deref()?, now, q.at));
5006    let wait = quota_wait(reset_at, now, QUOTA_WAIT_FALLBACK, QUOTA_WAIT_CAP);
5007    let secs = i64::try_from(wait.as_secs()).unwrap_or(i64::MAX);
5008    Some(
5009        now.checked_add(jiff::SignedDuration::from_secs(secs))
5010            .unwrap_or(Timestamp::MAX),
5011    )
5012}
5013
5014/// An explicit task count must agree with a run before any resume.
5015fn implementers_match(task: &Task, state: &RunState) -> bool {
5016    task.replacement.as_ref().map_or_else(
5017        || {
5018            task.implementers()
5019                .is_none_or(|n| n == state.config.graph.implementers)
5020        },
5021        |replacement| {
5022            state.config.graph.selected_implementer.as_deref() == Some(&replacement.agent)
5023                && state.config.graph.implementers == 1
5024        },
5025    )
5026}
5027
5028/// Never resume or take over candidate work built for a different seat count.
5029fn starter_with_implementers(
5030    task: &Task,
5031    previous: Option<&RunState>,
5032    proposed: Starter,
5033) -> Starter {
5034    if previous.is_some_and(|s| !implementers_match(task, s)) {
5035        task.review_of
5036            .as_ref()
5037            .map_or(Starter::Start, |branch| Starter::Review(branch.clone()))
5038    } else {
5039        proposed
5040    }
5041}
5042
5043/// Apply this task's explicit count to the new attempt, without changing defaults.
5044fn apply_task_implementers(config: &mut Config, task: &Task) {
5045    if let Some(n) = task.implementers() {
5046        config.graph.implementers = n;
5047    }
5048}
5049
5050/// [`prepare`] for one task: its own `--config` and command-line overrides
5051/// (`Task::overrides`, filed by `magi run` / `magi review`) take precedence
5052/// over the loop's, so handing a hand-started task to a daemon changes nothing
5053/// about what it does.
5054#[cfg(test)]
5055fn prepare_for(repo: &Path, opts: &Opts, task: &Task) -> Result<Config> {
5056    let explicit = match &task.overrides {
5057        Some(o) if !o.inherit_daemon_options => o.config.as_deref(),
5058        _ => opts.config.as_deref(),
5059    };
5060    let (mut config, _) = Config::discover(repo, explicit)?;
5061    apply_options(&mut config, opts, task)?;
5062    Ok(config)
5063}
5064
5065fn apply_options(config: &mut Config, opts: &Opts, task: &Task) -> Result<()> {
5066    if task
5067        .overrides
5068        .as_ref()
5069        .is_none_or(|o| o.inherit_daemon_options)
5070        && let Some(mode) = opts.merge.as_deref()
5071    {
5072        config.merge.mode = merge_mode(mode)?;
5073    }
5074    if let Some(o) = &task.overrides {
5075        o.apply(config);
5076    }
5077    Ok(())
5078}
5079
5080/// Load the config for a task's repository, with the merge override applied.
5081fn prepare(repo: &Path, opts: &Opts) -> Result<Config> {
5082    let (mut config, _layers) = Config::discover(repo, opts.config.as_deref())?;
5083    if let Some(mode) = &opts.merge {
5084        config.merge.mode = merge_mode(mode)?;
5085    }
5086    Ok(config)
5087}
5088
5089/// Prune the shared build cache at a between-runs boundary. The detached cache
5090/// service checks authoritative task jobs first; dispatch waits for that finite
5091/// service, so scheduler replacement cannot turn a compile into idle cleanup.
5092/// Its durable service cadence preserves [`CACHE_CHECK_INTERVAL_SECS`].
5093async fn maybe_prune_cache_between_runs(
5094    repo: &Path,
5095    opts: &Opts,
5096    home: &Path,
5097    stop: &Stop,
5098    last_checked: &mut Option<Timestamp>,
5099    now: Timestamp,
5100) {
5101    if stop.stopped() || !cache_check_due(*last_checked, now, CACHE_CHECK_INTERVAL_SECS) {
5102        return;
5103    }
5104    *last_checked = Some(now);
5105    let cfg = match prepare(repo, opts) {
5106        Ok(cfg) => cfg,
5107        Err(e) => {
5108            tracing::warn!("cache check: no config: {e:#}");
5109            return;
5110        }
5111    };
5112    match clean::prune_cache_if_over_limit(&cfg, home) {
5113        Ok(Some(pruned)) if pruned.files > 0 => tracing::info!(
5114            "housekeep: pruned {} file(s) ({} bytes) from the shared cache between runs",
5115            pruned.files,
5116            pruned.freed
5117        ),
5118        Ok(_) => {}
5119        Err(e) => {
5120            tracing::warn!("housekeep: prune cache: {e:#}");
5121            notices::raise_in(
5122                home,
5123                Notice::warn(
5124                    "housekeep:cache",
5125                    format!(
5126                        "Pruning the shared build cache failed: {}. Disk usage may keep growing.",
5127                        e.to_string()
5128                            .lines()
5129                            .next()
5130                            .unwrap_or("unknown cleanup error")
5131                    ),
5132                ),
5133            );
5134        }
5135    }
5136}
5137
5138/// Whether [`maybe_prune_cache_between_runs`] should re-measure the cache
5139/// now, given when it last did (if ever). Pure, so the cadence is asserted
5140/// directly rather than by waiting out real minutes in a test.
5141fn cache_check_due(last_checked: Option<Timestamp>, now: Timestamp, interval_secs: u64) -> bool {
5142    last_checked.is_none_or(|last| clean::due(now, last, interval_secs))
5143}
5144
5145/// The disk janitor, with its housekeeping logged rather than fatal.
5146///
5147/// Called only at the loop's idle points, for the reason the caller documents:
5148/// a prune racing a live compile would delete files mid-build. The config is
5149/// re-read on every call because the repository that just ran may not be the
5150/// daemon's own default, and the cache directory is a repository fact.
5151///
5152/// `home` and `worktrees_root` are parameters rather than [`crate::run::home`]
5153/// and [`crate::run::default_worktree_root`] read here, for the same reason
5154/// [`drive`] takes its queue and status file rather than resolving them: a
5155/// test driving the loop must not reach through to the operator's real home
5156/// or worktree bay just because the janitor runs on every idle tick.
5157/// `worktrees_root` staying unread by [`clean::fold_due`] once made this easy
5158/// to get wrong silently - a test's `home` was already isolated, but nothing
5159/// exercised the parameter next to it, so a real worktree bay stayed wired in
5160/// underneath. The moment [`clean::fold_orphaned_worktrees`] started reading
5161/// it for real, every test in this file that drives the loop at all started
5162/// sweeping the operator's actual `~/wt/<repo>` instead of a fixture's.
5163async fn janitor(repo: &Path, opts: &Opts, home: &Path, worktrees_root: &Path) {
5164    let cfg = match prepare(repo, opts) {
5165        Ok(cfg) => cfg,
5166        Err(e) => {
5167            tracing::warn!("housekeep: no config: {e:#}");
5168            return;
5169        }
5170    };
5171    // A run's own worktree lives under `config.graph.worktree_root` when the
5172    // repository sets one - the same precedence `RunState::worktree_root`
5173    // uses - and `worktrees_root` only stands in for the *default* an
5174    // unconfigured repository resolves to (see this function's own
5175    // parameter, or the test fixture wiring one to a fake path). Housekeeping
5176    // that always swept the default regardless of this override would never
5177    // see, and so never reclaim, a single worktree for a repository that
5178    // relocated them elsewhere.
5179    let worktrees_root = cfg.graph.worktree_root.as_deref().unwrap_or(worktrees_root);
5180    let out = clean::housekeep(&cfg, home, worktrees_root, repo, Timestamp::now()).await;
5181    // Reported whenever there is anything to say, not only when `folded > 0`:
5182    // the incident this exists to prevent was 90 of 93 runs skipped and 0
5183    // folded, on every single pass, for months - a report gated on `folded`
5184    // would have stayed silent through every one of them.
5185    if out.folded > 0 || out.unreadable > 0 || out.orphaned_worktrees > 0 {
5186        let mut extra = Vec::new();
5187        if out.unreadable > 0 {
5188            extra.push(format!("{} unreadable", out.unreadable));
5189        }
5190        if out.orphaned_worktrees > 0 {
5191            extra.push(format!("{} orphaned worktree(s)", out.orphaned_worktrees));
5192        }
5193        let detail = if extra.is_empty() {
5194            String::new()
5195        } else {
5196            format!(" ({})", extra.join(", "))
5197        };
5198        tracing::info!("housekeep: folded {} run(s){detail}", out.folded);
5199    }
5200    if out.external_merges_recorded > 0 {
5201        tracing::info!(
5202            "housekeep: recorded {} run(s) as merged externally",
5203            out.external_merges_recorded
5204        );
5205    }
5206    if out.stale_pr_states_repaired > 0 {
5207        tracing::info!(
5208            "housekeep: rewrote {} run record(s) whose pull request had already settled",
5209            out.stale_pr_states_repaired
5210        );
5211    }
5212    if out.cache_files > 0 {
5213        tracing::info!(
5214            "housekeep: pruned {} file(s) ({} bytes) from the shared cache",
5215            out.cache_files,
5216            out.cache_freed
5217        );
5218    }
5219    if out.questions_abandoned > 0 {
5220        tracing::info!(
5221            "housekeep: abandoned {} question(s) left open by a finished run",
5222            out.questions_abandoned
5223        );
5224    }
5225}
5226
5227/// Run [`triage::run_once`] and log whatever it did, the same "only when
5228/// there is something to say" rule [`janitor`] follows for its own report.
5229///
5230/// Called at the same idle points as [`janitor`] - once per full poll
5231/// interval, never mid-attempt - for the same reason: it is not liveness
5232/// critical, and a task's own `hold_reason` string is the one thing this
5233/// would otherwise re-check (via [`crate::disk::free_bytes`]) on every busy
5234/// tick for no benefit.
5235async fn triage_held(queue: &Queue, home: &Path, opts: &Opts) {
5236    let questions = Questions::at(home.join("questions"));
5237    let report = triage::run_once(queue, &questions, opts.config.as_deref(), Timestamp::now());
5238    if report.is_empty() {
5239        return;
5240    }
5241    if !report.quarantined.is_empty() {
5242        tracing::info!(
5243            "triage: held {} blocked task(s) whose blocked-on task or \
5244             question no longer exists: {}",
5245            report.quarantined.len(),
5246            report.quarantined.join(", ")
5247        );
5248    }
5249    if !report.resumed.is_empty() {
5250        tracing::info!(
5251            "triage: resumed {} held task(s) whose machine hold had resolved: {}",
5252            report.resumed.len(),
5253            report.resumed.join(", ")
5254        );
5255    }
5256    if !report.asked.is_empty() {
5257        tracing::info!(
5258            "triage: asked about {} held task(s): {}",
5259            report.asked.len(),
5260            report.asked.join(", ")
5261        );
5262    }
5263    if !report.answered.is_empty() {
5264        tracing::info!(
5265            "triage: applied {} operator answer(s): {}",
5266            report.answered.len(),
5267            report.answered.join(", ")
5268        );
5269    }
5270}
5271
5272/// The free-space gate: what stands between this task and a new run, if
5273/// anything. `Some(reason)` holds the task; `None` lets it start.
5274///
5275/// A zero [`Config::disk::min_free_bytes`] opens the gate unconditionally -
5276/// the operator opted out. A measurement failure is a gate, not a pass: both
5277/// sides of "cannot tell" are served by not starting.
5278pub(crate) fn disk_gate(repo: &Path, config: &Config) -> Option<String> {
5279    disk_gate_with(repo, config, crate::disk::free_bytes)
5280}
5281
5282/// [`disk_gate`] with its free-space measurement supplied by the caller, so a
5283/// test can assert the exact wiring `attempt` runs - config's threshold in,
5284/// task-holding reason out - without asking the real machine's disk anything.
5285fn disk_gate_with<F: Fn(&Path) -> Result<u64>>(
5286    repo: &Path,
5287    config: &Config,
5288    free_bytes: F,
5289) -> Option<String> {
5290    let min = config.disk.min_free_bytes;
5291    if min == 0 {
5292        return None;
5293    }
5294    let mut paths = vec![repo.to_owned()];
5295    if let Some(cache) = config.cache_dir() {
5296        paths.push(cache);
5297    }
5298    if let Some(root) = &config.graph.worktree_root {
5299        paths.push(root.clone());
5300    }
5301    for path in paths {
5302        let probe = if path == repo {
5303            path.as_path()
5304        } else {
5305            path.ancestors().find(|p| p.exists()).unwrap_or(&path)
5306        };
5307        let reason = match free_bytes(probe) {
5308            Ok(free) => crate::disk::gate_in(free, min, &config.graph.language),
5309            Err(e) => Some(crate::disk::unmeasured_in(
5310                probe,
5311                &e,
5312                &config.graph.language,
5313            )),
5314        };
5315        if reason.is_some() {
5316            return reason;
5317        }
5318    }
5319    None
5320}
5321
5322/// How long to wait before offering another task when a run lost a seat to a
5323/// rate limit and its [`QuotaLoss::reset`] carried no hint [`parse_reset_hint`]
5324/// could read, or carried nothing at all. Long enough that a quota outage
5325/// cannot burn through a whole backlog in the few seconds each doomed attempt
5326/// takes to fail; short enough that a quota which clears early is not left
5327/// idle for the fallback's sake.
5328const QUOTA_WAIT_FALLBACK: Duration = Duration::from_secs(5 * 60);
5329
5330/// Longest a parsed reset hint may push the wait out to. The hint comes from
5331/// the CLI's own words, not a contract, so a parsing slip that lands a day
5332/// away must not leave the loop asleep for a day.
5333const QUOTA_WAIT_CAP: Duration = Duration::from_secs(30 * 60);
5334
5335/// How long [`poll`] should wait before offering the next task, after a run
5336/// lost at least one seat to a rate limit.
5337///
5338/// Pure and separate from the loop so the policy can be exercised without a
5339/// real quota outage. `reset_at` is the time [`parse_reset_hint`] made of the
5340/// CLI's free-text hint, if it could; `fallback` is what to wait when there is
5341/// nothing to parse, or the parsed time has already passed; `cap` bounds how
5342/// far a parsed hint is trusted to push the wait out.
5343fn quota_wait(
5344    reset_at: Option<Timestamp>,
5345    now: Timestamp,
5346    fallback: Duration,
5347    cap: Duration,
5348) -> Duration {
5349    match reset_at {
5350        Some(at) if at > now => {
5351            let secs = u64::try_from(at.as_second() - now.as_second()).unwrap_or(0);
5352            Duration::from_secs(secs).min(cap)
5353        }
5354        _ => fallback,
5355    }
5356}
5357
5358/// Best-effort reading of a [`QuotaLoss::reset`] hint into a concrete time.
5359///
5360/// `reset` is deliberately free text — see [`crate::agent::Quota`], which
5361/// explains why parsing it exactly "would be a bug factory" — so this only
5362/// recognises the shapes actually observed in the wild, and returns `None`
5363/// for anything else rather than guess at a format nobody has seen.
5364///
5365/// `recorded` is when the loss was noted ([`QuotaLoss::at`]). It anchors the
5366/// relative shape (`"in 1h2m49s"`, agy's), which counts from the moment the CLI
5367/// said it, not from whenever this loop happens to read it: anchoring on `now`
5368/// would push the reset later on every read and would read an already-elapsed
5369/// reset as still in the future. (A long hint is still clamped by
5370/// [`QUOTA_WAIT_CAP`]; the anchor matters for short hints and elapsed ones.)
5371fn parse_reset_hint(text: &str, now: Timestamp, recorded: Timestamp) -> Option<Timestamp> {
5372    parse_reset_hint_zoned(text, now)
5373        .or_else(|| parse_reset_hint_dated(text))
5374        .or_else(|| parse_reset_hint_relative(text, recorded))
5375}
5376
5377/// agy's shape: `"in 1h2m49s"` - `in`, then hours/minutes/seconds, each unit
5378/// optional but at least one required, in that order. A bare number or any
5379/// unknown unit is refused.
5380fn parse_reset_hint_relative(text: &str, recorded: Timestamp) -> Option<Timestamp> {
5381    let rest = text.trim().trim_end_matches('.').strip_prefix("in ")?;
5382    let mut rest = rest.trim();
5383    if rest.is_empty() {
5384        return None;
5385    }
5386    let mut total: i64 = 0;
5387    let mut matched = false;
5388    for (unit, secs) in [('h', 3600), ('m', 60), ('s', 1)] {
5389        if let Some((digits, tail)) = rest.split_once(unit)
5390            && !digits.is_empty()
5391            && digits.bytes().all(|b| b.is_ascii_digit())
5392        {
5393            total += digits.parse::<i64>().ok()?.checked_mul(secs)?;
5394            rest = tail;
5395            matched = true;
5396        }
5397    }
5398    if !rest.is_empty() || !matched {
5399        return None;
5400    }
5401    recorded
5402        .checked_add(jiff::SignedDuration::from_secs(total))
5403        .ok()
5404}
5405
5406/// Reads a 12-hour `"H:MMam/pm"` clock reading (whitespace trimmed,
5407/// case-insensitive) into a 24-hour hour and minute. Shared by every
5408/// reset-hint shape below.
5409fn parse_12h_clock(clock: &str) -> Option<(i8, i8)> {
5410    let clock = clock.trim().to_lowercase();
5411    let (digits, pm) = clock
5412        .strip_suffix("am")
5413        .map(|d| (d, false))
5414        .or_else(|| clock.strip_suffix("pm").map(|d| (d, true)))?;
5415    let (h, m) = digits.trim().split_once(':')?;
5416    let mut hour: i8 = h.trim().parse().ok()?;
5417    let minute: i8 = m.trim().parse().ok()?;
5418    if !(1..=12).contains(&hour) || !(0..=59).contains(&minute) {
5419        return None;
5420    }
5421    if pm && hour != 12 {
5422        hour += 12;
5423    } else if !pm && hour == 12 {
5424        hour = 0;
5425    }
5426    Some((hour, minute))
5427}
5428
5429/// The Claude CLI's shape: `"H:MMam/pm (Zone)"`, naming only a clock reading
5430/// and a zone, never a date. A clock reading already past today is read as
5431/// tomorrow's: a CLI naming a same-day reset that has already gone by means
5432/// the window rolled over while nothing was watching.
5433fn parse_reset_hint_zoned(text: &str, now: Timestamp) -> Option<Timestamp> {
5434    let open = text.find('(')?;
5435    let close = text.rfind(')')?;
5436    if close <= open {
5437        return None;
5438    }
5439    let zone = text[open + 1..close].trim();
5440    let (hour, minute) = parse_12h_clock(&text[..open])?;
5441    let tz = jiff::tz::TimeZone::get(zone).ok()?;
5442    let candidate = now
5443        .to_zoned(tz)
5444        .with()
5445        .hour(hour)
5446        .minute(minute)
5447        .second(0)
5448        .millisecond(0)
5449        .microsecond(0)
5450        .nanosecond(0)
5451        .build()
5452        .ok()?;
5453    let mut at = candidate.timestamp();
5454    if at <= now {
5455        at += jiff::SignedDuration::from_hours(24);
5456    }
5457    Some(at)
5458}
5459
5460/// The Codex CLI's shape: `"Mon DDth, YYYY H:MMam/pm"` (English month
5461/// abbreviation, an ordinal day, a 4-digit year, a 12-hour clock reading),
5462/// with no zone at all — unlike [`parse_reset_hint_zoned`], so there is no
5463/// "already past today" correction to make: the year already disambiguates
5464/// it. Scanned as a five-word window so it can be pulled out of the middle
5465/// of a full sentence, e.g. Codex's actual wording: "...or try again at Sep
5466/// 19th, 2026 5:10 PM." The result is read as UTC, same as this crate reads
5467/// any other timestamp with no zone attached.
5468fn parse_reset_hint_dated(text: &str) -> Option<Timestamp> {
5469    let words: Vec<&str> = text.split_whitespace().collect();
5470    if words.len() < 5 {
5471        return None;
5472    }
5473    (0..=words.len() - 5)
5474        .find_map(|start| parse_dated_window(&words[start..start + 5], words.get(start + 5)))
5475}
5476
5477/// One five-word window: month, `"DDth,"`, `"YYYY"`, `"H:MM"`, `"am/pm"`. A
5478/// parenthesis right after the window is refused rather than ignored — it
5479/// reads as an explicit zone annotation on a shape that otherwise carries
5480/// none, and guessing UTC anyway would be exactly the silent misread this
5481/// module's parsing otherwise avoids.
5482fn parse_dated_window(window: &[&str], trailing: Option<&&str>) -> Option<Timestamp> {
5483    if trailing.is_some_and(|next| next.starts_with('(')) {
5484        return None;
5485    }
5486    let month = month_number(window[0])?;
5487    let day_token = window[1].strip_suffix(',')?.to_lowercase();
5488    let day_digits = ["st", "nd", "rd", "th"]
5489        .iter()
5490        .find_map(|suffix| day_token.strip_suffix(*suffix))?;
5491    let day: i8 = day_digits.parse().ok()?;
5492    let year_token = window[2];
5493    if year_token.len() != 4 || !year_token.bytes().all(|b| b.is_ascii_digit()) {
5494        return None;
5495    }
5496    let year: i16 = year_token.parse().ok()?;
5497    // The am/pm word carries the sentence's own trailing punctuation, e.g.
5498    // the period ending "...at Sep 19th, 2026 5:10 PM." — strip it before
5499    // reusing the same 12-hour clock reader the bracketed shape uses.
5500    let ampm = window[4].trim_matches(|c: char| !c.is_ascii_alphabetic());
5501    let (hour, minute) = parse_12h_clock(&format!("{}{}", window[3], ampm))?;
5502    let date = jiff::civil::Date::new(year, month, day).ok()?;
5503    let candidate = date
5504        .at(hour, minute, 0, 0)
5505        .to_zoned(jiff::tz::TimeZone::UTC)
5506        .ok()?;
5507    Some(candidate.timestamp())
5508}
5509
5510/// The 3-letter English month abbreviation [`parse_reset_hint_dated`] reads,
5511/// case-insensitively, into a 1-based month number.
5512fn month_number(name: &str) -> Option<i8> {
5513    const NAMES: [&str; 12] = [
5514        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
5515    ];
5516    let lower = name.to_lowercase();
5517    NAMES
5518        .iter()
5519        .position(|n| *n == lower.as_str())
5520        .map(|i| i as i8 + 1)
5521}
5522
5523/// Test probe for the shared resume policy's review-budget exclusion.
5524#[cfg(test)]
5525fn exhausted_review_budget(state: &RunState) -> bool {
5526    // A run whose task-head publication failed has nothing left to review:
5527    // resuming it only retries the publication, so it is never "exhausted".
5528    !crate::handover::can_resume(state)
5529        && state.status == RunStatus::Blocked
5530        && !state.released()
5531        && state.retired_to.is_none()
5532}
5533
5534/// This task's *most recent* run, if resuming it would actually make
5535/// progress. `short` is only for the warning's own message.
5536///
5537/// Only ever `runs.last()` — never a search back through older history.
5538/// `runs` accumulates one entry per fresh `Runner::start`/`Runner::review`
5539/// mint, oldest first, and every entry before the last one was already
5540/// superseded at the moment it was minted: the daemon only ever starts a new
5541/// run when the previous one was not worth resuming (unresumable, exhausted,
5542/// or unreadable), or when `crate::conduct::Recovery::Review` deliberately
5543/// opens a fresh review-only run alongside an older, already-failed
5544/// competition. Searching further back would let an old run that merely
5545/// *looks* resumable — a `Stalled` competition an earlier `Review` pass left
5546/// behind, say — get resumed instead of the fresh competition
5547/// `crate::conduct::Recovery::Requeue` actually promised, reviving history
5548/// nothing asked to revisit.
5549///
5550/// Two runs paid for the "prefer resuming over restarting" half of this
5551/// lesson, which is why this still checks `runs.last()` rather than always
5552/// restarting. Run 01c2 was blocked and the loop started 3cbf on the same
5553/// task a moment later, duplicating two and a half hours of agent work. Then
5554/// b25f stalled on a judge that timed out and one that answered with no JSON
5555/// — `quota: 0`, so nothing the machine was to blame for — and 4043 started
5556/// **one second** later, buying three fresh implementations to reach the
5557/// same panel. `RunStatus::resumable` rather than `!done()` is what catches
5558/// the second case: a stall is terminal, and its cheap recovery re-asks only
5559/// the absent seats. [`crate::handover::can_resume`] supplies the other half: a run
5560/// that is technically `resumable()` but provably cannot progress must not
5561/// count as "unfinished" either, or `Recovery::Requeue` becomes a silent
5562/// no-op instead of the fresh competition it promises.
5563///
5564/// A load failure is warned about rather than silently read as "not
5565/// resumable": the alternative is exactly what let a schema mismatch on run
5566/// `eba2` fall through to a full re-competition with nobody told why.
5567/// `crate::conduct` is what actually offers a better answer than
5568/// `Runner::start` here (see `Recovery::Review`), once this task's next
5569/// failure shows it up as `held`/`failed` with the run state unreadable.
5570fn unfinished_run(runs: &[String], short: &str) -> Option<String> {
5571    unfinished_run_with(runs, short, RunState::load)
5572}
5573
5574/// [`unfinished_run`] with an injected state reader. Tests provide their
5575/// fixtures directly rather than touching the process-global run home.
5576pub(crate) fn unfinished_run_with<F>(runs: &[String], short: &str, mut load: F) -> Option<String>
5577where
5578    F: FnMut(&str) -> Result<RunState>,
5579{
5580    let mut id = runs.last()?.clone();
5581    let mut visited = std::collections::BTreeSet::new();
5582    let mut repo = None;
5583    loop {
5584        if !runs.contains(&id) || !visited.insert(id.clone()) {
5585            tracing::warn!("invalid or cyclic recovery authority for task {short}");
5586            return None;
5587        }
5588        let state = match load(&id) {
5589            Ok(state) if state.id == id => state,
5590            Ok(_) => return None,
5591            Err(e) => {
5592                tracing::warn!("could not read run {id} for task {short}: {e:#}");
5593                return None;
5594            }
5595        };
5596        if repo.as_ref().is_some_and(|repo| *repo != state.repo) {
5597            return None;
5598        }
5599        repo = Some(state.repo.clone());
5600        if let Some(authority) = state.retired_to {
5601            id = authority;
5602            continue;
5603        }
5604        return (crate::handover::can_resume(&state)
5605            && state.liveness(false) != crate::run::Liveness::Live)
5606            .then_some(id);
5607    }
5608}
5609
5610/// Is `task` a parked (or, from an older build, failed) task whose last run was parked at a node boundary, so
5611/// that [`attempt`] will resume it on its own?
5612///
5613/// Mirrors [`unfinished_run_with`]'s own conditions (plus `parked`, and no
5614/// pending review choice, which [`choose_starter`] ranks first). Pure, with an
5615/// injected reader like its sibling. An unreadable run is not awaiting
5616/// anything: it keeps going to the conductor as before.
5617fn awaiting_resume_with<F>(task: &Task, load: F) -> bool
5618where
5619    F: FnOnce(&str) -> Result<RunState>,
5620{
5621    // `Failed` stays: a record an older build wrote for a parked run.
5622    if !matches!(task.status, TaskStatus::Parked | TaskStatus::Failed)
5623        || task.fresh_start
5624        || task.review_branch.is_some()
5625    {
5626        return false;
5627    }
5628    let Some(id) = task.runs.last() else {
5629        return false;
5630    };
5631    load(id).is_ok_and(|s| s.parked && crate::handover::can_resume(&s))
5632}
5633
5634/// Which of the three ways [`attempt`] can mint or continue a run this task
5635/// should use.
5636#[derive(Debug, Clone, PartialEq, Eq)]
5637enum Starter {
5638    /// `crate::graph::Runner::review` against a branch `crate::conduct` chose
5639    /// and that still exists.
5640    Review(String),
5641    /// `crate::graph::Runner::resume` on an unfinished run of this task.
5642    Resume(String),
5643    /// `crate::graph::Runner::start`: a fresh competition.
5644    Start,
5645}
5646
5647/// A second bound independent of inputs and the resettable failure counter.
5648/// Count recovery records, not history entries: resuming appends the same id.
5649/// Unknown history is not evidence that a new set of worktrees is affordable.
5650fn retained_attempt_hold(
5651    task: &Task,
5652    starter: &Starter,
5653    limit: usize,
5654    mut load: impl FnMut(&str) -> Result<RunState>,
5655) -> Option<String> {
5656    if limit == 0 || matches!(starter, Starter::Resume(_)) {
5657        return None;
5658    }
5659    let mut retained = 0;
5660    for id in task.runs.iter().collect::<std::collections::BTreeSet<_>>() {
5661        let state = match load(id) {
5662            Ok(state) if state.id == *id => state,
5663            _ => {
5664                return Some(format!(
5665                    "[recovery-backlog] Cannot verify historical run {id}. Restore its record and inspect the recovery lineage before starting another attempt. Existing history was retained."
5666                ));
5667            }
5668        };
5669        if state.retired_to.is_some() || state.released() {
5670            continue;
5671        }
5672        // Neither a completed status nor an empty candidate array proves that
5673        // all recovery sources (including unrecorded preparation) were retired.
5674        retained += 1;
5675    }
5676    (retained >= limit).then(|| format!(
5677        "[recovery-backlog] {retained} unretired attempts reach the limit of {limit}. Resume an existing recovery owner or inspect the lineage and retire proved-obsolete attempts before creating another run. Releases, refunds and changed upstream inputs do not reset this limit. No source or historical report was removed."
5678    ))
5679}
5680
5681/// A selected public implementation overrides every historical candidate and pin.
5682fn selected_starter(
5683    selected: &crate::pr_recovery::Selection,
5684    previous: Option<&RunState>,
5685    unfinished: Option<&str>,
5686) -> Starter {
5687    if let Some(id) = unfinished
5688        && previous.is_some_and(|r| {
5689            r.id == id && r.pr_recovery.as_ref() == Some(selected) && r.reviewed_commits.is_some()
5690        })
5691    {
5692        Starter::Resume(id.to_owned())
5693    } else {
5694        Starter::Review(selected.branch.clone())
5695    }
5696}
5697
5698/// The owner's answer to this branch's divergence question, consumed from the
5699/// task so a later, different divergence asks again. The runner applies it
5700/// (see `Runner::review_taking_over`), after the earlier worktree is released.
5701fn take_divergence_answer(
5702    branch: &str,
5703    remote: &str,
5704    task: &mut Task,
5705) -> Option<crate::reconcile::Choice> {
5706    let summary = crate::reconcile::summary_for(branch, remote);
5707    let (idx, choice) = task.answers.iter().enumerate().rev().find_map(|(i, a)| {
5708        (a.question == summary)
5709            .then(|| crate::reconcile::Choice::from_answer(&a.answer))
5710            .flatten()
5711            .map(|c| (i, c))
5712    })?;
5713    task.answers.remove(idx);
5714    Some(choice)
5715}
5716
5717/// Decide which of [`Runner::review`], [`Runner::resume`] or [`Runner::start`]
5718/// this attempt should use. Pure, and separate from [`attempt`], so the
5719/// routing itself is assertable without spawning a real graph or a git
5720/// process: `attempt`'s own `crate::git::branch_exists` call has already
5721/// happened by the time this is called.
5722///
5723/// `review_branch` wins whenever `branch_exists` confirms it; a `review_branch`
5724/// whose branch is gone falls all the way through to [`Starter::Start`], not
5725/// to [`Starter::Resume`] — `crate::conduct` chose review over resuming the
5726/// old (likely `Blocked`) run in the first place, and a branch that vanished
5727/// out from under that choice is not evidence resuming it would fare better.
5728fn choose_starter(
5729    review_branch: Option<&str>,
5730    branch_exists: bool,
5731    unfinished: Option<&str>,
5732) -> Starter {
5733    match review_branch {
5734        Some(branch) if branch_exists => Starter::Review(branch.to_owned()),
5735        Some(_) => Starter::Start,
5736        None => match unfinished {
5737            Some(id) => Starter::Resume(id.to_owned()),
5738            None => Starter::Start,
5739        },
5740    }
5741}
5742
5743/// Reuse a compatible latest review owner instead of taking its checked-out
5744/// branch into a new run. Different branches and changed criteria still use
5745/// the ordinary review/takeover policy.
5746fn review_retry_starter(
5747    task: &Task,
5748    previous: Option<&RunState>,
5749    branch: Option<&str>,
5750    exists: bool,
5751    unfinished: Option<&str>,
5752) -> Starter {
5753    if let Some(run) = previous
5754        && unfinished == Some(run.id.as_str())
5755        && run.reviewed_commits.is_some()
5756        && branch.is_some_and(|branch| {
5757            exists && run.candidates.len() == 1 && run.candidates[0].branch == branch
5758        })
5759        && may_resume_run(run, task)
5760    {
5761        return Starter::Resume(run.id.clone());
5762    }
5763    match (branch, unfinished, &task.review_of) {
5764        (None, None, Some(branch)) => Starter::Review(branch.clone()),
5765        _ => choose_starter(branch, exists, unfinished),
5766    }
5767}
5768
5769/// Consume a retained review only while its authored criteria still match.
5770fn take_review_branch(task: &mut Task, previous: Option<&RunState>) -> Option<String> {
5771    let branch = task.review_branch.take();
5772    let instruction = task.review_instruction.take();
5773    branch.filter(|branch| match &instruction {
5774        Some(authored) => *authored == task.instruction,
5775        // Legacy retained branches must agree with their prior run; only an
5776        // explicitly pinned review can proceed without recoverable provenance.
5777        None => previous.map_or_else(
5778            || task.review_of.as_ref() == Some(branch),
5779            |run| compatible_run_instruction(run, task),
5780        ),
5781    })
5782}
5783
5784/// A current explicit pin can select an otherwise incompatible run. Edits
5785/// and subsequent approvals invalidate pins selected for earlier criteria.
5786fn may_resume_run(run: &RunState, task: &Task) -> bool {
5787    task.resume_override
5788        .as_ref()
5789        .is_some_and(|r| r.pinned_run.as_ref() == Some(&run.id))
5790        || compatible_run_instruction(run, task)
5791}
5792
5793/// Review scaffolding is execution context, not evidence of an operator edit.
5794pub(crate) fn compatible_run_instruction(run: &RunState, task: &Task) -> bool {
5795    if let Some(authored) = &run.task_instruction {
5796        return compatible_instruction(authored, task);
5797    }
5798    if compatible_instruction(&run.instruction, task) {
5799        return true;
5800    }
5801    // Recognize only the existing generated review shapes on legacy runs.
5802    // Ordinary authored text with a similar heading is left intact.
5803    if run.reviewed_commits.is_some() {
5804        if let Some((authored, _)) = run.instruction.split_once("\n\n# Work already on branch `") {
5805            return strip_answers_block(authored, task).trim() == task.instruction.trim();
5806        }
5807        if let Some(branch) = &task.review_of {
5808            let canonical = format!(
5809                "Review the branch `{branch}` (review-only: no implementation, no judging)."
5810            );
5811            let generated = format!(
5812                "Review the work already on branch `{branch}`. There is no task statement: what the change claims to do is whatever its commits say.\n\n"
5813            );
5814            return task.instruction == canonical && run.instruction.starts_with(&generated);
5815        }
5816    }
5817    false
5818}
5819
5820/// Compare authored instructions, excluding answer blocks appended by the daemon.
5821pub(crate) fn compatible_instruction(old: &str, task: &Task) -> bool {
5822    strip_answers_block(old, task) == task.instruction
5823}
5824
5825/// Which repository a task runs in. A task that names none — the normal case
5826/// for one filed from a phone — runs in the daemon's own default.
5827fn repo_for(task: &Task, fallback: &Path) -> PathBuf {
5828    if task.repo.as_os_str().is_empty() || task.repo == Path::new(".") {
5829        return fallback.to_path_buf();
5830    }
5831    task.repo.clone()
5832}
5833
5834/// The header [`append_answers`] appends operator answers under. Shared with
5835/// [`strip_answers_block`] so a resumed run's instruction can be refreshed
5836/// rather than grown a new block on every resume.
5837const ANSWERS_HEADER: &str = "\n\n# Operator answers\n\n";
5838
5839/// Render the first `count` answers in the block appended to an instruction.
5840fn answers_block(task: &Task, count: usize) -> String {
5841    let mut s = ANSWERS_HEADER.to_owned();
5842    for a in &task.answers[..count] {
5843        s.push_str(&format!("- {}: {}\n", a.question, a.answer));
5844    }
5845    s
5846}
5847
5848/// Render the approval independently of transient task state, so historical
5849/// suffixes remain recognizable after another explicit release.
5850fn resumption_block(r: &crate::queue::Resumption) -> String {
5851    format!(
5852        "\n\n# Explicit operator resumption\n\nGeneration {} at {} approved this instruction:\n{}\n\
5853         Earlier answers (first {}) are audit history; their waiting premises are superseded by this approval.\n",
5854        r.generation, r.at, r.instruction, r.answers_through,
5855    )
5856}
5857
5858/// Append audit answers and the latest explicit approval to the instruction.
5859fn append_answers(base: &str, task: &Task) -> String {
5860    let mut s = base.to_owned();
5861    if !task.answers.is_empty() {
5862        s.push_str(&answers_block(task, task.answers.len()));
5863    }
5864    if let Some(r) = &task.resumption {
5865        s.push_str(&resumption_block(r));
5866    }
5867    s
5868}
5869
5870/// Drop the prior answer block only when it is exactly the suffix this task
5871/// could have appended on an earlier resume. An `ANSWERS_HEADER` written by
5872/// the task author is ordinary instruction text, not a block to remove.
5873fn strip_answers_block<'a>(instruction: &'a str, task: &Task) -> &'a str {
5874    let mut instruction = instruction;
5875    for approval in task
5876        .resumption
5877        .iter()
5878        .chain(task.resumption_history.iter().rev())
5879    {
5880        if let Some(base) = instruction.strip_suffix(&resumption_block(approval)) {
5881            instruction = base;
5882            break;
5883        }
5884    }
5885    for count in (1..=task.answers.len()).rev() {
5886        let block = answers_block(task, count);
5887        if let Some(base) = instruction.strip_suffix(&block) {
5888            return base;
5889        }
5890    }
5891    instruction
5892}
5893
5894/// The instruction handed to `Runner::start`: the task's own text, plus any
5895/// operator answers `crate::conduct` collected for it (see
5896/// [`Task::answers`]), so a decision the operator actually made reaches the
5897/// implementers rather than only clearing the block that was waiting on it.
5898///
5899/// Appended rather than merged into [`Task::instruction`] itself, so the
5900/// task's own record stays exactly what its author wrote.
5901fn instruction_for(task: &Task) -> String {
5902    append_answers(&task.instruction, task)
5903}
5904
5905/// The instruction a resumed run should carry on with: whatever it already
5906/// had, refreshed with the task's *current* operator answers.
5907///
5908/// A resumable run's own `RunState::instruction` predates any answer
5909/// `crate::conduct` collects after the run parks, so resuming it unchanged —
5910/// the behaviour before this function existed — silently drops the very
5911/// decision the operator made to unblock it. Re-stripping any block this
5912/// function appended on an earlier resume before re-appending the current
5913/// list (rather than blindly appending again) is what keeps a task resumed
5914/// three times over three answered questions from carrying the same answer
5915/// three times.
5916fn resumed_instruction(old_instruction: &str, task: &Task) -> String {
5917    append_answers(strip_answers_block(old_instruction, task), task)
5918}
5919
5920/// Preserve the producer's verified boundary and target, independent of prose.
5921fn record_config_failure(task: &mut Task, error: &anyhow::Error) {
5922    let failure = error.downcast_ref::<crate::config::ConfigFailure>();
5923    task.notice_cause(failure.map_or("config-load", |f| f.stage), None, None);
5924    if let Some(cause) = task.hold_notice_cause.as_mut() {
5925        cause.target = failure.map(|f| f.target.clone());
5926    }
5927}
5928
5929#[derive(Debug, thiserror::Error)]
5930#[error("attachment `{name}` is recorded on the task but {path} is missing")]
5931struct MissingAttachment {
5932    name: String,
5933    path: PathBuf,
5934}
5935
5936/// The task's attachments as absolute paths, each checked to still exist.
5937fn task_attachments(
5938    queue: &Queue,
5939    task: &Task,
5940) -> std::result::Result<Vec<PathBuf>, MissingAttachment> {
5941    let paths = queue.attachment_paths(task);
5942    for (name, path) in task.attachments.iter().zip(&paths) {
5943        if !path.is_file() {
5944            return Err(MissingAttachment {
5945                name: name.clone(),
5946                path: path.clone(),
5947            });
5948        }
5949    }
5950    Ok(paths)
5951}
5952
5953/// What [`attempt`] should tell a [`Starter`] about `task`'s current operator
5954/// answers before handing it to `Runner` — the actual boundary between
5955/// [`choose_starter`]'s routing and the graph, factored out so it is
5956/// assertable without a real repository, git branch, or agent CLI.
5957///
5958/// `Starter::Review` deliberately answers `None`: `Runner::review` builds its
5959/// instruction from the reviewed branch's own commit log because there is no
5960/// task statement to speak of for hand-written work, and splicing operator
5961/// answers into that text would contradict the very message it sends
5962/// reviewers ("there is no task statement").
5963fn prepare_instruction(
5964    starter: &Starter,
5965    old_instruction: Option<&str>,
5966    task: &Task,
5967) -> Option<String> {
5968    match starter {
5969        Starter::Start => Some(instruction_for(task)),
5970        Starter::Resume(_) => Some(resumed_instruction(
5971            old_instruction.expect("a resumed run always has a prior instruction"),
5972            task,
5973        )),
5974        Starter::Review(_) => None,
5975    }
5976}
5977
5978/// Persist a transition. A queue write failure is logged rather than fatal: the
5979/// run already happened, and taking the daemon down would only add a lost
5980/// backlog to a full disk.
5981fn record(queue: &Queue, task: &mut Task) {
5982    if let Err(e) = queue.put(task) {
5983        tracing::error!("could not record task {}: {e:#}", task.short());
5984        let notice = Notice::error(
5985            "loop:record",
5986            format!(
5987                "Task {} state could not be saved: {}. Check the disk.",
5988                task.id,
5989                e.to_string().lines().next().unwrap_or("unknown save error")
5990            ),
5991        )
5992        .link(Link::Task {
5993            id: task.id.clone(),
5994        });
5995        // The queue lives at `<home>/queue`, so its parent is the home.
5996        match queue.root().parent().filter(|p| !p.as_os_str().is_empty()) {
5997            Some(home) => notices::raise_in(home, notice),
5998            None => notices::raise(notice),
5999        }
6000    }
6001}
6002
6003/// Every runnable task, in the order the loop should try them.
6004///
6005/// The head of this list is exactly what [`Queue::next_runnable`] offers; the
6006/// tail exists so that a claim somebody else holds costs the loop the next
6007/// candidate rather than a whole poll interval of idleness.
6008fn runnable(queue: &Queue) -> Vec<Task> {
6009    let mut tasks: Vec<Task> = queue
6010        .list()
6011        .into_iter()
6012        .filter(|t| t.status.runnable())
6013        .collect();
6014    tasks.sort_unstable_by(crate::queue::dispatch_order);
6015    tasks
6016}
6017
6018/// Why a run ended where it did, in one line, for [`Task::last_error`].
6019///
6020/// A stalled run names the seats the quota took out: "out of quota" is not
6021/// actionable, while "judge-2, judge-3 hit a limit" tells the operator which
6022/// agent to replace or which plan to top up.
6023///
6024/// Uses [`RunStatus::display_label`] rather than [`label`]/`as_str` on
6025/// purpose: unlike `label`'s other callers (an internal log line, an
6026/// already-a-bug fallback message), this string becomes `Task::last_error`
6027/// verbatim, which the phone renders in the same alarm-styled box an
6028/// ordinary failure gets — see `web::tests` and `assets/ui/app.js`'s
6029/// `.err` styling. A bare `verified_noop` there would read exactly like the
6030/// failure this whole feature exists to tell apart from one.
6031fn describe(state: &RunState) -> String {
6032    let p = phrases(&state.config.graph.language);
6033    let mut detail = if state.status == RunStatus::Stalled {
6034        let mut seats: Vec<&str> = state.quota.iter().map(|q| q.seat.as_str()).collect();
6035        seats.sort_unstable();
6036        seats.dedup();
6037        if seats.is_empty() {
6038            p.quorum_lost.to_owned()
6039        } else {
6040            format!("{}{}{}", p.quorum_lost, p.quota_took_out, seats.join(", "))
6041        }
6042    } else {
6043        format!("{}{}", p.run_ended, state.status.display_label())
6044    };
6045    if let Some(last) = state.events.last() {
6046        // The review loop's "never answered" stop is the one event worth
6047        // translating; it is recognised from structured state, not by
6048        // matching the English event text (which stays as recorded).
6049        let unanswered = state
6050            .reviews
6051            .last()
6052            .filter(|r| {
6053                state.status == RunStatus::Blocked
6054                    && last.node == "review"
6055                    && r.incomplete()
6056                    && r.blocking == 0
6057                    && r.round == state.config.graph.review_rounds
6058                    && r.e2e.iter().all(crate::run::CommandOutcome::ok)
6059            })
6060            .map(|r| (r.expected - r.answered, r.round));
6061        match unanswered {
6062            Some((missing, rounds)) if crate::lang::is_japanese(&state.config.graph.language) => {
6063                detail.push_str(&format!(
6064                    " ({}: {})",
6065                    last.node,
6066                    (p.reviewers_never_answered)(missing, rounds)
6067                ));
6068            }
6069            _ => detail.push_str(&format!(" ({}: {})", last.node, last.message)),
6070        }
6071    }
6072    detail.push_str(&format!(" [run {}]", state.id));
6073    detail
6074}
6075
6076/// Upper bound on [`Task::diagnostic`]'s length, in bytes.
6077///
6078/// The task file lives in the backlog indefinitely; a diagnostic is an
6079/// excerpt of the run's own `artifacts/`, not a copy of them, so this has to
6080/// stay small regardless of how much a gate command or a candidate printed.
6081const DIAGNOSTIC_MAX: usize = 4_000;
6082
6083/// Tail kept from a single failing command's output inside a diagnostic.
6084/// Smaller than [`crate::graph`]'s own `OUTPUT_TAIL` on purpose: this is a
6085/// pointer for a human deciding whether to go read the full artifact by hand,
6086/// not a replacement for reading it.
6087const DIAGNOSTIC_OUTPUT_TAIL: usize = 800;
6088
6089/// Assemble a bounded diagnostic excerpt from a held task's own run, so
6090/// `magi task show` says more than the one-line reason in [`describe`].
6091///
6092/// The one-liner answers "where did the run stop"; this answers "what would a
6093/// human have found opening `artifacts/` by hand" — the point of the whole
6094/// feature is the case that one-liner actively misleads on: a run held as "no
6095/// candidate produced a change" can mean the implementer actually finished
6096/// the task (opened a PR, merged it, tagged a release) and only left a clean
6097/// local worktree behind, which reads as "nothing happened" unless someone
6098/// goes and reads what the agent actually said. `None` when the run carries
6099/// none of the three shapes this recognises — an ordinary run held for
6100/// something not diagnosable from `RunState` alone still explains itself
6101/// through `Task::last_error`.
6102fn diagnostic(state: &RunState) -> Option<String> {
6103    let mut parts: Vec<String> = Vec::new();
6104
6105    // Gate failure: which check(s), and the tail of what each printed.
6106    for o in state.gate.iter().filter(|o| !o.ok()) {
6107        parts.push(format!(
6108            "gate `{}` failed ({}):\n{}",
6109            o.command,
6110            notices::exit_status(o.code),
6111            crate::run::tail(&o.output_tail, DIAGNOSTIC_OUTPUT_TAIL)
6112        ));
6113    }
6114
6115    // The land loop gave up because the fixer declined while checks were
6116    // still red: the message already names them (see `land::run`).
6117    if let Some(last) = state
6118        .events
6119        .iter()
6120        .rev()
6121        .find(|e| e.node == "land" && e.message.contains("fixer produced no commit"))
6122    {
6123        parts.push(last.message.clone());
6124    }
6125
6126    // No viable candidate: every implementer's own final word, sanitized the
6127    // same way a judge would have read it, so a run that actually finished
6128    // the job does not read as an unexplained failure. A verified no-op is
6129    // called out ahead of its own summary and apart from an ordinary
6130    // failure's `why` — this is the one candidate shape whose diagnostic a
6131    // human is expected to actually judge, not just skim.
6132    if state.viable().is_empty() {
6133        for c in &state.candidates {
6134            if let Some(evidence) = &c.verified_noop {
6135                parts.push(format!(
6136                    "candidate {} (agent-verified no-op, unconfirmed by magi): {evidence}",
6137                    c.label
6138                ));
6139            } else if !c.summary.trim().is_empty() {
6140                parts.push(format!("candidate {}: {}", c.label, c.summary.trim()));
6141            } else if let Some(why) = &c.failed {
6142                parts.push(format!("candidate {}: {why}", c.label));
6143            }
6144        }
6145    }
6146
6147    if parts.is_empty() {
6148        return None;
6149    }
6150    // `run::tail` prefixes an "N earlier bytes omitted" marker whose own
6151    // length depends on N, so asking it for exactly `DIAGNOSTIC_MAX` can come
6152    // back slightly over. Leave it enough room to always land under the
6153    // limit.
6154    Some(crate::run::tail(
6155        &parts.join("\n\n"),
6156        DIAGNOSTIC_MAX.saturating_sub(100),
6157    ))
6158}
6159
6160/// Stable lower-case name for a run status, for an internal log line and the
6161/// "graph stopped without reaching a terminal status" bug message in
6162/// [`settle`] — never for [`Task::last_error`] itself; see [`describe`]'s own
6163/// doc for why that one reads [`RunStatus::display_label`] instead. One
6164/// definition of a status's name, on the type that owns it: this table used
6165/// to live here as a second copy, and a status renamed in one place would
6166/// have gone on reading correctly in the other.
6167fn label(status: RunStatus) -> &'static str {
6168    status.as_str()
6169}
6170
6171/// Parse a merge mode override.
6172pub(crate) fn merge_mode(mode: &str) -> Result<MergeMode> {
6173    match mode {
6174        "none" => Ok(MergeMode::None),
6175        "local" => Ok(MergeMode::Local),
6176        "pr" => Ok(MergeMode::Pr),
6177        other => bail!("unknown merge mode `{other}`; expected none, local or pr"),
6178    }
6179}
6180
6181/// Take the status lock, recovering from a poisoned one.
6182///
6183/// A panic elsewhere must not silently stop the heartbeat: the status is plain
6184/// data, and the worst a poisoned lock can hold is a stale timestamp.
6185fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
6186    mutex
6187        .lock()
6188        .unwrap_or_else(std::sync::PoisonError::into_inner)
6189}
6190
6191#[cfg(test)]
6192mod tests {
6193    use super::*;
6194    use crate::queue::{Source, TaskStatus};
6195    use crate::run::{Candidate, CommandOutcome};
6196    use pretty_assertions::assert_eq;
6197
6198    fn task() -> Task {
6199        Task::new(
6200            "add retries".to_owned(),
6201            "add retries".to_owned(),
6202            PathBuf::from("/repo"),
6203            Source::Human,
6204        )
6205    }
6206
6207    fn starting_task_reservation(home: &Path, request: &TaskWorker) -> crate::worker::Job {
6208        let job = crate::worker::Job {
6209            id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into(),
6210            sequence: 1,
6211            kind: "task".into(),
6212            key: format!("task:{}", request.task),
6213            payload: serde_json::to_value(request).unwrap(),
6214            generation: "a".repeat(64),
6215            version: None,
6216            status: crate::worker::Status::Starting,
6217            result: None,
6218            pid: None,
6219            created_at: u64::try_from(Timestamp::now().as_second()).unwrap(),
6220            finished_at: None,
6221        };
6222        let dir = home.join("workers/jobs").join(&job.id);
6223        std::fs::create_dir_all(&dir).unwrap();
6224        std::fs::write(dir.join("job.json"), serde_json::to_vec(&job).unwrap()).unwrap();
6225        job
6226    }
6227
6228    #[test]
6229    fn an_unacknowledged_own_reservation_stops_dispatch_without_cancelling_the_child() {
6230        let home = tempfile::tempdir().unwrap();
6231        let request = TaskWorker {
6232            opts: Opts::default(),
6233            queue: home.path().join("queue"),
6234            task: "startup".into(),
6235            lane: PermitKind::Ordinary,
6236            standalone: false,
6237        };
6238        let job = starting_task_reservation(home.path(), &request);
6239        let error = task_worker_spawn_error(
6240            home.path(),
6241            &request,
6242            anyhow::anyhow!("worker startup is unacknowledged: private-error-detail"),
6243        )
6244        .expect_err("our own reservation must not turn a failed spawn into a resync");
6245        let active = crate::worker::active(home.path()).unwrap();
6246        assert_eq!(active.len(), 1);
6247        assert_eq!(active[0].id, job.id);
6248        assert_eq!(active[0].status, crate::worker::Status::Starting);
6249        assert!(
6250            crate::worker::control(home.path(), &job.key)
6251                .unwrap()
6252                .is_none()
6253        );
6254        let notices = notices::Notices::at(home.path().join("notifications")).list();
6255        assert_eq!(notices.len(), 1);
6256        assert_eq!(notices[0].key, "loop:worker-startup");
6257        assert!(!notices[0].message.contains("private-error-detail"));
6258        assert!(notices[0].message.contains(&request.task));
6259        assert!(
6260            notices[0]
6261                .message
6262                .contains("could not be started or acknowledged")
6263        );
6264        assert!(notices[0].message.contains("worker.log"));
6265        assert_eq!(
6266            notices[0].link,
6267            Some(Link::Task {
6268                id: request.task.clone()
6269            })
6270        );
6271        assert!(format!("{error:#}").contains("private-error-detail"));
6272    }
6273
6274    #[test]
6275    fn a_standalone_handoff_reservation_resyncs_dispatch_without_replacing_it() {
6276        let home = tempfile::tempdir().unwrap();
6277        let mut request = TaskWorker {
6278            opts: Opts::default(),
6279            queue: home.path().join("queue"),
6280            task: "handoff".into(),
6281            lane: PermitKind::Ordinary,
6282            standalone: true,
6283        };
6284        let job = starting_task_reservation(home.path(), &request);
6285        request.standalone = false;
6286        task_worker_spawn_error(
6287            home.path(),
6288            &request,
6289            anyhow::anyhow!("operation key is already reserved for a different request"),
6290        )
6291        .expect("a different standalone request is the handoff race, not our startup failure");
6292        let active = crate::worker::active(home.path()).unwrap();
6293        assert_eq!(active.len(), 1);
6294        assert_eq!(active[0].id, job.id);
6295        assert_eq!(active[0].payload, job.payload);
6296        assert!(
6297            notices::Notices::at(home.path().join("notifications"))
6298                .list()
6299                .is_empty()
6300        );
6301    }
6302
6303    #[test]
6304    fn starting_worker_projects_the_previous_run_without_owning_its_execution() {
6305        let home = tempfile::tempdir().unwrap();
6306        let queue = Queue::at(home.path().join("queue"));
6307        let mut task = task();
6308        task.runs = vec!["previous".into()];
6309        queue.put(&mut task).unwrap();
6310        let request = TaskWorker {
6311            opts: Opts::default(),
6312            queue: home.path().join("queue"),
6313            task: task.id.clone(),
6314            lane: PermitKind::Ordinary,
6315            standalone: false,
6316        };
6317        let job = starting_task_reservation(home.path(), &request);
6318        let claims = current_work(home.path(), Timestamp::now());
6319        assert_eq!(claims.len(), 1);
6320        assert_eq!(claims[0].worker.as_deref(), Some(job.id.as_str()));
6321        assert_eq!(claims[0].run, "previous");
6322        assert!(other_claims_run(&claims, "previous", None));
6323        // Only after confirming the executing caller may admission ignore
6324        // this exact projection. Cleanup continues to observe it as live.
6325        assert!(!other_claims_run(&claims, "previous", Some(&job.id)));
6326        assert!(is_working_on(home.path(), "previous", Timestamp::now()));
6327    }
6328
6329    #[test]
6330    fn admission_excludes_only_the_callers_exact_reservation() {
6331        let own = Current {
6332            worker: Some("own".into()),
6333            task: "same-task".into(),
6334            run: "previous".into(),
6335        };
6336        assert!(!other_claims_run(
6337            std::slice::from_ref(&own),
6338            "previous",
6339            Some("own")
6340        ));
6341        assert!(other_claims_run(
6342            std::slice::from_ref(&own),
6343            "previous",
6344            None
6345        ));
6346        for worker in [None, Some("other".into())] {
6347            let other = Current {
6348                worker,
6349                ..own.clone()
6350            };
6351            assert!(other_claims_run(
6352                &[own.clone(), other],
6353                "previous",
6354                Some("own")
6355            ));
6356        }
6357        let legacy: Current =
6358            serde_json::from_str(r#"{"task":"same-task","run":"previous"}"#).unwrap();
6359        assert!(other_claims_run(&[legacy], "previous", Some("own")));
6360    }
6361
6362    #[test]
6363    fn review_retry_resumes_only_the_matching_authorized_latest_owner() {
6364        let mut task = task();
6365        let mut run = run_state(RunStatus::Reviewing);
6366        run.task_instruction = Some(task.instruction.clone());
6367        run.reviewed_commits = Some(vec!["committed-fix".into()]);
6368        let branch = "magi/test/B";
6369        run.candidates.push(crate::run::Candidate {
6370            branch: branch.into(),
6371            ..candidate('B', "preserved work", false, None)
6372        });
6373        task.runs = vec![run.id.clone()];
6374        let unfinished = unfinished_run_with(&task.runs, task.short(), |_| Ok(run.clone()));
6375        assert_eq!(
6376            review_retry_starter(&task, Some(&run), Some(branch), true, unfinished.as_deref()),
6377            Starter::Resume(run.id.clone())
6378        );
6379        assert!(matches!(
6380            review_retry_starter(
6381                &task,
6382                Some(&run),
6383                Some("different"),
6384                true,
6385                unfinished.as_deref()
6386            ),
6387            Starter::Review(_)
6388        ));
6389        assert_eq!(
6390            review_retry_starter(
6391                &task,
6392                Some(&run),
6393                Some(branch),
6394                false,
6395                unfinished.as_deref()
6396            ),
6397            Starter::Start
6398        );
6399        task.instruction.push_str(" changed criteria");
6400        assert!(matches!(
6401            review_retry_starter(&task, Some(&run), Some(branch), true, unfinished.as_deref()),
6402            Starter::Review(_)
6403        ));
6404        assert!(matches!(
6405            review_retry_starter(&task, Some(&run), Some(branch), true, None),
6406            Starter::Review(_)
6407        ));
6408    }
6409
6410    #[test]
6411    fn a_live_worker_reservation_prevents_reclaim_before_its_task_claim() {
6412        let dir = tempfile::tempdir().unwrap();
6413        let queue = Queue::at(dir.path().join("queue"));
6414        let mut task = task();
6415        task.status = TaskStatus::Running;
6416        task.attempts = 1;
6417        queue.put(&mut task).unwrap();
6418        let live = std::collections::BTreeSet::from([task.id.clone()]);
6419        assert!(reclaim_orphaned_running(&queue, 2, &live).is_empty());
6420        drop(queue.claim(&task.id).unwrap());
6421        let saved = queue.get(&task.id).unwrap();
6422        assert_eq!(saved.status, TaskStatus::Running);
6423        assert_eq!(saved.attempts, 1);
6424        assert!(saved.last_error.is_none());
6425    }
6426
6427    #[test]
6428    fn standalone_workers_do_not_consume_scheduler_lanes() {
6429        let request = |task: &str, lane, standalone| TaskWorker {
6430            opts: Opts::default(),
6431            queue: PathBuf::from("/queue"),
6432            task: task.to_owned(),
6433            lane,
6434            standalone,
6435        };
6436        let mut jobs = vec![
6437            request("ordinary", PermitKind::Ordinary, false),
6438            request("standalone-urgent", PermitKind::Urgent, true),
6439            request("standalone-ordinary", PermitKind::Ordinary, true),
6440            request("decision", PermitKind::None, false),
6441        ];
6442        assert_eq!(available_slots(&jobs, 2), (1, 1));
6443        jobs.push(request("urgent", PermitKind::Urgent, false));
6444        assert_eq!(available_slots(&jobs, 2), (1, 0));
6445        assert_eq!(available_slots(&jobs, 0), (0, 0));
6446    }
6447
6448    #[test]
6449    fn a_live_legacy_scheduler_blocks_migration_even_with_a_stale_heartbeat() {
6450        let now = Timestamp::now();
6451        let mut reading = Reading {
6452            pid: Some(std::process::id()),
6453            updated_at: Some(now - jiff::SignedDuration::from_secs(STALE_SECS + 1)),
6454            ..Reading::default()
6455        };
6456        assert!(!reading.running(now));
6457        assert!(legacy_scheduler_running(&reading, now, 0));
6458        reading.worker_protocol = Some(1);
6459        assert!(!legacy_scheduler_running(&reading, now, 0));
6460    }
6461
6462    #[test]
6463    fn selected_public_head_overrides_historical_resume_and_pin() {
6464        let mut t = task();
6465        let mut old = run_state(RunStatus::Blocked);
6466        let selection = crate::pr_recovery::Selection {
6467            url: "url".into(),
6468            branch: format!("magi/task-{}", t.id),
6469            head: "a".repeat(40),
6470            source_head: None,
6471            source_run: None,
6472            source_proof: None,
6473            disposition: Some(crate::pr_recovery::Disposition::Review),
6474            instruction: t.instruction.clone(),
6475            approved_at: Timestamp::now(),
6476        };
6477        t.resume_override = Some(crate::queue::OperatorResume {
6478            pinned_run: Some(old.id.clone()),
6479            question_id: "choice".into(),
6480            at: Timestamp::now(),
6481            conductor_rehold: None,
6482            forced: false,
6483        });
6484        assert_eq!(
6485            selected_starter(&selection, Some(&old), Some(&old.id)),
6486            Starter::Review(selection.branch.clone())
6487        );
6488        old.pr_recovery = Some(selection.clone());
6489        assert_eq!(
6490            selected_starter(&selection, Some(&old), Some(&old.id)),
6491            Starter::Review(selection.branch.clone())
6492        );
6493        old.reviewed_commits = Some(vec!["implementation".into()]);
6494        assert_eq!(
6495            selected_starter(&selection, Some(&old), Some(&old.id)),
6496            Starter::Resume(old.id.clone())
6497        );
6498        let mut changed = selection.clone();
6499        changed.head = "b".repeat(40);
6500        assert_eq!(
6501            selected_starter(&changed, Some(&old), Some(&old.id)),
6502            Starter::Review(changed.branch)
6503        );
6504    }
6505
6506    #[test]
6507    fn candidate_transplant_failure_is_one_actionable_hold() {
6508        let dir = tempfile::tempdir().unwrap();
6509        let q = Queue::at(dir.path().join("queue"));
6510        let mut t = task();
6511        let mut state = run_state(RunStatus::Blocked);
6512        state.event("prep", "cannot apply referenced commit: conflict");
6513        state.prep_failure = Some("conflict".into());
6514        t.start(state.id.clone());
6515        finish_attempt(3, &q, &mut t, &state, &[], Err(anyhow::anyhow!("conflict")));
6516        assert_eq!(t.attempts, 1);
6517        assert_eq!(t.status, TaskStatus::Held);
6518        assert!(t.operator_held());
6519        assert!(!t.status.runnable());
6520        assert!(t.hold_reason.as_deref().unwrap().contains("recover-pr"));
6521        let first = t.chat_notice_key().unwrap();
6522        t.chat_notices.push(first.clone());
6523        t.release_operator("test", None);
6524        state.id = "another-unprepared-run".into();
6525        t.start(state.id.clone());
6526        finish_attempt(
6527            3,
6528            &q,
6529            &mut t,
6530            &state,
6531            &[],
6532            Err(anyhow::anyhow!("still conflicts")),
6533        );
6534        assert_eq!(
6535            t.chat_notice_key().as_ref(),
6536            Some(&first),
6537            "no work happened"
6538        );
6539        t.release_operator("test", None);
6540        state.prep_failure = None;
6541        state.status = RunStatus::Failed;
6542        state.begin_pass();
6543        t.start(state.id.clone());
6544        finish_attempt(
6545            1,
6546            &q,
6547            &mut t,
6548            &state,
6549            &[],
6550            Err(anyhow::anyhow!("new gate failure")),
6551        );
6552        let failure = t.chat_notice_key().unwrap();
6553        assert_ne!(failure, first, "approved recovery reached a real failure");
6554        t.release_operator("test", None);
6555        state.begin_pass();
6556        t.start(state.id.clone());
6557        finish_attempt(
6558            1,
6559            &q,
6560            &mut t,
6561            &state,
6562            &[],
6563            Err(anyhow::anyhow!("new failure on resume")),
6564        );
6565        assert_ne!(
6566            t.chat_notice_key().as_ref(),
6567            Some(&failure),
6568            "a new settled pass"
6569        );
6570    }
6571
6572    #[test]
6573    fn revised_resumption_invalidates_old_pins_and_preserves_unchanged_pins() {
6574        for source in ["cli", "web"] {
6575            let mut t = task();
6576            let mut run = run_state(RunStatus::Blocked);
6577            run.instruction = "Run A original criteria".into();
6578            run.task_instruction = Some(run.instruction.clone());
6579            t.start(run.id.clone());
6580            t.release_operator("choice", Some("resume-question"));
6581            t.resume_override = Some(crate::queue::OperatorResume {
6582                question_id: "resume-question".into(),
6583                at: Timestamp::now(),
6584                conductor_rehold: None,
6585                forced: true,
6586                pinned_run: Some(run.id.clone()),
6587            });
6588            t.hold_machine(Some("not enough free space to start a run".into()));
6589            let mut unchanged = t.clone();
6590            unchanged
6591                .edit("title changed".into(), unchanged.instruction.clone())
6592                .unwrap();
6593            unchanged.release_operator(source, None);
6594            assert_eq!(
6595                unchanged
6596                    .resume_override
6597                    .as_ref()
6598                    .unwrap()
6599                    .pinned_run
6600                    .as_deref(),
6601                Some(run.id.as_str())
6602            );
6603            assert!(!compatible_run_instruction(&run, &unchanged));
6604            assert!(may_resume_run(&run, &unchanged));
6605
6606            t.edit(
6607                "approved recovery".into(),
6608                "Reuse measured PR head B; fix Clippy; review".into(),
6609            )
6610            .unwrap();
6611            assert!(t.resume_override.is_none());
6612            t.release_operator(source, None);
6613            let reloaded: Task = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
6614            assert!(reloaded.resume_override.is_none());
6615            assert!(!may_resume_run(&run, &reloaded));
6616            let unfinished =
6617                unfinished_run_with(&reloaded.runs, reloaded.short(), |_| Ok(run.clone()))
6618                    .filter(|_| may_resume_run(&run, &reloaded));
6619            assert_eq!(
6620                choose_starter(None, false, unfinished.as_deref()),
6621                Starter::Start
6622            );
6623            assert!(instruction_for(&reloaded).starts_with("Reuse measured PR head B"));
6624            assert_eq!(
6625                reloaded.resumption_history[0].question_id.as_deref(),
6626                Some("resume-question")
6627            );
6628
6629            // A revision already persisted with the old approval/pin is also
6630            // invalidated when the new operator release is recorded.
6631            unchanged.instruction = "Reuse measured PR head B".into();
6632            unchanged.hold_machine(Some("disk hold".into()));
6633            unchanged.release_operator(source, None);
6634            assert!(unchanged.resume_override.is_none());
6635            assert!(!may_resume_run(&run, &unchanged));
6636        }
6637    }
6638
6639    #[test]
6640    fn resumption_preserves_generated_review_runs_and_rejects_revised_targets() {
6641        let mut t = task();
6642        let mut run = run_state(RunStatus::Blocked);
6643        t.review_of = Some("approved-head".into());
6644        t.instruction =
6645            "Review the branch `approved-head` (review-only: no implementation, no judging)."
6646                .into();
6647        run.instruction = "Review the work already on branch `approved-head`. There is no task statement: what the change claims to do is whatever its commits say.\n\nCommit log".into();
6648        run.reviewed_commits = Some(vec!["Commit log".into()]);
6649        t.start(run.id.clone());
6650        t.hold_machine(Some("interrupted review".into()));
6651        t.release_operator("cli", None);
6652        assert!(
6653            compatible_run_instruction(&run, &t),
6654            "legacy explicit review remains resumable"
6655        );
6656        let unfinished = unfinished_run_with(&t.runs, t.short(), |_| Ok(run.clone()))
6657            .filter(|_| compatible_run_instruction(&run, &t));
6658        assert_eq!(
6659            choose_starter(None, false, unfinished.as_deref()),
6660            Starter::Resume(run.id.clone())
6661        );
6662        run.task_instruction = Some(t.instruction.clone());
6663        assert!(compatible_run_instruction(&run, &t));
6664        let reloaded: RunState =
6665            serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
6666        assert!(compatible_run_instruction(&reloaded, &t));
6667        t.edit(
6668            "new approved head".into(),
6669            "Reuse measured PR head B".into(),
6670        )
6671        .unwrap();
6672        assert!(!compatible_run_instruction(&run, &t));
6673
6674        // Legacy recovery reviews retain the task criteria ahead of the log.
6675        t.review_of = None;
6676        t.instruction = "Fix Clippy on approved head".into();
6677        run.task_instruction = None;
6678        run.instruction = format!(
6679            "{}\n\n# Work already on branch `approved-head` (commit subjects)\n\nCommit log",
6680            t.instruction
6681        );
6682        assert!(compatible_run_instruction(&run, &t));
6683        t.instruction.push('\n');
6684        assert!(
6685            compatible_run_instruction(&run, &t),
6686            "legacy generated review trimmed the original criteria"
6687        );
6688        t.hold_for_handover(Some("approved-head".into()), "refused".into());
6689        let mut unchanged = t.clone();
6690        assert_eq!(
6691            take_review_branch(&mut unchanged, Some(&run)).as_deref(),
6692            Some("approved-head")
6693        );
6694        t.edit("new plan".into(), "Reuse measured PR head B".into())
6695            .unwrap();
6696        assert!(take_review_branch(&mut t, Some(&run)).is_none());
6697        assert!(!compatible_run_instruction(&run, &t));
6698        t.review_branch = Some("approved-head".into());
6699        t.review_instruction = None;
6700        assert!(
6701            take_review_branch(&mut t, Some(&run)).is_none(),
6702            "legacy retained candidate cannot replace a revised approved head"
6703        );
6704    }
6705
6706    #[test]
6707    fn resumption_actions_survive_automatic_holds_of_hand_started_runs() {
6708        crate::run::pin_test_home();
6709        let dir = tempfile::tempdir().unwrap();
6710        let queue = Queue::at(dir.path().join("queue"));
6711        for resume in [true, false] {
6712            let state = run_state(RunStatus::Blocked);
6713            let mut t = task();
6714            t.start(state.id.clone());
6715            let action = if resume {
6716                ask::ChoiceAction::Resume {
6717                    run: state.id.clone(),
6718                }
6719            } else {
6720                ask::ChoiceAction::Requeue
6721            };
6722            let mut q = action_question(&state.id, action);
6723            q.status = ask::QuestionStatus::Open;
6724            q.answer = None;
6725            q.answered_at = None;
6726            t.fail("run stopped with a question", 3);
6727            hold_if_runnable(&queue, &mut t);
6728            assert!(t.operator_decided_at.is_none());
6729            assert!(t.operator_held());
6730            q.answer(ask::Answer::Choice(q.choices[0].clone())).unwrap();
6731            let expected = if resume {
6732                ActionDecision::Resume(state.id.clone())
6733            } else {
6734                ActionDecision::Requeue
6735            };
6736            assert_eq!(
6737                decide_action(&t, &q, phrases("en"), |_| Ok(state.clone())),
6738                expected
6739            );
6740            t.hold_manual(Some("later explicit hold".into()));
6741            assert_eq!(
6742                decide_action(&t, &q, phrases("en"), |_| Ok(state.clone())),
6743                ActionDecision::Stale
6744            );
6745        }
6746    }
6747
6748    #[test]
6749    fn explicit_resumption_keeps_revised_instruction_and_audit_answers() {
6750        let mut t = task();
6751        t.answers.push(crate::queue::AnsweredQuestion {
6752            question: "Inspect the remote?".into(),
6753            answer: "Inspect then wait".into(),
6754        });
6755        let old = instruction_for(&t);
6756        assert!(compatible_instruction(&old, &t));
6757        t.instruction = "Reuse approved PR head and review".into();
6758        t.release_operator("cli", None);
6759        assert!(!compatible_instruction(&old, &t));
6760        let approved = instruction_for(&t);
6761        assert!(approved.contains("waiting premises are superseded"));
6762        assert!(compatible_instruction(&approved, &t));
6763        assert_eq!(resumed_instruction(&approved, &t), approved);
6764        t.release_operator("web", None);
6765        assert!(compatible_instruction(&approved, &t));
6766        assert_eq!(t.resumption_history.len(), 1);
6767        t.start("run".into());
6768        assert!(instruction_for(&t).contains("Explicit operator resumption"));
6769    }
6770
6771    /// A run that stopped on an infrastructure wait asked no agent anything:
6772    /// the task is held by the machine under a `[resource]` reason (resumable,
6773    /// re-checked by triage), and the attempt it started is given back.
6774    #[test]
6775    fn unrelated_ci_refunds_once_per_pass_and_holds_for_the_owner() {
6776        let mut state = RunState::new(
6777            PathBuf::from("/repo"),
6778            "main".into(),
6779            "base".into(),
6780            "task".into(),
6781            Config::default(),
6782        );
6783        state.status = RunStatus::Landing;
6784        state.parked = true;
6785        state.pr_pushed_head = Some("head".into());
6786        state.land_unrelated = Some(
6787            serde_json::from_value(serde_json::json!({
6788                "head": "head", "question": "ci-question",
6789                "evidence": {"head": "head", "base": "base", "changed": [], "checks": []}
6790            }))
6791            .unwrap(),
6792        );
6793        let mut t = task();
6794        t.start(state.id.clone());
6795        let verdict = Verdict {
6796            status: RunStatus::Landing,
6797            left_pr: true,
6798            quota_hit: false,
6799            parked: true,
6800            no_viable_candidates: false,
6801        };
6802        settle_and_diagnose(&mut t, verdict, "CI hold", 2, &state);
6803        assert_eq!(t.attempts, 0);
6804        assert_eq!(t.status, TaskStatus::Held);
6805        assert!(!t.operator_held());
6806        assert!(
6807            t.hold_reason
6808                .as_deref()
6809                .unwrap()
6810                .starts_with("[ci-unrelated]")
6811        );
6812        // A second settlement must not refund an earlier task attempt.
6813        t.attempts = 1;
6814        settle_and_diagnose(&mut t, verdict, "CI hold", 2, &state);
6815        assert_eq!(t.attempts, 1);
6816        assert_eq!(t.passes.len(), 1);
6817        assert_eq!(t.passes[0].cost, crate::queue::PassCost::Refunded);
6818        state.pr_pushed_head = Some("new-head".into());
6819        state.parked = false;
6820        state.status = RunStatus::Blocked;
6821        let stale = Verdict {
6822            status: RunStatus::Blocked,
6823            parked: false,
6824            ..verdict
6825        };
6826        settle_and_diagnose(&mut t, stale, "stale CI hold", 2, &state);
6827        assert_eq!(t.attempts, 1);
6828        // A new execution pass can refund its own newly charged attempt.
6829        state.begin_pass();
6830        state.begin_pass();
6831        state.status = RunStatus::Landing;
6832        state.parked = true;
6833        state.pr_pushed_head = Some("head".into());
6834        settle_and_diagnose(&mut t, verdict, "CI hold on resume", 2, &state);
6835        assert_eq!(t.attempts, 0);
6836        assert_eq!(t.passes.len(), 2);
6837    }
6838
6839    #[test]
6840    fn a_resource_blocked_run_holds_its_task_and_spends_no_attempt() {
6841        let mut state = RunState::new(
6842            PathBuf::from("/repo"),
6843            "main".to_owned(),
6844            "abc1234".to_owned(),
6845            "task".to_owned(),
6846            Config::default(),
6847        );
6848        state.status = RunStatus::Blocked;
6849        state.resource_block = Some(crate::run::ResourceBlock {
6850            cause: crate::run::ResourceBlockCause::Other,
6851            node: "graft".to_owned(),
6852            kind: "shared build cache".to_owned(),
6853            detail: "timed out".to_owned(),
6854            at: jiff::Timestamp::now(),
6855        });
6856        let mut t = task();
6857        t.start("20260902-000000-aaaa".to_owned());
6858        assert_eq!(t.attempts, 1);
6859        let verdict = Verdict {
6860            status: RunStatus::Blocked,
6861            left_pr: false,
6862            quota_hit: false,
6863            parked: false,
6864            no_viable_candidates: false,
6865        };
6866        settle_and_diagnose(&mut t, verdict, "blocked", 2, &state);
6867        assert_eq!(t.status, TaskStatus::Held);
6868        assert_eq!(t.attempts, 0, "the attempt is refunded");
6869        assert!(t.hold_reason.as_deref().unwrap().starts_with("[resource] "));
6870        assert!(
6871            !t.operator_held(),
6872            "a machine hold, released without a human"
6873        );
6874
6875        let mut selected = task();
6876        selected.start("20260902-000000-cccc".into());
6877        selected.replacement = Some(crate::merge_decision::Replacement {
6878            question: "fixture".into(),
6879            run: "earlier".into(),
6880            pr: "https://example.invalid/pr/1".into(),
6881            agent: "selected".into(),
6882            context: "fixture".into(),
6883        });
6884        state.resource_block.as_mut().unwrap().kind = "repository coordination".into();
6885        settle_and_diagnose(&mut selected, verdict, "blocked", 2, &state);
6886        assert_eq!(selected.attempts, 0);
6887        assert!(
6888            !selected.operator_held(),
6889            "a selected agent also retains a machine resource hold"
6890        );
6891        assert!(
6892            selected
6893                .hold_reason
6894                .as_deref()
6895                .unwrap()
6896                .starts_with("[resource] ")
6897        );
6898
6899        // A park request outranks it: still the operator's doing.
6900        let mut p = task();
6901        p.start("20260902-000000-bbbb".to_owned());
6902        settle_and_diagnose(
6903            &mut p,
6904            Verdict {
6905                parked: true,
6906                ..verdict
6907            },
6908            "parked",
6909            2,
6910            &state,
6911        );
6912        assert_eq!(p.status, TaskStatus::Parked);
6913        assert_eq!(
6914            p.attempts, 0,
6915            "parking refunds once rather than also settling the resource hold"
6916        );
6917    }
6918
6919    #[test]
6920    fn record_failure_notice_links_the_task() {
6921        let dir = tempfile::tempdir().unwrap();
6922        // A regular file where the queue directory should be makes every
6923        // `put` fail, on every platform and whoever runs the test.
6924        std::fs::write(dir.path().join("queue"), b"not a directory").unwrap();
6925        let queue = Queue::at(dir.path().join("queue"));
6926        let mut t = interrupt_task("rec-fail-1");
6927        record(&queue, &mut t);
6928
6929        let found = crate::notices::Notices::at(dir.path().join("notifications")).list();
6930        assert_eq!(found.len(), 1);
6931        assert_eq!(found[0].key, "loop:record");
6932        assert_eq!(
6933            found[0].link,
6934            Some(Link::Task {
6935                id: "rec-fail-1".to_owned()
6936            })
6937        );
6938    }
6939
6940    /// A runnable task marked to interrupt, with an id fixed for assertions
6941    /// rather than the random one [`Task::new`] mints.
6942    fn interrupt_task(id: &str) -> Task {
6943        let mut t = task();
6944        t.id = id.to_owned();
6945        t.interrupt = true;
6946        t
6947    }
6948
6949    /// An ordinary runnable task with an id fixed for assertions.
6950    fn task_with_id(id: &str) -> Task {
6951        let mut t = task();
6952        t.id = id.to_owned();
6953        t
6954    }
6955
6956    /// A runnable task marked `--urgent`, with an id fixed for assertions.
6957    fn urgent_task(id: &str) -> Task {
6958        let mut t = task();
6959        t.id = id.to_owned();
6960        t.urgent = true;
6961        t
6962    }
6963
6964    #[test]
6965    fn upgrade_resume_dispatch_keeps_claim_and_lane_limits() {
6966        let dir = tempfile::tempdir().unwrap();
6967        let queue = Queue::at(dir.path().join("queue"));
6968        let mut parked = task_with_id("parked");
6969        parked.start("run".into());
6970        parked.park("upgrade");
6971        parked.record_pass_at(
6972            "run",
6973            0,
6974            Some(Timestamp::now()),
6975            1,
6976            Some(crate::run::ParkCause::Upgrade {
6977                from: "old".into(),
6978                to: None,
6979            }),
6980        );
6981        let mut fresh = task_with_id("fresh");
6982        fresh.priority = 100;
6983        let mut urgent_task = urgent_task("urgent");
6984        for t in [&mut parked, &mut fresh, &mut urgent_task] {
6985            queue.put(t).unwrap();
6986        }
6987        let ordinary = Arc::new(tokio::sync::Semaphore::new(1));
6988        let urgent = Arc::new(tokio::sync::Semaphore::new(1));
6989        let candidates = runnable(&queue);
6990        assert_eq!(candidates[0].id, parked.id);
6991        let first = claim_dispatch(&queue, &candidates[0], false, &ordinary, &urgent).unwrap();
6992        assert!(claim_dispatch(&queue, &fresh, false, &ordinary, &urgent).is_none());
6993        assert!(claim_dispatch(&queue, &urgent_task, false, &ordinary, &urgent).is_some());
6994        drop(first);
6995        let claimed_elsewhere = queue.claim(&parked.id).unwrap();
6996        let next = runnable(&queue)
6997            .into_iter()
6998            .find_map(|t| claim_dispatch(&queue, &t, false, &ordinary, &urgent))
6999            .unwrap();
7000        assert_eq!(next.task.id, fresh.id);
7001        assert_eq!(queue.get(&parked.id).unwrap().attempts, 0);
7002        drop(claimed_elsewhere);
7003        drop(next);
7004        assert!(claim_dispatch(&queue, &parked, true, &ordinary, &urgent).is_none());
7005    }
7006
7007    #[test]
7008    fn urgent_dispatch_has_one_extra_slot_and_never_uses_a_stale_flag() {
7009        let dir = tempfile::tempdir().unwrap();
7010        let queue = Queue::at(dir.path().join("queue"));
7011        let ordinary = Arc::new(tokio::sync::Semaphore::new(1));
7012        let urgent = Arc::new(tokio::sync::Semaphore::new(1));
7013        let occupied = Arc::clone(&ordinary).try_acquire_owned().unwrap();
7014        let mut first = task();
7015        queue.put(&mut first).unwrap();
7016        let stale_plain = queue.get(&first.id).unwrap();
7017        first.set_urgent(true).unwrap();
7018        queue.put(&mut first).unwrap();
7019        let dispatched = claim_dispatch(&queue, &stale_plain, false, &ordinary, &urgent).unwrap();
7020        let mut second = urgent_task("second");
7021        queue.put(&mut second).unwrap();
7022        assert!(claim_dispatch(&queue, &second, false, &ordinary, &urgent).is_none());
7023        drop(queue.claim(&second.id).unwrap());
7024        assert_eq!(queue.get(&second.id).unwrap().attempts, 0);
7025        drop(dispatched);
7026        let stale_urgent = queue.get(&first.id).unwrap();
7027        first.set_urgent(false).unwrap();
7028        queue.put(&mut first).unwrap();
7029        assert!(claim_dispatch(&queue, &stale_urgent, false, &ordinary, &urgent).is_none());
7030        assert_eq!(urgent.available_permits(), 1);
7031        drop(queue.claim(&first.id).unwrap());
7032        drop(occupied);
7033    }
7034
7035    #[test]
7036    fn urgent_dispatch_uses_saved_lane_and_releases_unsuccessful_claims() {
7037        let dir = tempfile::tempdir().unwrap();
7038        let queue = Queue::at(dir.path().join("queue"));
7039        let ordinary = Arc::new(tokio::sync::Semaphore::new(1));
7040        let urgent = Arc::new(tokio::sync::Semaphore::new(1));
7041        let mut t = task();
7042        queue.put(&mut t).unwrap();
7043        for enabled in [true, false] {
7044            let stale = queue.get(&t.id).unwrap();
7045            t.set_urgent(enabled).unwrap();
7046            queue.put(&mut t).unwrap();
7047            assert_ne!(stale.urgent, enabled);
7048            let DispatchClaim {
7049                task: fresh,
7050                claim,
7051                permit,
7052            } = claim_dispatch(&queue, &stale, false, &ordinary, &urgent).unwrap();
7053            assert_eq!(fresh.urgent, enabled);
7054            assert_eq!(ordinary.available_permits(), usize::from(enabled));
7055            assert_eq!(urgent.available_permits(), usize::from(!enabled));
7056            assert!(queue.claim(&t.id).is_err());
7057            assert_eq!(fresh.attempts, stale.attempts);
7058            drop((claim, permit));
7059            assert_eq!(ordinary.available_permits(), 1);
7060            assert_eq!(urgent.available_permits(), 1);
7061        }
7062        t.set_urgent(true).unwrap();
7063        queue.put(&mut t).unwrap();
7064        let occupied = Arc::clone(&urgent).try_acquire_owned().unwrap();
7065        assert!(claim_dispatch(&queue, &t, false, &ordinary, &urgent).is_none());
7066        assert_eq!(ordinary.available_permits(), 1, "no ordinary fallback");
7067        drop(queue.claim(&t.id).unwrap());
7068        assert_eq!(queue.get(&t.id).unwrap().attempts, t.attempts);
7069        drop(occupied);
7070        assert!(claim_dispatch(&queue, &t, true, &ordinary, &urgent).is_none());
7071        drop(queue.claim(&t.id).unwrap());
7072        let held = queue.claim(&t.id).unwrap();
7073        assert!(claim_dispatch(&queue, &t, false, &ordinary, &urgent).is_none());
7074        drop(held);
7075        t.status = TaskStatus::Held;
7076        queue.put(&mut t).unwrap();
7077        assert!(claim_dispatch(&queue, &t, false, &ordinary, &urgent).is_none());
7078        drop(queue.claim(&t.id).unwrap());
7079        std::fs::remove_file(queue.path_of(&t.id)).unwrap();
7080        assert!(claim_dispatch(&queue, &t, false, &ordinary, &urgent).is_none());
7081        drop(queue.claim(&t.id).unwrap());
7082        assert_eq!(urgent.available_permits(), 1);
7083    }
7084
7085    /// The land-resume exemption wins outright, whether or not the candidate
7086    /// also happens to be marked [`Task::urgent`]: a resume's own "must not
7087    /// queue behind anything" guarantee cannot be weaker just because the
7088    /// same task was also filed with `--urgent`.
7089    #[test]
7090    fn permit_kind_prefers_a_land_resume_over_the_urgent_slot() {
7091        assert_eq!(permit_kind(true, false), PermitKind::None);
7092        assert_eq!(permit_kind(true, true), PermitKind::None);
7093    }
7094
7095    /// The one property this whole feature exists for: `--urgent` draws from
7096    /// its own slot, never the ordinary `max_concurrent_runs` pool - and an
7097    /// ordinary candidate draws from the ordinary pool exactly as before,
7098    /// untouched by the urgent slot's existence.
7099    #[test]
7100    fn permit_kind_separates_urgent_from_ordinary() {
7101        assert_eq!(permit_kind(false, true), PermitKind::Urgent);
7102        assert_eq!(permit_kind(false, false), PermitKind::Ordinary);
7103    }
7104
7105    /// The exact wiring `attempt` runs before minting anything: a config's
7106    /// `min_free_bytes` in, a task-holding reason naming both numbers out.
7107    /// Free space is injected rather than asked of the real disk - the point
7108    /// of [`disk_gate_with`] existing separately from [`disk_gate`] - so this
7109    /// is deterministic on every machine this test runs on, never dependent
7110    /// on how full the CI runner's own disk happens to be.
7111    #[test]
7112    fn disk_gate_with_holds_a_task_below_the_threshold_and_names_both_numbers() {
7113        let cfg = Config::default();
7114        let repo = Path::new("/any/repo/path");
7115
7116        let reason =
7117            disk_gate_with(repo, &cfg, |_| Ok(1024)).expect("must hold below the threshold");
7118        assert!(reason.contains("1024"), "{reason}");
7119        assert!(
7120            reason.contains(&cfg.disk.min_free_bytes.to_string()),
7121            "{reason}"
7122        );
7123
7124        assert_eq!(
7125            disk_gate_with(repo, &cfg, |_| Ok(cfg.disk.min_free_bytes)),
7126            None,
7127            "exactly at the floor is open"
7128        );
7129        assert_eq!(
7130            disk_gate_with(repo, &cfg, |_| Ok(cfg.disk.min_free_bytes + 1)),
7131            None,
7132            "comfortably above the floor is open"
7133        );
7134    }
7135
7136    #[test]
7137    fn disk_gate_with_opens_unconditionally_when_the_operator_opted_out() {
7138        let mut cfg = Config::default();
7139        cfg.disk.min_free_bytes = 0;
7140        let repo = Path::new("/any/repo/path");
7141        assert_eq!(
7142            disk_gate_with(repo, &cfg, |_| Ok(0)),
7143            None,
7144            "a zero floor never measures at all"
7145        );
7146    }
7147
7148    #[test]
7149    fn disk_gate_with_closes_rather_than_starts_blind_when_it_cannot_measure() {
7150        let cfg = Config::default();
7151        let repo = Path::new("/any/repo/path");
7152        let reason = disk_gate_with(repo, &cfg, |_| Err(anyhow::anyhow!("no df on this box")))
7153            .expect("a measurement failure must close the gate, not open it");
7154        assert!(reason.contains("could not measure"), "{reason}");
7155    }
7156
7157    #[test]
7158    fn no_interrupt_task_leaves_the_sequence_idle_even_with_something_in_flight() {
7159        let ordinary = task();
7160        let next = advance_interrupt(
7161            Interrupt::Idle,
7162            std::slice::from_ref(&ordinary.id),
7163            std::slice::from_ref(&ordinary),
7164        );
7165        assert_eq!(next, Interrupt::Idle);
7166    }
7167
7168    #[test]
7169    fn an_interrupt_task_with_nothing_in_flight_never_starts_a_sequence() {
7170        // Nothing to interrupt - this is just an ordinary candidate, and the
7171        // loop's normal dispatch will pick it up like any other.
7172        let marked = interrupt_task("marked");
7173        let next = advance_interrupt(Interrupt::Idle, &[], std::slice::from_ref(&marked));
7174        assert_eq!(next, Interrupt::Idle);
7175    }
7176
7177    #[test]
7178    fn an_interrupt_task_with_something_in_flight_starts_parking_it() {
7179        let marked = interrupt_task("marked");
7180        let next = advance_interrupt(
7181            Interrupt::Idle,
7182            &["running".to_owned()],
7183            std::slice::from_ref(&marked),
7184        );
7185        assert_eq!(
7186            next,
7187            Interrupt::Parking {
7188                parked: vec!["running".to_owned()],
7189                interrupt_task: "marked".to_owned(),
7190            }
7191        );
7192    }
7193
7194    /// R1-1-2 / R2-1-2: above the default `max_concurrent_runs`, more than
7195    /// one run can be in flight when a task becomes runnable and marked.
7196    /// Parking all of them would mean `Resuming` later has more than one id
7197    /// to release back to ordinary dispatch, which cannot be made safe
7198    /// against that same setting's own extra concurrency slots letting two
7199    /// of them start together - see `advance_interrupt`'s own `Idle` branch.
7200    /// The simplification the task's own constraints ask for: do not begin
7201    /// a sequence at all until the herd settles back to exactly one.
7202    #[test]
7203    fn more_than_one_run_in_flight_never_starts_an_interrupt_sequence() {
7204        let marked = interrupt_task("marked");
7205
7206        let two = advance_interrupt(
7207            Interrupt::Idle,
7208            &["a".to_owned(), "b".to_owned()],
7209            std::slice::from_ref(&marked),
7210        );
7211        assert_eq!(two, Interrupt::Idle);
7212
7213        let none = advance_interrupt(Interrupt::Idle, &[], std::slice::from_ref(&marked));
7214        assert_eq!(none, Interrupt::Idle, "nothing to interrupt either");
7215    }
7216
7217    #[test]
7218    fn parking_holds_until_every_parked_id_has_actually_left_flight() {
7219        let state = Interrupt::Parking {
7220            parked: vec!["running".to_owned()],
7221            interrupt_task: "marked".to_owned(),
7222        };
7223        // Still in flight: no change.
7224        let still_going = advance_interrupt(state.clone(), &["running".to_owned()], &[]);
7225        assert_eq!(still_going, state);
7226
7227        // Left flight, but the interrupt task has not been dispatched yet on
7228        // this tick - stays `Parking` so `interrupt_gate` can let it through,
7229        // as long as it is still runnable.
7230        let stopped_but_not_yet_dispatched =
7231            advance_interrupt(state.clone(), &[], &[interrupt_task("marked")]);
7232        assert_eq!(stopped_but_not_yet_dispatched, state);
7233
7234        // Left flight, and the interrupt task is now in flight itself.
7235        let dispatched = advance_interrupt(state, &["marked".to_owned()], &[]);
7236        assert_eq!(
7237            dispatched,
7238            Interrupt::Running {
7239                parked: vec!["running".to_owned()],
7240                interrupt_task: "marked".to_owned(),
7241            }
7242        );
7243    }
7244
7245    #[test]
7246    fn the_sequence_moves_to_resuming_the_instant_the_interrupt_tasks_own_run_leaves_flight() {
7247        let state = Interrupt::Running {
7248            parked: vec!["running".to_owned()],
7249            interrupt_task: "marked".to_owned(),
7250        };
7251        let still_running = advance_interrupt(state.clone(), &["marked".to_owned()], &[]);
7252        assert_eq!(still_running, state);
7253
7254        // Whatever it ended as - merged, failed, held - is not this
7255        // function's concern: leaving flight is the only trigger, driven
7256        // straight off the same in-flight list `poll` already reaps. It does
7257        // not go straight to `Idle`: see `Interrupt::Running`'s own doc for
7258        // why that would let an unrelated task start ahead of, or alongside,
7259        // the guaranteed resume.
7260        let ended = advance_interrupt(state, &[], &[task_with_id("running")]);
7261        assert_eq!(
7262            ended,
7263            Interrupt::Resuming {
7264                parked: vec!["running".to_owned()]
7265            }
7266        );
7267    }
7268
7269    #[test]
7270    fn resuming_ends_the_instant_a_parked_task_is_seen_in_flight() {
7271        let state = Interrupt::Resuming {
7272            parked: vec!["running".to_owned()],
7273        };
7274        let still_waiting = advance_interrupt(state.clone(), &[], &[task_with_id("running")]);
7275        assert_eq!(still_waiting, state);
7276
7277        let dispatched = advance_interrupt(state, &["running".to_owned()], &[]);
7278        assert_eq!(dispatched, Interrupt::Idle);
7279    }
7280
7281    /// R1-2-1: an interrupt task that stops being runnable - held, blocked,
7282    /// or otherwise moved on by an operator with no claim standing in the
7283    /// way - must not wedge the sequence (and so the whole loop's dispatch,
7284    /// via `interrupt_gate`) waiting forever for a dispatch that can never
7285    /// come. The parked run still gets its resume.
7286    #[test]
7287    fn an_interrupt_task_that_stops_being_runnable_abandons_the_wait_without_losing_the_parked_run()
7288    {
7289        let state = Interrupt::Parking {
7290            parked: vec!["running".to_owned()],
7291            interrupt_task: "marked".to_owned(),
7292        };
7293        // `marked` has been held/blocked/deleted since the sequence began:
7294        // it no longer appears in `runnable` at all.
7295        let next = advance_interrupt(state, &[], &[]);
7296        assert_eq!(
7297            next,
7298            Interrupt::Resuming {
7299                parked: vec!["running".to_owned()]
7300            },
7301            "abandoning the interrupt must not abandon the resume it owes"
7302        );
7303    }
7304
7305    /// The same abandonment, one step later: `Resuming` itself must not wait
7306    /// forever for a parked task that has since become unrunnable.
7307    #[test]
7308    fn resuming_abandons_a_parked_task_that_stops_being_runnable() {
7309        let state = Interrupt::Resuming {
7310            parked: vec!["running".to_owned()],
7311        };
7312        let next = advance_interrupt(state, &[], &[]);
7313        assert_eq!(
7314            next,
7315            Interrupt::Idle,
7316            "nothing is left to wait for; the loop must not stay wedged"
7317        );
7318    }
7319
7320    #[test]
7321    fn disabled_by_config_the_sequence_can_never_leave_idle() {
7322        let marked = interrupt_task("marked");
7323        let next = advance_interrupt_tick(
7324            false,
7325            Interrupt::Idle,
7326            &["running".to_owned()],
7327            std::slice::from_ref(&marked),
7328        );
7329        assert_eq!(
7330            next,
7331            Interrupt::Idle,
7332            "an unmarked, unconfigured daemon must behave exactly as before"
7333        );
7334    }
7335
7336    #[test]
7337    fn the_gate_blocks_everyone_while_something_parked_is_still_in_flight() {
7338        let state = Interrupt::Parking {
7339            parked: vec!["running".to_owned()],
7340            interrupt_task: "marked".to_owned(),
7341        };
7342        let candidates = vec![interrupt_task("marked"), task()];
7343        let allowed = interrupt_gate(&state, &["running".to_owned()], candidates);
7344        assert!(
7345            allowed.is_empty(),
7346            "nothing may dispatch - not even the interrupt task itself - \
7347             until the parked run has actually stopped"
7348        );
7349    }
7350
7351    /// `interrupt_gate` knows nothing about [`Task::urgent`] and must not
7352    /// grow a special case for it: an urgent candidate that is not the
7353    /// interrupt task withholds exactly like an ordinary one for as long as
7354    /// the parked run it is racing has not actually left flight. A version
7355    /// of this feature once bypassed the gate for urgent candidates, on the
7356    /// theory that a genuinely separate concurrency lane could not collide
7357    /// with 75dd's own; it could, and did - the bypassed task dispatched
7358    /// alongside a run 75dd's own state machine had only *asked* to park,
7359    /// not yet confirmed gone, which is exactly the second run
7360    /// `Interrupt::Parking`'s own doc says must never happen. There is no
7361    /// tick during Parking/Running/Resuming where letting an extra
7362    /// candidate through is safe, urgent or not - the parked run may still
7363    /// be genuinely mid-call.
7364    #[test]
7365    fn urgent_gains_no_exemption_from_an_active_interrupt_sequence() {
7366        for state in [
7367            Interrupt::Parking {
7368                parked: vec!["running".to_owned()],
7369                interrupt_task: "marked".to_owned(),
7370            },
7371            Interrupt::Running {
7372                parked: vec!["running".to_owned()],
7373                interrupt_task: "marked".to_owned(),
7374            },
7375            Interrupt::Resuming {
7376                parked: vec!["running".to_owned()],
7377            },
7378        ] {
7379            let candidates = vec![
7380                interrupt_task("marked"),
7381                urgent_task("hot"),
7382                task_with_id("ordinary"),
7383            ];
7384            let allowed = interrupt_gate(&state, &["running".to_owned()], candidates);
7385            assert!(
7386                !allowed.iter().any(|t| t.id == "hot"),
7387                "an urgent candidate must wait out the same gate as anything \
7388                 else while the run it would run alongside has not actually \
7389                 left flight, for state {state:?}: {allowed:?}"
7390            );
7391        }
7392    }
7393
7394    /// The one case where marking a task `--urgent` and `interrupt` at once
7395    /// is not redundant: once the interrupt sequence's own guaranteed
7396    /// resume/dispatch actually admits the task, it is unaffected by also
7397    /// carrying `urgent` - `interrupt_gate` decides purely on identity,
7398    /// never on the urgent flag.
7399    #[test]
7400    fn a_task_marked_both_urgent_and_interrupt_is_admitted_once_the_gate_itself_says_so() {
7401        let state = Interrupt::Resuming {
7402            parked: vec!["hot".to_owned()],
7403        };
7404        let candidates = vec![urgent_task("hot"), task()];
7405        let allowed = interrupt_gate(&state, &[], candidates);
7406        assert_eq!(
7407            allowed.iter().filter(|t| t.id == "hot").count(),
7408            1,
7409            "the gate's own decision is unaffected by the urgent flag: {allowed:?}"
7410        );
7411    }
7412
7413    #[test]
7414    fn the_gate_lets_only_the_interrupt_task_through_once_parked_work_has_stopped() {
7415        let state = Interrupt::Parking {
7416            parked: vec!["running".to_owned()],
7417            interrupt_task: "marked".to_owned(),
7418        };
7419        let other = task();
7420        let candidates = vec![interrupt_task("marked"), other.clone()];
7421        let allowed = interrupt_gate(&state, &[], candidates);
7422        assert_eq!(allowed.len(), 1);
7423        assert_eq!(allowed[0].id, "marked");
7424    }
7425
7426    #[test]
7427    fn the_gate_blocks_everyone_while_the_interrupt_task_itself_is_in_flight() {
7428        let state = Interrupt::Running {
7429            parked: vec!["running".to_owned()],
7430            interrupt_task: "marked".to_owned(),
7431        };
7432        let candidates = vec![task(), task()];
7433        let allowed = interrupt_gate(&state, &["marked".to_owned()], candidates);
7434        assert!(allowed.is_empty());
7435    }
7436
7437    /// R1-1-1 / R1-1-2: even when more than one task was in flight when the
7438    /// sequence began (only reachable above the default
7439    /// `max_concurrent_runs = 1`), `Resuming` offers at most one of them -
7440    /// never both in the same tick, which is what "exactly one resume, no
7441    /// simultaneous run" actually requires structurally rather than by
7442    /// coincidence of how many ordinary slots happen to be free.
7443    #[test]
7444    fn the_gate_offers_at_most_one_candidate_while_resuming_even_with_two_parked() {
7445        let state = Interrupt::Resuming {
7446            parked: vec!["a".to_owned(), "c".to_owned()],
7447        };
7448        let candidates = vec![task_with_id("a"), task_with_id("c"), task_with_id("other")];
7449        let allowed = interrupt_gate(&state, &[], candidates);
7450        assert_eq!(
7451            allowed.len(),
7452            1,
7453            "at most one candidate may be offered while resuming: {allowed:?}"
7454        );
7455        assert_eq!(allowed[0].id, "a");
7456    }
7457
7458    #[test]
7459    fn the_gate_offers_nothing_while_resuming_if_no_parked_task_is_runnable() {
7460        let state = Interrupt::Resuming {
7461            parked: vec!["a".to_owned()],
7462        };
7463        let allowed = interrupt_gate(&state, &[], vec![task_with_id("other")]);
7464        assert!(allowed.is_empty());
7465    }
7466
7467    /// The invariant the completion criteria ask for by name: across a whole
7468    /// simulated sequence, there is never a tick where the gate would let
7469    /// through both the parked run's resume and the interrupt task, and
7470    /// exactly one candidate resumes the instant the interrupt task's run
7471    /// ends - never zero, never more than one.
7472    #[test]
7473    fn a_full_sequence_never_gates_two_runs_through_at_once_and_resumes_exactly_one() {
7474        let running = task(); // id: whatever `Task::new` minted
7475        let marked = interrupt_task("marked");
7476
7477        let mut state = Interrupt::Idle;
7478        // Tick 1: `running` is in flight, `marked` becomes runnable.
7479        let in_flight = vec![running.id.clone()];
7480        state = advance_interrupt_tick(true, state, &in_flight, std::slice::from_ref(&marked));
7481        let gated = interrupt_gate(&state, &in_flight, vec![marked.clone(), running.clone()]);
7482        assert!(gated.is_empty(), "still waiting on `running` to park");
7483
7484        // Tick 2: `running` parked and left flight; nothing dispatched yet.
7485        state = advance_interrupt_tick(true, state, &[], &[marked.clone(), running.clone()]);
7486        let gated = interrupt_gate(&state, &[], vec![marked.clone(), running.clone()]);
7487        assert_eq!(
7488            gated.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
7489            vec!["marked"],
7490            "only the interrupt task may be offered to the dispatcher now"
7491        );
7492
7493        // Tick 3: `marked` is now in flight (dispatched from tick 2's gate).
7494        state = advance_interrupt_tick(
7495            true,
7496            state,
7497            &["marked".to_owned()],
7498            std::slice::from_ref(&running),
7499        );
7500        let gated = interrupt_gate(
7501            &state,
7502            &["marked".to_owned()],
7503            vec![marked.clone(), running.clone()],
7504        );
7505        assert!(
7506            gated.is_empty(),
7507            "the parked run must not be offered back while the interrupt \
7508             task is still running"
7509        );
7510
7511        // Tick 4: `marked`'s run reached a terminal status and left flight.
7512        // A higher-priority ordinary task `other` is also runnable now - it
7513        // must not be let through instead of, or alongside, `running`.
7514        let other = task_with_id("other");
7515        state = advance_interrupt_tick(true, state, &[], &[running.clone(), other.clone()]);
7516        assert_eq!(
7517            state,
7518            Interrupt::Resuming {
7519                parked: vec![running.id.clone()]
7520            }
7521        );
7522        let gated = interrupt_gate(&state, &[], vec![other.clone(), running.clone()]);
7523        assert_eq!(
7524            gated.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
7525            vec![running.id.as_str()],
7526            "exactly the parked run resumes - not the unrelated task, even \
7527             though it was offered first"
7528        );
7529
7530        // Tick 5: `running` is now in flight (dispatched from tick 4's
7531        // gate). Only now does the sequence end and ordinary dispatch fully
7532        // resume.
7533        state = advance_interrupt_tick(
7534            true,
7535            state,
7536            std::slice::from_ref(&running.id),
7537            std::slice::from_ref(&other),
7538        );
7539        assert_eq!(state, Interrupt::Idle);
7540        let gated = interrupt_gate(
7541            &state,
7542            std::slice::from_ref(&running.id),
7543            vec![other.clone()],
7544        );
7545        assert_eq!(
7546            gated.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
7547            vec![other.id.as_str()],
7548            "ordinary dispatch is unrestricted again"
7549        );
7550    }
7551
7552    #[test]
7553    fn every_run_status_settles_the_task_it_came_from() {
7554        // run status, resulting task status, attempts still standing after one
7555        let table = [
7556            (RunStatus::Merged, TaskStatus::Done, 1),
7557            (RunStatus::Ready, TaskStatus::Done, 1),
7558            (RunStatus::Stalled, TaskStatus::Failed, 0),
7559            (RunStatus::Blocked, TaskStatus::Failed, 1),
7560            (RunStatus::Failed, TaskStatus::Failed, 1),
7561            (RunStatus::VerifiedNoop, TaskStatus::Held, 1),
7562            (RunStatus::Prep, TaskStatus::Failed, 1),
7563            (RunStatus::Implementing, TaskStatus::Failed, 1),
7564            (RunStatus::Judging, TaskStatus::Failed, 1),
7565            (RunStatus::Deliberating, TaskStatus::Failed, 1),
7566            (RunStatus::Voting, TaskStatus::Failed, 1),
7567            (RunStatus::Reviewing, TaskStatus::Failed, 1),
7568            (RunStatus::Gating, TaskStatus::Failed, 1),
7569        ];
7570        for (run, want, attempts) in table {
7571            let mut t = task();
7572            t.start("20260902-000000-aaaa".to_owned());
7573            settle(
7574                &mut t,
7575                Verdict {
7576                    status: run,
7577                    left_pr: false,
7578                    parked: false,
7579                    quota_hit: matches!(run, RunStatus::Stalled),
7580                    no_viable_candidates: false,
7581                },
7582                "why",
7583                2,
7584            );
7585            assert_eq!(t.status, want, "task status after {}", label(run));
7586            assert_eq!(t.attempts, attempts, "attempts after {}", label(run));
7587        }
7588    }
7589
7590    #[test]
7591    fn a_quota_stall_costs_the_task_no_attempt_but_a_block_does() {
7592        let mut stalled = task();
7593        stalled.start("20260902-000000-aaaa".to_owned());
7594        settle(
7595            &mut stalled,
7596            Verdict {
7597                status: RunStatus::Stalled,
7598                left_pr: false,
7599                parked: false,
7600                quota_hit: true,
7601                no_viable_candidates: false,
7602            },
7603            "quota",
7604            1,
7605        );
7606        assert_eq!(stalled.attempts, 0);
7607        assert!(
7608            stalled.status.runnable(),
7609            "a machine problem must leave the task in line"
7610        );
7611
7612        let mut blocked = task();
7613        blocked.start("20260902-000000-aaaa".to_owned());
7614        settle(
7615            &mut blocked,
7616            Verdict {
7617                status: RunStatus::Blocked,
7618                left_pr: false,
7619                parked: false,
7620                quota_hit: false,
7621                no_viable_candidates: false,
7622            },
7623            "findings open",
7624            1,
7625        );
7626        assert_eq!(blocked.attempts, 1);
7627        assert_eq!(
7628            blocked.status,
7629            TaskStatus::Held,
7630            "the last attempt hands the task to a human"
7631        );
7632    }
7633
7634    #[test]
7635    fn a_run_that_opened_a_pull_request_is_never_re_competed() {
7636        // Attempts to spare: without the pull request this task would go
7637        // straight back in line and run the whole competition again.
7638        let mut delivered = task();
7639        delivered.start("20260903-080619-01c2".to_owned());
7640        settle(
7641            &mut delivered,
7642            Verdict {
7643                status: RunStatus::Blocked,
7644                left_pr: true,
7645                parked: false,
7646                quota_hit: false,
7647                no_viable_candidates: false,
7648            },
7649            "no check status",
7650            4,
7651        );
7652        assert_eq!(
7653            delivered.status,
7654            TaskStatus::Held,
7655            "a pull request waiting on CI or a person is not a retryable failure"
7656        );
7657        assert!(
7658            !delivered.status.runnable(),
7659            "the loop must not pick this task up again"
7660        );
7661        assert_eq!(
7662            delivered.last_error.as_deref(),
7663            Some("no check status"),
7664            "the operator needs to be told what the gate was waiting for"
7665        );
7666
7667        // The same status without a pull request is a plain failure, and with
7668        // attempts left it is retried.
7669        let mut empty_handed = task();
7670        empty_handed.start("20260903-080619-01c2".to_owned());
7671        settle(
7672            &mut empty_handed,
7673            Verdict {
7674                status: RunStatus::Blocked,
7675                left_pr: false,
7676                parked: false,
7677                quota_hit: false,
7678                no_viable_candidates: false,
7679            },
7680            "findings open",
7681            4,
7682        );
7683        assert_eq!(empty_handed.status, TaskStatus::Failed);
7684        assert!(empty_handed.status.runnable());
7685    }
7686
7687    #[test]
7688    fn a_verified_noop_run_hands_off_rather_than_closing_or_auto_retrying() {
7689        // Every candidate agreed, with evidence, that nothing belonged in the
7690        // worktree. That is not a confirmed success to close automatically -
7691        // a human still has to check the claim - and it is not an ordinary
7692        // failure either, so this settles exactly like a pull request nobody
7693        // merged yet: `Held`, same as `Blocked` with a PR.
7694        let mut noop = task();
7695        noop.start("20260912-131304-391f".to_owned());
7696        settle(
7697            &mut noop,
7698            Verdict {
7699                status: RunStatus::VerifiedNoop,
7700                left_pr: false,
7701                parked: false,
7702                quota_hit: false,
7703                no_viable_candidates: true,
7704            },
7705            "candidate A: already fixed by b32cfc4, on main",
7706            4,
7707        );
7708        assert_eq!(
7709            noop.status,
7710            TaskStatus::Held,
7711            "an unverified claim is a request for a human, not a failure"
7712        );
7713        assert!(
7714            !noop.status.runnable(),
7715            "the loop must not requeue this on the same unverified claim"
7716        );
7717        // `Task::release` resets attempts to zero the moment a human looks at
7718        // the evidence and lets it run again, so it does not matter here
7719        // whether the one attempt already spent stays spent - what matters is
7720        // that nothing retries this task unattended in the meantime.
7721        assert_eq!(noop.attempts, 1);
7722    }
7723
7724    #[test]
7725    fn parking_costs_the_task_no_attempt_and_leaves_it_in_line() {
7726        // Parking is the operator asking for the process back - to replace the
7727        // binary, most of all. The run's work is intact on disk, so this is
7728        // not a failed attempt, and charging for it would mean a few upgrades
7729        // could exhaust a budget meant for agents that misbehaved.
7730        let mut parked = task();
7731        parked.start("20260903-183634-2d98".to_owned());
7732        settle(
7733            &mut parked,
7734            Verdict {
7735                status: RunStatus::Implementing,
7736                left_pr: false,
7737                quota_hit: false,
7738                parked: true,
7739                no_viable_candidates: false,
7740            },
7741            "parked after `implementing`",
7742            2,
7743        );
7744        assert_eq!(parked.attempts, 0, "a park is refunded");
7745        assert!(
7746            parked.status.runnable(),
7747            "and the task stays in line so the next loop resumes its run"
7748        );
7749        assert_eq!(parked.status, TaskStatus::Parked);
7750        assert_eq!(
7751            parked.park_reason.as_deref(),
7752            Some("parked after `implementing`"),
7753            "the card says where it stopped"
7754        );
7755        assert_eq!(parked.last_error, None, "nothing failed");
7756
7757        // Without the park flag the same non-terminal status is what it always
7758        // was: `execute` returning mid-flight, which is a bug and spends an
7759        // attempt so a task cannot loop on it forever.
7760        let mut broken = task();
7761        broken.start("20260903-183634-2d98".to_owned());
7762        settle(
7763            &mut broken,
7764            Verdict {
7765                status: RunStatus::Implementing,
7766                left_pr: false,
7767                quota_hit: false,
7768                parked: false,
7769                no_viable_candidates: false,
7770            },
7771            "returned mid-flight",
7772            2,
7773        );
7774        assert_eq!(broken.attempts, 1);
7775    }
7776
7777    #[test]
7778    fn only_a_rate_limit_buys_the_task_its_attempt_back() {
7779        // Run e633: quorum lost because two judges answered with the wrong
7780        // JSON shape, `quota: []`. Refunding that takes the bound off the
7781        // retry loop, and each retry pays for a fresh hour-long implement
7782        // wave before it can fail the same way.
7783        let mut flaky = task();
7784        flaky.start("20260903-123023-e633".to_owned());
7785        settle(
7786            &mut flaky,
7787            Verdict {
7788                status: RunStatus::Stalled,
7789                left_pr: false,
7790                parked: false,
7791                quota_hit: false,
7792                no_viable_candidates: false,
7793            },
7794            "verdict rests on 1 of 3 judges",
7795            2,
7796        );
7797        assert_eq!(
7798            flaky.attempts, 1,
7799            "flakiness spends an attempt, so `max_attempts` still bounds it"
7800        );
7801        assert!(flaky.status.runnable(), "and it is still worth retrying");
7802
7803        // The same status, lost to a rate limit, is the machine's fault.
7804        let mut limited = task();
7805        limited.start("20260903-123023-e633".to_owned());
7806        settle(
7807            &mut limited,
7808            Verdict {
7809                status: RunStatus::Stalled,
7810                left_pr: false,
7811                parked: false,
7812                quota_hit: true,
7813                no_viable_candidates: false,
7814            },
7815            "judge-2, judge-3 out of quota",
7816            2,
7817        );
7818        assert_eq!(limited.attempts, 0, "a quota window is refunded");
7819        assert!(limited.status.runnable());
7820
7821        // And the bound really binds: a task that keeps stalling on flakiness
7822        // reaches a human instead of running the roster forever.
7823        let mut worn = task();
7824        for _ in 0..2 {
7825            worn.release();
7826        }
7827        worn.start("20260903-123023-e633".to_owned());
7828        worn.attempts = 2;
7829        settle(
7830            &mut worn,
7831            Verdict {
7832                status: RunStatus::Stalled,
7833                left_pr: false,
7834                parked: false,
7835                quota_hit: false,
7836                no_viable_candidates: false,
7837            },
7838            "no quorum again",
7839            2,
7840        );
7841        assert_eq!(worn.status, TaskStatus::Held);
7842        assert!(!worn.status.runnable());
7843    }
7844
7845    #[test]
7846    fn a_quota_wipeout_that_leaves_nothing_to_judge_also_costs_no_attempt() {
7847        // The implement wave loses every seat to the same rate limit and
7848        // `after_implement` bails with nothing viable, which surfaces as
7849        // `Failed` rather than `Stalled`. That is the same machine fact the
7850        // `Stalled`-quota row already refunds, and must be refunded the same
7851        // way, or a quota outage quietly holds every task it touches instead
7852        // of leaving them in line for the reset.
7853        let mut wiped_out = task();
7854        wiped_out.start("20260907-025000-a1b2".to_owned());
7855        settle(
7856            &mut wiped_out,
7857            Verdict {
7858                status: RunStatus::Failed,
7859                left_pr: false,
7860                parked: false,
7861                quota_hit: true,
7862                no_viable_candidates: true,
7863            },
7864            "no candidate produced a change; nothing to judge",
7865            2,
7866        );
7867        assert_eq!(wiped_out.attempts, 0, "a total quota wipeout is refunded");
7868        assert!(
7869            wiped_out.status.runnable(),
7870            "a machine problem must leave the task in line"
7871        );
7872
7873        // This is the exemption that must stay narrow: a candidate that did
7874        // produce a change, and then failed for some other reason, still
7875        // spends the attempt even though a seat elsewhere hit its quota.
7876        // Otherwise every ordinary failure that happens to share a run with
7877        // an unrelated rate limit would be refunded for free.
7878        let mut partial_progress = task();
7879        partial_progress.start("20260907-025500-c3d4".to_owned());
7880        settle(
7881            &mut partial_progress,
7882            Verdict {
7883                status: RunStatus::Failed,
7884                left_pr: false,
7885                parked: false,
7886                quota_hit: true,
7887                no_viable_candidates: false,
7888            },
7889            "gate failed on the winning candidate",
7890            2,
7891        );
7892        assert_eq!(
7893            partial_progress.attempts, 1,
7894            "a candidate that actually produced a change spends the attempt \
7895             even though some other seat hit its quota"
7896        );
7897        assert!(partial_progress.status.runnable());
7898    }
7899
7900    #[test]
7901    fn reclaim_refunds_a_recovered_quota_wipeout_the_same_way_a_live_settle_does() {
7902        // `reclaim` builds its own `Verdict` from a `RunState` it loads off
7903        // disk, and that construction must reach the same conclusion as the
7904        // one `attempt` builds from a live run, or a crash at exactly the
7905        // wrong moment gives a recovered task a different policy than one a
7906        // daemon finished settling itself.
7907        let mut t = task();
7908        t.start("20260907-025000-a1b2".to_owned());
7909        let mut state = run_state(RunStatus::Failed);
7910        state.quota.push(QuotaLoss {
7911            seat: "cand-a".to_owned(),
7912            node: "implement".to_owned(),
7913            at: Timestamp::now(),
7914            reset: None,
7915        });
7916        assert!(
7917            state.viable().is_empty(),
7918            "no candidate was added, so nothing is viable"
7919        );
7920        reclaim(&mut t, Some(state), 2, "en");
7921        assert_eq!(t.attempts, 0, "a recovered quota wipeout is refunded");
7922        assert!(t.status.runnable());
7923    }
7924
7925    #[test]
7926    fn a_held_task_is_never_offered_to_the_loop() {
7927        let dir = tempfile::tempdir().unwrap();
7928        let queue = Queue::at(dir.path().to_path_buf());
7929        for (n, priority) in [(1, 0), (2, 5), (3, 5)] {
7930            let mut t = task();
7931            t.id = format!("2026090{n}-000000-000{n}");
7932            t.priority = priority;
7933            queue.put(&mut t).unwrap();
7934        }
7935        let mut held = task();
7936        held.id = "20260909-000000-9999".to_owned();
7937        held.priority = 99;
7938        held.hold_machine(None);
7939        queue.put(&mut held).unwrap();
7940
7941        let order: Vec<String> = runnable(&queue).into_iter().map(|t| t.id).collect();
7942        assert_eq!(order.len(), 3);
7943        assert!(!order.contains(&held.id));
7944        assert_eq!(
7945            order.first().cloned(),
7946            queue.next_runnable().map(|t| t.id),
7947            "the loop's first candidate is exactly what the queue offers"
7948        );
7949        assert_eq!(
7950            order,
7951            vec![
7952                "20260902-000000-0002".to_owned(),
7953                "20260903-000000-0003".to_owned(),
7954                "20260901-000000-0001".to_owned(),
7955            ],
7956            "priority first, then oldest, so nothing starves"
7957        );
7958    }
7959
7960    #[test]
7961    fn sweep_removes_an_old_unparseable_lock_and_keeps_a_live_one() {
7962        let dir = tempfile::tempdir().unwrap();
7963        let queue = Queue::at(dir.path().to_path_buf());
7964        let mut old = task();
7965        old.id = "20260101-000000-old0".to_owned();
7966        queue.put(&mut old).unwrap();
7967        let mut fresh = task();
7968        fresh.id = "20260101-000000-new0".to_owned();
7969        queue.put(&mut fresh).unwrap();
7970
7971        // No parseable pid at all, so age is the only signal there is to
7972        // check - unlike a real `Queue::claim`, which always names a real,
7973        // and therefore alive, pid this test cannot fake as dead.
7974        std::fs::write(dir.path().join(format!("{}.lock", old.id)), "not a pid").unwrap();
7975        std::thread::sleep(Duration::from_millis(60));
7976        let live = queue.claim(&fresh.id).unwrap();
7977
7978        let swept = sweep_stale_claims(&queue, Duration::from_millis(50));
7979        assert_eq!(swept, vec![old.id.clone()]);
7980        assert!(
7981            queue.claim(&old.id).is_ok(),
7982            "an unparseable lock older than the threshold is swept"
7983        );
7984        assert!(
7985            queue.claim(&fresh.id).is_err(),
7986            "a live pid protects its lock regardless of age"
7987        );
7988        drop(live);
7989    }
7990
7991    #[test]
7992    fn an_old_lock_whose_pid_is_still_alive_is_never_swept_by_age_alone() {
7993        // A live worker's claim remains authoritative even beyond the sweep's
7994        // age limit. Sweeping it would permit a second competing attempt.
7995        let dir = tempfile::tempdir().unwrap();
7996        let queue = Queue::at(dir.path().to_path_buf());
7997        let mut t = task();
7998        t.id = "20260101-000000-live".to_owned();
7999        queue.put(&mut t).unwrap();
8000
8001        let claim = queue.claim(&t.id).unwrap();
8002        std::thread::sleep(Duration::from_millis(60));
8003
8004        let swept = sweep_stale_claims(&queue, Duration::from_millis(50));
8005        assert!(
8006            swept.is_empty(),
8007            "a lock naming a live pid must never be swept by age, no matter how old: {swept:?}"
8008        );
8009        assert!(
8010            queue.claim(&t.id).is_err(),
8011            "the lock still protects its task"
8012        );
8013        drop(claim);
8014    }
8015
8016    /// このテストプロセスにはなり得ない決定的なフィクスチャ PID。
8017    /// OS 上の状態は意図的に無関係で、各利用箇所が方針問い合わせを注入する。
8018    fn injected_dead_pid() -> u32 {
8019        std::process::id().checked_add(1).unwrap_or(1)
8020    }
8021
8022    #[test]
8023    fn a_lock_naming_a_dead_pid_is_swept_at_once_regardless_of_age() {
8024        let dir = tempfile::tempdir().unwrap();
8025        let queue = Queue::at(dir.path().to_path_buf());
8026        let mut t = task();
8027        t.id = "20260101-000000-dead".to_owned();
8028        queue.put(&mut t).unwrap();
8029        let dead_pid = injected_dead_pid();
8030
8031        // Written directly rather than through `Queue::claim`, which would
8032        // stamp this test process's own very much alive pid and defeat the
8033        // point: this is what a `.lock` left by a `SIGKILL`ed daemon looks
8034        // like moments after it died, not six hours later.
8035        std::fs::write(
8036            dir.path().join(format!("{}.lock", t.id)),
8037            dead_pid.to_string(),
8038        )
8039        .unwrap();
8040
8041        let swept = sweep_stale_claims_with(&queue, Duration::from_secs(6 * 60 * 60), |pid| {
8042            pid != dead_pid
8043        });
8044        assert_eq!(
8045            swept,
8046            vec![t.id.clone()],
8047            "a dead owner is reclaimed immediately, not after STALE_CLAIM"
8048        );
8049        assert!(queue.claim(&t.id).is_ok(), "the task is claimable again");
8050    }
8051
8052    #[test]
8053    fn sweeping_on_every_poll_catches_a_lock_that_appears_after_the_first_sweep() {
8054        let dir = tempfile::tempdir().unwrap();
8055        let queue = Queue::at(dir.path().to_path_buf());
8056        let mut t = task();
8057        t.id = "20260101-000000-late".to_owned();
8058        queue.put(&mut t).unwrap();
8059        let dead_pid = injected_dead_pid();
8060
8061        // Tick one, standing in for the sweep `poll` already runs at
8062        // startup: nothing to find yet.
8063        assert!(
8064            sweep_stale_claims(&queue, Duration::from_secs(6 * 60 * 60)).is_empty(),
8065            "nothing has claimed the task yet"
8066        );
8067
8068        // A second daemon claims the task and dies before it ever writes
8069        // `running`, well after this loop's own startup sweep already ran.
8070        std::fs::write(
8071            dir.path().join(format!("{}.lock", t.id)),
8072            dead_pid.to_string(),
8073        )
8074        .unwrap();
8075
8076        // Tick two, standing in for a poll long into this daemon's uptime:
8077        // the same function, called again, notices what only just appeared -
8078        // proving the sweep is not a one-shot startup check.
8079        let swept = sweep_stale_claims_with(&queue, Duration::from_secs(6 * 60 * 60), |pid| {
8080            pid != dead_pid
8081        });
8082        assert_eq!(swept, vec![t.id.clone()]);
8083    }
8084
8085    #[test]
8086    fn a_running_task_behind_a_dead_daemons_lock_recovers_once_swept_and_keeps_its_history() {
8087        // `reclaim_orphaned_running` looks up the task's last run, which
8088        // touches `run::home()`; the first call anywhere in this binary wins,
8089        // so this is a no-op if another test already pinned one, and either
8090        // way the run id below is never written under it.
8091        crate::run::pin_test_home();
8092        let dir = tempfile::tempdir().unwrap();
8093        let queue = Queue::at(dir.path().to_path_buf());
8094        let mut t = task();
8095        t.id = "20260101-000000-crsh".to_owned();
8096        t.status = TaskStatus::Running;
8097        t.attempts = 1;
8098        // No `run.json` behind this id: standing in for a run this test does
8099        // not need to make readable, since the point is the lock, not the
8100        // recovery table `reclaim` already has its own tests for.
8101        t.runs.push("20260904-000000-4043".to_owned());
8102        queue.put(&mut t).unwrap();
8103        let dead_pid = injected_dead_pid();
8104
8105        // The crashed daemon's own claim, naming a pid nothing on the
8106        // machine holds anymore.
8107        std::fs::write(
8108            dir.path().join(format!("{}.lock", t.id)),
8109            dead_pid.to_string(),
8110        )
8111        .unwrap();
8112
8113        // Before the lock is swept the task looks claimed, and
8114        // `reclaim_orphaned_running` must leave it alone - this is exactly
8115        // the bug: a `running` task stranded behind a dead daemon's lock,
8116        // invisible to the claim-as-proof check because the lock outlived
8117        // the process that wrote it.
8118        assert!(reclaim_orphaned_running(&queue, 2, &Default::default()).is_empty());
8119        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Running);
8120
8121        let swept = sweep_stale_claims_with(&queue, Duration::from_secs(6 * 60 * 60), |pid| {
8122            pid != dead_pid
8123        });
8124        assert_eq!(swept, vec![t.id.clone()]);
8125
8126        let reclaimed = reclaim_orphaned_running(&queue, 2, &Default::default());
8127        assert_eq!(reclaimed, vec![t.id.clone()]);
8128        let after = queue.get(&t.id).unwrap();
8129        assert_eq!(
8130            after.status,
8131            TaskStatus::Held,
8132            "no run.json to recover from, so a human is asked"
8133        );
8134        assert_eq!(
8135            after.runs,
8136            vec!["20260904-000000-4043".to_owned()],
8137            "the crashed run's id is kept as evidence, not discarded"
8138        );
8139    }
8140
8141    #[test]
8142    fn a_lock_is_kept_when_the_process_query_is_unavailable() {
8143        let dir = tempfile::tempdir().unwrap();
8144        let queue = Queue::at(dir.path().to_path_buf());
8145        let mut t = task();
8146        t.id = "20260101-000000-unknown".to_owned();
8147        queue.put(&mut t).unwrap();
8148        let dead_pid = injected_dead_pid();
8149        std::fs::write(
8150            dir.path().join(format!("{}.lock", t.id)),
8151            dead_pid.to_string(),
8152        )
8153        .unwrap();
8154
8155        let swept = sweep_stale_claims_with(&queue, Duration::ZERO, |_| true);
8156        assert!(swept.is_empty(), "an unknown pid must keep its lock");
8157        assert!(queue.claim(&t.id).is_err(), "the lock remains protective");
8158    }
8159
8160    fn run_state_in(status: RunStatus, language: &str) -> RunState {
8161        let mut s = run_state(status);
8162        s.config.graph.language = language.to_owned();
8163        s
8164    }
8165
8166    fn unstarted_verdict(status: RunStatus) -> Verdict {
8167        Verdict {
8168            status,
8169            left_pr: false,
8170            quota_hit: false,
8171            parked: false,
8172            no_viable_candidates: false,
8173        }
8174    }
8175
8176    #[test]
8177    fn an_already_in_base_run_finishes_the_task_without_spending_an_attempt() {
8178        let mut t = task();
8179        t.attempts = 1;
8180        settle_in(
8181            &mut t,
8182            unstarted_verdict(RunStatus::AlreadyInBase),
8183            "already in main",
8184            1,
8185            phrases("en"),
8186        );
8187        assert_eq!(t.status, TaskStatus::Done);
8188        assert_eq!(t.attempts, 0);
8189    }
8190
8191    #[test]
8192    fn settle_renders_the_non_terminal_reason_in_the_configured_language() {
8193        let reason = |language: &str| {
8194            let mut t = task();
8195            settle_in(
8196                &mut t,
8197                unstarted_verdict(RunStatus::Judging),
8198                "boom",
8199                1,
8200                phrases(language),
8201            );
8202            t.last_error.or(t.hold_reason).unwrap_or_default()
8203        };
8204        assert!(
8205            reason("en").starts_with("the graph stopped at `"),
8206            "{}",
8207            reason("en")
8208        );
8209        assert!(reason("ja").starts_with("グラフが終端状態に達しないまま"));
8210        assert!(reason("日本語").contains("boom"));
8211        assert_eq!(reason("fr"), reason("en"));
8212    }
8213
8214    #[test]
8215    fn describe_follows_the_run_language_and_keeps_the_run_id() {
8216        let en = describe(&run_state_in(RunStatus::Stalled, "en"));
8217        assert!(en.starts_with("the judging panel lost its quorum"), "{en}");
8218        let ja = describe(&run_state_in(RunStatus::Stalled, "ja"));
8219        assert!(ja.starts_with("審査パネルが定足数を失いました"), "{ja}");
8220        assert!(ja.contains("[run "), "{ja}");
8221        let ended = describe(&run_state_in(RunStatus::Failed, "jp"));
8222        assert!(ended.starts_with("run 終了: "), "{ended}");
8223        let mut de = run_state_in(RunStatus::Failed, "de");
8224        let mut en = run_state_in(RunStatus::Failed, "en");
8225        de.id = "same".to_owned();
8226        en.id = "same".to_owned();
8227        assert_eq!(describe(&de), describe(&en));
8228    }
8229
8230    #[test]
8231    fn handover_refusals_follow_the_language_and_keep_the_detail_apart() {
8232        use crate::handover::Refused;
8233        let cases = [
8234            Refused::Foreign {
8235                branch: "b".into(),
8236                path: "/w/x".into(),
8237                why: "made by hand".into(),
8238            },
8239            Refused::Unsafe {
8240                branch: "b".into(),
8241                path: "/w/x".into(),
8242                why: "its worktree has uncommitted changes (a.rs)".into(),
8243            },
8244            Refused::ReleaseFailed {
8245                branch: "b".into(),
8246                path: "/w/x".into(),
8247                run: "ab12".into(),
8248            },
8249        ];
8250        for r in &cases {
8251            let en = (phrases("en").handover_refused)(r);
8252            assert_eq!(en, r.to_string());
8253            let ja = (phrases("ja").handover_refused)(r);
8254            assert!(
8255                !ja.contains("is checked out") && !ja.contains("try again"),
8256                "{ja}"
8257            );
8258            assert!(ja.contains("`b`") && ja.contains("/w/x"), "{ja}");
8259            if let Refused::Foreign { why, .. } | Refused::Unsafe { why, .. } = r {
8260                assert!(ja.contains(&format!("(詳細: {why})")), "{ja}");
8261            }
8262        }
8263        assert!(phrases("en").handover_hint.contains("release the task"));
8264        assert!(phrases("ja").handover_hint.contains("解放"));
8265    }
8266
8267    #[test]
8268    fn describe_translates_the_unanswered_reviewer_stop_only_in_ja() {
8269        let build = |lang: &str| {
8270            let mut s = run_state_in(RunStatus::Blocked, lang);
8271            s.id = "same".to_owned();
8272            s.config.graph.review_rounds = 3;
8273            let mut r = review_round(3);
8274            r.expected = 3;
8275            r.answered = 1;
8276            s.reviews.push(r);
8277            s.event(
8278                "review",
8279                "2 reviewer seat(s) never answered after 3 rounds; refusing to call it clean",
8280            );
8281            s
8282        };
8283        let en = describe(&build("en"));
8284        assert!(en.contains("(review: 2 reviewer seat(s) never answered after 3 rounds; refusing to call it clean)"), "{en}");
8285        let ja = describe(&build("ja"));
8286        assert!(ja.contains("2 席のレビュアーが 3 ラウンド"), "{ja}");
8287        assert!(!ja.contains("never answered"), "{ja}");
8288        assert!(ja.contains("[run "), "{ja}");
8289
8290        // An incomplete panel whose verification failed is a different stop:
8291        // the real event must survive in ja too.
8292        let mut failed = build("ja");
8293        failed.reviews[0].e2e.push(crate::run::CommandOutcome {
8294            command: "cargo test".to_owned(),
8295            code: Some(1),
8296            output_tail: String::new(),
8297            duration_ms: 0,
8298            resource_blocked: false,
8299        });
8300        failed.event("review", "stopped; e2e failed: cargo test");
8301        let ja = describe(&failed);
8302        assert!(ja.contains("stopped; e2e failed: cargo test"), "{ja}");
8303        assert!(!ja.contains("席のレビュアー"), "{ja}");
8304    }
8305
8306    #[test]
8307    fn refusals_and_recovery_prose_follow_the_language() {
8308        let t = held_task_with("r1");
8309        let q = action_question(
8310            "r1",
8311            ask::ChoiceAction::Resume {
8312                run: "r1".to_owned(),
8313            },
8314        );
8315        let refuse = |p: &Phrases| match decide_action(&t, &q, p, |_: &str| bail!("gone")) {
8316            ActionDecision::Refuse(s) => s,
8317            other => panic!("{other:?}"),
8318        };
8319        assert!(refuse(phrases("en")).contains("could not be read: gone"));
8320        assert!(refuse(phrases("ja")).contains("読み込めませんでした: gone"));
8321        assert_eq!(refuse(phrases("fr")), refuse(phrases("en")));
8322
8323        let mut held = task();
8324        reclaim(&mut held, None, 2, "ja");
8325        assert!(held.hold_reason.unwrap().contains("保留にしました"));
8326        let mut held = task();
8327        reclaim(&mut held, None, 2, "xx");
8328        assert!(held.hold_reason.unwrap().contains("held for a human"));
8329    }
8330
8331    fn run_state(status: RunStatus) -> RunState {
8332        let mut state = RunState::new(
8333            PathBuf::from("/repo"),
8334            "main".to_owned(),
8335            "abc1234def".to_owned(),
8336            "add retries".to_owned(),
8337            Config::default(),
8338        );
8339        state.status = status;
8340        state
8341    }
8342
8343    fn candidate(label: char, summary: &str, empty: bool, failed: Option<&str>) -> Candidate {
8344        Candidate {
8345            index: 0,
8346            label,
8347            agent: "claude".to_owned(),
8348            branch: format!("magi/x/{label}"),
8349            worktree: PathBuf::from("/repo"),
8350            summary: summary.to_owned(),
8351            stat: String::new(),
8352            files: 0,
8353            commits: usize::from(!empty),
8354            empty,
8355            failed: failed.map(str::to_owned),
8356            verified_noop: None,
8357            duration_ms: 0,
8358            folded: false,
8359        }
8360    }
8361
8362    #[test]
8363    fn diagnostic_names_the_failing_gate_checks_and_their_output() {
8364        let mut state = run_state(RunStatus::Blocked);
8365        state.gate = vec![
8366            CommandOutcome {
8367                command: "cargo make check".to_owned(),
8368                code: Some(0),
8369                output_tail: "ok".to_owned(),
8370                duration_ms: 0,
8371                resource_blocked: false,
8372            },
8373            CommandOutcome {
8374                command: "cargo test".to_owned(),
8375                code: Some(101),
8376                output_tail: "thread 'x' panicked: assertion failed".to_owned(),
8377                duration_ms: 0,
8378                resource_blocked: false,
8379            },
8380        ];
8381        let d = diagnostic(&state).expect("a failing gate must produce a diagnostic");
8382        assert!(d.contains("cargo test"), "{d}");
8383        assert!(
8384            !d.contains("cargo make check"),
8385            "a passing check is not a diagnostic: {d}"
8386        );
8387        assert!(d.contains("assertion failed"), "{d}");
8388    }
8389
8390    #[test]
8391    fn diagnostic_names_the_checks_the_fixer_gave_up_in_front_of() {
8392        let mut state = run_state(RunStatus::Blocked);
8393        state.event(
8394            "land",
8395            "stopped: the fixer produced no commit while 2 check(s) were failing \
8396             (build, lint); stopping instead of looping on an unchanged tree",
8397        );
8398        let d = diagnostic(&state).expect("a stalled land loop must produce a diagnostic");
8399        assert!(d.contains("build"), "{d}");
8400        assert!(d.contains("lint"), "{d}");
8401        assert!(d.contains("fixer produced no commit"), "{d}");
8402    }
8403
8404    #[test]
8405    fn describe_never_leaves_a_verified_noop_reading_as_a_bare_status_code() {
8406        // `describe`'s output becomes `Task::last_error` verbatim, and the
8407        // phone renders that in the same alarm-styled box an ordinary
8408        // failure gets. A bare `verified_noop` there would read exactly like
8409        // the failure this status exists to be told apart from.
8410        let state = run_state(RunStatus::VerifiedNoop);
8411        let d = describe(&state);
8412        assert!(
8413            d.contains("agent-verified no-op"),
8414            "expected the display label, not the wire spelling: {d}"
8415        );
8416        assert!(!d.contains("verified_noop"), "{d}");
8417    }
8418
8419    #[test]
8420    fn diagnostic_carries_a_candidates_own_final_word_when_none_was_viable() {
8421        // The whole point of the feature: a run held as "no candidate produced
8422        // a change" can mean the implementer actually finished the task and
8423        // only left a clean local tree behind - see AGENTS.md on this exact
8424        // failure mode. The diagnostic has to carry what the agent actually
8425        // said, not just the fact that nothing was there to judge.
8426        let mut state = run_state(RunStatus::Failed);
8427        state.candidates = vec![candidate(
8428            'A',
8429            "opened pull request #42, merged it, tagged v1.2.3 and published the release",
8430            true,
8431            None,
8432        )];
8433        let d = diagnostic(&state).expect("an empty candidate with a summary must be surfaced");
8434        assert!(d.contains("candidate A"), "{d}");
8435        assert!(d.contains("tagged v1.2.3"), "{d}");
8436    }
8437
8438    #[test]
8439    fn diagnostic_falls_back_to_a_candidates_failure_reason_when_it_has_no_summary() {
8440        let mut state = run_state(RunStatus::Failed);
8441        state.candidates = vec![candidate('A', "", true, Some("agent timed out"))];
8442        let d = diagnostic(&state).expect("a candidate's own failure reason must be surfaced");
8443        assert!(d.contains("candidate A"), "{d}");
8444        assert!(d.contains("agent timed out"), "{d}");
8445    }
8446
8447    #[test]
8448    fn diagnostic_is_none_when_nothing_recognisable_explains_the_hold() {
8449        // A viable candidate existed, the gate never ran, and nothing land
8450        // said matches - `Task::last_error` is left to explain this one alone.
8451        let mut state = run_state(RunStatus::Failed);
8452        state.candidates = vec![candidate('A', "did the work", false, None)];
8453        assert!(diagnostic(&state).is_none());
8454    }
8455
8456    #[test]
8457    fn diagnostic_is_bounded_however_much_a_run_printed() {
8458        let mut state = run_state(RunStatus::Blocked);
8459        state.gate = vec![
8460            CommandOutcome {
8461                command: "cargo test".to_owned(),
8462                code: Some(101),
8463                output_tail: "x".repeat(50_000),
8464                duration_ms: 0,
8465                resource_blocked: false,
8466            },
8467            CommandOutcome {
8468                command: "cargo clippy".to_owned(),
8469                code: Some(1),
8470                output_tail: "y".repeat(50_000),
8471                duration_ms: 0,
8472                resource_blocked: false,
8473            },
8474        ];
8475        state.candidates = vec![
8476            candidate('A', &"z".repeat(50_000), true, None),
8477            candidate('B', &"w".repeat(50_000), true, None),
8478        ];
8479        let d = diagnostic(&state).expect("plenty here to diagnose");
8480        assert!(
8481            d.len() <= DIAGNOSTIC_MAX,
8482            "diagnostic grew to {} bytes, unbounded",
8483            d.len()
8484        );
8485    }
8486
8487    #[test]
8488    fn selected_delivery_guards_survive_recovery_failure_accounting() {
8489        for status in [RunStatus::Ready, RunStatus::Blocked, RunStatus::Failed] {
8490            let mut t = task();
8491            let mut state = run_state(status);
8492            t.pr_recovery = Some(crate::pr_recovery::Selection {
8493                url: "url".into(),
8494                branch: format!("magi/task-{}", t.id),
8495                head: "a".repeat(40),
8496                source_head: None,
8497                source_run: None,
8498                source_proof: None,
8499                disposition: Some(crate::pr_recovery::Disposition::Land),
8500                instruction: t.instruction.clone(),
8501                approved_at: Timestamp::now(),
8502            });
8503            state.pr_recovery = t.pr_recovery.clone();
8504            t.start(state.id.clone());
8505            settle_and_diagnose(
8506                &mut t,
8507                Verdict {
8508                    status,
8509                    left_pr: false,
8510                    quota_hit: false,
8511                    parked: false,
8512                    no_viable_candidates: false,
8513                },
8514                "delivery stopped",
8515                5,
8516                &state,
8517            );
8518            assert_eq!(t.status, TaskStatus::Held);
8519            let reason = t.hold_reason.as_deref().unwrap();
8520            if status == RunStatus::Ready {
8521                assert!(reason.contains("Selected PR delivery is incomplete"));
8522                assert!(t.recovery_budget.is_none());
8523            } else {
8524                assert!(reason.contains("Selected recovery stopped"));
8525                assert_eq!(t.recovery_budget.as_ref().unwrap().failures, 1);
8526            }
8527            assert!(t.pr_recovery.is_some());
8528        }
8529    }
8530
8531    #[test]
8532    fn settle_and_diagnose_attaches_a_diagnostic_only_once_the_task_is_held() {
8533        let mut state = run_state(RunStatus::Blocked);
8534        state.gate = vec![CommandOutcome {
8535            command: "cargo test".to_owned(),
8536            code: Some(101),
8537            output_tail: "assertion failed".to_owned(),
8538            duration_ms: 0,
8539            resource_blocked: false,
8540        }];
8541        let verdict = Verdict {
8542            status: RunStatus::Blocked,
8543            left_pr: false,
8544            quota_hit: false,
8545            parked: false,
8546            no_viable_candidates: false,
8547        };
8548
8549        // Attempt one of two still has a retry coming: no diagnostic yet, the
8550        // task is going to run again and this run's evidence would go stale.
8551        let mut t = task();
8552        t.start("run-1".to_owned());
8553        settle_and_diagnose(&mut t, verdict, "gate failed", 2, &state);
8554        assert_eq!(t.status, TaskStatus::Failed);
8555        assert!(t.diagnostic.is_none());
8556
8557        // Attempt two exhausts the budget: now it is held, and the
8558        // diagnostic is what `magi task show` has to say more than one line.
8559        t.start("run-2".to_owned());
8560        settle_and_diagnose(&mut t, verdict, "gate failed", 2, &state);
8561        assert_eq!(t.status, TaskStatus::Held);
8562        let d = t.diagnostic.expect("a held task must carry its diagnostic");
8563        assert!(d.contains("cargo test"), "{d}");
8564    }
8565
8566    #[test]
8567    fn a_held_task_names_the_open_question_it_is_waiting_on() {
8568        // Task 20260928-191407-05fd's own bug: a verified no-op that filed a
8569        // `magi ask` question settled Held with `hold_reason: null`, so the
8570        // notification said nothing actionable. `note_open_question` should
8571        // append the question id to whatever `handed_off` already wrote.
8572        crate::run::pin_test_home();
8573        let home = crate::run::home();
8574        let state = run_state(RunStatus::VerifiedNoop);
8575        let mut q = ask::Question::new(
8576            state.id.clone(),
8577            "implement".to_owned(),
8578            "impl-A".to_owned(),
8579            "is this really a no-op?".to_owned(),
8580            String::new(),
8581            Vec::new(),
8582        );
8583        Questions::at(home.join("questions")).put(&mut q).unwrap();
8584
8585        let verdict = Verdict {
8586            status: RunStatus::VerifiedNoop,
8587            left_pr: false,
8588            quota_hit: false,
8589            parked: false,
8590            no_viable_candidates: false,
8591        };
8592        let mut t = task();
8593        t.start(state.id.clone());
8594        settle_and_diagnose(&mut t, verdict, "run ended agent-verified no-op", 5, &state);
8595
8596        assert_eq!(t.status, TaskStatus::Held);
8597        let reason = t.hold_reason.expect("a held task must record why");
8598        assert!(
8599            reason.starts_with("run ended agent-verified no-op"),
8600            "the original settle reason must survive unchanged: {reason}"
8601        );
8602        assert!(
8603            reason.contains(q.short()),
8604            "the open question's id must be named so the notice is actionable: {reason}"
8605        );
8606    }
8607
8608    #[test]
8609    fn a_held_task_with_no_open_question_keeps_its_plain_reason() {
8610        crate::run::pin_test_home();
8611        let state = run_state(RunStatus::VerifiedNoop);
8612
8613        let verdict = Verdict {
8614            status: RunStatus::VerifiedNoop,
8615            left_pr: false,
8616            quota_hit: false,
8617            parked: false,
8618            no_viable_candidates: false,
8619        };
8620        let mut t = task();
8621        t.start(state.id.clone());
8622        settle_and_diagnose(&mut t, verdict, "run ended agent-verified no-op", 5, &state);
8623
8624        assert_eq!(t.status, TaskStatus::Held);
8625        assert_eq!(
8626            t.hold_reason.as_deref(),
8627            Some("run ended agent-verified no-op"),
8628            "nothing to append when the question was already answered or never asked"
8629        );
8630    }
8631
8632    #[test]
8633    fn supersede_prior_runs_rewrites_an_earlier_blocked_attempt_once_a_later_one_lands() {
8634        crate::run::pin_test_home();
8635        let mut first = run_state(RunStatus::Blocked);
8636        first.id = "20260101-000000-sup1".to_owned();
8637        first.save().unwrap();
8638        let mut second = run_state(RunStatus::Merged);
8639        second.id = "20260101-000000-sup2".to_owned();
8640        second.save().unwrap();
8641
8642        let mut t = task();
8643        t.runs = vec![first.id.clone(), second.id.clone()];
8644        t.status = TaskStatus::Done;
8645
8646        supersede_prior_runs(&t, &crate::run::home());
8647
8648        assert_eq!(
8649            RunState::load(&first.id).unwrap().status,
8650            RunStatus::Superseded,
8651            "the first attempt's Blocked no longer needs anyone's attention"
8652        );
8653        assert_eq!(
8654            RunState::load(&second.id).unwrap().status,
8655            RunStatus::Merged,
8656            "the run that actually succeeded is left exactly as it was"
8657        );
8658    }
8659
8660    #[test]
8661    fn supersede_prior_runs_leaves_a_manually_resumed_attempt_alone() {
8662        // `is_working_on` alone only proves a *daemon* claim; a `magi run
8663        // --resume` invoked by hand outside the daemon never touches
8664        // `daemon.json` at all, so it would look identical to a genuinely
8665        // idle run without also consulting `RunState::liveness`, which reads
8666        // this run's own recorded `driver_pid` instead.
8667        crate::run::pin_test_home();
8668        let mut first = run_state(RunStatus::Blocked);
8669        first.id = "20260101-000000-sup9".to_owned();
8670        // This test process's own pid: guaranteed alive without needing a
8671        // real second process or daemon.
8672        first.driver_pid = Some(std::process::id());
8673        first.driver_started_at = Some(
8674            crate::proc::process_started_at(std::process::id())
8675                .expect("this test process's own start time must be queryable"),
8676        );
8677        first.save().unwrap();
8678        let mut second = run_state(RunStatus::Merged);
8679        second.id = "20260101-000000-supa".to_owned();
8680        second.save().unwrap();
8681
8682        let mut t = task();
8683        t.runs = vec![first.id.clone(), second.id.clone()];
8684        t.status = TaskStatus::Done;
8685
8686        supersede_prior_runs(&t, &crate::run::home());
8687
8688        assert_eq!(
8689            RunState::load(&first.id).unwrap().status,
8690            RunStatus::Blocked,
8691            "a live driver_pid means something is still actually working this run, \
8692             even though no daemon claims it - rewriting under it would just be \
8693             undone the next time that process saves"
8694        );
8695    }
8696
8697    #[test]
8698    fn resweep_catches_up_a_run_left_live_once_its_manual_process_is_no_longer_driving_it() {
8699        // The first resweep must skip a still-live attempt exactly like
8700        // `supersede_prior_runs` itself does; a later resweep, once that
8701        // process is no longer actually driving it, is what closes the gap
8702        // `supersede_prior_runs` alone leaves - a task only ever reaches
8703        // `Done` once, so nothing else would ever look at this run again.
8704        let dir = tempfile::tempdir().unwrap();
8705        let home = dir.path().to_path_buf();
8706        let queue = Queue::at(dir.path().join("queue"));
8707
8708        let mut first = run_state(RunStatus::Blocked);
8709        first.id = "20260101-000000-supd".to_owned();
8710        first.driver_pid = Some(std::process::id());
8711        first.driver_started_at = Some(
8712            crate::proc::process_started_at(std::process::id())
8713                .expect("this test process's own start time must be queryable"),
8714        );
8715        first.save_under(&home).unwrap();
8716        let mut second = run_state(RunStatus::Merged);
8717        second.id = "20260101-000000-supe".to_owned();
8718        second.save_under(&home).unwrap();
8719
8720        let mut t = task();
8721        t.runs = vec![first.id.clone(), second.id.clone()];
8722        t.status = TaskStatus::Done;
8723        queue.put(&mut t).unwrap();
8724
8725        resweep_superseded_attempts(&queue, &home);
8726        assert_eq!(
8727            RunState::load_under(&first.id, &home).unwrap().status,
8728            RunStatus::Blocked,
8729            "still live on the first pass, so still untouched"
8730        );
8731
8732        // Stand in for the manual process no longer being the one driving
8733        // this run: same pid (this test process really is still alive), but
8734        // an identity marker that no longer matches it - see
8735        // `RunState::liveness`'s own doc for why a mismatched
8736        // `driver_started_at` reads as `Dead`, not merely `Unknown`.
8737        let mut stale = RunState::load_under(&first.id, &home).unwrap();
8738        stale.driver_started_at = Some("1".to_owned());
8739        stale.save_under(&home).unwrap();
8740
8741        resweep_superseded_attempts(&queue, &home);
8742        assert_eq!(
8743            RunState::load_under(&first.id, &home).unwrap().status,
8744            RunStatus::Superseded,
8745            "the second pass catches up what the first one correctly skipped"
8746        );
8747    }
8748
8749    #[test]
8750    fn supersede_prior_runs_leaves_concurrent_blocked_attempts_alone_while_the_task_is_not_done() {
8751        crate::run::pin_test_home();
8752        let mut first = run_state(RunStatus::Blocked);
8753        first.id = "20260101-000000-sup3".to_owned();
8754        first.save().unwrap();
8755        let mut second = run_state(RunStatus::Blocked);
8756        second.id = "20260101-000000-sup4".to_owned();
8757        second.save().unwrap();
8758
8759        let mut t = task();
8760        t.runs = vec![first.id.clone(), second.id.clone()];
8761        // Still retrying: nothing about this task's story is settled yet, so
8762        // neither Blocked run may be relabelled, even though a later attempt
8763        // already exists.
8764        t.status = TaskStatus::Failed;
8765
8766        supersede_prior_runs(&t, &crate::run::home());
8767
8768        assert_eq!(
8769            RunState::load(&first.id).unwrap().status,
8770            RunStatus::Blocked
8771        );
8772        assert_eq!(
8773            RunState::load(&second.id).unwrap().status,
8774            RunStatus::Blocked
8775        );
8776    }
8777
8778    #[test]
8779    fn supersede_prior_runs_does_nothing_when_the_task_was_closed_by_hand() {
8780        // `magi task done` (or the API's equivalent) can close a task with no
8781        // run of its own ever having succeeded - there is nothing here that
8782        // counts as "the attempt that finished it", so nothing is rewritten.
8783        crate::run::pin_test_home();
8784        let mut first = run_state(RunStatus::Blocked);
8785        first.id = "20260101-000000-sup5".to_owned();
8786        first.save().unwrap();
8787
8788        let mut t = task();
8789        t.runs = vec![first.id.clone()];
8790        t.status = TaskStatus::Done;
8791
8792        supersede_prior_runs(&t, &crate::run::home());
8793
8794        assert_eq!(
8795            RunState::load(&first.id).unwrap().status,
8796            RunStatus::Blocked,
8797            "a single-attempt task has no earlier run to supersede"
8798        );
8799    }
8800
8801    #[test]
8802    fn supersede_prior_runs_does_nothing_when_the_last_recorded_attempt_never_landed() {
8803        // `magi task done` (or the web/conductor equivalents) can close a
8804        // task in any status, including one whose *last* recorded attempt is
8805        // itself still `Blocked`/`Failed` - a manual merge magi's own loop
8806        // never watched, say. `runs.last()` being `Done`-adjacent is not
8807        // proof it actually succeeded, so nothing earlier may be relabelled
8808        // on its say-so alone.
8809        crate::run::pin_test_home();
8810        let mut first = run_state(RunStatus::Blocked);
8811        first.id = "20260101-000000-supb".to_owned();
8812        first.save().unwrap();
8813        let mut second = run_state(RunStatus::Failed);
8814        second.id = "20260101-000000-supc".to_owned();
8815        second.save().unwrap();
8816
8817        let mut t = task();
8818        t.runs = vec![first.id.clone(), second.id.clone()];
8819        t.status = TaskStatus::Done;
8820
8821        supersede_prior_runs(&t, &crate::run::home());
8822
8823        assert_eq!(
8824            RunState::load(&first.id).unwrap().status,
8825            RunStatus::Blocked,
8826            "the task's last attempt never landed, so there is nothing here \
8827             actually superseding it"
8828        );
8829    }
8830
8831    #[test]
8832    fn supersede_prior_runs_leaves_a_failed_or_verified_noop_attempt_as_is() {
8833        // Only `Blocked`/`Stalled` mean "sitting there waiting for a human
8834        // to look" - `Failed` and `VerifiedNoop` are already their own
8835        // terminal answers and must not be relabelled into a third one.
8836        crate::run::pin_test_home();
8837        let mut failed = run_state(RunStatus::Failed);
8838        failed.id = "20260101-000000-sup6".to_owned();
8839        failed.save().unwrap();
8840        let mut noop = run_state(RunStatus::VerifiedNoop);
8841        noop.id = "20260101-000000-sup7".to_owned();
8842        noop.save().unwrap();
8843        let mut winner = run_state(RunStatus::Ready);
8844        winner.id = "20260101-000000-sup8".to_owned();
8845        winner.save().unwrap();
8846
8847        let mut t = task();
8848        t.runs = vec![failed.id.clone(), noop.id.clone(), winner.id.clone()];
8849        t.status = TaskStatus::Done;
8850
8851        supersede_prior_runs(&t, &crate::run::home());
8852
8853        assert_eq!(
8854            RunState::load(&failed.id).unwrap().status,
8855            RunStatus::Failed
8856        );
8857        assert_eq!(
8858            RunState::load(&noop.id).unwrap().status,
8859            RunStatus::VerifiedNoop
8860        );
8861    }
8862
8863    fn approval_question(run: &str) -> ask::Question {
8864        ask::Question::new(
8865            run.to_owned(),
8866            land::APPROVAL_NODE.to_owned(),
8867            "land".to_owned(),
8868            "merge?".to_owned(),
8869            String::new(),
8870            vec!["merge".to_owned(), "hold".to_owned()],
8871        )
8872    }
8873
8874    #[test]
8875    fn land_resume_state_leaves_a_fresh_open_question_waiting() {
8876        crate::run::pin_test_home();
8877        let mut state = run_state(RunStatus::Landing);
8878        state.id = "20260101-000000-fre1".to_owned();
8879        state.parked = true;
8880        state.save().unwrap();
8881        ask::Questions::open()
8882            .put(&mut approval_question(&state.id))
8883            .unwrap();
8884
8885        let mut t = task();
8886        t.runs.push(state.id.clone());
8887        assert_eq!(
8888            land_resume_state(&t),
8889            LandResume::StillWaiting,
8890            "nobody has answered and the timeout has not passed"
8891        );
8892    }
8893
8894    #[test]
8895    fn land_resume_state_waits_on_a_withheld_text_question_then_resumes() {
8896        crate::run::pin_test_home();
8897        let mut state = run_state(RunStatus::Gating);
8898        state.id = "20260101-000000-gt01".to_owned();
8899        state.parked = true;
8900        state.config.graph.answer_timeout = 60;
8901        state.github_text = Some(crate::run::GithubTextAsk {
8902            rewrite: None,
8903            fingerprint: "f".into(),
8904            title: true,
8905            body: false,
8906            categories: vec!["title-language".into()],
8907            question: None,
8908            asks: 1,
8909            resolved: false,
8910            chosen_title: None,
8911            choice: None,
8912        });
8913        state.save().unwrap();
8914        let store = ask::Questions::open();
8915        let mut q = ask::Question::new(
8916            state.id.clone(),
8917            crate::github_text::ASK_NODE.to_owned(),
8918            crate::github_text::ASK_SEAT.to_owned(),
8919            "s".into(),
8920            String::new(),
8921            vec!["use fallback".into()],
8922        );
8923        store.put(&mut q).unwrap();
8924        let mut t = task();
8925        t.runs.push(state.id.clone());
8926        assert_eq!(land_resume_state(&t), LandResume::StillWaiting);
8927        q.asked_at = Timestamp::now() - jiff::SignedDuration::from_secs(120);
8928        store.put(&mut q).unwrap();
8929        assert_eq!(land_resume_state(&t), LandResume::Ready);
8930        assert!(!store.get(&q.id).unwrap().status.open());
8931        // Once decided, a parked run is an ordinary candidate again.
8932        state.github_text.as_mut().unwrap().resolved = true;
8933        state.save().unwrap();
8934        assert_eq!(land_resume_state(&t), LandResume::NotLanding);
8935    }
8936
8937    #[test]
8938    fn land_resume_state_abandons_a_question_that_outlived_answer_timeout() {
8939        // `ask::ask_and_wait`'s own deadline used to retire a question
8940        // nobody answered; land's approval bypasses that wait (see
8941        // `land::approval_gate`), so this is now the only place
8942        // `graph.answer_timeout` is enforced for a land approval at all.
8943        crate::run::pin_test_home();
8944        let mut state = run_state(RunStatus::Landing);
8945        state.id = "20260101-000000-exp1".to_owned();
8946        state.parked = true;
8947        state.config.graph.answer_timeout = 60;
8948        state.save().unwrap();
8949
8950        let store = ask::Questions::open();
8951        let mut q = approval_question(&state.id);
8952        q.asked_at = Timestamp::now() - jiff::SignedDuration::from_secs(120);
8953        store.put(&mut q).unwrap();
8954
8955        let mut t = task();
8956        t.runs.push(state.id.clone());
8957        assert_eq!(
8958            land_resume_state(&t),
8959            LandResume::Ready,
8960            "an expired question must not be waited on forever"
8961        );
8962
8963        let after = store.get(&q.id).unwrap();
8964        assert!(
8965            !after.status.open(),
8966            "the question is abandoned, not silently ignored"
8967        );
8968        assert!(
8969            after.resolution().is_none(),
8970            "an abandoned question is not read as a decision"
8971        );
8972    }
8973
8974    #[test]
8975    fn reclaim_settles_a_running_task_against_its_last_run() {
8976        let mut t = task();
8977        t.start("20260904-000000-4043".to_owned());
8978        reclaim(&mut t, Some(run_state(RunStatus::Ready)), 2, "en");
8979        assert_eq!(
8980            t.status,
8981            TaskStatus::Done,
8982            "a run that actually finished must not stay `running` forever"
8983        );
8984    }
8985
8986    #[test]
8987    fn reclaim_maps_a_parked_run_to_parked_without_touching_last_error() {
8988        let mut t = task();
8989        t.start("20260904-000000-4043".to_owned());
8990        t.last_error = Some("earlier trouble".to_owned());
8991        let mut state = run_state(RunStatus::Judging);
8992        state.parked = true;
8993        reclaim(&mut t, Some(state), 1, "en");
8994        assert_eq!(t.status, TaskStatus::Parked);
8995        assert_eq!(t.attempts, 0, "the park is refunded, even at max_attempts");
8996        assert_eq!(t.last_error.as_deref(), Some("earlier trouble"));
8997        assert!(t.park_reason.is_some());
8998        assert_eq!(t.runs, ["20260904-000000-4043"], "the same run is kept");
8999        assert!(!t.fresh_start);
9000        assert!(t.status.runnable());
9001    }
9002
9003    #[test]
9004    fn settle_parks_a_task_without_a_failure_and_a_new_start_clears_the_reason() {
9005        let mut t = task();
9006        t.start("20260904-000000-4043".to_owned());
9007        t.last_error = Some("earlier trouble".to_owned());
9008        settle(
9009            &mut t,
9010            Verdict {
9011                status: RunStatus::Judging,
9012                left_pr: false,
9013                quota_hit: false,
9014                parked: true,
9015                no_viable_candidates: false,
9016            },
9017            "parked after `judging`",
9018            1,
9019        );
9020        assert_eq!(t.status, TaskStatus::Parked);
9021        assert_eq!(t.attempts, 0);
9022        assert_eq!(t.last_error.as_deref(), Some("earlier trouble"));
9023        assert_eq!(t.park_reason.as_deref(), Some("parked after `judging`"));
9024        assert_eq!(t.schema, crate::queue::SCHEMA);
9025        assert!(
9026            crate::notices::task_held(&t).is_none(),
9027            "a park is not news"
9028        );
9029        t.start("20260904-000000-4043".to_owned());
9030        assert_eq!(t.park_reason, None);
9031    }
9032
9033    #[test]
9034    fn reclaim_reuses_the_same_retry_policy_as_a_live_settle() {
9035        // A blocked run with attempts left goes back to `Failed`, exactly as
9036        // it would from `attempt` itself - `reclaim` must not invent a second
9037        // policy for a task a daemon merely stopped without reporting.
9038        let mut t = task();
9039        t.start("20260904-000000-4043".to_owned());
9040        reclaim(&mut t, Some(run_state(RunStatus::Blocked)), 2, "en");
9041        assert_eq!(t.status, TaskStatus::Failed);
9042        assert!(t.status.runnable());
9043    }
9044
9045    #[test]
9046    fn a_settled_parked_pass_records_its_cause_and_the_refund() {
9047        let mut t = task();
9048        t.start("20260904-000000-4043".to_owned());
9049        assert_eq!(t.attempts, 1);
9050        let mut run = run_state(RunStatus::Implementing);
9051        run.id = "20260904-000000-4043".to_owned();
9052        run.begin_pass();
9053        let cause = crate::run::ParkCause::Upgrade {
9054            from: "0.121.0".to_owned(),
9055            to: Some("0.121.1".to_owned()),
9056        };
9057        run.end_pass_parked(cause.clone());
9058        run.parked = true;
9059        let verdict = Verdict {
9060            status: run.status,
9061            left_pr: false,
9062            quota_hit: false,
9063            parked: true,
9064            no_viable_candidates: false,
9065        };
9066        // A crash after the checkpoint but before queue settlement must
9067        // recover exactly the same scheduling evidence and refund.
9068        let mut orphan = t.clone();
9069        reclaim(&mut orphan, Some(run.clone()), 2, "en");
9070        assert_eq!(orphan.status, TaskStatus::Parked);
9071        assert_eq!(orphan.attempts, 0);
9072        assert_eq!(
9073            orphan.upgrade_resume_key(),
9074            Some((run.passes[0].started_at, orphan.id.as_str()))
9075        );
9076        settle_and_diagnose(&mut t, verdict, "parked", 2, &run);
9077        assert_eq!(t.attempts, 0);
9078        assert_eq!(t.passes.len(), 1);
9079        assert_eq!(t.passes[0].cost, crate::queue::PassCost::Refunded);
9080        assert_eq!(t.passes[0].parked, Some(cause));
9081        assert_eq!(t.passes[0].pass_index, 0);
9082        assert_eq!(t.passes[0].started_at, Some(run.passes[0].started_at));
9083        assert!(t.upgrade_resume_key().is_some());
9084
9085        // A resumed pass that then fails spends its attempt, and the earlier
9086        // pass's entry is untouched.
9087        t.start("20260904-000000-4043".to_owned());
9088        run.begin_pass();
9089        run.status = RunStatus::Failed;
9090        let verdict = Verdict {
9091            status: RunStatus::Failed,
9092            left_pr: false,
9093            quota_hit: false,
9094            parked: false,
9095            no_viable_candidates: false,
9096        };
9097        settle_and_diagnose(&mut t, verdict, "failed", 2, &run);
9098        assert_eq!(t.passes.len(), 2);
9099        assert_eq!(t.passes[0].cost, crate::queue::PassCost::Refunded);
9100        assert_eq!(t.passes[1].cost, crate::queue::PassCost::Spent);
9101        assert_eq!(t.passes[1].pass_index, 1);
9102        assert_eq!(t.attempts, 1);
9103    }
9104
9105    #[test]
9106    fn reclaim_holds_a_running_task_whose_run_cannot_be_found() {
9107        let mut t = task();
9108        t.start("20260904-000000-4043".to_owned());
9109        reclaim(&mut t, None, 2, "en");
9110        assert_eq!(t.status, TaskStatus::Held);
9111        assert!(
9112            t.last_error
9113                .as_deref()
9114                .is_some_and(|e| e.contains("running")),
9115            "the operator needs to know why this task was held"
9116        );
9117    }
9118
9119    #[test]
9120    fn orphaned_running_tasks_are_reclaimed_but_live_ones_are_left_alone() {
9121        let dir = tempfile::tempdir().unwrap();
9122        let queue = Queue::at(dir.path().to_path_buf());
9123
9124        // No run recorded, so this never has to touch `RunState::load`.
9125        let mut orphaned = task();
9126        orphaned.id = "20260904-000000-orph".to_owned();
9127        orphaned.status = TaskStatus::Running;
9128        orphaned.attempts = 1;
9129        queue.put(&mut orphaned).unwrap();
9130
9131        let mut alive = task();
9132        alive.id = "20260904-000000-live".to_owned();
9133        alive.status = TaskStatus::Running;
9134        alive.attempts = 1;
9135        queue.put(&mut alive).unwrap();
9136        let _held_by_a_live_daemon = queue.claim(&alive.id).unwrap();
9137
9138        let mut queued = task();
9139        queued.id = "20260904-000000-wait".to_owned();
9140        queue.put(&mut queued).unwrap();
9141
9142        let reclaimed = reclaim_orphaned_running(&queue, 2, &Default::default());
9143        assert_eq!(reclaimed, vec![orphaned.id.clone()]);
9144
9145        assert_eq!(
9146            queue.get(&orphaned.id).unwrap().status,
9147            TaskStatus::Held,
9148            "nothing was driving it and there was no run to recover"
9149        );
9150        assert_eq!(
9151            queue.get(&alive.id).unwrap().status,
9152            TaskStatus::Running,
9153            "a live claim must protect the task it belongs to"
9154        );
9155        assert_eq!(queue.get(&queued.id).unwrap().status, TaskStatus::Queued);
9156    }
9157
9158    /// Read a run.json back from an explicit `home`, the same way
9159    /// `reclaim_abandoned_runs` itself does - never through the
9160    /// process-global `RunState::load`, which this test's own `home` (an
9161    /// isolated tempdir, never pinned into the shared `OnceLock`) does not
9162    /// use at all.
9163    fn read_run_under(home: &Path, id: &str) -> RunState {
9164        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9165        serde_json::from_str(&body).unwrap()
9166    }
9167
9168    #[test]
9169    fn reclaim_abandoned_runs_fails_a_run_whose_active_seats_are_all_provably_dead() {
9170        let dir = tempfile::tempdir().unwrap();
9171        let home = dir.path().to_path_buf();
9172        let now = Timestamp::now();
9173        let overrun_seat = || crate::run::ActiveSeat {
9174            node: "implement".to_owned(),
9175            started_at: now - jiff::SignedDuration::new(21_000, 0),
9176            timeout_secs: 3_600,
9177            attempt: 0,
9178            task: None,
9179            command: None,
9180            index: None,
9181            total: None,
9182        };
9183
9184        let mut dead = run_state(RunStatus::Implementing);
9185        dead.id = "20260101-000000-dead".to_owned();
9186        dead.active.insert("impl-A".to_owned(), overrun_seat());
9187        // A `driver_pid` the injected query below confirms gone outright —
9188        // `liveness` reads this as `Dead`, not merely "no daemon claims it".
9189        dead.driver_pid = Some(4242);
9190        dead.save_under(&home).unwrap();
9191
9192        // Same shape, but a live daemon's heartbeat names it: must be left
9193        // exactly alone, however far past its own timeout the seat sits.
9194        let mut alive = run_state(RunStatus::Implementing);
9195        alive.id = "20260101-000000-aliv".to_owned();
9196        alive.active.insert("impl-A".to_owned(), overrun_seat());
9197        alive.save_under(&home).unwrap();
9198        let mut status = Status::new();
9199        status.current = vec![Current {
9200            worker: None,
9201            task: "20260101-000000-task".to_owned(),
9202            run: alive.id.clone(),
9203        }];
9204        write_status_to(&home.join("daemon.json"), &status).unwrap();
9205
9206        // The abandoned seat left an open question behind: nobody is left to
9207        // read an answer once the run is failed, and this must not wait for
9208        // some later daemon startup's own sweep to notice that.
9209        let questions = Questions::at(home.join("questions"));
9210        let mut q = ask::Question::new(
9211            dead.id.clone(),
9212            "implement".to_owned(),
9213            "impl-A".to_owned(),
9214            "Which storage backend?".to_owned(),
9215            String::new(),
9216            vec!["SQLite".to_owned(), "Redis".to_owned()],
9217        );
9218        questions.put(&mut q).unwrap();
9219
9220        let abandoned = reclaim_abandoned_runs_with(
9221            &home,
9222            now,
9223            |pid| if pid == 4242 { Some(false) } else { None },
9224            |_| panic!("a query answering Dead outright needs no identity corroboration"),
9225        );
9226        assert_eq!(abandoned, vec![dead.id.clone()]);
9227
9228        let reloaded = read_run_under(&home, &dead.id);
9229        assert_eq!(reloaded.status, RunStatus::Failed);
9230        assert!(reloaded.active.is_empty());
9231        assert!(
9232            !questions.get(&q.id).unwrap().status.open(),
9233            "the failed run's own open question must be settled in the same pass"
9234        );
9235
9236        let still_alive = read_run_under(&home, &alive.id);
9237        assert_eq!(
9238            still_alive.status,
9239            RunStatus::Implementing,
9240            "a live daemon's claim protects it"
9241        );
9242        assert!(!still_alive.active.is_empty());
9243    }
9244
9245    /// The exact shape a review round flagged as broken: `magi serve` running
9246    /// in this same `home` scans *every* run on disk, including a manual
9247    /// `magi review` / `magi run` this daemon never started and that
9248    /// therefore claims no heartbeat of its own. Before this scan asked
9249    /// `liveness` rather than just `is_working_on`, a manual run whose active
9250    /// seat merely ran a little past its own timeout — the CLI finishing up,
9251    /// its result still being collected — got wiped and failed by a daemon
9252    /// that had nothing to do with it, out from under a process that was
9253    /// still very much running.
9254    #[test]
9255    fn reclaim_abandoned_runs_leaves_a_live_manual_run_alone_even_though_no_daemon_claims_it() {
9256        let dir = tempfile::tempdir().unwrap();
9257        let home = dir.path().to_path_buf();
9258        let now = Timestamp::now();
9259
9260        let mut manual = run_state(RunStatus::Reviewing);
9261        manual.id = "20260101-000000-manl".to_owned();
9262        manual.active.insert(
9263            "review-1".to_owned(),
9264            crate::run::ActiveSeat {
9265                node: "review".to_owned(),
9266                started_at: now - jiff::SignedDuration::new(21_000, 0),
9267                timeout_secs: 3_600,
9268                attempt: 0,
9269                task: None,
9270                command: None,
9271                index: None,
9272                total: None,
9273            },
9274        );
9275        // Not claimed by any daemon (no `daemon.json` at all in this `home`),
9276        // but a real, still-running process: `liveness` must corroborate this
9277        // as `Live`, not read the missing daemon claim as death.
9278        manual.driver_pid = Some(4242);
9279        manual.driver_started_at = Some("1790000000".to_owned());
9280        manual.save_under(&home).unwrap();
9281
9282        let abandoned = reclaim_abandoned_runs_with(
9283            &home,
9284            now,
9285            |pid| if pid == 4242 { Some(true) } else { None },
9286            |pid| {
9287                if pid == 4242 {
9288                    Some("1790000000".to_owned())
9289                } else {
9290                    None
9291                }
9292            },
9293        );
9294        assert!(
9295            abandoned.is_empty(),
9296            "a manual run a real process is still driving must never be reclaimed: {abandoned:?}"
9297        );
9298
9299        let reloaded = read_run_under(&home, &manual.id);
9300        assert_eq!(reloaded.status, RunStatus::Reviewing);
9301        assert!(!reloaded.active.is_empty());
9302    }
9303
9304    #[test]
9305    fn an_already_claimed_task_is_skipped_rather_than_failed() {
9306        let dir = tempfile::tempdir().unwrap();
9307        let queue = Queue::at(dir.path().to_path_buf());
9308        let mut only = task();
9309        queue.put(&mut only).unwrap();
9310
9311        let _elsewhere = queue.claim(&only.id).unwrap();
9312        let candidates = runnable(&queue);
9313        assert_eq!(candidates.len(), 1, "the task is still runnable");
9314        assert!(
9315            queue.claim(&candidates[0].id).is_err(),
9316            "the loop cannot take a claim somebody else holds"
9317        );
9318
9319        let after = queue.get(&only.id).unwrap();
9320        assert_eq!(after.status, TaskStatus::Queued);
9321        assert_eq!(
9322            after.attempts, 0,
9323            "losing the race is not an attempt at the task"
9324        );
9325        assert_eq!(after.last_error, None);
9326    }
9327
9328    #[test]
9329    fn the_status_file_round_trips_and_its_heartbeat_advances() {
9330        let dir = tempfile::tempdir().unwrap();
9331        let path = dir.path().join("daemon.json");
9332
9333        let mut status = Status::new();
9334        status.idle = false;
9335        status.completed = 7;
9336        status.current = vec![Current {
9337            worker: None,
9338            task: "20260902-000000-t111".to_owned(),
9339            run: "20260902-000001-r111".to_owned(),
9340        }];
9341        write_status_to(&path, &status).unwrap();
9342        let first: Status = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9343        assert_eq!(first.schema, SCHEMA);
9344        assert_eq!(first.pid, std::process::id());
9345        assert!(!first.idle);
9346        assert_eq!(first.completed, 7);
9347        assert_eq!(first.current, status.current);
9348        assert!(
9349            !path.with_extension("json.tmp").exists(),
9350            "the temp file is renamed, not left behind"
9351        );
9352
9353        std::thread::sleep(Duration::from_millis(5));
9354        status.updated_at = Timestamp::now();
9355        status.polls = 3;
9356        write_status_to(&path, &status).unwrap();
9357        let second: Status =
9358            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9359        assert!(
9360            second.updated_at > first.updated_at,
9361            "a reader can only detect staleness if the heartbeat moves"
9362        );
9363        assert_eq!(
9364            second.started_at, first.started_at,
9365            "the start time is not a heartbeat"
9366        );
9367        assert_eq!(second.polls, 3);
9368    }
9369
9370    #[test]
9371    fn reading_counts_as_running_only_while_its_heartbeat_is_fresh() {
9372        let dir = tempfile::tempdir().unwrap();
9373
9374        assert!(read_status(dir.path()).is_none(), "no file, no daemon");
9375
9376        let mut status = Status::new();
9377        status.updated_at = Timestamp::now() - jiff::SignedDuration::from_secs(60);
9378        write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
9379        let stale = read_status(dir.path()).unwrap();
9380        assert!(
9381            !stale.running(Timestamp::now()),
9382            "a minute without a heartbeat is a dead daemon, not a busy one"
9383        );
9384        assert!(stale.age_secs(Timestamp::now()).is_some_and(|s| s >= 55));
9385
9386        status.updated_at = Timestamp::now();
9387        write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
9388        let fresh = read_status(dir.path()).unwrap();
9389        assert!(fresh.running(Timestamp::now()));
9390    }
9391
9392    #[test]
9393    fn only_a_live_daemon_on_this_very_run_counts_as_working_on_it() {
9394        let dir = tempfile::tempdir().unwrap();
9395        let now = Timestamp::now();
9396        let mine = "20260903-080619-01c2";
9397
9398        assert!(
9399            !is_working_on(dir.path(), mine, now),
9400            "no status file means nobody is working on anything"
9401        );
9402
9403        let mut status = Status::new();
9404        status.current = vec![Current {
9405            worker: None,
9406            task: "20260903-080340-0167".to_owned(),
9407            run: mine.to_owned(),
9408        }];
9409        status.updated_at = now;
9410        write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
9411        assert!(is_working_on(dir.path(), mine, now));
9412        assert!(
9413            !is_working_on(dir.path(), "20260903-105039-3cbf", now),
9414            "a daemon busy with one run is not working on another"
9415        );
9416
9417        // A killed daemon stops writing heartbeats but leaves the file behind
9418        // naming the run it died in. That run must not be undeletable forever.
9419        status.updated_at = now - jiff::SignedDuration::from_secs(600);
9420        write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
9421        assert!(
9422            !is_working_on(dir.path(), mine, now),
9423            "a stale heartbeat is a dead daemon, so its run is a leftover"
9424        );
9425    }
9426
9427    #[test]
9428    fn is_working_on_short_matches_by_the_worktree_bays_own_name() {
9429        let dir = tempfile::tempdir().unwrap();
9430        let now = Timestamp::now();
9431
9432        assert!(
9433            !is_working_on_short(dir.path(), "01c2", now),
9434            "no status file means nobody is working on anything"
9435        );
9436
9437        let mut status = Status::new();
9438        status.current = vec![Current {
9439            worker: None,
9440            task: "20260903-080340-0167".to_owned(),
9441            run: "20260903-080619-01c2".to_owned(),
9442        }];
9443        status.updated_at = now;
9444        write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
9445        assert!(
9446            is_working_on_short(dir.path(), "01c2", now),
9447            "the run's short id is the last block of its full id"
9448        );
9449        assert!(
9450            !is_working_on_short(dir.path(), "3cbf", now),
9451            "a daemon busy with one worktree bay is not working on another"
9452        );
9453    }
9454
9455    #[test]
9456    fn a_newer_status_file_still_yields_a_reading() {
9457        let dir = tempfile::tempdir().unwrap();
9458        // A field this build has never heard of must not turn the reading into
9459        // nothing at all; that is the whole reason the reader is permissive.
9460        std::fs::write(
9461            dir.path().join("daemon.json"),
9462            serde_json::json!({
9463                "schema": 2,
9464                "updated_at": Timestamp::now().to_string(),
9465                "idle": true,
9466                "surprise": { "nested": [1, 2, 3] },
9467            })
9468            .to_string(),
9469        )
9470        .unwrap();
9471
9472        let reading = read_status(dir.path()).expect("a forward-compatible read");
9473        assert!(reading.running(Timestamp::now()));
9474        assert!(reading.idle);
9475        assert!(reading.current.is_empty());
9476    }
9477
9478    #[test]
9479    fn an_older_daemons_single_object_current_still_reads_as_a_one_item_list() {
9480        // A daemon started before `current` became a list keeps writing this
9481        // shape on every heartbeat until it is restarted. A rolling upgrade
9482        // - a newer `magi web` or `magi doctor` reading an older `magi
9483        // serve`'s heartbeat - must still see the run it is on, not "no
9484        // daemon" from a type mismatch failing the whole struct.
9485        let dir = tempfile::tempdir().unwrap();
9486        std::fs::write(
9487            dir.path().join("daemon.json"),
9488            serde_json::json!({
9489                "schema": 1,
9490                "pid": 4242,
9491                "updated_at": Timestamp::now().to_string(),
9492                "idle": false,
9493                "current": {"task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb"},
9494                "completed": 3,
9495                "polls": 9,
9496            })
9497            .to_string(),
9498        )
9499        .unwrap();
9500
9501        let reading = read_status(dir.path()).expect("an older shape must still parse");
9502        assert!(reading.running(Timestamp::now()));
9503        assert_eq!(
9504            reading.current,
9505            vec![Current {
9506                worker: None,
9507                task: "20260902-140501-aaaa".to_owned(),
9508                run: "20260902-140502-bbbb".to_owned(),
9509            }]
9510        );
9511    }
9512
9513    #[test]
9514    fn an_absent_or_null_current_reads_as_idle_not_a_parse_failure() {
9515        let dir = tempfile::tempdir().unwrap();
9516        std::fs::write(
9517            dir.path().join("daemon.json"),
9518            serde_json::json!({
9519                "schema": 1,
9520                "updated_at": Timestamp::now().to_string(),
9521                "idle": true,
9522                "current": null,
9523            })
9524            .to_string(),
9525        )
9526        .unwrap();
9527        let with_null = read_status(dir.path()).expect("null must still parse");
9528        assert!(with_null.current.is_empty());
9529
9530        std::fs::write(
9531            dir.path().join("daemon.json"),
9532            serde_json::json!({
9533                "schema": 1,
9534                "updated_at": Timestamp::now().to_string(),
9535                "idle": true,
9536            })
9537            .to_string(),
9538        )
9539        .unwrap();
9540        let absent = read_status(dir.path()).expect("a missing field must still parse");
9541        assert!(absent.current.is_empty());
9542    }
9543
9544    #[test]
9545    fn a_task_without_a_repository_runs_in_the_daemons_default() {
9546        let fallback = Path::new("/default");
9547        let mut blank = task();
9548        blank.repo = PathBuf::new();
9549        assert_eq!(repo_for(&blank, fallback), PathBuf::from("/default"));
9550        let mut dot = task();
9551        dot.repo = PathBuf::from(".");
9552        assert_eq!(repo_for(&dot, fallback), PathBuf::from("/default"));
9553        assert_eq!(
9554            repo_for(&task(), fallback),
9555            PathBuf::from("/repo"),
9556            "a task that names a repository keeps it"
9557        );
9558    }
9559
9560    #[test]
9561    fn task_implementers_edit_inherits_daemon_config_and_merge() {
9562        let dir = tempfile::tempdir().unwrap();
9563        let path = dir.path().join("explicit.toml");
9564        std::fs::write(&path, "[graph]\nimplementers = 3\njudges = 1\n").unwrap();
9565        let opts = Opts {
9566            config: Some(path),
9567            merge: Some("none".into()),
9568            ..Default::default()
9569        };
9570        let mut t = task();
9571        t.set_implementers(
9572            2,
9573            &toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
9574                .unwrap(),
9575        )
9576        .unwrap();
9577        let cfg = prepare_for(dir.path(), &opts, &t).unwrap();
9578        assert_eq!(cfg.graph.implementers, 2);
9579        assert_eq!(cfg.graph.judges, 1);
9580        assert_eq!(cfg.merge.mode, MergeMode::None);
9581        t.set_implementers(1, &cfg).unwrap();
9582        let mut cfg = prepare_for(dir.path(), &opts, &t).unwrap();
9583        apply_task_implementers(&mut cfg, &t);
9584        assert_eq!(cfg.graph.implementers, 1);
9585        assert_eq!(cfg.graph.judges, 1);
9586        assert_eq!(cfg.merge.mode, MergeMode::None);
9587    }
9588
9589    #[test]
9590    fn task_implementers_direct_overrides_stay_isolated_after_edits() {
9591        let dir = tempfile::tempdir().unwrap();
9592        let repo = dir.path().join("repo");
9593        std::fs::create_dir(&repo).unwrap();
9594        std::fs::write(
9595            repo.join("magi.toml"),
9596            "[graph]\njudges = 2\n[merge]\nmode = 'none'\n",
9597        )
9598        .unwrap();
9599        let daemon_config = dir.path().join("daemon.toml");
9600        std::fs::write(
9601            &daemon_config,
9602            "[graph]\njudges = 1\n[merge]\nmode = 'pr'\n",
9603        )
9604        .unwrap();
9605        let opts = Opts {
9606            config: Some(daemon_config),
9607            merge: Some("pr".into()),
9608            ..Default::default()
9609        };
9610        let q = Queue::at(dir.path().join("queue"));
9611        let validation: Config =
9612            toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
9613                .unwrap();
9614        // No provenance field on legacy direct tasks: both shapes stay isolated.
9615        for record in ["{}", r#"{"candidates":2}"#] {
9616            let overrides = serde_json::from_str(record).unwrap();
9617            let filing = crate::direct::Filing {
9618                instruction: "work".into(),
9619                title: "direct".into(),
9620                repo: repo.clone(),
9621                source: crate::queue::Source::Human,
9622                solo: false,
9623                overrides,
9624                review_of: None,
9625                sources: Default::default(),
9626            };
9627            let filed =
9628                crate::direct::file(&q, dir.path(), Timestamp::now(), 1, filing, true).unwrap();
9629            let crate::direct::Filed::Standalone {
9630                mut task,
9631                claim: _claim,
9632            } = filed
9633            else {
9634                panic!("local filing")
9635            };
9636            let before = prepare_for(&repo, &opts, &task).unwrap();
9637            assert_eq!(before.graph.judges, 2, "daemon --config must not leak");
9638            assert_eq!(
9639                before.merge.mode,
9640                MergeMode::None,
9641                "daemon --merge must not leak"
9642            );
9643            for n in [2, 1, 2] {
9644                task.set_implementers(n, &validation).unwrap();
9645                q.put(&mut task).unwrap();
9646                task = q.get(&task.id).unwrap();
9647                assert!(!task.overrides.as_ref().unwrap().inherit_daemon_options);
9648                let mut cfg = prepare_for(&repo, &opts, &task).unwrap();
9649                apply_task_implementers(&mut cfg, &task);
9650                assert_eq!(cfg.graph.implementers, usize::from(n));
9651                assert_eq!(cfg.graph.judges, 2);
9652                assert_eq!(cfg.merge.mode, MergeMode::None);
9653            }
9654        }
9655    }
9656
9657    #[test]
9658    fn task_implementers_mismatch_keeps_explicit_reviews_review_only() {
9659        let cfg: Config =
9660            toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
9661                .unwrap();
9662        let mut t = task();
9663        t.review_of = Some("review-target".into());
9664        t.hold_manual(Some("retry".into()));
9665        let mut previous = RunState::new(
9666            PathBuf::from("/repo"),
9667            "main".into(),
9668            "abc1234".into(),
9669            "review".into(),
9670            cfg.clone(),
9671        );
9672        previous.config.graph.implementers = 2;
9673        for count in [1, 3] {
9674            t.set_implementers(count, &cfg).unwrap();
9675            t.release();
9676            assert_eq!(t.review_of.as_deref(), Some("review-target"));
9677            for proposed in [
9678                Starter::Resume(previous.id.clone()),
9679                Starter::Review("recovery-target".into()),
9680            ] {
9681                assert_eq!(
9682                    starter_with_implementers(&t, Some(&previous), proposed),
9683                    Starter::Review("review-target".into())
9684                );
9685            }
9686            let mut configured = cfg.clone();
9687            apply_task_implementers(&mut configured, &t);
9688            assert_eq!(configured.graph.implementers, usize::from(count));
9689            assert_eq!(previous.config.graph.implementers, 2);
9690        }
9691    }
9692
9693    #[test]
9694    fn task_implementers_dispatch_and_resume_agree() {
9695        let mut t = task();
9696        let cfg: crate::config::Config =
9697            toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
9698                .unwrap();
9699        let mut run = RunState::new(
9700            PathBuf::from("/repo"),
9701            "main".into(),
9702            "abc1234".into(),
9703            "work".into(),
9704            cfg.clone(),
9705        );
9706        run.config.graph.implementers = 2;
9707        for n in [2, 1, 3, 2] {
9708            t.set_implementers(n, &cfg).unwrap();
9709            let mut dispatched = cfg.clone();
9710            apply_task_implementers(&mut dispatched, &t);
9711            assert_eq!(dispatched.graph.implementers, usize::from(n));
9712            assert_eq!(implementers_match(&t, &run), n == 2);
9713            for proposed in [
9714                Starter::Resume(run.id.clone()),
9715                Starter::Review("old-branch".into()),
9716            ] {
9717                let expected = if n == 2 {
9718                    proposed.clone()
9719                } else {
9720                    Starter::Start
9721                };
9722                assert_eq!(
9723                    starter_with_implementers(&t, Some(&run), proposed),
9724                    expected
9725                );
9726            }
9727            assert_eq!(
9728                run.config.graph.implementers, 2,
9729                "history must not be rewritten"
9730            );
9731        }
9732        t.solo = false;
9733        t.overrides = None;
9734        assert!(implementers_match(&t, &run));
9735    }
9736
9737    #[test]
9738    fn a_solo_task_runs_with_one_candidate_and_a_plain_task_keeps_the_configs() {
9739        // Three seats said out loud. What `solo` promises is one candidate
9740        // *whatever the config asks for*, so the contrast has to be a number
9741        // this test owns - it used to be `Config::default()`'s, which became
9742        // 1 when one implementation became the default and left the two
9743        // halves of this test asserting the same thing.
9744        let mut solo_cfg = Config::default();
9745        solo_cfg.graph.implementers = 3;
9746        let mut solo_task = task();
9747        solo_task.solo = true;
9748        apply_task_implementers(&mut solo_cfg, &solo_task);
9749        assert_eq!(solo_cfg.graph.implementers, 1);
9750
9751        let mut plain_cfg = Config::default();
9752        plain_cfg.graph.implementers = 3;
9753        let plain_task = task();
9754        assert!(!plain_task.solo);
9755        apply_task_implementers(&mut plain_cfg, &plain_task);
9756        assert_eq!(
9757            plain_cfg.graph.implementers, 3,
9758            "a task that did not ask to run alone keeps the config's candidates"
9759        );
9760    }
9761
9762    fn loss(seat: &str, at: &str, reset: Option<&str>) -> QuotaLoss {
9763        QuotaLoss {
9764            seat: seat.into(),
9765            node: "judge".into(),
9766            at: at.parse().unwrap(),
9767            reset: reset.map(str::to_string),
9768        }
9769    }
9770
9771    #[test]
9772    fn a_resumed_run_with_only_old_quota_losses_arms_no_cooldown() {
9773        let old: Vec<QuotaLoss> = (1..=4)
9774            .map(|i| {
9775                loss(
9776                    &format!("judge-{i}"),
9777                    "2026-09-23T05:23:00Z",
9778                    Some("2:40pm (Asia/Tokyo)"),
9779                )
9780            })
9781            .collect();
9782        let fresh = losses_this_attempt(&old, &old);
9783        assert!(fresh.is_empty());
9784        assert_eq!(cooldown_until(&fresh, Timestamp::now()), None);
9785        // And it is not a `quota_hit` either: that is `!fresh.is_empty()`.
9786    }
9787
9788    #[test]
9789    fn a_new_quota_loss_during_the_attempt_still_arms_the_cooldown() {
9790        let old = vec![loss("judge-1", "2026-09-23T05:23:00Z", None)];
9791        let now = Timestamp::now();
9792        let mut after = old.clone();
9793        after.push(loss("judge-2", &now.to_string(), None));
9794        let fresh = losses_this_attempt(&old, &after);
9795        assert_eq!(fresh, vec![after[1].clone()]);
9796        let until = cooldown_until(&fresh, now).expect("a fresh loss arms the cooldown");
9797        assert_eq!(
9798            until,
9799            now + jiff::SignedDuration::from_secs(QUOTA_WAIT_FALLBACK.as_secs() as i64)
9800        );
9801    }
9802
9803    #[test]
9804    fn a_recovered_seat_dropping_out_of_the_history_does_not_hide_a_new_loss() {
9805        // `recover_stall` removes judge-1's loss and the retry then hits quota
9806        // again: the vector is the same length, so an index diff sees nothing.
9807        let before = vec![
9808            loss("judge-1", "2026-09-23T05:23:00Z", None),
9809            loss("judge-2", "2026-09-23T05:24:00Z", None),
9810        ];
9811        let after = vec![
9812            loss("judge-2", "2026-09-23T05:24:00Z", None),
9813            loss("judge-1", "2026-09-24T01:00:00Z", None),
9814        ];
9815        assert_eq!(losses_this_attempt(&before, &after), vec![after[1].clone()]);
9816    }
9817
9818    #[test]
9819    fn merge_overrides_are_parsed_or_refused() {
9820        assert_eq!(merge_mode("none").unwrap(), MergeMode::None);
9821        assert_eq!(merge_mode("local").unwrap(), MergeMode::Local);
9822        assert_eq!(merge_mode("pr").unwrap(), MergeMode::Pr);
9823        assert!(merge_mode("squash").is_err());
9824    }
9825
9826    #[test]
9827    fn quota_wait_uses_a_future_reset_time_capped_and_falls_back_otherwise() {
9828        let now = Timestamp::now();
9829        let fallback = Duration::from_secs(300);
9830        let cap = Duration::from_secs(1800);
9831
9832        // No reset hint at all: the fallback.
9833        assert_eq!(quota_wait(None, now, fallback, cap), fallback);
9834
9835        // A reset ten minutes out, well inside the cap: waited for exactly.
9836        let soon = now + jiff::SignedDuration::from_secs(600);
9837        assert_eq!(
9838            quota_wait(Some(soon), now, fallback, cap),
9839            Duration::from_secs(600)
9840        );
9841
9842        // A reset already in the past is not trusted: the fallback, not a
9843        // zero or negative wait that would spin the loop right back around.
9844        let past = now - jiff::SignedDuration::from_secs(60);
9845        assert_eq!(quota_wait(Some(past), now, fallback, cap), fallback);
9846
9847        // A reset further out than the cap is trusted for direction but not
9848        // for magnitude: a parsing slip must not sleep the loop for a day.
9849        let far = now + jiff::SignedDuration::from_secs(3 * 3600);
9850        assert_eq!(quota_wait(Some(far), now, fallback, cap), cap);
9851    }
9852
9853    #[test]
9854    fn parse_reset_hint_reads_the_claude_cli_shape_and_rolls_a_past_clock_to_tomorrow() {
9855        let now = "2026-09-07T02:50:00Z".parse::<Timestamp>().unwrap();
9856
9857        let at = parse_reset_hint("4:50am (UTC)", now, now).expect("a recognised shape parses");
9858        assert_eq!(at.to_string(), "2026-09-07T04:50:00Z");
9859
9860        // Same clock reading, but it has already gone by today: read as
9861        // tomorrow's, since the CLI would not still be reporting a limit past
9862        // its own stated reset.
9863        let already_past =
9864            parse_reset_hint("1:00am (UTC)", now, now).expect("a recognised shape parses");
9865        assert_eq!(already_past.to_string(), "2026-09-08T01:00:00Z");
9866
9867        assert!(
9868            parse_reset_hint("session limit reached", now, now).is_none(),
9869            "free text with no recognised shape is not guessed at"
9870        );
9871        assert!(
9872            parse_reset_hint("4:50am (Nowhere/Fake)", now, now).is_none(),
9873            "an unresolvable zone name is not guessed at either"
9874        );
9875    }
9876
9877    #[test]
9878    fn parse_reset_hint_reads_the_codex_cli_shape_with_no_year_rollover_needed() {
9879        let now = "2026-09-07T02:50:00Z".parse::<Timestamp>().unwrap();
9880
9881        let at = parse_reset_hint(
9882            "You've hit your usage limit. Visit \
9883             https://chatgpt.com/codex/settings/usage to purchase more \
9884             credits or try again at Sep 19th, 2026 5:10 PM.",
9885            now,
9886            now,
9887        )
9888        .expect("the codex reset wording is a recognised shape");
9889        assert_eq!(at.to_string(), "2026-09-19T17:10:00Z");
9890
9891        // The month is explicit, so a date already earlier in the same
9892        // sentence-implied year than `now` is trusted as written rather than
9893        // rolled forward a year the way the bracketed shape rolls a
9894        // same-day clock reading to tomorrow.
9895        let earlier = parse_reset_hint("try again at Jan 2nd, 2026 1:00 AM.", now, now)
9896            .expect("an explicit year needs no rollover");
9897        assert_eq!(earlier.to_string(), "2026-01-02T01:00:00Z");
9898
9899        assert!(
9900            parse_reset_hint("try again at Sep 19th, 26 5:10 PM.", now, now).is_none(),
9901            "a two-digit year is not the documented shape and is not guessed at"
9902        );
9903        assert!(
9904            parse_reset_hint("try again at Sept 19th, 2026 5:10 PM.", now, now).is_none(),
9905            "a four-letter month name is not the documented three-letter abbreviation"
9906        );
9907        assert!(
9908            parse_reset_hint("try again at Sep 19th, 2026 5:10 PM (UTC).", now, now).is_none(),
9909            "an explicit zone on the dated shape is a format nobody has \
9910             documented, and is refused rather than guessed at as UTC"
9911        );
9912    }
9913
9914    #[test]
9915    fn parse_reset_hint_reads_agys_relative_shape_from_when_the_loss_was_recorded() {
9916        let now = "2026-09-24T12:00:00Z".parse::<Timestamp>().unwrap();
9917        let recorded = "2026-09-24T08:00:00Z".parse::<Timestamp>().unwrap();
9918
9919        let at = parse_reset_hint("in 1h2m49s", now, recorded).expect("agy's shape parses");
9920        assert_eq!(at.as_second() - recorded.as_second(), 3769);
9921
9922        let partial = parse_reset_hint("in 45m", now, recorded).expect("units are optional");
9923        assert_eq!(partial.as_second() - recorded.as_second(), 45 * 60);
9924
9925        for bad in ["in ", "in 45", "in 3x", "in m", "in 1h junk", "1h2m"] {
9926            assert!(
9927                parse_reset_hint(bad, now, recorded).is_none(),
9928                "{bad:?} must not be guessed at"
9929            );
9930        }
9931    }
9932
9933    /// A loop whose queue lives in a temp tree and whose poll interval is far
9934    /// longer than the test's patience, so anything that waits out a poll
9935    /// instead of noticing the stop fails rather than merely being slow.
9936    fn idle_loop(dir: &Path) -> (Opts, Queue, PathBuf, PathBuf, PathBuf) {
9937        let config = dir.join("magi.toml");
9938        std::fs::write(
9939            &config,
9940            "[disk]\nmin_free_bytes = 0\nauto_fold = false\ncache_limit_bytes = 0\n",
9941        )
9942        .unwrap();
9943        let opts = Opts {
9944            poll: Duration::from_secs(30),
9945            config: Some(config),
9946            // The explicit fixture config keeps startup cleanup from reading
9947            // machine configuration. This fictional repository likewise
9948            // keeps any best-effort git cleanup away from this checkout.
9949            repo: dir.join("repo"),
9950            ..Opts::default()
9951        };
9952        // The status file goes in a directory that does not exist yet, so its
9953        // creation is itself evidence the loop published one. `worktrees`
9954        // must be just as fictional: the janitor reclaims worktrees under it
9955        // for real, and a test that let it fall through to
9956        // `crate::run::default_worktree_root()` would have it reclaim
9957        // worktrees out of the operator's real `~/wt/<repo>`, not a fixture -
9958        // which is exactly what happened before this function took the
9959        // parameter at all.
9960        let home = dir.join("home");
9961        // These control-only fixtures do not launch native workers. Give the
9962        // independently tested maintenance services a fresh cadence receipt.
9963        let stamps = home.join("service-ticks");
9964        std::fs::create_dir_all(&stamps).unwrap();
9965        for service in ["waiter", "deputies", "release-watch"] {
9966            std::fs::write(
9967                stamps.join(service),
9968                Timestamp::now().as_second().to_string(),
9969            )
9970            .unwrap();
9971        }
9972        for operation in [
9973            "conductor",
9974            "maintenance",
9975            "housekeeping",
9976            "disk-pressure",
9977            "cache",
9978            "fetch",
9979        ] {
9980            std::fs::write(
9981                stamps.join(format!("daemon-{operation}")),
9982                serde_json::to_vec(&Timestamp::now()).unwrap(),
9983            )
9984            .unwrap();
9985        }
9986        let worktrees = dir.join("wt");
9987        (
9988            opts,
9989            Queue::at(dir.join("queue")),
9990            home.join("daemon.json"),
9991            home,
9992            worktrees,
9993        )
9994    }
9995
9996    #[test]
9997    fn a_stop_is_idempotent_and_once_set_stays_set() {
9998        let stop = Stop::new();
9999        assert!(!stop.stopped());
10000
10001        stop.stop();
10002        assert!(stop.stopped());
10003        stop.stop();
10004        assert!(stop.stopped(), "a second stop is not a toggle");
10005
10006        let shared = stop.clone();
10007        assert!(
10008            shared.stopped(),
10009            "a clone is the same stop; that is how the loop and its caller share one"
10010        );
10011    }
10012
10013    #[tokio::test]
10014    async fn once_drain_rechecks_answers_after_queue_services_release_their_claims() {
10015        for operation in ["conductor", "maintenance", "housekeeping"] {
10016            let dir = tempfile::tempdir().unwrap();
10017            let (mut opts, queue, _, home, worktrees) = idle_loop(dir.path());
10018            opts.once = true;
10019            opts.poll = Duration::from_millis(1);
10020            let mut task = task();
10021            task.runs.push("fixture-run".into());
10022            queue.put(&mut task).unwrap();
10023            let claim = queue.claim(&task.id).unwrap();
10024
10025            // Model a live service holding the task's claim across the
10026            // scheduler's dispatch scan. No real subprocess or timed race.
10027            let directory = home.join("workers/jobs/abcdef");
10028            std::fs::create_dir_all(&directory).unwrap();
10029            let lease = std::fs::File::create(directory.join("live.lock")).unwrap();
10030            crate::pr_head::try_lock_file(&lease).unwrap();
10031            let mut job = crate::worker::Job {
10032                id: "abcdef".into(),
10033                sequence: 1,
10034                kind: "daemon-service".into(),
10035                key: format!("service:daemon-{operation}"),
10036                payload: serde_json::Value::Null,
10037                generation: "fixture".into(),
10038                version: None,
10039                status: crate::worker::Status::Running,
10040                result: None,
10041                pid: Some(std::process::id()),
10042                created_at: Timestamp::now().as_second() as u64,
10043                finished_at: None,
10044            };
10045            let record = directory.join("job.json");
10046            std::fs::write(&record, serde_json::to_vec(&job).unwrap()).unwrap();
10047
10048            let status = Arc::new(Mutex::new(Status::new()));
10049            let stop = Stop::new();
10050            let mut drain = Box::pin(poll(
10051                &opts,
10052                &queue,
10053                &status,
10054                &home,
10055                &worktrees,
10056                &stop,
10057                DispatchLimits {
10058                    max_concurrent: 1,
10059                    pause_for_interrupts: false,
10060                    recovery_cfg: None,
10061                },
10062            ));
10063            std::future::poll_fn(|cx| {
10064                use std::future::Future;
10065                assert!(
10066                    drain.as_mut().poll(cx).is_pending(),
10067                    "--once must wait for {operation} to release its queue claim"
10068                );
10069                std::task::Poll::Ready(())
10070            })
10071            .await;
10072            assert!(lock(&status).polls > 0, "the dispatch scan was exercised");
10073
10074            // An answer arrives while the service still owns the claim. A
10075            // fresh scheduling pass must apply it before declaring idle.
10076            let questions = Questions::at(home.join("questions"));
10077            let mut q = action_question("fixture-run", ask::ChoiceAction::Done);
10078            questions.put(&mut q).unwrap();
10079            drop(claim);
10080            // Model services that took longer than their cadence, without a
10081            // wall-clock sleep. The final scans must not respawn them even
10082            // though their receipts now permit another periodic invocation.
10083            let expired =
10084                serde_json::to_vec(&"2000-01-01T00:00:00Z".parse::<Timestamp>().unwrap()).unwrap();
10085            for service in [operation, "housekeeping"] {
10086                std::fs::write(
10087                    home.join("service-ticks").join(format!("daemon-{service}")),
10088                    &expired,
10089                )
10090                .unwrap();
10091            }
10092            job.status = crate::worker::Status::Finished;
10093            std::fs::write(&record, serde_json::to_vec(&job).unwrap()).unwrap();
10094            drop(lease);
10095            tokio::time::timeout(Duration::from_secs(2), drain)
10096                .await
10097                .expect("the settled service must allow the drain to finish")
10098                .unwrap();
10099            let after = queue.get(&task.id).unwrap();
10100            assert_eq!(after.status, TaskStatus::Done, "answer after {operation}");
10101            assert!(after.action_applied(&q.id));
10102            for service in [operation, "housekeeping"] {
10103                assert_eq!(
10104                    std::fs::read(home.join("service-ticks").join(format!("daemon-{service}")))
10105                        .unwrap(),
10106                    expired,
10107                    "the final once rescan must not restart {service}"
10108                );
10109            }
10110        }
10111    }
10112
10113    #[test]
10114    fn only_a_stop_with_a_run_in_flight_reads_as_finishing() {
10115        let stop = Stop::new();
10116        stop.busy.store(1, Ordering::SeqCst);
10117        assert!(
10118            !stop.finishing(),
10119            "a busy loop nobody has asked to stop is just running"
10120        );
10121
10122        stop.stop();
10123        assert!(
10124            stop.finishing(),
10125            "a stop asked for mid-run has not landed until the run is settled"
10126        );
10127
10128        stop.busy.store(0, Ordering::SeqCst);
10129        assert!(
10130            !stop.finishing(),
10131            "once the run is settled the stop has landed and there is nothing to finish"
10132        );
10133    }
10134
10135    #[test]
10136    fn finishing_stays_true_until_the_last_of_several_runs_exits() {
10137        let stop = Stop::new();
10138        stop.busy.store(2, Ordering::SeqCst);
10139        stop.stop();
10140        assert!(stop.finishing(), "two runs still in flight");
10141
10142        stop.busy.store(1, Ordering::SeqCst);
10143        assert!(
10144            stop.finishing(),
10145            "one run finished, but a sibling is still working"
10146        );
10147
10148        stop.busy.store(0, Ordering::SeqCst);
10149        assert!(
10150            !stop.finishing(),
10151            "the last run out is what actually lands the stop"
10152        );
10153    }
10154
10155    #[tokio::test]
10156    async fn a_loop_already_asked_to_stop_returns_without_waiting_out_a_poll() {
10157        let dir = tempfile::tempdir().unwrap();
10158        let (opts, queue, status_file, home, worktrees) = idle_loop(dir.path());
10159        let stop = Stop::new();
10160        stop.stop();
10161
10162        let began = std::time::Instant::now();
10163        tokio::time::timeout(
10164            Duration::from_secs(2),
10165            drive(&opts, &queue, &status_file, &home, &worktrees, &stop),
10166        )
10167        .await
10168        .expect("a stopped loop must return, not sit out its poll interval")
10169        .expect("the loop's own setup and teardown must not fail");
10170        assert!(
10171            began.elapsed() < opts.poll,
10172            "returned only after {:?}, which is a poll interval, not a stop",
10173            began.elapsed()
10174        );
10175    }
10176
10177    #[tokio::test]
10178    async fn a_stop_while_idle_wakes_the_wait_instead_of_sleeping_it_out() {
10179        let dir = tempfile::tempdir().unwrap();
10180        let (opts, queue, status_file, home, worktrees) = idle_loop(dir.path());
10181        let stop = Stop::new();
10182
10183        // Asked for after the loop is already parked on its empty queue, which
10184        // is the case an operator tapping stop on a phone actually hits.
10185        let asker = {
10186            let stop = stop.clone();
10187            tokio::spawn(async move {
10188                tokio::time::sleep(Duration::from_millis(20)).await;
10189                stop.stop();
10190            })
10191        };
10192
10193        let began = std::time::Instant::now();
10194        tokio::time::timeout(
10195            Duration::from_secs(2),
10196            drive(&opts, &queue, &status_file, &home, &worktrees, &stop),
10197        )
10198        .await
10199        .expect("a stop asked for while idle must wake the wait")
10200        .expect("the loop's own setup and teardown must not fail");
10201        asker.await.unwrap();
10202        assert!(
10203            began.elapsed() < opts.poll,
10204            "returned only after {:?}, so the stop waited on the sleep",
10205            began.elapsed()
10206        );
10207    }
10208
10209    #[tokio::test]
10210    async fn a_stopped_loop_leaves_no_status_file_claiming_it_is_running() {
10211        let dir = tempfile::tempdir().unwrap();
10212        let (opts, queue, status_file, home, worktrees) = idle_loop(dir.path());
10213        let stop = Stop::new();
10214        stop.stop();
10215
10216        tokio::time::timeout(
10217            Duration::from_secs(2),
10218            drive(&opts, &queue, &status_file, &home, &worktrees, &stop),
10219        )
10220        .await
10221        .expect("a stopped loop must return")
10222        .expect("the loop's own setup and teardown must not fail");
10223
10224        assert!(
10225            home.is_dir(),
10226            "the loop did publish a status file, so its removal is the teardown and not an absence"
10227        );
10228        assert!(
10229            !status_file.exists(),
10230            "a stopped loop clears its status file"
10231        );
10232        assert!(
10233            read_status(&home).is_none(),
10234            "a reader must see no daemon at all, not a heartbeat that merely stopped"
10235        );
10236    }
10237
10238    #[tokio::test]
10239    async fn housekeeping_worker_settles_questions_without_a_scheduler() {
10240        let dir = tempfile::tempdir().unwrap();
10241        let (opts, queue, _, home, worktrees) = idle_loop(dir.path());
10242
10243        let mut settled = RunState::new(
10244            dir.path().join("repo"),
10245            "main".to_owned(),
10246            "abc1234".to_owned(),
10247            "fixture".to_owned(),
10248            Config::default(),
10249        );
10250        settled.status = RunStatus::Ready;
10251        let run_dir = home.join("runs").join(&settled.id);
10252        std::fs::create_dir_all(&run_dir).unwrap();
10253        std::fs::write(
10254            run_dir.join("run.json"),
10255            serde_json::to_string_pretty(&settled).unwrap(),
10256        )
10257        .unwrap();
10258        let questions = Questions::at(home.join("questions"));
10259        let mut question = ask::Question::new(
10260            settled.id.clone(),
10261            "review".to_owned(),
10262            "reviewer-1".to_owned(),
10263            "Continue?".to_owned(),
10264            String::new(),
10265            Vec::new(),
10266        );
10267        questions.put(&mut question).unwrap();
10268
10269        run_service_worker(
10270            &home,
10271            serde_json::to_value(ServiceWorker {
10272                opts,
10273                queue: queue.root().to_path_buf(),
10274                worktrees_root: worktrees,
10275                operation: "housekeeping".to_owned(),
10276            })
10277            .unwrap(),
10278        )
10279        .await
10280        .unwrap();
10281
10282        assert_eq!(
10283            questions.get(&question.id).unwrap().status,
10284            ask::QuestionStatus::Abandoned,
10285            "the independent housekeeping worker settles ended-run questions"
10286        );
10287    }
10288
10289    #[test]
10290    fn cache_check_due_fires_immediately_then_waits_out_its_own_interval() {
10291        let t0 = "2026-09-15T00:00:00Z".parse::<Timestamp>().unwrap();
10292
10293        assert!(
10294            cache_check_due(None, t0, CACHE_CHECK_INTERVAL_SECS),
10295            "never checked before: due at once"
10296        );
10297
10298        let one_sec_later = t0 + jiff::SignedDuration::from_secs(1);
10299        assert!(
10300            !cache_check_due(Some(t0), one_sec_later, CACHE_CHECK_INTERVAL_SECS),
10301            "well inside the interval: not due yet"
10302        );
10303
10304        let at_the_edge = t0 + jiff::SignedDuration::from_secs(CACHE_CHECK_INTERVAL_SECS as i64);
10305        assert!(
10306            !cache_check_due(Some(t0), at_the_edge, CACHE_CHECK_INTERVAL_SECS),
10307            "exactly at the edge: not yet due, same convention as `clean::due`"
10308        );
10309
10310        let past_it = t0 + jiff::SignedDuration::from_secs(CACHE_CHECK_INTERVAL_SECS as i64 + 1);
10311        assert!(
10312            cache_check_due(Some(t0), past_it, CACHE_CHECK_INTERVAL_SECS),
10313            "past the interval: due again"
10314        );
10315    }
10316
10317    /// A `magi.toml` whose `[verify] gate` names `cache_dir` as its shared
10318    /// `CARGO_TARGET_DIR`, capped at `limit_bytes`, plus a repository path
10319    /// that is never created - the fixtures [`maybe_prune_cache_between_runs`]
10320    /// and the congestion test below both need, and must not drift apart.
10321    fn cache_check_opts(dir: &Path, cache_dir: &Path, limit_bytes: u64) -> Opts {
10322        let config = dir.join("magi.toml");
10323        // A literal (single-quoted) TOML string, not a basic one: the cache
10324        // path is a Windows path full of backslashes, and a basic string
10325        // would have TOML try to interpret `\U` (from `\Users\...`) as a
10326        // Unicode escape and fail to parse - the same trap `magi.toml`'s own
10327        // `{{ vars.cache }}` rendering documents.
10328        std::fs::write(
10329            &config,
10330            format!(
10331                "[disk]\nmin_free_bytes = 0\nauto_fold = false\ncache_limit_bytes = {limit_bytes}\n\n\
10332                 [verify]\ngate = ['CARGO_TARGET_DIR={} cargo make check']\n",
10333                cache_dir.display()
10334            ),
10335        )
10336        .unwrap();
10337        Opts {
10338            config: Some(config),
10339            repo: dir.join("repo"),
10340            ..Opts::default()
10341        }
10342    }
10343
10344    #[tokio::test]
10345    async fn maybe_prune_cache_between_runs_reprunes_only_once_its_own_interval_elapses() {
10346        let dir = tempfile::tempdir().unwrap();
10347        let home = dir.path().join("home");
10348        let cache_dir = dir.path().join("cache");
10349        std::fs::create_dir_all(&cache_dir).unwrap();
10350        std::fs::write(cache_dir.join("a"), vec![0u8; 10]).unwrap();
10351        let opts = cache_check_opts(dir.path(), &cache_dir, 1);
10352
10353        // Nobody has asked this daemon to stop, which is the ordinary case;
10354        // the skip that a stop buys is asserted by its own test below.
10355        let running = Stop::new();
10356        let mut last_checked = None;
10357        let t0 = "2026-09-15T00:00:00Z".parse::<Timestamp>().unwrap();
10358        maybe_prune_cache_between_runs(&opts.repo, &opts, &home, &running, &mut last_checked, t0)
10359            .await;
10360        assert_eq!(
10361            crate::disk::dir_size(&cache_dir),
10362            0,
10363            "over the cap on the first check ever: pruned at once, no idle queue required"
10364        );
10365        assert_eq!(last_checked, Some(t0));
10366
10367        // A fresh oversized file lands, but the next check is not due yet.
10368        std::fs::write(cache_dir.join("b"), vec![0u8; 10]).unwrap();
10369        let too_soon = t0 + jiff::SignedDuration::from_secs(1);
10370        maybe_prune_cache_between_runs(
10371            &opts.repo,
10372            &opts,
10373            &home,
10374            &running,
10375            &mut last_checked,
10376            too_soon,
10377        )
10378        .await;
10379        assert_eq!(
10380            crate::disk::dir_size(&cache_dir),
10381            10,
10382            "too soon since the last check: left alone rather than rescanned every call"
10383        );
10384        assert_eq!(
10385            last_checked,
10386            Some(t0),
10387            "an idle check does not reset the clock"
10388        );
10389
10390        // Once the interval elapses, the same oversized cache is caught again.
10391        let due_again = t0 + jiff::SignedDuration::from_secs(CACHE_CHECK_INTERVAL_SECS as i64 + 1);
10392        maybe_prune_cache_between_runs(
10393            &opts.repo,
10394            &opts,
10395            &home,
10396            &running,
10397            &mut last_checked,
10398            due_again,
10399        )
10400        .await;
10401        assert_eq!(
10402            crate::disk::dir_size(&cache_dir),
10403            0,
10404            "due again: pruned back under the cap"
10405        );
10406    }
10407
10408    /// A stop must not queue behind housekeeping. The prune below is a
10409    /// synchronous walk of the whole cache with no await point in it, so a
10410    /// loop that entered it could not get back to its own `stopped()` test
10411    /// until the walk finished - and because no run is in flight at this
10412    /// boundary, `Stop::finishing` would meanwhile tell the operator's screen
10413    /// the stop had already landed. The idle branch has always made this same
10414    /// check before reaching `janitor`; the between-runs path makes it too.
10415    #[tokio::test]
10416    async fn a_stop_already_asked_for_skips_the_between_runs_cache_walk() {
10417        let dir = tempfile::tempdir().unwrap();
10418        let home = dir.path().join("home");
10419        let cache_dir = dir.path().join("cache");
10420        std::fs::create_dir_all(&cache_dir).unwrap();
10421        std::fs::write(cache_dir.join("a"), vec![0u8; 10]).unwrap();
10422        let opts = cache_check_opts(dir.path(), &cache_dir, 1);
10423
10424        let stop = Stop::new();
10425        stop.stop();
10426        assert!(
10427            !stop.finishing(),
10428            "no run is in flight at a between-runs boundary, so nothing else \
10429             would tell the operator this stop had not taken effect yet"
10430        );
10431
10432        let mut last_checked = None;
10433        let t0 = "2026-09-15T00:00:00Z".parse::<Timestamp>().unwrap();
10434        maybe_prune_cache_between_runs(&opts.repo, &opts, &home, &stop, &mut last_checked, t0)
10435            .await;
10436        assert_eq!(
10437            crate::disk::dir_size(&cache_dir),
10438            10,
10439            "over its cap, and due for the first check ever, but a stop outranks \
10440             it: the cap is a standing policy the next start measures again"
10441        );
10442        assert_eq!(
10443            last_checked, None,
10444            "a check that never happened must not claim the interval"
10445        );
10446    }
10447
10448    /// A backlog must not starve cache pruning. The finite cache worker runs
10449    /// independently of queue drain and must not consume any task attempts.
10450    #[tokio::test]
10451    async fn cache_prune_reaches_a_queue_that_never_goes_idle() {
10452        let dir = tempfile::tempdir().unwrap();
10453        let cache_dir = dir.path().join("cache");
10454        std::fs::create_dir_all(&cache_dir).unwrap();
10455        std::fs::write(cache_dir.join("stale"), vec![0u8; 4096]).unwrap();
10456
10457        let mut opts = cache_check_opts(dir.path(), &cache_dir, 1);
10458        opts.poll = Duration::from_millis(20);
10459        opts.max_attempts = 1_000;
10460
10461        let queue = Queue::at(dir.path().join("queue"));
10462        for i in 0..128 {
10463            let mut task = Task::new(
10464                format!("queued work {i}"),
10465                format!("queued work {i}"),
10466                opts.repo.clone(),
10467                Source::Human,
10468            );
10469            queue.put(&mut task).unwrap();
10470        }
10471
10472        let home = dir.path().join("home");
10473        let worktrees = dir.path().join("wt");
10474        run_service_worker(
10475            &home,
10476            serde_json::to_value(ServiceWorker {
10477                opts,
10478                queue: queue.root().to_path_buf(),
10479                worktrees_root: worktrees,
10480                operation: "cache".to_owned(),
10481            })
10482            .unwrap(),
10483        )
10484        .await
10485        .unwrap();
10486        let after = queue.list();
10487        assert_eq!(after.len(), 128);
10488        assert!(
10489            after
10490                .iter()
10491                .all(|task| task.status.runnable() && task.attempts == 0)
10492        );
10493
10494        assert_eq!(
10495            crate::disk::dir_size(&cache_dir),
10496            0,
10497            "an oversized cache must not be left to grow unboundedly just because the \
10498             queue kept the loop busy the whole time"
10499        );
10500    }
10501
10502    #[test]
10503    fn task_question_reconciliation_keeps_references_and_retires_manual_releases() {
10504        let dir = tempfile::tempdir().unwrap();
10505        let queue = Queue::at(dir.path().join("queue"));
10506        let questions = Questions::at(dir.path().join("questions"));
10507        let mut task = task();
10508        queue.put(&mut task).unwrap();
10509
10510        let mut task_question = ask::Question::new(
10511            task.id.clone(),
10512            crate::conduct::NODE.to_owned(),
10513            "conduct".to_owned(),
10514            "Which backend?".to_owned(),
10515            String::new(),
10516            Vec::new(),
10517        );
10518        questions.put(&mut task_question).unwrap();
10519        task.block(vec![task_question.id.clone()], None);
10520        queue.put(&mut task).unwrap();
10521
10522        let mut run_question = ask::Question::new(
10523            "20260101-000000-run1".to_owned(),
10524            "review".to_owned(),
10525            "reviewer-1".to_owned(),
10526            "Run question".to_owned(),
10527            String::new(),
10528            Vec::new(),
10529        );
10530        questions.put(&mut run_question).unwrap();
10531
10532        // A question from another node whose `run` happens to equal this
10533        // task's id — the same field, filled in for an unrelated reason. Only
10534        // `crate::conduct::NODE` questions use `run` as a task id; this one
10535        // must never be touched by this reconciliation, even after release.
10536        let mut coincidental = ask::Question::new(
10537            task.id.clone(),
10538            "review".to_owned(),
10539            "reviewer-1".to_owned(),
10540            "Unrelated review question".to_owned(),
10541            String::new(),
10542            Vec::new(),
10543        );
10544        questions.put(&mut coincidental).unwrap();
10545
10546        reconcile_task_questions(&queue, &questions);
10547        assert!(questions.get(&task_question.id).unwrap().status.open());
10548        assert!(questions.get(&run_question.id).unwrap().status.open());
10549        assert!(questions.get(&coincidental.id).unwrap().status.open());
10550
10551        task.release();
10552        queue.put(&mut task).unwrap();
10553        reconcile_task_questions(&queue, &questions);
10554        assert_eq!(
10555            questions.get(&task_question.id).unwrap().status,
10556            ask::QuestionStatus::Abandoned
10557        );
10558        assert!(
10559            questions.get(&run_question.id).unwrap().status.open(),
10560            "run questions remain the run janitor's responsibility"
10561        );
10562        assert!(
10563            questions.get(&coincidental.id).unwrap().status.open(),
10564            "a non-conductor question must not be abandoned just because its \
10565             run id coincides with a task id"
10566        );
10567    }
10568
10569    #[test]
10570    fn a_freshly_started_running_task_is_never_stalled() {
10571        let dir = tempfile::tempdir().unwrap();
10572        let mut t = task();
10573        t.start("run-1".to_owned());
10574        // `updated_at` is `Timestamp::now()`, left alone: no live daemon
10575        // named in `dir`, but nowhere near `STALLED_RUNNING` yet.
10576        assert!(!is_stalled(&t, dir.path(), Timestamp::now()));
10577    }
10578
10579    #[test]
10580    fn a_long_running_task_with_no_live_daemon_is_stalled() {
10581        let dir = tempfile::tempdir().unwrap();
10582        let mut t = task();
10583        t.start("run-1".to_owned());
10584        t.updated_at = Timestamp::now()
10585            - jiff::SignedDuration::from_secs(STALLED_RUNNING.as_secs() as i64 + 60);
10586        assert!(is_stalled(&t, dir.path(), Timestamp::now()));
10587        assert_eq!(
10588            stalled_tasks(
10589                &Queue::at(dir.path().join("q")),
10590                dir.path(),
10591                Timestamp::now()
10592            )
10593            .len(),
10594            0,
10595            "the task was never written to this queue"
10596        );
10597    }
10598
10599    #[test]
10600    fn a_long_running_task_a_live_daemon_still_names_is_not_stalled() {
10601        let dir = tempfile::tempdir().unwrap();
10602        let mut t = task();
10603        t.id = "20260903-080340-0167".to_owned();
10604        t.start("20260903-080619-01c2".to_owned());
10605        t.updated_at = Timestamp::now()
10606            - jiff::SignedDuration::from_secs(STALLED_RUNNING.as_secs() as i64 + 60);
10607
10608        let mut status = Status::new();
10609        status.current = vec![Current {
10610            worker: None,
10611            task: t.id.clone(),
10612            run: "20260903-080619-01c2".to_owned(),
10613        }];
10614        write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
10615
10616        assert!(
10617            !is_stalled(&t, dir.path(), Timestamp::now()),
10618            "a live daemon's own heartbeat rules out stalled, however long the task has run"
10619        );
10620    }
10621
10622    /// Rewrite a task's `updated_at` on disk directly, bypassing
10623    /// `Queue::put`'s own `Timestamp::now()` stamping - the only way to make
10624    /// a fixture look like it has genuinely been `running` for a while.
10625    fn backdate_task(queue: &Queue, id: &str, seconds_ago: i64) {
10626        let path = queue.path_of(id);
10627        let body = std::fs::read_to_string(&path).unwrap();
10628        let mut v: serde_json::Value = serde_json::from_str(&body).unwrap();
10629        let old = Timestamp::now() - jiff::SignedDuration::from_secs(seconds_ago);
10630        v["updated_at"] = serde_json::Value::String(old.to_string());
10631        std::fs::write(&path, serde_json::to_string_pretty(&v).unwrap()).unwrap();
10632    }
10633
10634    #[test]
10635    fn stalled_tasks_still_reaches_a_task_reclaim_could_not_claim_yet() {
10636        // The realistic `poll()` ordering, not `is_stalled` in isolation:
10637        // `reclaim_orphaned_running` runs first, on every poll, and settles
10638        // any `running` task whose claim it can actually take. For most
10639        // crashes that is immediate - a dead pid is proof enough for
10640        // `sweep_stale_claims` to drop the lock the same tick, and the very
10641        // next claim attempt succeeds. But a lock whose pid cannot be parsed
10642        // at all falls back to `STALE_CLAIM`'s six-hour age instead (see
10643        // `sweep_stale_claims`'s own doc), so the lock - and the claim
10644        // failure behind it - can legitimately outlive many polls. This is
10645        // exactly the gap `stalled_tasks` exists to surface well before that
10646        // six-hour sweep would: reclaim leaves the task `running`, and it
10647        // must still reach the conductor as stalled.
10648        let dir = tempfile::tempdir().unwrap();
10649        let queue = Queue::at(dir.path().join("queue"));
10650        let home = dir.path().join("home");
10651
10652        let mut t = task();
10653        t.id = "20260101-000001-lock".to_owned();
10654        t.start("run-1".to_owned());
10655        queue.put(&mut t).unwrap();
10656        backdate_task(&queue, &t.id, STALLED_RUNNING.as_secs() as i64 + 60);
10657        std::fs::write(
10658            dir.path().join("queue").join(format!("{}.lock", t.id)),
10659            "not a pid",
10660        )
10661        .unwrap();
10662
10663        let now = Timestamp::now();
10664        assert!(
10665            reclaim_orphaned_running(&queue, 2, &Default::default()).is_empty(),
10666            "the unparseable lock is still well within STALE_CLAIM, so the claim fails \
10667             and reclaim must leave the task alone"
10668        );
10669        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Running);
10670
10671        let stalled = stalled_tasks(&queue, &home, now);
10672        assert_eq!(
10673            stalled.len(),
10674            1,
10675            "reclaim's inability to claim it yet must not hide it from the conductor"
10676        );
10677        assert_eq!(stalled[0].id, t.id);
10678    }
10679
10680    #[test]
10681    fn ordinary_dead_daemon_task_is_shown_stalled_before_reclaim_and_can_be_requeued() {
10682        let dir = tempfile::tempdir().unwrap();
10683        crate::run::set_home(dir.path().join("run-home"));
10684        let queue = Queue::at(dir.path().join("queue"));
10685        let home = dir.path().join("home");
10686        let questions = Questions::at(dir.path().join("questions"));
10687
10688        let mut t = task();
10689        t.id = "20260101-000003-dead".to_owned();
10690        t.start("missing-run".to_owned());
10691        queue.put(&mut t).unwrap();
10692        backdate_task(&queue, &t.id, STALLED_RUNNING.as_secs() as i64 + 60);
10693
10694        // This is the real poll ordering: retain the deterministic stalled
10695        // input before a claim proves the owner is gone and reclaims it.
10696        let stalled = stalled_tasks(&queue, &home, Timestamp::now());
10697        assert_eq!(
10698            stalled.iter().map(|task| &task.id).collect::<Vec<_>>(),
10699            [&t.id]
10700        );
10701        assert_eq!(
10702            reclaim_orphaned_running(&queue, 2, &Default::default()),
10703            [t.id.clone()]
10704        );
10705        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
10706
10707        // Reclaim drops its guard before conductor decisions are applied, so
10708        // the decision for the captured stalled input has a real write path.
10709        crate::conduct::apply(
10710            &queue,
10711            &questions,
10712            &crate::conduct::Verdict {
10713                decisions: vec![crate::conduct::Decision {
10714                    id: t.id.clone(),
10715                    recovery: Some(crate::conduct::Recovery::Requeue),
10716                    ..crate::conduct::Decision::default()
10717                }],
10718            },
10719        )
10720        .unwrap();
10721        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
10722    }
10723
10724    #[test]
10725    fn stalled_tasks_reports_exactly_the_tasks_is_stalled_agrees_on() {
10726        let dir = tempfile::tempdir().unwrap();
10727        let queue = Queue::at(dir.path().join("queue"));
10728        let home = dir.path().join("home");
10729
10730        let mut fresh = task();
10731        fresh.id = "20260101-000001-aaaa".to_owned();
10732        fresh.start("run-1".to_owned());
10733        queue.put(&mut fresh).unwrap();
10734
10735        let mut old = task();
10736        old.id = "20260101-000002-bbbb".to_owned();
10737        old.start("run-2".to_owned());
10738        queue.put(&mut old).unwrap();
10739        backdate_task(&queue, &old.id, STALLED_RUNNING.as_secs() as i64 + 60);
10740
10741        let stalled = stalled_tasks(&queue, &home, Timestamp::now());
10742        assert_eq!(stalled.len(), 1);
10743        assert_eq!(stalled[0].id, old.id);
10744    }
10745
10746    #[test]
10747    fn queued_and_finished_task_views_partition_by_status() {
10748        let dir = tempfile::tempdir().unwrap();
10749        let queue = Queue::at(dir.path().join("queue"));
10750
10751        let mut queued = task();
10752        queued.id = "20260101-000001-aaaa".to_owned();
10753        queue.put(&mut queued).unwrap();
10754
10755        let mut failed = task();
10756        failed.id = "20260101-000002-bbbb".to_owned();
10757        failed.start("run-1".to_owned());
10758        failed.fail("gate red", 5);
10759        queue.put(&mut failed).unwrap();
10760
10761        let mut held = task();
10762        held.id = "20260101-000003-cccc".to_owned();
10763        held.hold_machine(None);
10764        queue.put(&mut held).unwrap();
10765
10766        let mut running = task();
10767        running.id = "20260101-000004-dddd".to_owned();
10768        running.start("run-2".to_owned());
10769        queue.put(&mut running).unwrap();
10770
10771        let queued_ids: Vec<String> = queued_tasks(&queue).into_iter().map(|t| t.id).collect();
10772        assert_eq!(queued_ids, [queued.id.clone()]);
10773
10774        let mut finished_ids: Vec<String> =
10775            finished_tasks(&queue).into_iter().map(|t| t.id).collect();
10776        finished_ids.sort_unstable();
10777        let mut want = vec![failed.id.clone(), held.id.clone()];
10778        want.sort_unstable();
10779        assert_eq!(finished_ids, want);
10780    }
10781
10782    #[test]
10783    fn dependency_resolution_preserves_existing_short_and_full_running_chains() {
10784        for short in [false, true] {
10785            let dir = tempfile::tempdir().unwrap();
10786            let queue = Queue::at(dir.path().join("queue"));
10787            let questions = ask::Questions::at(dir.path().join("questions"));
10788            let mut running = task();
10789            running.id = "20261013-192213-feb2".into();
10790            running.start("existing-run".into());
10791            queue.put(&mut running).unwrap();
10792            let mut middle = task();
10793            middle.id = "20261013-193611-ca5b".into();
10794            middle.block(
10795                vec![if short {
10796                    "feb2".into()
10797                } else {
10798                    running.id.clone()
10799                }],
10800                None,
10801            );
10802            queue.put(&mut middle).unwrap();
10803            let mut waiting = task();
10804            waiting.id = "20261013-202849-ee37".into();
10805            waiting.block(
10806                vec![if short {
10807                    "ca5b".into()
10808                } else {
10809                    middle.id.clone()
10810                }],
10811                Some("wait for implementation".into()),
10812            );
10813            queue.put(&mut waiting).unwrap();
10814            assert_eq!(queue.get("ca5b").unwrap().id, middle.id);
10815            let inv = crate::blockers::Inventory::load(&queue, &questions);
10816            assert_eq!(inv.waits_on(&waiting), ["ca5b (blocked → feb2 running)"]);
10817            assert!(inv.stuck().is_empty());
10818            let report = crate::triage::run_once(&queue, &questions, None, Timestamp::now());
10819            assert!(report.quarantined.is_empty());
10820            assert!(report.asked.is_empty());
10821            resolve_blockers(&queue, &questions);
10822            assert_eq!(queue.get(&waiting.id).unwrap().status, TaskStatus::Blocked);
10823            assert!(questions.list().is_empty());
10824            // Finishing the transitive prerequisite alone does not satisfy
10825            // the task's direct dependency.
10826            running.succeed();
10827            queue.put(&mut running).unwrap();
10828            resolve_blockers(&queue, &questions);
10829            assert_eq!(queue.get(&middle.id).unwrap().status, TaskStatus::Queued);
10830            assert_eq!(queue.get(&waiting.id).unwrap().status, TaskStatus::Blocked);
10831            middle = queue.get(&middle.id).unwrap();
10832            middle.succeed();
10833            queue.put(&mut middle).unwrap();
10834            resolve_blockers(&queue, &questions);
10835            let after = queue.get(&waiting.id).unwrap();
10836            assert_eq!(after.status, TaskStatus::Queued);
10837            assert!(after.blocked_by.is_empty());
10838            assert_eq!(after.instruction, waiting.instruction);
10839        }
10840    }
10841
10842    #[test]
10843    fn dependency_resolution_unknown_records_never_hold_ask_or_release() {
10844        for future_schema in [false, true] {
10845            let name = "20261013-193611-ca5b";
10846            let mut future = task();
10847            future.id = name.into();
10848            future.schema = crate::queue::SCHEMA + 1;
10849            let contents = if future_schema {
10850                serde_json::to_string(&future).unwrap()
10851            } else {
10852                "corrupt".into()
10853            };
10854            let dir = tempfile::tempdir().unwrap();
10855            let queue = Queue::at(dir.path().join("queue"));
10856            let questions = ask::Questions::at(dir.path().join("questions"));
10857            let mut waiting = task();
10858            waiting.id = "20261013-202849-ee37".into();
10859            waiting.block(vec!["ca5b".into()], None);
10860            queue.put(&mut waiting).unwrap();
10861            std::fs::write(queue.path_of(name), contents).unwrap();
10862            assert_eq!(queue.resolve_id("ca5b").unwrap(), name);
10863            assert!(
10864                crate::queue::missing_blockers(&queue, &questions, &waiting.blocked_by).is_empty()
10865            );
10866            let report = crate::triage::run_once(&queue, &questions, None, Timestamp::now());
10867            assert!(report.asked.is_empty());
10868            resolve_blockers(&queue, &questions);
10869            assert_eq!(queue.get(&waiting.id).unwrap().status, TaskStatus::Blocked);
10870            assert!(questions.list().is_empty());
10871        }
10872        let dir = tempfile::tempdir().unwrap();
10873        let queue = Queue::at(dir.path().join("queue"));
10874        let questions = ask::Questions::at(dir.path().join("questions"));
10875        let mut waiting = task();
10876        waiting.id = "20261013-202849-ee37".into();
10877        waiting.block(vec!["ca5b".into()], None);
10878        queue.put(&mut waiting).unwrap();
10879        let mut done = task();
10880        done.id = "20261013-193611-ca5b".into();
10881        done.succeed();
10882        queue.put(&mut done).unwrap();
10883        // An unreadable second match must not disappear from uniqueness
10884        // checks just because Queue::list skips it.
10885        std::fs::write(queue.path_of("20261014-193611-ca5b"), "bad").unwrap();
10886        assert!(queue.get("ca5b").is_err());
10887        assert_eq!(
10888            crate::queue::dependency(&queue, &questions, "ca5b"),
10889            crate::queue::Dependency::Ambiguous
10890        );
10891        resolve_blockers(&queue, &questions);
10892        let report = crate::triage::run_once(&queue, &questions, None, Timestamp::now());
10893        assert!(report.asked.is_empty());
10894        assert_eq!(queue.get(&waiting.id).unwrap().status, TaskStatus::Blocked);
10895        assert_eq!(queue.get(&done.id).unwrap().status, TaskStatus::Done);
10896    }
10897
10898    #[test]
10899    fn dependency_resolution_preserves_independent_consent_and_holds_after_completion() {
10900        for held in [false, true] {
10901            for question in [false, true] {
10902                for reference in ["ca5b", "20261013-193611-ca5b"] {
10903                    let dir = tempfile::tempdir().unwrap();
10904                    let queue = Queue::at(dir.path().join("queue"));
10905                    let questions = ask::Questions::at(dir.path().join("questions"));
10906                    let mut prerequisite = task();
10907                    prerequisite.id = "20261013-193611-ca5b".into();
10908                    prerequisite.start("existing-run".into());
10909                    queue.put(&mut prerequisite).unwrap();
10910                    let mut waiting = task();
10911                    waiting.id = "20261013-202849-ee37".into();
10912                    if held {
10913                        waiting.hold_machine(Some("waiting for separate consent".into()));
10914                    }
10915                    queue.put(&mut waiting).unwrap();
10916                    let reason = "Deleting saved data requires separate owner consent";
10917                    crate::conduct::apply(
10918                        &queue,
10919                        &questions,
10920                        &crate::conduct::Verdict {
10921                            decisions: vec![crate::conduct::Decision {
10922                                id: waiting.id.clone(),
10923                                blocked_by: vec![reference.into()],
10924                                question: question.then(|| "May the saved data be deleted?".into()),
10925                                recovery: (!question).then_some(crate::conduct::Recovery::Hold),
10926                                reason: Some(reason.into()),
10927                                ..crate::conduct::Decision::default()
10928                            }],
10929                        },
10930                    )
10931                    .unwrap();
10932                    let before = queue.get(&waiting.id).unwrap();
10933                    if question {
10934                        assert_eq!(before.status, TaskStatus::Blocked);
10935                        let q = questions.get(&before.blocked_by[0]).unwrap();
10936                        assert_eq!(q.summary, "May the saved data be deleted?");
10937                        assert!(q.status.open());
10938                    } else {
10939                        assert_eq!(before.status, TaskStatus::Held);
10940                        let recorded_reason = before.hold_reason.as_deref().unwrap();
10941                        assert!(recorded_reason.starts_with(reason));
10942                        if held {
10943                            assert!(recorded_reason.contains("waiting for separate consent"));
10944                        }
10945                    }
10946                    prerequisite.succeed();
10947                    queue.put(&mut prerequisite).unwrap();
10948                    resolve_blockers(&queue, &questions);
10949                    let after = queue.get(&waiting.id).unwrap();
10950                    assert_eq!(after.status, before.status);
10951                    assert_eq!(after.blocked_by, before.blocked_by);
10952                    assert_eq!(after.hold_reason, before.hold_reason);
10953                    assert_eq!(after.instruction, waiting.instruction);
10954                    if question {
10955                        assert!(questions.get(&after.blocked_by[0]).unwrap().status.open());
10956                    }
10957                    assert!(
10958                        queue.next_runnable().is_none(),
10959                        "dependency completion is not consent"
10960                    );
10961                }
10962            }
10963        }
10964    }
10965
10966    #[test]
10967    fn resolve_blockers_clears_a_done_dependency_and_keeps_an_unresolved_one() {
10968        let dir = tempfile::tempdir().unwrap();
10969        let queue = Queue::at(dir.path().join("queue"));
10970        let questions = ask::Questions::at(dir.path().join("questions"));
10971
10972        let mut dep = task();
10973        dep.id = "20260101-000001-dep0".to_owned();
10974        dep.succeed();
10975        queue.put(&mut dep).unwrap();
10976
10977        let mut still_going = task();
10978        still_going.id = "20260101-000002-dep1".to_owned();
10979        queue.put(&mut still_going).unwrap();
10980
10981        let mut blocked = task();
10982        blocked.id = "20260101-000003-main".to_owned();
10983        blocked.block(
10984            vec![dep.id.clone(), still_going.id.clone()],
10985            Some("waits on both".to_owned()),
10986        );
10987        queue.put(&mut blocked).unwrap();
10988
10989        resolve_blockers(&queue, &questions);
10990
10991        let after = queue.get(&blocked.id).unwrap();
10992        assert_eq!(
10993            after.status,
10994            TaskStatus::Blocked,
10995            "one dependency is still outstanding"
10996        );
10997        assert_eq!(after.blocked_by, [still_going.id.clone()]);
10998    }
10999
11000    #[test]
11001    fn resolve_blockers_carries_an_answers_content_onto_the_task_and_unblocks_it() {
11002        let dir = tempfile::tempdir().unwrap();
11003        let queue = Queue::at(dir.path().join("queue"));
11004        let questions = ask::Questions::at(dir.path().join("questions"));
11005
11006        let mut q = crate::ask::Question::new(
11007            "20260101-000001-main".to_owned(),
11008            crate::conduct::NODE.to_owned(),
11009            "conduct".to_owned(),
11010            "Which backend?".to_owned(),
11011            String::new(),
11012            Vec::new(),
11013        );
11014        questions.put(&mut q).unwrap();
11015        q.answer(crate::ask::Answer::Text("SQLite".to_owned()))
11016            .unwrap();
11017        questions.put(&mut q).unwrap();
11018
11019        let mut blocked = task();
11020        blocked.id = "20260101-000001-main".to_owned();
11021        blocked.block(vec![q.id.clone()], Some("which backend?".to_owned()));
11022        queue.put(&mut blocked).unwrap();
11023
11024        resolve_blockers(&queue, &questions);
11025
11026        let after = queue.get(&blocked.id).unwrap();
11027        assert_eq!(
11028            after.status,
11029            TaskStatus::Queued,
11030            "the only blocker resolved"
11031        );
11032        assert_eq!(after.answers.len(), 1);
11033        assert_eq!(after.answers[0].question, "Which backend?");
11034        assert_eq!(after.answers[0].answer, "SQLite");
11035
11036        // And the run this task starts next is told about it.
11037        let instruction = instruction_for(&after);
11038        assert!(instruction.contains("Which backend?"));
11039        assert!(instruction.contains("SQLite"));
11040    }
11041
11042    #[test]
11043    fn resolve_blockers_holds_a_task_whose_conductor_question_was_abandoned() {
11044        let dir = tempfile::tempdir().unwrap();
11045        let queue = Queue::at(dir.path().join("queue"));
11046        let questions = ask::Questions::at(dir.path().join("questions"));
11047
11048        let mut q = crate::ask::Question::new(
11049            "20260101-000001-main".to_owned(),
11050            crate::conduct::NODE.to_owned(),
11051            "conduct".to_owned(),
11052            "Is the setup done?".to_owned(),
11053            String::new(),
11054            Vec::new(),
11055        );
11056        q.abandon("no answer within 60s of asking");
11057        questions.put(&mut q).unwrap();
11058
11059        let mut blocked = task();
11060        blocked.id = "20260101-000001-main".to_owned();
11061        blocked.block(vec![q.id.clone()], Some("setup?".to_owned()));
11062        queue.put(&mut blocked).unwrap();
11063
11064        resolve_blockers(&queue, &questions);
11065
11066        let after = queue.get(&blocked.id).unwrap();
11067        assert_eq!(
11068            after.status,
11069            TaskStatus::Held,
11070            "never left blocked on nothing"
11071        );
11072        assert!(!after.operator_held(), "a machine hold, for triage");
11073        assert!(
11074            after
11075                .hold_reason
11076                .as_deref()
11077                .unwrap_or_default()
11078                .contains("went unanswered")
11079        );
11080    }
11081
11082    #[test]
11083    fn resolve_blockers_restores_a_held_task_to_held_instead_of_queuing_it() {
11084        // Reproduces the reported bug (task 3958): a task held out of
11085        // attempts, blocked on a `crate::conduct` follow-up question, must
11086        // come back `held` once that question is answered - never `queued`,
11087        // whatever the answer said - or it silently re-enters the
11088        // competition queue with its attempts already exhausted.
11089        let dir = tempfile::tempdir().unwrap();
11090        let queue = Queue::at(dir.path().join("queue"));
11091        let questions = ask::Questions::at(dir.path().join("questions"));
11092
11093        let mut q = crate::ask::Question::new(
11094            "20260101-000001-main".to_owned(),
11095            crate::conduct::NODE.to_owned(),
11096            "conduct".to_owned(),
11097            "How should this be handled?".to_owned(),
11098            String::new(),
11099            Vec::new(),
11100        );
11101        questions.put(&mut q).unwrap();
11102        q.answer(crate::ask::Answer::Text(
11103            "leave it held, a human will look at it later".to_owned(),
11104        ))
11105        .unwrap();
11106        questions.put(&mut q).unwrap();
11107
11108        let mut held = task();
11109        held.id = "20260101-000001-main".to_owned();
11110        held.hold_machine(Some("out of attempts".to_owned()));
11111        held.block(vec![q.id.clone()], Some("what now?".to_owned()));
11112        queue.put(&mut held).unwrap();
11113
11114        resolve_blockers(&queue, &questions);
11115
11116        let after = queue.get(&held.id).unwrap();
11117        assert_eq!(after.status, TaskStatus::Held);
11118        assert_eq!(after.hold_reason.as_deref(), Some("out of attempts"));
11119        assert_eq!(
11120            after.answers[0].answer,
11121            "leave it held, a human will look at it later"
11122        );
11123    }
11124
11125    #[test]
11126    fn resolve_blockers_releases_a_task_whose_dependency_was_deleted_on_purpose() {
11127        let dir = tempfile::tempdir().unwrap();
11128        let queue = Queue::at(dir.path().join("queue"));
11129        let questions = ask::Questions::at(dir.path().join("questions"));
11130        let mut dep = task();
11131        dep.id = "20260101-000001-gone".to_owned();
11132        queue.put(&mut dep).unwrap();
11133        let mut blocked = task();
11134        blocked.id = "20260101-000003-main".to_owned();
11135        blocked.block(vec![dep.id.clone()], None);
11136        queue.put(&mut blocked).unwrap();
11137
11138        // Claimed, so `remove` cannot rewrite it and the resolver must.
11139        let claim = queue.claim(&blocked.id).unwrap();
11140        queue.remove(&dep.id, false, &questions).unwrap();
11141        drop(claim);
11142        resolve_blockers(&queue, &questions);
11143
11144        let after = queue.get(&blocked.id).unwrap();
11145        assert_eq!(after.status, TaskStatus::Queued);
11146        assert!(after.blocked_by.is_empty());
11147    }
11148
11149    #[test]
11150    fn resolve_blockers_holds_a_task_whose_dependency_was_deleted() {
11151        // Reproduces the reported bug: a task blocked on a task id that was
11152        // removed (`magi task rm`, or deleted by hand) can never see that id
11153        // reach `Done`, so the ordinary per-id loop has nothing to notice and
11154        // would otherwise leave the task `blocked` forever with no way for an
11155        // operator to find out why.
11156        let dir = tempfile::tempdir().unwrap();
11157        let queue = Queue::at(dir.path().join("queue"));
11158        let questions = ask::Questions::at(dir.path().join("questions"));
11159
11160        let mut still_going = task();
11161        still_going.id = "20260101-000002-dep1".to_owned();
11162        queue.put(&mut still_going).unwrap();
11163
11164        let mut blocked = task();
11165        blocked.id = "20260101-000003-main".to_owned();
11166        blocked.block(
11167            vec!["20260101-000001-gone".to_owned(), still_going.id.clone()],
11168            Some("waits on both".to_owned()),
11169        );
11170        queue.put(&mut blocked).unwrap();
11171
11172        resolve_blockers(&queue, &questions);
11173
11174        let after = queue.get(&blocked.id).unwrap();
11175        assert_eq!(
11176            after.status,
11177            TaskStatus::Held,
11178            "a missing dependency must not leave the task blocked forever"
11179        );
11180        assert_eq!(after.hold_source, Some(crate::queue::HoldSource::Machine));
11181        assert!(after.blocked_by.is_empty());
11182        let reason = after.hold_reason.as_deref().unwrap_or_default();
11183        assert!(
11184            reason.contains("20260101-000001-gone"),
11185            "the missing id must be named so an operator can tell what happened: {reason}"
11186        );
11187        assert!(
11188            reason.contains(&still_going.id),
11189            "the still-valid dependency must not silently vanish from the record: {reason}"
11190        );
11191    }
11192
11193    #[test]
11194    fn instruction_for_is_unchanged_without_any_answers() {
11195        let t = task();
11196        assert_eq!(instruction_for(&t), t.instruction);
11197    }
11198
11199    #[test]
11200    fn task_attachments_are_absolute_and_a_missing_file_is_an_error() {
11201        let dir = tempfile::tempdir().unwrap();
11202        let q = Queue::at(dir.path().join("queue"));
11203        let src = dir.path().join("shot.png");
11204        std::fs::write(&src, "x").unwrap();
11205        let mut t = task();
11206        q.attach(&mut t, &[src]).unwrap();
11207        let paths = task_attachments(&q, &t).unwrap();
11208        assert_eq!(paths.len(), 1);
11209        assert!(paths[0].is_absolute() && paths[0].is_file());
11210        std::fs::remove_file(&paths[0]).unwrap();
11211        let err = task_attachments(&q, &t).unwrap_err().to_string();
11212        assert!(err.contains("shot.png"), "{err}");
11213    }
11214
11215    #[tokio::test]
11216    async fn config_failure_notices_distinguish_boundaries_and_targets_after_restart() {
11217        let dir = tempfile::tempdir().unwrap();
11218        let queue_path = dir.path().join("queue");
11219        let queue = Queue::at(queue_path.clone());
11220        let mut t = task();
11221        t.source = Source::Agent {
11222            run: "originating-chat".into(),
11223            node: crate::queue::CHAT_NODE.into(),
11224        };
11225        let fetch = anyhow::anyhow!("network unavailable").context(crate::config::ConfigFailure {
11226            stage: "config-fetch",
11227            target: "origin/main".into(),
11228        });
11229        record_config_failure(&mut t, &fetch);
11230        t.hold_machine(Some(format!("{fetch:#}")));
11231        let fetch_key = t.chat_notice_due().unwrap();
11232        t.chat_notices.push(fetch_key.clone());
11233        queue.put(&mut t).unwrap();
11234
11235        let queue = Queue::at(queue_path);
11236        let mut t = queue.get(&t.id).unwrap();
11237        let a = dir.path().join("a.toml");
11238        let b = dir.path().join("b.toml");
11239        for (path, text, new_cause) in [
11240            (&a, "[graph", true),
11241            (&a, "[verify", false),
11242            (&a, "[graph]\nimplementers = 'invalid-type'", true),
11243            (&b, "[graph]\nimplementers = 'invalid-type'", true),
11244        ] {
11245            t.release_operator("test", None);
11246            std::fs::write(path, text).unwrap();
11247            let error = Config::discover_fetched(dir.path(), Some(path))
11248                .await
11249                .unwrap_err();
11250            record_config_failure(&mut t, &error);
11251            t.hold_machine(Some(format!("{error:#}")));
11252            let due = t.chat_notice_due();
11253            assert_eq!(due.is_some(), new_cause, "{error:#}");
11254            if let Some(key) = due {
11255                assert_ne!(key, fetch_key);
11256                t.chat_notices.push(key);
11257            }
11258        }
11259        t.succeed();
11260        assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
11261    }
11262
11263    #[test]
11264    fn different_missing_attachments_have_distinct_persisted_notice_causes() {
11265        let dir = tempfile::tempdir().unwrap();
11266        let queue_path = dir.path().join("queue");
11267        let queue = Queue::at(queue_path.clone());
11268        let mut t = task();
11269        t.source = crate::queue::Source::Agent {
11270            run: "originating-chat".into(),
11271            node: crate::queue::CHAT_NODE.into(),
11272        };
11273        let a = dir.path().join("a.png");
11274        let b = dir.path().join("b.png");
11275        std::fs::write(&a, "a").unwrap();
11276        std::fs::write(&b, "b").unwrap();
11277        queue.attach(&mut t, &[a, b]).unwrap();
11278        let paths = task_attachments(&queue, &t).unwrap();
11279        for path in &paths {
11280            std::fs::remove_file(path).unwrap();
11281        }
11282        let missing = task_attachments(&queue, &t).unwrap_err();
11283        t.attempts += 1;
11284        let reason = missing.to_string();
11285        t.fail_startup_target("startup-attachments", missing.name, reason, 1);
11286        let first = t.chat_notice_due().expect("first attachment notice");
11287        t.chat_notices.push(first.clone());
11288        queue.put(&mut t).unwrap();
11289
11290        let queue = Queue::at(queue_path.clone());
11291        let mut t = queue.get(&t.id).unwrap();
11292        assert!(t.chat_notice_due().is_none(), "restart does not replay");
11293        t.release_operator("test", None);
11294        t.attempts += 1;
11295        let missing = task_attachments(&queue, &t).unwrap_err();
11296        t.fail_startup_target(
11297            "startup-attachments",
11298            missing.name,
11299            "same missing attachment with regenerated wording",
11300            1,
11301        );
11302        assert!(t.chat_notice_due().is_none(), "same object stays quiet");
11303
11304        std::fs::write(&paths[0], "restored").unwrap();
11305        t.release_operator("test", None);
11306        t.attempts += 1;
11307        let missing = task_attachments(&queue, &t).unwrap_err();
11308        let reason = missing.to_string();
11309        t.fail_startup_target("startup-attachments", missing.name, reason, 1);
11310        let second = t
11311            .chat_notice_due()
11312            .expect("different attachment must notify");
11313        assert_ne!(first, second);
11314        assert!(t.hold_reason.as_ref().unwrap().contains("b.png"));
11315    }
11316
11317    #[test]
11318    fn resumed_instruction_is_unchanged_without_any_answers() {
11319        let t = task();
11320        assert_eq!(resumed_instruction(&t.instruction, &t), t.instruction);
11321    }
11322
11323    #[test]
11324    fn resumed_instruction_carries_a_new_answer_onto_the_old_run() {
11325        let mut t = task();
11326        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
11327        // The run's own instruction on disk predates the answer: it is the
11328        // plain original text `Runner::start` saved before the operator was
11329        // ever asked anything.
11330        let old = t.instruction.clone();
11331
11332        let refreshed = resumed_instruction(&old, &t);
11333        assert!(refreshed.starts_with(&old), "the original text is kept");
11334        assert!(refreshed.contains("Which backend?"));
11335        assert!(refreshed.contains("SQLite"));
11336    }
11337
11338    #[test]
11339    fn resumed_instruction_keeps_an_original_answers_heading() {
11340        let mut t = task();
11341        t.instruction = "Context\n\n# Operator answers\n\nThis is part of the task.".to_owned();
11342        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
11343
11344        let refreshed = resumed_instruction(&t.instruction, &t);
11345
11346        assert!(
11347            refreshed.starts_with(&t.instruction),
11348            "an answers heading in the original instruction is not the appended block"
11349        );
11350        assert_eq!(refreshed.matches(ANSWERS_HEADER).count(), 2);
11351        assert!(refreshed.contains("Which backend?"));
11352        assert!(refreshed.contains("SQLite"));
11353
11354        let repeated = resumed_instruction(&refreshed, &t);
11355        assert_eq!(
11356            repeated, refreshed,
11357            "only the final appended block is refreshed"
11358        );
11359    }
11360
11361    #[test]
11362    fn resumed_instruction_does_not_duplicate_across_repeated_resumes() {
11363        let mut t = task();
11364        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
11365
11366        // A first resume appends the block; a second resume of the same run,
11367        // with no new answer in between, must reproduce exactly the same
11368        // text rather than appending the block a second time.
11369        let once = resumed_instruction(&t.instruction, &t);
11370        let twice = resumed_instruction(&once, &t);
11371        assert_eq!(once, twice);
11372        assert_eq!(once.matches("Which backend?").count(), 1);
11373
11374        // A later answer replaces the block wholesale rather than growing it.
11375        t.record_answer("Which cache?".to_owned(), "Redis".to_owned());
11376        let refreshed = resumed_instruction(&once, &t);
11377        assert_eq!(refreshed.matches(ANSWERS_HEADER).count(), 1);
11378        assert!(refreshed.contains("Which backend?"));
11379        assert!(refreshed.contains("Which cache?"));
11380    }
11381
11382    #[test]
11383    fn prepare_instruction_covers_all_three_starters() {
11384        let mut t = task();
11385        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
11386
11387        // Start: a fresh run gets the task text plus every answer so far —
11388        // exactly `instruction_for`.
11389        assert_eq!(
11390            prepare_instruction(&Starter::Start, None, &t),
11391            Some(instruction_for(&t))
11392        );
11393
11394        // Resume: the run's prior instruction is refreshed with the answer,
11395        // not discarded and not left stale.
11396        let old = t.instruction.clone();
11397        assert_eq!(
11398            prepare_instruction(&Starter::Resume("some-run".to_owned()), Some(&old), &t),
11399            Some(resumed_instruction(&old, &t))
11400        );
11401
11402        // Review: a review-only pass builds its own instruction from the
11403        // branch's history in `crate::graph`, with no task statement at all -
11404        // this boundary must leave it alone.
11405        assert_eq!(
11406            prepare_instruction(&Starter::Review("magi/eba2/A".to_owned()), Some(&old), &t),
11407            None
11408        );
11409    }
11410
11411    #[test]
11412    fn choose_starter_prefers_review_over_resume_when_the_branch_survived() {
11413        assert_eq!(
11414            choose_starter(Some("magi/eba2/A"), true, Some("some-run")),
11415            Starter::Review("magi/eba2/A".to_owned())
11416        );
11417    }
11418
11419    #[test]
11420    fn choose_starter_falls_back_to_start_when_the_review_branch_is_gone() {
11421        assert_eq!(
11422            choose_starter(Some("magi/eba2/A"), false, Some("some-run")),
11423            Starter::Start,
11424            "a vanished review branch must not fall back to resuming the old run either"
11425        );
11426    }
11427
11428    #[test]
11429    fn a_refused_handover_retries_as_a_review_of_the_same_branch() {
11430        let mut t = task();
11431        t.start("old-run".to_owned());
11432        t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
11433        t.release();
11434        let branch = t.review_branch.take();
11435        assert_eq!(
11436            choose_starter(branch.as_deref(), true, Some("old-run")),
11437            Starter::Review("magi/eba2/A".to_owned()),
11438            "a review wins over resuming the old run"
11439        );
11440    }
11441
11442    #[test]
11443    fn choose_starter_resumes_or_starts_when_there_is_no_review_choice_at_all() {
11444        assert_eq!(
11445            choose_starter(None, false, Some("some-run")),
11446            Starter::Resume("some-run".to_owned())
11447        );
11448        assert_eq!(choose_starter(None, false, None), Starter::Start);
11449    }
11450
11451    #[test]
11452    fn an_explicit_release_forces_a_fresh_competition_even_with_a_resumable_run() {
11453        let mut released = task();
11454        released.start("stalled-run".to_owned());
11455        released.requeue();
11456        let unfinished = (!released.fresh_start)
11457            .then(|| Some("stalled-run".to_owned()))
11458            .flatten();
11459        assert_eq!(
11460            choose_starter(None, false, unfinished.as_deref()),
11461            Starter::Start,
11462            "release keeps run history but must not resume it"
11463        );
11464        assert_eq!(released.runs, ["stalled-run"]);
11465    }
11466
11467    fn stopped_public_run(status: RunStatus) -> RunState {
11468        let mut state = run_state(status);
11469        state.driver_exited = true;
11470        state.config.graph.land = true;
11471        state.config.graph.review_rounds = 1;
11472        state.reviews = vec![review_round(1)];
11473        state.merge = Some(crate::run::MergeOutcome {
11474            mode: MergeMode::Pr,
11475            ok: false,
11476            detail: "landing stopped".into(),
11477            empty: false,
11478        });
11479        state.pr = Some(crate::run::PrRecord {
11480            url: "https://example.test/pull/1".into(),
11481            number: 1,
11482            state: "open".into(),
11483            checks: "red".into(),
11484            round: 1,
11485            rounds: 1,
11486            red_at_merge: Vec::new(),
11487        });
11488        state
11489    }
11490
11491    #[test]
11492    fn stopped_public_owner_resumes_on_release_choice_and_control_after_review_exhaustion() {
11493        for status in [RunStatus::Blocked, RunStatus::Gating] {
11494            let home = tempfile::tempdir().unwrap();
11495            let mut state = stopped_public_run(status);
11496            state.save_under(home.path()).unwrap();
11497            assert!(crate::handover::has_resume_checkpoint(&state, home.path()));
11498            assert!(!exhausted_review_budget(&state));
11499            let mut task = held_task_with(&state.id);
11500            task.release();
11501            let unfinished = unfinished_run_with(&task.runs, task.short(), |_| Ok(state.clone()));
11502            assert_eq!(
11503                choose_starter(None, false, unfinished.as_deref()),
11504                Starter::Resume(state.id.clone())
11505            );
11506            let question = action_question(&state.id, resume_action(&state.id));
11507            assert_eq!(
11508                decide_action(&task, &question, &PHRASES_EN, |_| Ok(state.clone())),
11509                ActionDecision::Resume(state.id.clone())
11510            );
11511            // The control-plane command uses this same preflight before pinning.
11512            assert_eq!(
11513                crate::resume::validate(home.path(), &state.id).unwrap().id,
11514                state.id
11515            );
11516            let queue = Queue::at(home.path().join("queue"));
11517            let mut controlled = Task::new(
11518                "resume land".into(),
11519                state.instruction.clone(),
11520                state.repo.clone(),
11521                Source::Agent {
11522                    run: "talk".into(),
11523                    node: crate::queue::CHAT_NODE.into(),
11524                },
11525            );
11526            controlled.runs.push(state.id.clone());
11527            controlled.hold_machine(Some("land stopped".into()));
11528            queue.put(&mut controlled).unwrap();
11529            let resumed =
11530                crate::chat_control::resume(&queue, home.path(), "talk", &state.id).unwrap();
11531            assert_eq!(resumed.status, TaskStatus::Queued);
11532            assert_eq!(
11533                resumed.resume_override.unwrap().pinned_run,
11534                Some(state.id.clone())
11535            );
11536            assert_eq!(resumed.attempts, 0);
11537        }
11538    }
11539
11540    #[test]
11541    fn exhausted_gate_failure_with_clean_or_disabled_reviews_is_not_resumable() {
11542        for review_rounds in [0, 1] {
11543            let home = tempfile::tempdir().unwrap();
11544            let mut state = run_state(RunStatus::Blocked);
11545            state.config.graph.review_rounds = review_rounds;
11546            if review_rounds > 0 {
11547                let mut review = review_round(1);
11548                review.clean = true;
11549                state.reviews = vec![review];
11550            }
11551            state.gate_ran = true;
11552            state.gate = vec![crate::run::CommandOutcome {
11553                command: "verify".into(),
11554                code: Some(1),
11555                output_tail: "gate failed after repair budget exhausted".into(),
11556                duration_ms: 0,
11557                resource_blocked: false,
11558            }];
11559            state.save_under(home.path()).unwrap();
11560            assert!(!crate::handover::has_resume_checkpoint(&state, home.path()));
11561            assert!(exhausted_review_budget(&state));
11562            let mut task = held_task_with(&state.id);
11563            task.release();
11564            assert!(unfinished_run_with(&task.runs, task.short(), |_| Ok(state.clone())).is_none());
11565            let question = action_question(&state.id, resume_action(&state.id));
11566            assert_ne!(
11567                decide_action(&task, &question, &PHRASES_EN, |_| Ok(state.clone())),
11568                ActionDecision::Resume(state.id.clone())
11569            );
11570            let error = crate::resume::validate(home.path(), &state.id)
11571                .unwrap_err()
11572                .to_string();
11573            assert!(!error.contains("review budget exhausted"), "{error}");
11574            assert!(
11575                error.contains(if review_rounds == 0 {
11576                    "review is disabled"
11577                } else {
11578                    "gate already ran"
11579                }),
11580                "{error}"
11581            );
11582
11583            // An unexecuted gate still has work to do with or without reviews.
11584            state.gate_ran = false;
11585            state.gate.clear();
11586            assert!(!exhausted_review_budget(&state));
11587            assert_eq!(
11588                unfinished_run_with(&task.runs, task.short(), |_| Ok(state.clone())),
11589                Some(state.id)
11590            );
11591        }
11592    }
11593
11594    #[test]
11595    fn resource_blocked_verification_resumes_the_same_run() {
11596        for (kind, stage) in [
11597            ("cache", "clean"),
11598            ("coordination", "clean"),
11599            ("cache", "review-e2e"),
11600            ("coordination", "no-reviews"),
11601            ("coordination", "handoff"),
11602        ] {
11603            let home = tempfile::tempdir().unwrap();
11604            let mut state = run_state(RunStatus::Blocked);
11605            state.config.graph.review_rounds = 1;
11606            let mut review = review_round(1);
11607            review.clean = true;
11608            state.reviews = vec![review];
11609            if stage == "review-e2e" {
11610                let review = &mut state.reviews[0];
11611                review.clean = false;
11612                review.blocking = 1;
11613                review.e2e = vec![crate::run::CommandOutcome {
11614                    command: "verify".into(),
11615                    code: None,
11616                    output_tail: "resource wait expired".into(),
11617                    duration_ms: 0,
11618                    resource_blocked: true,
11619                }];
11620            } else if stage == "no-reviews" {
11621                state.config.graph.review_rounds = 0;
11622                state.reviews.clear();
11623            } else if stage == "handoff" {
11624                // A completed final verification can hand off to the gate
11625                // even when the review still carries contested findings.
11626                let review = &mut state.reviews[0];
11627                review.clean = false;
11628                review.blocking = 1;
11629                review.e2e = vec![crate::run::CommandOutcome {
11630                    command: "verify".into(),
11631                    code: Some(0),
11632                    output_tail: String::new(),
11633                    duration_ms: 0,
11634                    resource_blocked: false,
11635                }];
11636            }
11637            state.gate_ran = false;
11638            state.resource_block = Some(crate::run::ResourceBlock {
11639                cause: crate::run::ResourceBlockCause::Other,
11640                node: if stage == "review-e2e" {
11641                    "review"
11642                } else {
11643                    "gate"
11644                }
11645                .into(),
11646                kind: kind.into(),
11647                detail: "resource wait expired".into(),
11648                at: jiff::Timestamp::now(),
11649            });
11650            state.save_under(home.path()).unwrap();
11651            assert!(!crate::handover::has_resume_checkpoint(&state, home.path()));
11652            assert!(!exhausted_review_budget(&state));
11653            let mut task = held_task_with(&state.id);
11654            task.release();
11655            let unfinished = unfinished_run_with(&task.runs, task.short(), |_| Ok(state.clone()));
11656            assert_eq!(
11657                choose_starter(None, false, unfinished.as_deref()),
11658                Starter::Resume(state.id.clone())
11659            );
11660            let question = action_question(&state.id, resume_action(&state.id));
11661            assert_eq!(
11662                decide_action(&task, &question, &PHRASES_EN, |_| Ok(state.clone())),
11663                ActionDecision::Resume(state.id.clone())
11664            );
11665            assert_eq!(
11666                crate::resume::validate(home.path(), &state.id).unwrap().id,
11667                state.id
11668            );
11669            let queue = Queue::at(home.path().join("queue"));
11670            let mut controlled = Task::new(
11671                "resume verification".into(),
11672                state.instruction.clone(),
11673                state.repo.clone(),
11674                Source::Agent {
11675                    run: "talk".into(),
11676                    node: crate::queue::CHAT_NODE.into(),
11677                },
11678            );
11679            controlled.runs.push(state.id.clone());
11680            controlled.hold_machine(Some("verification resource unavailable".into()));
11681            queue.put(&mut controlled).unwrap();
11682            let resumed =
11683                crate::chat_control::resume(&queue, home.path(), "talk", &state.id).unwrap();
11684            assert_eq!(resumed.status, TaskStatus::Queued);
11685            assert_eq!(
11686                resumed.resume_override.unwrap().pinned_run,
11687                Some(state.id.clone())
11688            );
11689            assert_eq!(resumed.attempts, 0);
11690
11691            // A genuinely unresolved final review still exhausts the budget.
11692            state.config.graph.review_rounds = 1;
11693            let mut unresolved = review_round(1);
11694            unresolved.blocking = 1;
11695            state.reviews = vec![unresolved];
11696            state.resource_block = None;
11697            state.save_under(home.path()).unwrap();
11698            assert!(exhausted_review_budget(&state));
11699            assert!(crate::resume::validate(home.path(), &state.id).is_err());
11700        }
11701    }
11702
11703    #[tokio::test]
11704    async fn ownership_contention_waits_and_reads_the_settled_record_before_resuming() {
11705        if !crate::standalone_test(
11706            "daemon::tests::ownership_contention_waits_and_reads_the_settled_record_before_resuming",
11707        ) {
11708            return;
11709        }
11710        let home = tempfile::tempdir().unwrap();
11711        let mut owner = stopped_public_run(RunStatus::Blocked);
11712        owner.save_under(home.path()).unwrap();
11713        let history = vec![owner.id.clone()];
11714        let repo = owner.repo.clone();
11715        let lock = crate::pr_head::owner_lock(home.path(), &owner.id).unwrap();
11716        let mut lookup = Box::pin(public_resume_owner(home.path(), &history, &repo));
11717        let initial =
11718            std::future::poll_fn(|cx| std::task::Poll::Ready(lookup.as_mut().poll(cx))).await;
11719        assert!(
11720            initial.is_pending(),
11721            "a busy record must not hold or resume the task"
11722        );
11723        owner.retired_to = Some("20260101-000001-bbbb".into());
11724        owner.save_under(home.path()).unwrap();
11725        drop(lock);
11726        let result = tokio::time::timeout(Duration::from_secs(10), lookup)
11727            .await
11728            .expect("ownership lock released")
11729            .expect("read settled owner");
11730        assert!(
11731            result.is_none(),
11732            "the retired owner must not be resumed from a stale read"
11733        );
11734    }
11735
11736    #[tokio::test]
11737    async fn retained_public_owner_wins_over_an_existing_successor_and_unknown_history_holds() {
11738        if !crate::standalone_test(
11739            "daemon::tests::retained_public_owner_wins_over_an_existing_successor_and_unknown_history_holds",
11740        ) {
11741            return;
11742        }
11743        let home = tempfile::tempdir().unwrap();
11744        let mut owner = stopped_public_run(RunStatus::Blocked);
11745        let repo = tempfile::tempdir().unwrap();
11746        crate::git::git(repo.path(), &["init"]).await.unwrap();
11747        owner.repo = crate::git::toplevel(repo.path()).await.unwrap();
11748        owner.id = "20260101-000000-aaaa".into();
11749        owner.save_under(home.path()).unwrap();
11750        let mut successor = run_state(RunStatus::Blocked);
11751        successor.id = "20260101-000001-bbbb".into();
11752        successor.save_under(home.path()).unwrap();
11753        let runs = vec![owner.id.clone(), successor.id.clone()];
11754        assert_eq!(
11755            public_resume_owner(home.path(), &runs, &owner.repo)
11756                .await
11757                .unwrap()
11758                .unwrap()
11759                .id,
11760            owner.id
11761        );
11762        let subdir = owner.repo.join("nested");
11763        std::fs::create_dir(&subdir).unwrap();
11764        assert_eq!(
11765            public_resume_owner(home.path(), &runs, &subdir)
11766                .await
11767                .unwrap()
11768                .unwrap()
11769                .id,
11770            owner.id
11771        );
11772        #[cfg(unix)]
11773        {
11774            let alias = home.path().join("repo-alias");
11775            std::os::unix::fs::symlink(&owner.repo, &alias).unwrap();
11776            assert_eq!(
11777                public_resume_owner(home.path(), &runs, &alias.join("nested"))
11778                    .await
11779                    .unwrap()
11780                    .unwrap()
11781                    .id,
11782                owner.id
11783            );
11784        }
11785        let different = tempfile::tempdir().unwrap();
11786        crate::git::git(different.path(), &["init"]).await.unwrap();
11787        assert!(
11788            public_resume_owner(home.path(), &runs, different.path())
11789                .await
11790                .is_err()
11791        );
11792        std::fs::remove_file(
11793            home.path()
11794                .join("runs")
11795                .join(&successor.id)
11796                .join("run.json"),
11797        )
11798        .unwrap();
11799        assert!(
11800            public_resume_owner(home.path(), &runs, &owner.repo)
11801                .await
11802                .is_err()
11803        );
11804    }
11805
11806    #[test]
11807    fn an_ordinary_release_keeps_a_resumable_run_available() {
11808        let mut released = task();
11809        released.start("stalled-run".to_owned());
11810        released.release();
11811        let unfinished = (!released.fresh_start)
11812            .then(|| Some("stalled-run".to_owned()))
11813            .flatten();
11814        assert_eq!(
11815            choose_starter(None, false, unfinished.as_deref()),
11816            Starter::Resume("stalled-run".to_owned()),
11817            "manual release must preserve the normal resume path"
11818        );
11819    }
11820
11821    #[test]
11822    fn resume_refusals_explain_admission_without_changing_task_or_run() {
11823        crate::run::pin_test_home();
11824        use crate::handover::{ResumeRefusal, resume_refusal_under};
11825        let home = tempfile::tempdir().unwrap();
11826        let mut exhausted = run_state(RunStatus::Blocked);
11827        exhausted.driver_exited = true;
11828        exhausted.config.graph.review_rounds = 3;
11829        exhausted.reviews = (1..=3).map(review_round).collect();
11830        for refusal in [
11831            ResumeRefusal::ReviewBudget { used: 3, limit: 3 },
11832            ResumeRefusal::Status(RunStatus::Ready),
11833            ResumeRefusal::Released("20261022-000000-aaaa".into()),
11834            ResumeRefusal::Retired("retirement-successor".into()),
11835            ResumeRefusal::OwnershipTransferred("20261022-000000-bbbb".into()),
11836        ] {
11837            let mut state = exhausted.clone();
11838            let expected = match refusal {
11839                ResumeRefusal::Status(_) => {
11840                    state.status = RunStatus::Ready;
11841                    "status `ready`"
11842                }
11843                ResumeRefusal::Released(ref target) => {
11844                    state.released_to = Some(target.clone());
11845                    "execution was released"
11846                }
11847                ResumeRefusal::Retired(ref target) => {
11848                    state.retired_to = Some(target.clone());
11849                    "execution was retired"
11850                }
11851                ResumeRefusal::OwnershipTransferred(ref target) => {
11852                    state.pr_taken_over_by = Some(target.clone());
11853                    "PR ownership was transferred"
11854                }
11855                _ => "review budget exhausted (3/3 rounds used)",
11856            };
11857            assert_eq!(
11858                resume_refusal_under(&state, Some(home.path())),
11859                Some(refusal.clone())
11860            );
11861            assert_eq!(
11862                refusal.successor(),
11863                state
11864                    .released_to
11865                    .as_deref()
11866                    .or(state.retired_to.as_deref())
11867                    .or(state.pr_taken_over_by.as_deref())
11868            );
11869            state.save_under(home.path()).unwrap();
11870            let queue = Queue::at(home.path().join("queue"));
11871            let mut task = Task::new(
11872                "retained implementation".into(),
11873                state.instruction.clone(),
11874                state.repo.clone(),
11875                Source::Agent {
11876                    run: "talk".into(),
11877                    node: crate::queue::CHAT_NODE.into(),
11878                },
11879            );
11880            task.runs.push(state.id.clone());
11881            task.hold_machine(Some("stopped".into()));
11882            queue.put(&mut task).unwrap();
11883            let before_task = serde_json::to_value(queue.get(&task.id).unwrap()).unwrap();
11884            let before_run = serde_json::to_value(&state).unwrap();
11885            let error = crate::chat_control::resume(&queue, home.path(), "talk", &state.id)
11886                .unwrap_err()
11887                .to_string();
11888            assert!(error.contains(expected), "{error}");
11889            assert!(!error.contains("only a stalled or blocked"), "{error}");
11890            if matches!(refusal, ResumeRefusal::Status(_)) {
11891                assert!(
11892                    error.contains("is final; there is nothing left to resume"),
11893                    "{error}"
11894                );
11895            }
11896            if matches!(refusal, ResumeRefusal::ReviewBudget { .. }) {
11897                assert!(error.contains("magi review <saved-branch> --task <task-id> --merge none"));
11898                assert!(error.contains("review-only successor with a new review budget"));
11899                assert!(error.contains("does not resume this run"));
11900            }
11901            for target in [
11902                &state.released_to,
11903                &state.retired_to,
11904                &state.pr_taken_over_by,
11905            ]
11906            .into_iter()
11907            .flatten()
11908            {
11909                let short = if state.retired_to.as_ref() == Some(target) {
11910                    target.as_str()
11911                } else {
11912                    crate::run::short_of(target)
11913                };
11914                assert!(
11915                    error.contains(&format!("run {short}; inspect `magi show {short}`")),
11916                    "{error}"
11917                );
11918                if short != target {
11919                    assert!(!error.contains(target), "full successor id leaked: {error}");
11920                }
11921            }
11922            assert_eq!(
11923                serde_json::to_value(queue.get(&task.id).unwrap()).unwrap(),
11924                before_task
11925            );
11926            assert_eq!(
11927                serde_json::to_value(RunState::load_under(&state.id, home.path()).unwrap())
11928                    .unwrap(),
11929                before_run
11930            );
11931            let question = action_question(&state.id, resume_action(&state.id));
11932            for phrases in [&PHRASES_EN, &PHRASES_JA] {
11933                let ActionDecision::Refuse(detail) =
11934                    decide_action(&task, &question, phrases, |_| Ok(state.clone()))
11935                else {
11936                    panic!("refusal expected");
11937                };
11938                assert!(detail.contains(expected), "{detail}");
11939            }
11940        }
11941        // Preserve predicate ordering, while keeping every recorded destination visible.
11942        exhausted.status = RunStatus::Ready;
11943        exhausted.released_to = Some("released".into());
11944        exhausted.retired_to = Some("retired".into());
11945        exhausted.pr_taken_over_by = Some("owner".into());
11946        let reason = resume_refusal_under(&exhausted, Some(home.path())).unwrap();
11947        assert_eq!(reason, ResumeRefusal::Status(RunStatus::Ready));
11948        let detail = reason.diagnostic(&exhausted);
11949        for target in ["released", "retired", "owner"] {
11950            assert!(detail.contains(&format!("magi show {target}")));
11951        }
11952    }
11953
11954    #[test]
11955    fn resume_checkpoint_admission_uses_the_supplied_question_store() {
11956        let home = tempfile::tempdir().unwrap();
11957        let mut state = run_state(RunStatus::Blocked);
11958        state.driver_exited = true;
11959        state.config.graph.review_rounds = 1;
11960        state.reviews = vec![review_round(1)];
11961        assert!(crate::handover::resume_refusal_under(&state, Some(home.path())).is_some());
11962        for checkpoint in ["publication", "approval", "armed", "landing", "question"] {
11963            let mut saved = state.clone();
11964            match checkpoint {
11965                "publication" => saved.pr_publication_pending = true,
11966                "approval" => {
11967                    saved.land_approval = Some(crate::run::LandApproval {
11968                        question: "approval".into(),
11969                        head: "head".into(),
11970                    })
11971                }
11972                "armed" => saved.land_armed_head = Some("head".into()),
11973                "landing" => saved.status = RunStatus::Landing,
11974                _ => {
11975                    let mut question = ask::Question::new(
11976                        saved.id.clone(),
11977                        "review".into(),
11978                        "review-1".into(),
11979                        "continue?".into(),
11980                        String::new(),
11981                        vec!["continue".into()],
11982                    );
11983                    ask::Questions::at(home.path().join("questions"))
11984                        .put(&mut question)
11985                        .unwrap();
11986                }
11987            }
11988            saved.save_under(home.path()).unwrap();
11989            assert!(
11990                crate::handover::resume_refusal_under(&saved, Some(home.path())).is_none(),
11991                "{checkpoint}"
11992            );
11993            assert!(
11994                crate::resume::validate(home.path(), &saved.id).is_ok(),
11995                "{checkpoint}"
11996            );
11997        }
11998    }
11999
12000    #[test]
12001    fn a_blocked_run_that_spent_every_review_round_has_exhausted_its_budget() {
12002        let mut state = run_state(RunStatus::Blocked);
12003        state.config.graph.review_rounds = 3;
12004        state.reviews = vec![review_round(1), review_round(2), review_round(3)];
12005        assert!(exhausted_review_budget(&state));
12006
12007        // One round still unused: resuming can still ask a reviewer something.
12008        state.reviews.pop();
12009        assert!(!exhausted_review_budget(&state));
12010
12011        // Exhausted rounds on a non-`Blocked` status (a stall, say) do not
12012        // count: only a `Blocked` run re-enters the review loop on resume.
12013        let mut stalled = run_state(RunStatus::Stalled);
12014        stalled.config.graph.review_rounds = 1;
12015        stalled.reviews = vec![review_round(1)];
12016        assert!(!exhausted_review_budget(&stalled));
12017    }
12018
12019    fn review_round(round: usize) -> crate::run::ReviewRound {
12020        crate::run::ReviewRound {
12021            round,
12022            head: "deadbeef".to_owned(),
12023            verified_head: None,
12024            verified_at: None,
12025            reviews: Vec::new(),
12026            e2e: Vec::new(),
12027            verify_retried: false,
12028            e2e_deferred: false,
12029            e2e_defer_reason: None,
12030            fix: None,
12031            blocking: 0,
12032            answered: 1,
12033            expected: 1,
12034            clean: false,
12035            progressed: true,
12036            vote_split: false,
12037            reconsideration: Vec::new(),
12038            verdict: None,
12039        }
12040    }
12041
12042    #[test]
12043    fn awaiting_resume_is_a_failed_task_whose_last_run_parked_and_can_be_resumed() {
12044        let mut run = RunState::new(
12045            PathBuf::from("/repo"),
12046            "main".to_owned(),
12047            "abc1234def".to_owned(),
12048            "add retries".to_owned(),
12049            Config::default(),
12050        );
12051        run.status = RunStatus::Judging;
12052        run.parked = true;
12053        let mut task = Task::new(
12054            "add retries".to_owned(),
12055            "add retries".to_owned(),
12056            PathBuf::from("/repo"),
12057            crate::queue::Source::Human,
12058        );
12059        task.status = TaskStatus::Failed;
12060        task.runs = vec![run.id.clone()];
12061        let with = |t: &Task, r: &RunState| awaiting_resume_with(t, |_| Ok(r.clone()));
12062        assert!(with(&task, &run), "parked after judging is the case");
12063        let mut parked_task = task.clone();
12064        parked_task.status = TaskStatus::Parked;
12065        assert!(with(&parked_task, &run), "the Parked status is the case");
12066
12067        let mut not_parked = run.clone();
12068        not_parked.parked = false;
12069        not_parked.status = RunStatus::Stalled;
12070        assert!(!with(&task, &not_parked), "a stall is the conductor's");
12071
12072        let mut fresh = task.clone();
12073        fresh.fresh_start = true;
12074        assert!(!with(&fresh, &run), "a requeue asked for a new competition");
12075
12076        let mut review = task.clone();
12077        review.review_branch = Some("magi/x/A".to_owned());
12078        assert!(!with(&review, &run), "review is ranked before resume");
12079
12080        let mut held = task.clone();
12081        held.status = TaskStatus::Held;
12082        assert!(!with(&held, &run), "a hold stays visible to the conductor");
12083
12084        let mut released = run.clone();
12085        released.released_to = Some("20260901-000000-new1".to_owned());
12086        assert!(!with(&task, &released), "nothing left to resume into");
12087
12088        assert!(!awaiting_resume_with(&task, |_| anyhow::bail!(
12089            "unreadable"
12090        )));
12091    }
12092
12093    #[test]
12094    fn unfinished_run_never_offers_a_run_whose_worktree_was_released() {
12095        let mut released = RunState::new(
12096            PathBuf::from("/repo"),
12097            "main".to_owned(),
12098            "abc1234def".to_owned(),
12099            "add retries".to_owned(),
12100            Config::default(),
12101        );
12102        released.status = RunStatus::Blocked;
12103        assert_eq!(
12104            unfinished_run_with(&[released.id.clone()], "t", |_| Ok(released.clone())),
12105            Some(released.id.clone())
12106        );
12107        released.released_to = Some("20260901-000000-new1".to_owned());
12108        assert_eq!(
12109            unfinished_run_with(&[released.id.clone()], "t", |_| Ok(released.clone())),
12110            None,
12111            "there is nothing left to resume it into"
12112        );
12113    }
12114
12115    #[test]
12116    fn unfinished_run_skips_a_round_exhausted_blocked_run_so_requeue_means_a_fresh_competition() {
12117        // Mirrors the failure this exists to close: a task's last run ended
12118        // `Blocked` with the review budget spent, `crate::conduct` chose
12119        // `Recovery::Requeue` (`Task::release`, which keeps `runs` as
12120        // evidence), and without this check `attempt` would go on treating
12121        // that exhausted run as "unfinished" and resume it - `graph::Runner`'s
12122        // review loop iterates zero times over an already-spent budget, so
12123        // the resumed run settles right back to `Blocked` having asked nobody
12124        // anything, and `Requeue`'s promised fresh competition never happens.
12125        let mut exhausted = RunState::new(
12126            PathBuf::from("/repo"),
12127            "main".to_owned(),
12128            "abc1234def".to_owned(),
12129            "add retries".to_owned(),
12130            Config::default(),
12131        );
12132        exhausted.status = RunStatus::Blocked;
12133        exhausted.config.graph.review_rounds = 1;
12134        exhausted.reviews = vec![review_round(1)];
12135
12136        assert_eq!(
12137            unfinished_run_with(&[exhausted.id.clone()], "t", |_| Ok(exhausted.clone())),
12138            None,
12139            "an exhausted `Blocked` run must not be offered as resumable"
12140        );
12141
12142        // A `Blocked` run with rounds still unused is genuinely worth
12143        // resuming, and must still be found.
12144        let mut has_budget_left = RunState::new(
12145            PathBuf::from("/repo"),
12146            "main".to_owned(),
12147            "abc1234def".to_owned(),
12148            "add retries".to_owned(),
12149            Config::default(),
12150        );
12151        has_budget_left.status = RunStatus::Blocked;
12152        has_budget_left.config.graph.review_rounds = 3;
12153        has_budget_left.reviews = vec![review_round(1)];
12154
12155        assert_eq!(
12156            unfinished_run_with(&[has_budget_left.id.clone()], "t", |_| {
12157                Ok(has_budget_left.clone())
12158            }),
12159            Some(has_budget_left.id.clone())
12160        );
12161    }
12162
12163    #[test]
12164    fn unfinished_run_resumes_a_run_whose_task_head_publication_is_pending() {
12165        let mut run = RunState::new(
12166            PathBuf::from("/repo"),
12167            "main".to_owned(),
12168            "abc1234def".to_owned(),
12169            "add retries".to_owned(),
12170            Config::default(),
12171        );
12172        run.status = RunStatus::Blocked;
12173        run.config.graph.review_rounds = 1;
12174        run.reviews = vec![review_round(1)];
12175        run.pr_publication_pending = true;
12176        assert_eq!(
12177            unfinished_run_with(&[run.id.clone()], "t", |_| Ok(run.clone())),
12178            Some(run.id.clone()),
12179            "a spent review budget must not turn a failed publication into a re-competition"
12180        );
12181    }
12182
12183    #[test]
12184    fn retained_attempt_bound_survives_changed_bases_releases_and_duplicate_history() {
12185        let mut task = Task::new(
12186            "recover work".into(),
12187            "recover work".into(),
12188            PathBuf::from("/repo"),
12189            crate::queue::Source::Human,
12190        );
12191        let mut history = std::collections::BTreeMap::new();
12192        for index in 0..8 {
12193            let mut run = run_state(RunStatus::Blocked);
12194            run.id = format!("20260101-000000-{index:04}");
12195            run.base_commit = format!("upstream-{index}");
12196            // Seven empty newer attempts must not erase the old recovery owner.
12197            if index > 0 {
12198                run.candidates.clear();
12199            }
12200            task.runs.extend([run.id.clone(), run.id.clone()]);
12201            history.insert(run.id.clone(), run);
12202            task.requeue();
12203        }
12204        let check = |task: &Task, starter: &Starter, limit| {
12205            retained_attempt_hold(task, starter, limit, |id| Ok(history[id].clone()))
12206        };
12207        assert!(
12208            check(&task, &Starter::Start, 8)
12209                .unwrap()
12210                .contains("8 unretired")
12211        );
12212        assert!(check(&task, &Starter::Start, 9).is_none());
12213        assert!(check(&task, &Starter::Review("existing".into()), 8).is_some());
12214        task.release_operator("owner", None);
12215        assert_eq!(task.attempts, 0);
12216        let restored: Task = serde_json::from_slice(&serde_json::to_vec(&task).unwrap()).unwrap();
12217        assert!(check(&restored, &Starter::Start, 8).is_some());
12218        assert!(check(&restored, &Starter::Resume(task.runs[0].clone()), 8).is_none());
12219        assert!(check(&restored, &Starter::Start, 0).is_none());
12220    }
12221
12222    #[test]
12223    fn retained_attempt_bound_requires_verified_retirement_not_completion_labels() {
12224        let mut task = Task::new(
12225            "recover work".into(),
12226            "recover work".into(),
12227            PathBuf::from("/repo"),
12228            crate::queue::Source::Human,
12229        );
12230        let mut run = run_state(RunStatus::Superseded);
12231        run.candidates.clear();
12232        task.runs.push(run.id.clone());
12233        assert!(retained_attempt_hold(&task, &Starter::Start, 1, |_| Ok(run.clone())).is_some());
12234        run.retired_to = Some("authority".into());
12235        assert!(retained_attempt_hold(&task, &Starter::Start, 1, |_| Ok(run.clone())).is_none());
12236        run.retired_to = None;
12237        run.released_to = Some("authority".into());
12238        assert!(retained_attempt_hold(&task, &Starter::Start, 1, |_| Ok(run.clone())).is_none());
12239        assert!(
12240            retained_attempt_hold(&task, &Starter::Start, 8, |_| anyhow::bail!("unreadable"))
12241                .unwrap()
12242                .contains("Cannot verify")
12243        );
12244        // Resuming an existing owner needs no new inventory or worktree budget.
12245        assert!(
12246            retained_attempt_hold(&task, &Starter::Resume(run.id), 1, |_| {
12247                panic!("resume must not load historical attempts")
12248            })
12249            .is_none()
12250        );
12251    }
12252
12253    #[test]
12254    fn retired_resume_requires_a_valid_same_repository_authority() {
12255        let mut source = run_state(RunStatus::Blocked);
12256        let mut empty = run_state(RunStatus::Blocked);
12257        empty.retired_to = Some(source.id.clone());
12258        let runs = vec![source.id.clone(), empty.id.clone()];
12259        let resolve = |source: &RunState, empty: &RunState| {
12260            unfinished_run_with(&runs, "fixture", |id| {
12261                if id == source.id {
12262                    Ok(source.clone())
12263                } else if id == empty.id {
12264                    Ok(empty.clone())
12265                } else {
12266                    bail!("unreadable authority")
12267                }
12268            })
12269        };
12270        assert_eq!(resolve(&source, &empty), Some(source.id.clone()));
12271        assert_eq!(
12272            choose_starter(None, false, resolve(&source, &empty).as_deref()),
12273            Starter::Resume(source.id.clone())
12274        );
12275        source.retired_to = Some(empty.id.clone());
12276        assert!(
12277            resolve(&source, &empty).is_none(),
12278            "cycles do not revive obsolete runs"
12279        );
12280        source.retired_to = None;
12281        source.repo = PathBuf::from("/another-repository");
12282        assert!(resolve(&source, &empty).is_none());
12283        source.repo = empty.repo.clone();
12284        source.released_to = Some("later".into());
12285        assert!(resolve(&source, &empty).is_none());
12286        empty.retired_to = Some("unlisted-run".into());
12287        assert!(resolve(&source, &empty).is_none());
12288    }
12289
12290    #[test]
12291    fn unfinished_run_never_falls_back_to_an_older_resumable_run() {
12292        // A task whose history holds an *older* run that still looks
12293        // resumable (say, a competition `Runner::review` was started
12294        // alongside after that older run went `Stalled`) and a *newest* run
12295        // that is `Blocked` with its review budget spent. `Recovery::Requeue`
12296        // on this task must mean a fresh competition — falling back to the
12297        // stale, superseded `Stalled` run instead would resurrect history
12298        // nothing asked to revisit and silently defeat the requeue.
12299        let mut older_stalled = RunState::new(
12300            PathBuf::from("/repo"),
12301            "main".to_owned(),
12302            "abc1234def".to_owned(),
12303            "add retries".to_owned(),
12304            Config::default(),
12305        );
12306        older_stalled.status = RunStatus::Stalled;
12307
12308        let mut newest_exhausted = RunState::new(
12309            PathBuf::from("/repo"),
12310            "main".to_owned(),
12311            "abc1234def".to_owned(),
12312            "add retries".to_owned(),
12313            Config::default(),
12314        );
12315        newest_exhausted.status = RunStatus::Blocked;
12316        newest_exhausted.config.graph.review_rounds = 1;
12317        newest_exhausted.reviews = vec![review_round(1)];
12318
12319        assert_eq!(
12320            unfinished_run_with(
12321                &[older_stalled.id.clone(), newest_exhausted.id.clone()],
12322                "t",
12323                |_| Ok(newest_exhausted.clone())
12324            ),
12325            None,
12326            "the newest run is exhausted, so nothing here is worth resuming - \
12327             least of all the older, already-superseded run"
12328        );
12329    }
12330
12331    #[test]
12332    fn unfinished_run_warns_and_skips_a_run_it_cannot_read() {
12333        assert_eq!(
12334            unfinished_run_with(&["20260101-000000-gone".to_owned()], "t", |_| {
12335                Err(anyhow::anyhow!("fixture is absent"))
12336            }),
12337            None
12338        );
12339    }
12340
12341    fn action_question(run: &str, action: ask::ChoiceAction) -> ask::Question {
12342        let mut q = ask::Question::new(
12343            run.to_owned(),
12344            "implement".to_owned(),
12345            "impl-A".to_owned(),
12346            "continue?".to_owned(),
12347            String::new(),
12348            vec!["resume で続行する".to_owned(), "other".to_owned()],
12349        );
12350        q.actions.insert("resume で続行する".to_owned(), action);
12351        q.answer(ask::Answer::Choice("resume で続行する".to_owned()))
12352            .unwrap();
12353        q
12354    }
12355
12356    fn held_task_with(run: &str) -> Task {
12357        let mut t = task();
12358        t.runs = vec![run.to_owned()];
12359        t.hold_machine(Some("waiting for magi resume to be executed".to_owned()));
12360        t
12361    }
12362
12363    fn resume_action(run: &str) -> ask::ChoiceAction {
12364        ask::ChoiceAction::Resume { run: run.into() }
12365    }
12366
12367    #[test]
12368    fn explicitly_resumed_older_run_remains_the_release_and_question_target() {
12369        for parked in [false, true] {
12370            let mut t = task();
12371            t.start("A".into());
12372            t.start("B".into());
12373            assert_eq!(t.runs, vec!["A", "B"]);
12374            t.start("A".into());
12375            let mut resumed = run_state(RunStatus::Blocked);
12376            resumed.id = "A".into();
12377            resumed.parked = parked;
12378            resumed.driver_exited = true;
12379            t.hold_machine(Some("resumed run stopped".into()));
12380            let q = action_question("A", resume_action("A"));
12381            assert_eq!(action_standing(&t, &q), ActionStanding::Pending);
12382            assert_eq!(
12383                decide_action(&t, &q, &PHRASES_EN, |id| {
12384                    assert_eq!(id, "A");
12385                    Ok(resumed.clone())
12386                }),
12387                ActionDecision::Resume("A".into())
12388            );
12389            assert_eq!(
12390                action_standing(&t, &action_question("B", resume_action("B"))),
12391                ActionStanding::Stale
12392            );
12393            t.release_operator("cli", None);
12394            let unfinished = unfinished_run_with(&t.runs, t.short(), |id| {
12395                assert_eq!(id, "A");
12396                Ok(resumed.clone())
12397            });
12398            assert_eq!(
12399                choose_starter(None, false, unfinished.as_deref()),
12400                Starter::Resume("A".into())
12401            );
12402            assert_eq!(t.runs, vec!["B", "A"]);
12403        }
12404    }
12405
12406    #[test]
12407    fn merge_repair_dispatch_refuses_obsolete_bindings_and_unsupported_owners() {
12408        let mut state = crate::land::tests::repair_state(true, true);
12409        state.status = RunStatus::Landing;
12410        state.parked = true;
12411        state.driver_exited = true;
12412        let mut q = ask::Question::new(
12413            state.id.clone(),
12414            land::APPROVAL_NODE.into(),
12415            "land".into(),
12416            "Merge?".into(),
12417            String::new(),
12418            vec![land::APPROVE.into(), land::HOLD.into()],
12419        );
12420        q.id = state.id.clone();
12421        state.land_approval = Some(crate::run::LandApproval {
12422            question: q.id.clone(),
12423            head: "current-head".into(),
12424        });
12425        assert!(land::upgrade_repair_choice(&mut q, &state));
12426        q.answer(ask::Answer::Choice(land::REPAIR.into())).unwrap();
12427        let mut task = held_task_with(&state.id);
12428        task.status = TaskStatus::Parked;
12429        assert_eq!(
12430            decide_action(&task, &q, &PHRASES_EN, |_| Ok(state.clone())),
12431            ActionDecision::Resume(state.id.clone())
12432        );
12433        q.asked_at = "2026-01-01T00:00:00Z".parse().unwrap();
12434        q.answered_at = Some("2026-01-01T00:00:20Z".parse().unwrap());
12435        task.operator_decided_at = Some("2026-01-01T00:00:10Z".parse().unwrap());
12436        assert_eq!(
12437            decide_action(&task, &q, &PHRASES_EN, |_| Ok(state.clone())),
12438            ActionDecision::Resume(state.id.clone()),
12439            "a repair answer newer than the manual hold resumes the existing owner"
12440        );
12441        task.operator_decided_at = Some("2026-01-01T00:00:30Z".parse().unwrap());
12442        for answered_at in [q.answered_at, None] {
12443            let mut older = q.clone();
12444            older.answered_at = answered_at;
12445            let refused = decide_action(&task, &older, &PHRASES_EN, |_| {
12446                panic!("a later manual hold must prevent dispatch")
12447            });
12448            let ActionDecision::Refuse(reason) = refused else {
12449                panic!("expected an actionable refusal, got {refused:?}");
12450            };
12451            assert!(reason.contains("later operator decision"));
12452            assert!(reason.contains(&format!("magi task release {}", task.id)));
12453        }
12454        task.operator_decided_at = None;
12455        let mut superseded = state.clone();
12456        superseded.land_approval.as_mut().unwrap().question = "fresh-decision".into();
12457        assert_eq!(
12458            decide_action(&task, &q, &PHRASES_EN, |_| Ok(superseded)),
12459            ActionDecision::Stale
12460        );
12461        for case in 1..4 {
12462            let mut changed = state.clone();
12463            match case {
12464                1 => changed.status = RunStatus::Blocked,
12465                2 => changed.released_to = Some("new-owner".into()),
12466                _ => changed.land_approval.as_mut().unwrap().head.clear(),
12467            }
12468            assert!(matches!(
12469                decide_action(&task, &q, &PHRASES_EN, |_| Ok(changed)),
12470                ActionDecision::Refuse(_)
12471            ));
12472        }
12473        assert!(matches!(
12474            decide_action(&task, &q, &PHRASES_EN, |_| anyhow::bail!("missing owner")),
12475            ActionDecision::Refuse(_)
12476        ));
12477        task.runs.push("replacement".into());
12478        task.operator_decided_at = Some("2026-01-01T00:00:30Z".parse().unwrap());
12479        assert_eq!(
12480            decide_action(&task, &q, &PHRASES_EN, |_| panic!(
12481                "old owner must not load"
12482            )),
12483            ActionDecision::Stale
12484        );
12485    }
12486
12487    #[test]
12488    fn merge_repair_dispatch_obeys_capacity_and_quota_while_hold_stays_decision_only() {
12489        let mut state = crate::land::tests::repair_state(true, true);
12490        state.status = RunStatus::Landing;
12491        state.parked = true;
12492        state.driver_exited = true;
12493        let mut q = ask::Question::new(
12494            state.id.clone(),
12495            land::APPROVAL_NODE.into(),
12496            "land".into(),
12497            "Merge?".into(),
12498            String::new(),
12499            vec![land::APPROVE.into(), land::HOLD.into()],
12500        );
12501        q.id = state.id.clone();
12502        state.land_approval = Some(crate::run::LandApproval {
12503            question: q.id.clone(),
12504            head: "current-head".into(),
12505        });
12506        state.save().unwrap();
12507        land::upgrade_repair_choice(&mut q, &state);
12508        q.answer(ask::Answer::Choice(land::REPAIR.into())).unwrap();
12509        Questions::open().put(&mut q).unwrap();
12510        let tmp = tempfile::tempdir().unwrap();
12511        let queue = Queue::at(tmp.path().join("queue"));
12512        let mut task = held_task_with(&state.id);
12513        task.status = TaskStatus::Parked;
12514        queue.put(&mut task).unwrap();
12515        let empty = Arc::new(tokio::sync::Semaphore::new(0));
12516        let free = Arc::new(tokio::sync::Semaphore::new(1));
12517        assert!(claim_dispatch(&queue, &task, false, &empty, &free).is_none());
12518        assert!(claim_dispatch(&queue, &task, true, &free, &free).is_none());
12519        let claim = claim_dispatch(&queue, &task, false, &free, &free).unwrap();
12520        assert!(claim.permit.is_some());
12521        assert!(claim_dispatch(&queue, &task, false, &free, &free).is_none());
12522        drop(claim);
12523        Questions::open()
12524            .update(&q.id, |q| {
12525                q.answer = Some(ask::Answer::Choice(land::HOLD.into()));
12526                Ok(())
12527            })
12528            .unwrap();
12529        let claim = claim_dispatch(&queue, &task, true, &empty, &empty).unwrap();
12530        assert!(claim.permit.is_none());
12531        assert!(RunState::load(&state.id).unwrap().merge_repairs.is_empty());
12532    }
12533
12534    #[test]
12535    fn decide_action_resumes_only_the_latest_resumable_run() {
12536        let t = held_task_with("r1");
12537        let q = action_question("r1", resume_action("r1"));
12538        let load = |s: RunState| move |_: &str| Ok(s);
12539        assert_eq!(
12540            decide_action(&t, &q, &PHRASES_EN, load(run_state(RunStatus::Blocked))),
12541            ActionDecision::Resume("r1".into())
12542        );
12543        // Not the latest run of the task.
12544        let q_other = action_question("r1", resume_action("r0"));
12545        assert!(matches!(
12546            decide_action(
12547                &t,
12548                &q_other,
12549                &PHRASES_EN,
12550                load(run_state(RunStatus::Blocked))
12551            ),
12552            ActionDecision::Refuse(_)
12553        ));
12554        // A finished run cannot make progress.
12555        assert!(matches!(
12556            decide_action(&t, &q, &PHRASES_EN, load(run_state(RunStatus::Ready))),
12557            ActionDecision::Refuse(_)
12558        ));
12559        // A released one either.
12560        let mut released = run_state(RunStatus::Blocked);
12561        released.released_to = Some("elsewhere".into());
12562        assert!(matches!(
12563            decide_action(&t, &q, &PHRASES_EN, load(released)),
12564            ActionDecision::Refuse(_)
12565        ));
12566        // Unreadable state is a refusal, never a fresh competition.
12567        assert!(matches!(
12568            decide_action(&t, &q, &PHRASES_EN, |_: &str| bail!("gone")),
12569            ActionDecision::Refuse(_)
12570        ));
12571    }
12572
12573    #[test]
12574    fn decide_action_ignores_a_question_about_an_earlier_run() {
12575        let mut t = held_task_with("r1");
12576        t.runs.push("r2".to_owned());
12577        let never = |_: &str| -> Result<RunState> { bail!("not read") };
12578        assert_eq!(
12579            decide_action(
12580                &t,
12581                &action_question("r1", ask::ChoiceAction::Done),
12582                &PHRASES_EN,
12583                never
12584            ),
12585            ActionDecision::Stale
12586        );
12587    }
12588
12589    #[test]
12590    fn decide_action_maps_requeue_and_done_and_never_acts_twice() {
12591        let mut t = held_task_with("r1");
12592        let never = |_: &str| -> Result<RunState> { bail!("not read") };
12593        assert_eq!(
12594            decide_action(
12595                &t,
12596                &action_question("r1", ask::ChoiceAction::Requeue),
12597                &PHRASES_EN,
12598                never
12599            ),
12600            ActionDecision::Requeue
12601        );
12602        let done_q = action_question("r1", ask::ChoiceAction::Done);
12603        assert_eq!(
12604            decide_action(&t, &done_q, &PHRASES_EN, never),
12605            ActionDecision::Done
12606        );
12607        t.mark_action_applied(&done_q.id);
12608        assert_eq!(
12609            decide_action(&t, &done_q, &PHRASES_EN, never),
12610            ActionDecision::Skip
12611        );
12612
12613        // A plain answer (no action for that label) does nothing.
12614        let mut plain = action_question("r1", ask::ChoiceAction::Done);
12615        plain.actions.clear();
12616        assert_eq!(
12617            decide_action(&held_task_with("r1"), &plain, &PHRASES_EN, never),
12618            ActionDecision::Skip
12619        );
12620        // A running task is left alone.
12621        let mut running = held_task_with("r1");
12622        running.status = TaskStatus::Running;
12623        assert_eq!(
12624            decide_action(
12625                &running,
12626                &action_question("r1", ask::ChoiceAction::Done),
12627                &PHRASES_EN,
12628                never
12629            ),
12630            ActionDecision::Skip
12631        );
12632    }
12633
12634    #[test]
12635    fn merge_repair_rejected_after_manual_hold_keeps_the_conductor_guard() {
12636        crate::run::pin_test_home();
12637        for legacy in [false, true] {
12638            let dir = tempfile::tempdir().unwrap();
12639            let queue = Queue::at(dir.path().join("queue"));
12640            let questions = Questions::at(dir.path().join("questions"));
12641            let run = "20260101-000000-hold";
12642            let mut task = held_task_with(run);
12643            task.status = TaskStatus::Parked;
12644            let mut q = ask::Question::new(
12645                run.into(),
12646                land::APPROVAL_NODE.into(),
12647                "land".into(),
12648                "Repair?".into(),
12649                String::new(),
12650                vec![land::REPAIR.into()],
12651            );
12652            q.actions.insert(land::REPAIR.into(), resume_action(run));
12653            q.answer(ask::Answer::Choice(land::REPAIR.into())).unwrap();
12654            // Deterministically order the answer before the actual manual hold.
12655            q.asked_at = "2026-01-01T00:00:00Z".parse().unwrap();
12656            q.answered_at = Some("2026-01-01T00:00:01Z".parse().unwrap());
12657            questions.put(&mut q).unwrap();
12658            task.hold_manual(Some("Keep this PR paused".into()));
12659            if legacy {
12660                task.hold_source = None;
12661            }
12662            queue.put(&mut task).unwrap();
12663
12664            apply_task_choice_actions(&queue, &questions, dir.path());
12665            let after = queue.get(&task.id).unwrap();
12666            assert!(after.operator_held());
12667            assert_eq!(after.hold_source, task.hold_source);
12668            assert_eq!(after.operator_decided_at, task.operator_decided_at);
12669            assert_eq!(after.runs, task.runs);
12670            assert_eq!(after.instruction, task.instruction);
12671            assert!(after.resume_override.is_none());
12672            assert!(after.action_applied(&q.id));
12673            let reason = after.hold_reason.as_deref().unwrap();
12674            assert!(reason.starts_with("Keep this PR paused"));
12675            assert!(reason.contains("later operator decision"));
12676            assert!(reason.contains(&format!("magi task release {}", task.id)));
12677
12678            for recovery in [
12679                crate::conduct::Recovery::Review,
12680                crate::conduct::Recovery::Requeue,
12681            ] {
12682                crate::conduct::apply(
12683                    &queue,
12684                    &questions,
12685                    &crate::conduct::Verdict {
12686                        decisions: vec![crate::conduct::Decision {
12687                            id: task.id.clone(),
12688                            recovery: Some(recovery),
12689                            ..Default::default()
12690                        }],
12691                    },
12692                )
12693                .unwrap();
12694            }
12695            // Reconciliation is idempotent, and automatic recovery cannot
12696            // reopen this owner after it observes the rejected repair answer.
12697            apply_task_choice_actions(&queue, &questions, dir.path());
12698            let still_held = queue.get(&task.id).unwrap();
12699            assert!(still_held.operator_held());
12700            assert_eq!(still_held.hold_reason, after.hold_reason);
12701            assert_eq!(still_held.runs, task.runs);
12702            assert!(still_held.review_branch.is_none());
12703            assert!(!still_held.fresh_start);
12704            assert!(queue.next_runnable().is_none());
12705        }
12706    }
12707
12708    #[test]
12709    fn conductor_driver_choices_apply_from_both_queued_and_held_tasks() {
12710        for held in [false, true] {
12711            for retry in [false, true] {
12712                let dir = tempfile::tempdir().unwrap();
12713                let queue = Queue::at(dir.path().join("queue"));
12714                let questions = Questions::at(dir.path().join("questions"));
12715                let home = dir.path().join("home");
12716                let mut t = task();
12717                if held {
12718                    t.hold_machine(Some("driver state unknown".into()));
12719                }
12720                queue.put(&mut t).unwrap();
12721                let verdict = serde_json::from_value(serde_json::json!({
12722                    "decisions": [{
12723                        "id": t.id,
12724                        "question": "Driver state unknown; retry the checks or hold?",
12725                        "choices": ["daemon: recheck and retry", "daemon: hold the task"],
12726                        "actions": {
12727                            "daemon: recheck and retry": {"do": "requeue"},
12728                            "daemon: hold the task": {"do": "hold"}
12729                        }
12730                    }]
12731                }))
12732                .unwrap();
12733                crate::conduct::apply(&queue, &questions, &verdict).unwrap();
12734                assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
12735                let mut q = questions.list().pop().unwrap();
12736                let choice = if retry {
12737                    "daemon: recheck and retry"
12738                } else {
12739                    "daemon: hold the task"
12740                };
12741                assert_eq!(q.actions.len(), 2);
12742                q.answer(ask::Answer::Choice(choice.into())).unwrap();
12743                questions.put(&mut q).unwrap();
12744
12745                // A Web answer does not remove the deputy's fresh lease.
12746                questions.beat(&q.id, ask::WaiterKind::Deputy);
12747                for _ in 0..2 {
12748                    resolve_blockers(&queue, &questions);
12749                    apply_task_choice_actions(&queue, &questions, &home);
12750                    let waiting = queue.get(&t.id).unwrap();
12751                    assert_eq!(waiting.status, TaskStatus::Blocked);
12752                    assert_eq!(waiting.blocked_by, [q.id.clone()]);
12753                    assert!(waiting.answers.is_empty());
12754                    assert!(!waiting.action_applied(&q.id));
12755                    assert!(runnable(&queue).is_empty());
12756                    let free = Arc::new(tokio::sync::Semaphore::new(1));
12757                    assert!(claim_dispatch(&queue, &t, false, &free, &free).is_none());
12758                }
12759                std::fs::remove_file(questions.root().join(format!("{}.lease", q.id))).unwrap();
12760
12761                // Releasing the blocker and applying the operation share one save.
12762                resolve_blockers(&queue, &questions);
12763                assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Blocked);
12764                apply_task_choice_actions(&queue, &questions, &home);
12765                let after = queue.get(&t.id).unwrap();
12766                assert_eq!(
12767                    after.status,
12768                    if retry {
12769                        TaskStatus::Queued
12770                    } else {
12771                        TaskStatus::Held
12772                    }
12773                );
12774                assert!(after.action_applied(&q.id));
12775                assert_eq!(after.operator_held(), !retry);
12776                assert_eq!(after.answers.len(), 1);
12777
12778                // A later operator decision must not replay this answer.
12779                let mut changed = after;
12780                if retry {
12781                    changed.hold_manual(Some("later hold".into()));
12782                } else {
12783                    changed.release_operator("test", None);
12784                }
12785                let expected = changed.status;
12786                queue.put(&mut changed).unwrap();
12787                apply_task_choice_actions(&queue, &questions, &home);
12788                assert_eq!(queue.get(&t.id).unwrap().status, expected);
12789            }
12790        }
12791    }
12792
12793    #[test]
12794    fn apply_choice_actions_releases_the_task_pinned_to_its_run_and_only_once() {
12795        let dir = tempfile::tempdir().unwrap();
12796        let queue = Queue::at(dir.path().join("queue"));
12797        let questions = Questions::at(dir.path().join("questions"));
12798        let home = dir.path().join("home");
12799        let mut state = run_state(RunStatus::Blocked);
12800        state.id = "20260101-000000-act1".to_owned();
12801        state.save_under(&home).unwrap();
12802
12803        let mut t = held_task_with(&state.id);
12804        queue.put(&mut t).unwrap();
12805        let mut q = action_question(&state.id, resume_action(&state.id));
12806        questions.put(&mut q).unwrap();
12807
12808        apply_task_choice_actions(&queue, &questions, &home);
12809        let after = queue.get(&t.id).unwrap();
12810        assert_eq!(after.status, TaskStatus::Queued);
12811        assert!(!after.fresh_start);
12812        assert!(after.action_applied(&q.id));
12813        let pin = after.resume_override.clone().unwrap();
12814        assert_eq!(pin.pinned_run.as_deref(), Some(state.id.as_str()));
12815        assert!(pin.forced);
12816
12817        // Held again later: the same answer does not release it a second time.
12818        let mut again = queue.get(&t.id).unwrap();
12819        again.hold_machine(Some("later".into()));
12820        queue.put(&mut again).unwrap();
12821        apply_task_choice_actions(&queue, &questions, &home);
12822        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
12823    }
12824
12825    #[test]
12826    fn a_delivered_answer_is_still_acted_on_exactly_once() {
12827        let dir = tempfile::tempdir().unwrap();
12828        let queue = Queue::at(dir.path().join("queue"));
12829        let questions = Questions::at(dir.path().join("questions"));
12830        let home = dir.path().join("home");
12831        let mut state = run_state(RunStatus::Blocked);
12832        state.id = "20260101-000000-act2".to_owned();
12833        state.save_under(&home).unwrap();
12834
12835        let mut t = held_task_with(&state.id);
12836        queue.put(&mut t).unwrap();
12837        let mut q = action_question(&state.id, resume_action(&state.id));
12838        q.answer_delivered = true;
12839        questions.put(&mut q).unwrap();
12840
12841        // Delivered, and the agent then died: the task is still held, so the
12842        // chosen action applies.
12843        apply_task_choice_actions(&queue, &questions, &home);
12844        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
12845        assert!(queue.get(&t.id).unwrap().action_applied(&q.id));
12846
12847        // Held again: the same answer does nothing a second time.
12848        let mut again = queue.get(&t.id).unwrap();
12849        again.hold_machine(Some("later".into()));
12850        queue.put(&mut again).unwrap();
12851        apply_task_choice_actions(&queue, &questions, &home);
12852        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
12853    }
12854
12855    #[test]
12856    fn a_fresh_asker_defers_the_action_until_it_goes_quiet() {
12857        let dir = tempfile::tempdir().unwrap();
12858        let queue = Queue::at(dir.path().join("queue"));
12859        let questions = Questions::at(dir.path().join("questions"));
12860        let home = dir.path().join("home");
12861        let mut state = run_state(RunStatus::Blocked);
12862        state.id = "20260101-000000-act3".to_owned();
12863        state.save_under(&home).unwrap();
12864        let mut t = held_task_with(&state.id);
12865        queue.put(&mut t).unwrap();
12866        let mut q = action_question(&state.id, ask::ChoiceAction::Requeue);
12867        questions.put(&mut q).unwrap();
12868
12869        questions.beat(&q.id, crate::ask::WaiterKind::Asker);
12870        apply_task_choice_actions(&queue, &questions, &home);
12871        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
12872
12873        std::fs::remove_file(questions.root().join(format!("{}.lease", q.id))).unwrap();
12874        apply_task_choice_actions(&queue, &questions, &home);
12875        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Queued);
12876    }
12877
12878    #[test]
12879    fn action_standing_tells_the_reasons_apart() {
12880        let mut t = held_task_with("r1");
12881        let q = action_question("r1", ask::ChoiceAction::Requeue);
12882        assert_eq!(action_standing(&t, &q), ActionStanding::Pending);
12883        let mut plain = q.clone();
12884        plain.actions.clear();
12885        assert_eq!(action_standing(&t, &plain), ActionStanding::NoAction);
12886
12887        // Applied wins over everything, even a running task.
12888        t.mark_action_applied(&q.id);
12889        assert_eq!(action_standing(&t, &q), ActionStanding::Applied);
12890
12891        // A newer attempt running: the old question is Stale, not Busy.
12892        let mut t = held_task_with("r1");
12893        t.start("r2".to_owned());
12894        assert_eq!(action_standing(&t, &q), ActionStanding::Stale);
12895        // The same question about the latest run just waits.
12896        let q2 = action_question("r2", ask::ChoiceAction::Requeue);
12897        assert_eq!(action_standing(&t, &q2), ActionStanding::Busy);
12898        t.status = TaskStatus::Blocked;
12899        assert_eq!(action_standing(&t, &q2), ActionStanding::Busy);
12900
12901        // A conductor question is keyed by the task, never stale.
12902        let mut c = action_question("r0", ask::ChoiceAction::Requeue);
12903        c.node = crate::conduct::NODE.to_owned();
12904        assert_eq!(action_standing(&t, &c), ActionStanding::Busy);
12905        assert!(!ActionStanding::Busy.daemon_owns());
12906    }
12907
12908    #[test]
12909    fn a_running_task_waits_and_a_dead_asker_with_a_cwd_is_still_actioned() {
12910        let dir = tempfile::tempdir().unwrap();
12911        let queue = Queue::at(dir.path().join("queue"));
12912        let questions = Questions::at(dir.path().join("questions"));
12913        let home = dir.path().join("home");
12914        let mut state = run_state(RunStatus::Blocked);
12915        state.id = "20260101-000000-act4".to_owned();
12916        state.save_under(&home).unwrap();
12917        let mut t = held_task_with(&state.id);
12918        t.status = TaskStatus::Running;
12919        queue.put(&mut t).unwrap();
12920        let mut q = action_question(&state.id, ask::ChoiceAction::Done);
12921        q.cwd = Some(dir.path().display().to_string());
12922        questions.put(&mut q).unwrap();
12923
12924        // While the task runs the daemon will not act, so the waiter still
12925        // delivers the word to the dead asker's seat ...
12926        let running = queue.get(&t.id).unwrap();
12927        assert_eq!(action_standing(&running, &q), ActionStanding::Busy);
12928        assert!(matches!(
12929            crate::waiter::decide_owned(&q, None, false, 86_400, Timestamp::now(), false),
12930            crate::waiter::Action::Deliver(_)
12931        ));
12932        // ... and the daemon waits while the task runs, then applies once.
12933        apply_task_choice_actions(&queue, &questions, &home);
12934        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Running);
12935        let mut back = queue.get(&t.id).unwrap();
12936        back.hold_machine(Some("later".into()));
12937        queue.put(&mut back).unwrap();
12938        apply_task_choice_actions(&queue, &questions, &home);
12939        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Done);
12940        let mut again = queue.get(&t.id).unwrap();
12941        again.hold_machine(Some("again".into()));
12942        queue.put(&mut again).unwrap();
12943        apply_task_choice_actions(&queue, &questions, &home);
12944        assert_eq!(queue.get(&t.id).unwrap().status, TaskStatus::Held);
12945    }
12946}