Skip to main content

magi/
queue.rs

1//! The task queue: what magi should do next, and who asked for it.
2//!
3//! The queue is what lets magi run unattended. `magi serve` takes the next
4//! task, runs the graph on it, records the outcome, and takes the next one.
5//!
6//! It is also the reason an agent can ask for work. `magi task add` is the
7//! whole interface, and it is the same command whether a human types it at a
8//! prompt, a phone posts it through the web UI, or an implementer inside a run
9//! shells out to it because it noticed something worth doing but out of scope.
10//! magi's CLI is the operating surface for both kinds of user; the queue is
11//! where their intentions meet.
12//!
13//! One task is one JSON file under [`Queue`]'s root. Files rather than a
14//! database because the operator has to be able to read, edit, and delete the
15//! backlog with the tools already on the machine, and because a crashed daemon
16//! must leave a queue the next one can pick up without recovery ceremony.
17//!
18//! # Shape
19//!
20//! [`Task`] is data plus *pure* state transitions - [`Task::fail`] decides
21//! whether an attempt was the last one, and touches no disk. [`Queue`] owns all
22//! I/O and is constructed with its root, so a test drives a real queue in a
23//! temp directory without setting a process-global home. Splitting them this
24//! way is why the retry policy below can be asserted directly.
25//!
26//! # Bounded by construction
27//!
28//! An autonomous loop that retries forever is a way to spend money on a task
29//! that cannot succeed. Every claim increments [`Task::attempts`]; a task that
30//! has burned its attempts becomes [`TaskStatus::Held`] and waits for a human
31//! rather than for another agent.
32
33use std::collections::HashMap;
34use std::num::NonZeroUsize;
35use std::path::{Path, PathBuf};
36
37use anyhow::{Context, Result, bail};
38use jiff::Timestamp;
39use serde::{Deserialize, Serialize};
40
41use crate::ask::Questions;
42
43/// On-disk format for a queued task. Bumped when a field's meaning changes.
44///
45/// 18: retained review criteria snapshots survive operator edits.
46///
47/// 17: durable explicit operator resumption, independent of run startup.
48///
49/// 7: added [`Task::attachments`], names of files copied under
50/// `<id>.attachments/` beside the task file (a field only; `#[serde(default)]`,
51/// so an older record reads as empty and [`read_path`] still accepts it).
52///
53/// 6: added [`Task::resume_override`] (a field only; `#[serde(default)]`, so
54/// an older record reads as `None` and [`read_path`] still accepts it).
55///
56/// 5: added [`Task::triage_applied`], the ids of triage questions whose
57/// answer has already been applied to this task. A "resume" answer used to
58/// leave no trace ([`Task::release`] clears [`Task::hold_reason`], which was
59/// the only place the applied marker lived), so when the released task failed
60/// its attempts and went back to `held`, the next idle pass found the same
61/// answered question "not applied" and released it again with `attempts` reset
62/// to 0 - the `max_attempts` bound never held. `#[serde(default)]` so an older
63/// record reads as empty. A task already looping when this build arrives has
64/// no record, so it is released once more, recorded, and then stays held.
65///
66/// 8: added [`Task::followup`], the origin of a task `crate::followup` filed
67/// from a merged run's leftover findings. Field-only bump, `#[serde(default)]`.
68///
69/// 4: added [`Task::blocked_from`], the status a task had the moment it
70/// became [`TaskStatus::Blocked`], so [`Task::unblock`] restores it instead
71/// of always landing on [`TaskStatus::Queued`]. Without it, a task a human
72/// or `crate::triage` had deliberately left [`TaskStatus::Held`] — machine
73/// or manual — would lose that the instant `crate::conduct` blocked it on a
74/// follow-up question, and come back `Queued` the moment the question was
75/// answered, regardless of what the answer said: exactly the loop where a
76/// task the operator told to stay held instead re-enters the competition
77/// queue every time someone answers a question about it. `#[serde(default)]`
78/// so an older record reads as `None`; [`Task::unblock`] then falls back to
79/// inferring `Held` from surviving hold evidence ([`Task::hold_reason`] /
80/// [`Task::hold_source`], never cleared by [`Task::block`]) rather than
81/// guessing `Queued` outright — see [`Task::unblock`]'s own doc.
82///
83/// 9: added [`Task::held_at`], the start of the current hold, so a notice can
84/// tell a question filed for this hold from one about an older cause.
85/// Field-only, `#[serde(default)]`.
86///
87/// 10: added [`Task::overrides`] and [`Task::review_of`], so a run started by
88/// hand (`magi run` / `magi review`) is carried by a task without losing its
89/// command-line choices. Field-only.
90///
91/// 11: added [`TaskStatus::Parked`] and [`Task::park_reason`], so a run that
92/// stopped at a node boundary (an upgrade, an operator's park) is no longer
93/// recorded as a failed task. Variant plus `#[serde(default)]` field; an older
94/// build must not half-read the new status.
95///
96/// 12: added [`Task::origin_chat`], the talk a task descends from, so a
97/// follow-up still knows its chat after an ancestor task is deleted.
98/// Field-only, `#[serde(default)]`.
99///
100/// 13: added [`Task::chat_notice`], the record that a chat-filed task's final
101/// state was reported to its conversation. Field-only, `#[serde(default)]`;
102/// a record below this schema is never notified (an upgrade must not replay
103/// the whole history into chats).
104///
105/// 3: added [`HoldSource`] so conductor recovery cannot release a hold an
106/// operator deliberately placed. Old records default to `None` and are
107/// protected as operator-held until an explicit release; the safe direction
108/// when their author was never recorded.
109///
110/// 2: added [`TaskStatus::Blocked`], [`Task::blocked_by`] and
111/// [`Task::block_reason`] (`crate::conduct`'s decisions) and
112/// [`Task::answers`] (operator answers carried forward to the next
113/// conductor prompt and the next run's instruction). All three are
114/// `#[serde(default)]`, so [`read_path`] accepts anything up to and
115/// including this schema rather than only an exact match — a task written
116/// by a build that only knew about schema 1 has nothing to say about
117/// blocking or answers, and defaulting those fields is exactly as good a
118/// reading as a value that build never had a chance to write.
119///
120/// 15: added [`Task::passes`], what each settled pass over a run did to the
121/// attempt budget and why it stopped. Field-only, `#[serde(default)]`; a task
122/// from before this has no entry for its earlier passes, which stay unknown.
123///
124/// 14: record whether a task count edit's overrides inherit daemon options.
125/// Old overrides default to isolated command-line settings, including empty ones.
126///
127/// 16: added [`Task::attempt_limit`], an optional task-specific retry budget.
128/// Field-only, `#[serde(default)]`; old records inherit the daemon limit.
129///
130/// 19: persist settled pass start times for upgrade-resume scheduling.
131/// Old passes default to unknown and use settlement order as an approximation.
132pub const SCHEMA: u32 = 19;
133
134/// Candidate order only: all claims, lanes and eligibility gates still apply.
135/// Upgrade-parked runs retain their pass-start order ahead of ordinary work,
136/// regardless of ordinary priority. Old records without a start time use
137/// settlement time and task id as an approximation. Other parks are ordinary.
138/// Urgent slots and explicit interrupts are handled separately by the daemon.
139pub(crate) fn dispatch_order(a: &Task, b: &Task) -> std::cmp::Ordering {
140    match (a.upgrade_resume_key(), b.upgrade_resume_key()) {
141        (Some(a), Some(b)) => a.cmp(&b),
142        (Some(_), None) => std::cmp::Ordering::Less,
143        (None, Some(_)) => std::cmp::Ordering::Greater,
144        (None, None) => b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)),
145    }
146}
147
148/// Who placed the current hold.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum HoldSource {
152    /// An operator used the CLI or web UI.
153    Manual,
154    /// The daemon or conductor placed the hold as part of its own recovery.
155    Machine,
156}
157
158impl HoldSource {
159    /// Short human-facing label for reports and the CLI.
160    pub fn label(self) -> &'static str {
161        match self {
162            Self::Manual => "manual",
163            Self::Machine => "machine",
164        }
165    }
166}
167
168/// Where a task came from. Recorded because "who asked for this" is the first
169/// question about an autonomous run, and the answer is not recoverable later.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "kind", rename_all = "lowercase")]
172pub enum Source {
173    /// A person, at a terminal or through the web UI.
174    Human,
175    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
176    /// task can be traced back to the exact seat that asked for it.
177    Agent {
178        /// Run the asking agent belonged to.
179        run: String,
180        /// Node it was working in, e.g. `implement` or `review`.
181        node: String,
182    },
183    /// A GitHub issue, imported by number.
184    Issue {
185        /// Issue number.
186        number: u64,
187        /// `owner/repo`, as `gh` reports it.
188        repo: String,
189    },
190}
191
192/// The `node` a [`Source::Agent`] carries when the asker is a chat
193/// conversation (its `run` is then the conversation's id).
194pub const CHAT_NODE: &str = "chat";
195
196impl Source {
197    /// Short human-facing label, for lists and the web UI.
198    pub fn label(&self) -> String {
199        match self {
200            Self::Human => "human".to_owned(),
201            Self::Agent { run, node } => format!("{node}@{}", short(run)),
202            Self::Issue { number, .. } => format!("issue #{number}"),
203        }
204    }
205}
206
207/// Where a task is in its life.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum TaskStatus {
211    /// Waiting to be claimed.
212    Queued,
213    /// Claimed by a daemon; a run is in flight.
214    Running,
215    /// A run finished and its gate passed.
216    Done,
217    /// A run finished without passing, and attempts remain.
218    Failed,
219    /// Out of attempts, or held by hand. The loop will not pick it up.
220    Held,
221    /// Waiting on another task or an unanswered question. See
222    /// [`Task::blocked_by`]. Set and cleared by `crate::conduct` and
223    /// `crate::daemon`'s deterministic resolver, never by hand.
224    Blocked,
225    /// The run stopped at a node boundary because the process was asked to
226    /// give itself back. Nothing failed: the run's work is intact and the next
227    /// loop resumes it. Runnable like [`TaskStatus::Queued`]; the reason is in
228    /// [`Task::park_reason`], never [`Task::last_error`].
229    Parked,
230}
231
232impl TaskStatus {
233    /// Is this task eligible for a daemon to claim?
234    pub fn runnable(self) -> bool {
235        matches!(self, Self::Queued | Self::Failed | Self::Parked)
236    }
237
238    /// Lowercase name, as it appears on disk and in the API.
239    pub fn as_str(self) -> &'static str {
240        match self {
241            Self::Queued => "queued",
242            Self::Running => "running",
243            Self::Done => "done",
244            Self::Failed => "failed",
245            Self::Held => "held",
246            Self::Blocked => "blocked",
247            Self::Parked => "parked",
248        }
249    }
250}
251
252/// How many tasks sit in each [`TaskStatus`], for a dashboard tile — never a
253/// per-task view, so it carries no ids.
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
255pub struct TaskCounts {
256    /// Waiting to be claimed.
257    pub queued: usize,
258    /// Claimed; a run is in flight.
259    pub running: usize,
260    /// A run finished and its gate passed.
261    pub done: usize,
262    /// A run finished without passing, and attempts remain.
263    pub failed: usize,
264    /// Out of attempts, or held by hand.
265    pub held: usize,
266    /// Waiting on another task or an unanswered question.
267    pub blocked: usize,
268    /// Run parked at a node boundary; resumable, not a failure.
269    pub parked: usize,
270}
271
272impl TaskCounts {
273    /// Tally `tasks` by status. A pure function over whatever [`Queue::list`]
274    /// already read, so it needs no root of its own and stays trivially
275    /// testable against a hand-built slice.
276    pub fn of(tasks: &[Task]) -> Self {
277        Self::of_statuses(tasks.iter().map(|task| task.status))
278    }
279
280    /// Count current statuses from a reconciled display projection.
281    pub(crate) fn of_statuses(statuses: impl IntoIterator<Item = TaskStatus>) -> Self {
282        let mut counts = Self::default();
283        for status in statuses {
284            match status {
285                TaskStatus::Queued => counts.queued += 1,
286                TaskStatus::Running => counts.running += 1,
287                TaskStatus::Done => counts.done += 1,
288                TaskStatus::Failed => counts.failed += 1,
289                TaskStatus::Held => counts.held += 1,
290                TaskStatus::Blocked => counts.blocked += 1,
291                TaskStatus::Parked => counts.parked += 1,
292            }
293        }
294        counts
295    }
296}
297
298/// One unit of work.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct Task {
302    /// On-disk format version.
303    pub schema: u32,
304    /// Task id, e.g. `20260902-140501-a1b2`.
305    pub id: String,
306    /// One line, for lists and notifications.
307    pub title: String,
308    /// The task itself, handed to the graph verbatim.
309    pub instruction: String,
310    /// Explicit, SHA-pinned implementation imports; separate from instruction text.
311    #[serde(default, skip_serializing_if = "crate::refs::Sources::is_empty")]
312    pub sources: crate::refs::Sources,
313    /// Repository to work in.
314    pub repo: PathBuf,
315    /// Who asked.
316    pub source: Source,
317    /// Higher runs first; ties break oldest-first so nothing starves.
318    #[serde(default)]
319    pub priority: i32,
320    /// Run this task alone: one implementer, no panel of judges to convince.
321    ///
322    /// `#[serde(default)]` so a queue file written before this field existed
323    /// still reads, as `false` - the ordinary multi-candidate competition,
324    /// unchanged. A task set to `solo` still runs the whole graph; only the
325    /// candidate count the daemon builds it with changes, and
326    /// [`crate::graph::Runner`] already collapses a single-candidate run to
327    /// implement → review → gate → merge on its own (see
328    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
329    /// deliberation or voting had to change to support this.
330    #[serde(default)]
331    pub solo: bool,
332    /// Current state.
333    pub status: TaskStatus,
334    /// How many times this task has been claimed.
335    #[serde(default)]
336    pub attempts: usize,
337    /// Total attempt budget; `None` inherits the daemon limit.
338    #[serde(default)]
339    pub attempt_limit: Option<NonZeroUsize>,
340    /// Distinct runs, ordered by their most recent claim. Older records may
341    /// contain repeated ids; execution history lives in the run pass records.
342    #[serde(default)]
343    pub runs: Vec<String>,
344    /// Why the last attempt did not land.
345    #[serde(default)]
346    pub last_error: Option<String>,
347    /// What a human hold is waiting on.
348    ///
349    /// `None` covers both the ordinary cases: a hold the loop makes itself
350    /// (out of attempts, or the disk gate closed) explains itself through
351    /// [`Task::last_error`] instead, and a human hold nobody bothered to
352    /// explain is still a valid hold. The queue has no way to express a
353    /// dependency between two tasks, so on the occasions a hold really is
354    /// "wait for that other task first", this is the only place that reason
355    /// survives - see [`Task::hold_manual`] and [`Task::release`].
356    ///
357    /// `#[serde(default)]` so a queue file written before this field existed
358    /// still reads, with no reason recorded rather than a parse error.
359    #[serde(default)]
360    pub hold_reason: Option<String>,
361    /// Who placed [`Task::hold_reason`].  `None` is a compatible old record;
362    /// see [`Task::operator_held`] for its deliberately conservative meaning.
363    #[serde(default)]
364    pub hold_source: Option<HoldSource>,
365    /// Diagnostic detail excerpted from the run that led to a hold - what a
366    /// human would have found opening `artifacts/` by hand, not the one-line
367    /// reason in [`Task::last_error`]. Set only when a run's own attempts are
368    /// exhausted and the task becomes [`TaskStatus::Held`]; `daemon` computes
369    /// it from the run's own record, since this module has no notion of a
370    /// run's internals. Bounded in length by the writer - see
371    /// `daemon::diagnostic` - so a verbose run cannot make this file grow
372    /// without limit.
373    ///
374    /// `#[serde(default)]` so a queue file written before this field existed
375    /// still reads, with no diagnostic recorded rather than a parse error.
376    #[serde(default)]
377    pub diagnostic: Option<String>,
378    /// What this task is waiting on: other task ids, unanswered
379    /// `crate::ask::Question` ids, or both. Non-empty exactly when
380    /// [`TaskStatus::Blocked`]; emptying it — see [`Task::unblock`] — is what
381    /// puts the task back at [`TaskStatus::Queued`].
382    ///
383    /// Set by `crate::conduct`'s decisions and cleared deterministically by
384    /// `crate::daemon` as each dependency resolves, never by a person. Never
385    /// `#[serde(default)]` is skipped: a queue file from before this field
386    /// existed has nothing to report here, and an empty list is exactly that.
387    #[serde(default)]
388    pub blocked_by: Vec<String>,
389    /// One line explaining the current [`Task::blocked_by`], written by
390    /// `crate::conduct`. Cleared whenever `blocked_by` empties.
391    #[serde(default)]
392    pub block_reason: Option<String>,
393    /// The status this task had the moment [`Task::block`] most recently
394    /// moved it to [`TaskStatus::Blocked`] — what [`Task::unblock`] restores
395    /// once nothing is left in `blocked_by`, instead of always landing on
396    /// [`TaskStatus::Queued`]. See [`SCHEMA`]'s doc for schema 4 on why this
397    /// exists: an answer to a question `crate::conduct` filed about a
398    /// [`TaskStatus::Held`] task must not itself be what puts the task back
399    /// in the competition queue.
400    ///
401    /// `#[serde(default)]` so a queue file written before this field existed
402    /// reads as `None`; [`Task::unblock`] treats that the same as a task
403    /// blocked straight from `Queued`, unless surviving hold evidence says
404    /// otherwise.
405    #[serde(default)]
406    pub blocked_from: Option<TaskStatus>,
407    /// Last change of blocking targets, independent of explanation or delivery writes.
408    #[serde(default)]
409    pub blocked_at: Option<Timestamp>,
410    /// Questions `crate::conduct` asked about this task that the operator has
411    /// since answered, oldest first — what was asked, and what they said.
412    ///
413    /// A blocking question's id leaves [`Task::blocked_by`] the moment
414    /// [`crate::ask::QuestionStatus::Answered`] is observed, but the id alone
415    /// tells nobody what was decided. This is what carries the answer's
416    /// *content* forward: into the next conductor prompt for this task, and
417    /// into the instruction handed to the next run — see `crate::daemon`'s
418    /// deterministic blocker resolution. Kept for the task's whole life, the
419    /// same as [`Task::runs`]: a release resets attempts, not evidence.
420    #[serde(default)]
421    pub answers: Vec<AnsweredQuestion>,
422    /// Ids of the `crate::triage` questions whose answer has been applied to
423    /// this task. Unlike [`Task::hold_reason`], [`Task::release`] and every
424    /// hold transition leave it alone, so an answer is applied at most once
425    /// however many times the task is held again. See [`SCHEMA`]'s doc for
426    /// schema 5. `#[serde(default)]` so an older record reads as empty.
427    #[serde(default)]
428    pub triage_applied: Vec<String>,
429    /// Ids of the `magi ask` questions whose [`crate::ask::ChoiceAction`] has
430    /// been applied to this task. Separate from [`Task::triage_applied`]
431    /// (a different producer) and, like it, untouched by [`Task::release`],
432    /// so one answer acts at most once however often the task is held again.
433    /// `#[serde(default)]` so an older record reads as empty.
434    #[serde(default)]
435    pub actions_applied: Vec<String>,
436    /// The operator's "resume" answer to a triage question, kept until the
437    /// task actually runs (or is done) so `crate::conduct` cannot silently
438    /// undo it and `crate::triage` can tell that a hold it sees now came
439    /// *after* the answer. See [`OperatorResume`]. `#[serde(default)]`.
440    #[serde(default)]
441    pub resume_override: Option<OperatorResume>,
442    /// Latest explicit operator release. Historical answers remain audit evidence;
443    /// this approval takes precedence over their earlier waiting premises.
444    #[serde(default)]
445    pub resumption: Option<Resumption>,
446    /// Earlier explicit approvals retained for audit and instruction suffix matching.
447    #[serde(default)]
448    pub resumption_history: Vec<Resumption>,
449    /// Latest explicit release or manual hold, used to reject delayed answers.
450    #[serde(default)]
451    pub operator_decided_at: Option<Timestamp>,
452    /// Set by `crate::conduct` when it chooses `Review` recovery for a task
453    /// whose branch survived a blocked run: the branch to reopen with
454    /// `crate::graph::Runner::review` instead of competing from scratch.
455    ///
456    /// Requeues the task the same way [`Task::release`] does, so it is
457    /// picked up by the ordinary loop; `crate::daemon` reads this field once,
458    /// when it actually starts the run, and clears it either way — consumed
459    /// on success, dropped if the branch no longer exists by then. Never set
460    /// from the conductor's own words: `crate::daemon` derives the branch
461    /// name itself from the task's last run, so a hallucinated branch can
462    /// never reach here.
463    #[serde(default)]
464    pub review_branch: Option<String>,
465    /// Authored criteria when the retained recovery review was selected.
466    #[serde(default)]
467    pub review_instruction: Option<String>,
468    /// Explicit public implementation selected by the operator; never inferred from prose.
469    #[serde(default)]
470    pub pr_recovery: Option<crate::pr_recovery::Selection>,
471    /// A release deliberately starts a new competition instead of resuming
472    /// the prior run. History remains as evidence in `runs`.
473    #[serde(default)]
474    pub fresh_start: bool,
475    /// Explicit replacement selection; persists through dispatch and resume.
476    #[serde(default)]
477    pub replacement: Option<crate::merge_decision::Replacement>,
478    /// Marked by an operator (`magi task interrupt`) to ask `magi serve` to
479    /// run this one ahead of whatever it already has in flight, once
480    /// `[daemon] pause_for_interrupts` is on - see
481    /// `crate::daemon::advance_interrupt`. Never set by the loop itself, and
482    /// deliberately a different operation from [`Task::set_priority`]: a
483    /// priority only reorders the queue a claim has not reached yet, while
484    /// this asks a run already in flight to park at its next safe boundary
485    /// and step aside. `#[serde(default)]` so a queue file written before
486    /// this field existed still reads, as `false` - no task interrupts
487    /// anything unless asked to, exactly as before.
488    #[serde(default)]
489    pub interrupt: bool,
490    /// Marked by `magi task add --urgent` or `magi task urgent`.
491    /// `crate::daemon::poll` dispatches this task through its own one-slot `urgent_sem` the moment it is
492    /// runnable, in addition to whatever is already running under the
493    /// ordinary `[daemon] max_concurrent_runs` pool - never instead of it,
494    /// and never by pausing or otherwise touching that run. This is the
495    /// opposite direction from [`Task::interrupt`]: that one asks a run
496    /// already in flight to step aside; this one never asks anything to
497    /// step aside, it only spends one additional, temporary concurrency
498    /// slot. The two are independent and may both be set on the same task,
499    /// but this exemption stops at `[daemon] pause_for_interrupts`'s own
500    /// park/resume handoff (75dd): while an interrupt sequence is actively
501    /// parking, running, or resuming - its own, or an unrelated task's -
502    /// `crate::daemon::interrupt_gate` withholds an urgent candidate exactly
503    /// like an ordinary one, never exempted. 75dd's "at most one run, ever,
504    /// at once" guarantee takes precedence, because the alternative is a run
505    /// still genuinely in flight (only *asked* to park, not yet gone) ending
506    /// up alongside a second one this feature let through - the very thing
507    /// that guarantee exists to rule out.
508    ///
509    /// `#[serde(default)]` so a queue file written before this field existed
510    /// still reads, as `false` - no task claims the urgent slot unless asked
511    /// to, exactly as before.
512    #[serde(default)]
513    pub urgent: bool,
514    /// Files (typically screenshots) copied into `<id>.attachments/` beside
515    /// this task's file by [`Queue::attach`], as bare validated names. The
516    /// copy is what makes them reach the implementer: the original may be
517    /// cleaned up long before the task runs. Untouched by [`Task::edit`].
518    /// `#[serde(default)]` so an older record reads as empty.
519    #[serde(default)]
520    pub attachments: Vec<String>,
521    /// Set on a task `crate::followup` filed from the findings a merged run
522    /// left open: which run and findings it carries, and how many follow-ups
523    /// deep it is. `None` for every ordinary task. `#[serde(default)]`.
524    #[serde(default)]
525    pub followup: Option<FollowUp>,
526    /// Id of the chat this task descends from: set by [`Task::new`] when a
527    /// chat files it, inherited by the follow-ups `crate::followup` files, so
528    /// the provenance survives deleting an ancestor. `None` for a task written
529    /// before the field existed (`crate::consult` then walks the ancestry).
530    /// `#[serde(default)]`.
531    #[serde(default)]
532    pub origin_chat: Option<String>,
533    /// The command-line choices (`--merge`, `-c`, `--seed`, ...) of the
534    /// `magi run` / `magi review` that filed this task, applied on top of the
535    /// repository's config by whoever executes it - a daemon included - so
536    /// handing the task over does not change what it does. A count edit on an
537    /// ordinary queue task explicitly inherits daemon options instead.
538    /// `#[serde(default)]`.
539    #[serde(default)]
540    pub overrides: Option<RunOverrides>,
541    /// The branch an explicit `magi review <branch>` asked to review. Unlike
542    /// [`Task::review_branch`] (a recovery choice the daemon consumes and
543    /// drops back to a competition when the branch is gone), this is the whole
544    /// request: it is never consumed, and a missing branch fails the attempt
545    /// rather than paying for an implementation nobody asked for.
546    /// `#[serde(default)]`.
547    #[serde(default)]
548    pub review_of: Option<String>,
549    /// When the current hold began. Set by the transitions into
550    /// [`TaskStatus::Held`] (a re-hold keeps it), cleared by
551    /// [`Task::release`]. `crate::notices` uses it as the identity of the
552    /// cause: a question filed before it is about something older.
553    #[serde(default)]
554    pub held_at: Option<Timestamp>,
555    /// Why the run parked, while the task is [`TaskStatus::Parked`]. Kept apart
556    /// from [`Task::last_error`] because nothing failed. Cleared by every
557    /// transition out of the parked state. `#[serde(default)]`.
558    #[serde(default)]
559    pub park_reason: Option<String>,
560    /// The last final state reported to the originating chat. See
561    /// [`Task::chat_notice_due`]. Never cleared by a release or requeue.
562    #[serde(default)]
563    pub chat_notice: Option<ChatNotice>,
564    /// Delivery ledger, unioned under the task lock even on stale writes.
565    #[serde(default)]
566    pub chat_notices: Vec<String>,
567    /// Structured producer identity of the unresolved hold. Requeues and prose
568    /// updates retain it; a settled run or a distinct startup stage replaces it.
569    #[serde(default)]
570    pub hold_notice_cause: Option<NoticeCause>,
571    /// Append-only: one entry per pass over a run that the loop settled. A
572    /// resumed run is one run with several passes, and `attempts` counts
573    /// claims net of refunds, so the two are not the same number; this says
574    /// which pass cost what. Kept across a release.
575    #[serde(default)]
576    pub passes: Vec<TaskPass>,
577    /// When the loop claimed this task for its latest dispatch. Stamped under
578    /// the claim, before any slow start-up work, so it is the real dispatch
579    /// order; `record_pass_at` prefers it over the graph's own pass start.
580    #[serde(default)]
581    pub dispatched_at: Option<Timestamp>,
582    /// Durable failed-input budget, unaffected by automatic release/requeue.
583    #[serde(default)]
584    pub recovery_budget: Option<RecoveryBudget>,
585    /// Inputs resolved before starting the current automatic attempt. The
586    /// settlement uses this same identity even for review-only runs.
587    #[serde(default)]
588    pub recovery_admission: Option<RecoveryAdmission>,
589    /// Previously seen input generations. Switching inputs away and back does
590    /// not refund an exhausted generation without explicit operator authority.
591    #[serde(default)]
592    pub recovery_generations: Vec<RecoveryBudget>,
593    /// When the task was filed.
594    pub created_at: Timestamp,
595    /// Last change to this file.
596    pub updated_at: Timestamp,
597}
598
599/// Admission identity shared by start and settlement.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct RecoveryAdmission {
602    /// Fetched upstream commit before any run-local rebase.
603    pub base: String,
604    /// Resolved instruction references and explicit review source.
605    pub inputs: String,
606}
607
608/// Failures against one recovery input generation. An owner release explicitly
609/// starts a new generation; a machine requeue never replenishes it.
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct RecoveryBudget {
612    /// Authored instructions, not generated review scaffolding.
613    pub instruction: String,
614    /// Upstream commit used to prepare this generation.
615    pub base: String,
616    /// Referenced input commits, including their resolved identities.
617    pub inputs: String,
618    /// Stable processing stage of the last failure.
619    pub stage: String,
620    /// Failed passes, independent of the resettable claim counter.
621    pub failures: usize,
622    /// Fingerprint of actual candidate patches; equivalent rebased commits do
623    /// not count as progress. Unknown inputs never replenish the budget.
624    #[serde(default)]
625    pub progress: Option<String>,
626}
627
628fn recovery_progress(state: &crate::run::RunState) -> Option<String> {
629    use crate::proc::Quiet as _;
630    use std::hash::{Hash, Hasher};
631    let mut patches = Vec::new();
632    for candidate in state.candidates.iter().filter(|c| c.commits > 0) {
633        let output = std::process::Command::new("git")
634            .current_dir(&state.repo)
635            .args(["diff", "--binary", &state.base_commit, &candidate.branch])
636            .quiet()
637            .output()
638            .ok()?;
639        if !output.status.success() {
640            return None;
641        }
642        patches.push(output.stdout);
643    }
644    patches.sort();
645    patches.dedup();
646    let mut hash = std::collections::hash_map::DefaultHasher::new();
647    patches.hash(&mut hash);
648    Some(format!("{:x}", hash.finish()))
649}
650
651/// The implementation inputs a recovery budget is keyed on: the explicit,
652/// SHA-pinned sources only, never references parsed from instruction prose.
653/// No sources is `[]`, the key an input-free task always had.
654pub(crate) fn recovery_inputs(sources: &crate::refs::Sources) -> String {
655    serde_json::to_string(
656        &sources
657            .start_from
658            .iter()
659            .map(|i| ("start", &i.token, &i.sha))
660            .chain(
661                sources
662                    .pick_commits
663                    .iter()
664                    .map(|i| ("pick", &i.token, &i.sha)),
665            )
666            .collect::<Vec<_>>(),
667    )
668    .unwrap_or_default()
669}
670
671/// Facts a producer can independently attest about a held outcome.
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct NoticeCause {
674    /// Processing stage or verified external evidence.
675    pub stage: String,
676    /// Producer-verified blocker object, independent of the displayed error.
677    /// Omitted when absent to preserve existing persisted notice keys.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub target: Option<String>,
680    /// An actual run that reached settlement; absent for preflight failures.
681    pub run: Option<String>,
682    /// The settled pass, so a resumed run can report a new failure.
683    pub pass: Option<usize>,
684}
685
686/// Current and legacy completion keys; legacy run counts contain only digits.
687pub(crate) fn is_done_notice_key(key: &str) -> bool {
688    key == "done"
689        || key
690            .strip_prefix("done#")
691            .is_some_and(|count| !count.is_empty() && count.bytes().all(|b| b.is_ascii_digit()))
692}
693
694/// What a settled pass did to the attempt budget.
695#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "snake_case")]
697pub enum PassCost {
698    /// The attempt stays counted.
699    Spent,
700    /// The attempt was handed back.
701    Refunded,
702}
703
704/// One settled pass over a run. See [`Task::passes`].
705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
706pub struct TaskPass {
707    /// The run the pass belongs to.
708    pub run: String,
709    /// [`crate::run::RunPass::index`] in that run.
710    pub pass_index: usize,
711    /// What it did to the budget.
712    pub cost: PassCost,
713    /// `attempts` once this pass was settled.
714    pub attempts_after: usize,
715    /// When this pass began, retained across daemon upgrades.
716    #[serde(default)]
717    pub started_at: Option<Timestamp>,
718    /// When the loop settled it.
719    pub settled_at: Timestamp,
720    /// Why the pass parked, when it did.
721    #[serde(default)]
722    pub parked: Option<crate::run::ParkCause>,
723}
724
725/// What became of a completion notice. See [`Task::chat_notice`].
726#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(rename_all = "lowercase")]
728pub enum ChatNoticeOutcome {
729    /// Queued as a draft of the originating talk.
730    Posted,
731    /// The talk is gone or closed; nothing was queued, and nothing will be.
732    Skipped,
733}
734
735/// The final state of a chat-filed task that was already reported (or skipped).
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
737pub struct ChatNotice {
738    /// Identity of the cause, see [`Task::chat_notice_key`].
739    pub key: String,
740    /// When it was recorded.
741    pub at: Timestamp,
742    /// Posted or skipped.
743    pub outcome: ChatNoticeOutcome,
744}
745
746/// Command-line choices carried by a task. See [`Task::overrides`].
747#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
748pub struct RunOverrides {
749    /// Count edits on ordinary queued tasks inherit the loop's config and merge.
750    /// False for direct-run overrides and old records, even when otherwise empty.
751    #[serde(default)]
752    pub inherit_daemon_options: bool,
753    /// `--merge`: `none`, `local` or `pr`.
754    #[serde(default)]
755    pub merge: Option<String>,
756    /// `-c` / `--implementers`: parallel implementations (persisted as `candidates`).
757    #[serde(default)]
758    pub candidates: Option<usize>,
759    /// `-j`: independent judges.
760    #[serde(default)]
761    pub judges: Option<usize>,
762    /// `--reviewers`: reviewers per round.
763    #[serde(default)]
764    pub reviewers: Option<usize>,
765    /// `--review-rounds`.
766    #[serde(default)]
767    pub review_rounds: Option<usize>,
768    /// `--seed`.
769    #[serde(default)]
770    pub seed: Option<u64>,
771    /// `--config`, absolute: the daemon's own working directory differs.
772    #[serde(default)]
773    pub config: Option<PathBuf>,
774}
775
776impl RunOverrides {
777    /// Lay `other` over these: every choice `other` made wins, the rest stay.
778    pub fn merge_over(&mut self, other: &RunOverrides) {
779        macro_rules! take {
780            ($($f:ident),*) => {$(
781                if other.$f.is_some() {
782                    self.$f.clone_from(&other.$f);
783                }
784            )*};
785        }
786        take!(
787            merge,
788            candidates,
789            judges,
790            reviewers,
791            review_rounds,
792            seed,
793            config
794        );
795    }
796
797    /// Apply these on top of `config`.
798    pub fn apply(&self, config: &mut crate::config::Config) {
799        if let Some(n) = self.candidates {
800            config.graph.implementers = n;
801        }
802        if let Some(n) = self.judges {
803            config.graph.judges = n;
804        }
805        if let Some(n) = self.reviewers {
806            config.graph.reviewers = n;
807        }
808        if let Some(n) = self.review_rounds {
809            config.graph.review_rounds = n;
810        }
811        if let Some(m) = &self.merge
812            && let Ok(mode) = crate::daemon::merge_mode(m)
813        {
814            config.merge.mode = mode;
815        }
816        if let Some(s) = self.seed {
817            config.blind.seed = Some(s);
818        }
819    }
820}
821
822/// What a follow-up task came from. See [`Task::followup`].
823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
824pub struct FollowUp {
825    /// The merged run whose open findings this task carries.
826    pub run: String,
827    /// The queue task that run served, when it had one.
828    #[serde(default)]
829    pub origin_task: Option<String>,
830    /// The merged pull request.
831    pub pr: String,
832    /// Ids of the findings (e.g. `R3-1-1`) this task covers.
833    pub findings: Vec<String>,
834    /// Follow-up depth: 1 for a follow-up of an ordinary task's run, 2 for a
835    /// follow-up of that, and so on. An ordinary task is generation 0.
836    pub generation: u32,
837}
838
839/// Durable scope of an explicit operator resumption, surviving run startup.
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub struct Resumption {
842    /// Monotonic count of explicit resumptions.
843    pub generation: u64,
844    /// Mutation path that applied the approval.
845    pub source: String,
846    /// Answered question, if the approval came from one.
847    pub question_id: Option<String>,
848    /// When this approval was applied.
849    pub at: Timestamp,
850    /// Authored instruction approved at release.
851    pub instruction: String,
852    /// Number of historical answers superseded as waiting premises.
853    pub answers_through: usize,
854    /// Legacy run-list boundary retained for record compatibility. Evidence
855    /// uses approval time and run passes because resumes retain the same id.
856    pub runs_through: usize,
857    /// Previous hold or dependency reason cleared by this release.
858    pub cleared_reason: Option<String>,
859    /// Verified evidence versions already used to block after this approval.
860    #[serde(default)]
861    pub blocking_evidence: Vec<String>,
862}
863
864/// A triage "resume" answer and what became of it. See
865/// [`Task::resume_override`].
866#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
867pub struct OperatorResume {
868    /// 回答された Question の ID。Question を伴わない再開では空文字列。
869    pub question_id: String,
870    /// When the answer was applied.
871    pub at: Timestamp,
872    /// The reason `crate::conduct` gave for holding the task again after the
873    /// answer, if it did. The conductor may do this once.
874    #[serde(default)]
875    pub conductor_rehold: Option<String>,
876    /// The operator answered "resume" a second time, to the question about
877    /// that contradiction: the conductor may no longer hold this task.
878    #[serde(default)]
879    pub forced: bool,
880    /// Set when the answer was a structured `resume` action on a `magi ask`
881    /// choice ([`crate::ask::ChoiceAction::Resume`]): the run the operator
882    /// chose to continue. Its presence also stops `crate::conduct` from
883    /// requeuing the task into a fresh competition, which would discard
884    /// exactly the run that was named. Cleared with the rest of the record
885    /// once the task runs. `#[serde(default)]` so older records read as `None`.
886    #[serde(default)]
887    pub pinned_run: Option<String>,
888}
889
890/// One question `crate::conduct` asked about a task, and what the operator
891/// said back. See [`Task::answers`].
892#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
893pub struct AnsweredQuestion {
894    /// The question as asked, e.g. [`crate::ask::Question::summary`].
895    pub question: String,
896    /// What the operator answered.
897    pub answer: String,
898}
899
900impl Task {
901    /// File a new task. Persist it with [`Queue::put`].
902    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
903        let now = Timestamp::now();
904        let origin_chat = match &source {
905            Source::Agent { run, node } if node == CHAT_NODE => Some(run.clone()),
906            _ => None,
907        };
908        Self {
909            schema: SCHEMA,
910            id: new_id(),
911            title,
912            instruction,
913            sources: crate::refs::Sources::default(),
914            repo,
915            source,
916            priority: 0,
917            solo: false,
918            status: TaskStatus::Queued,
919            attempts: 0,
920            attempt_limit: None,
921            runs: Vec::new(),
922            last_error: None,
923            hold_reason: None,
924            hold_source: None,
925            diagnostic: None,
926            blocked_by: Vec::new(),
927            block_reason: None,
928            blocked_from: None,
929            blocked_at: None,
930            answers: Vec::new(),
931            triage_applied: Vec::new(),
932            actions_applied: Vec::new(),
933            resume_override: None,
934            resumption: None,
935            resumption_history: Vec::new(),
936            operator_decided_at: None,
937            review_branch: None,
938            review_instruction: None,
939            pr_recovery: None,
940            fresh_start: false,
941            replacement: None,
942            interrupt: false,
943            urgent: false,
944            attachments: Vec::new(),
945            followup: None,
946            origin_chat,
947            overrides: None,
948            review_of: None,
949            held_at: None,
950            park_reason: None,
951            chat_notice: None,
952            chat_notices: Vec::new(),
953            hold_notice_cause: None,
954            passes: Vec::new(),
955            dispatched_at: None,
956            recovery_budget: None,
957            recovery_admission: None,
958            recovery_generations: Vec::new(),
959            created_at: now,
960            updated_at: now,
961        }
962    }
963
964    /// The chat this task itself says it came from: the recorded
965    /// [`Task::origin_chat`], else its own source when a chat filed it.
966    pub fn chat_talk(&self) -> Option<&str> {
967        if let Some(id) = &self.origin_chat {
968            return Some(id);
969        }
970        match &self.source {
971            Source::Agent { run, node } if node == CHAT_NODE => Some(run),
972            _ => None,
973        }
974    }
975
976    /// The talk that filed this task *itself* (`Source::Agent` with
977    /// [`CHAT_NODE`]); unlike [`Task::chat_talk`] a follow-up's inherited
978    /// `origin_chat` does not count.
979    pub fn filed_by_chat(&self) -> Option<&str> {
980        match &self.source {
981            Source::Agent { run, node } if node == CHAT_NODE => Some(run),
982            _ => None,
983        }
984    }
985
986    /// Identity of a material outcome. Prose and wall-clock times are never
987    /// evidence. Dependencies are a set; held causes survive intermediate states.
988    pub fn chat_notice_key(&self) -> Option<String> {
989        match self.status {
990            TaskStatus::Done => Some("done".to_owned()),
991            TaskStatus::Held => {
992                let cause = self
993                    .hold_notice_cause
994                    .clone()
995                    .unwrap_or_else(|| self.unresolved_hold_cause());
996                Some(format!(
997                    "cause:{}",
998                    serde_json::to_string(&cause).expect("cause")
999                ))
1000            }
1001            TaskStatus::Blocked => {
1002                let ids: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1003                // Only new verified blocking evidence versions the dependency
1004                // set. Freezing an unrelated hold on release must not do so.
1005                let evidence = self
1006                    .hold_notice_cause
1007                    .as_ref()
1008                    .filter(|c| c.stage.starts_with("conductor-evidence:"));
1009                Some(format!(
1010                    "dependencies:{}",
1011                    serde_json::to_string(&(ids, evidence)).expect("ids")
1012                ))
1013            }
1014            _ => None,
1015        }
1016    }
1017
1018    fn unresolved_hold_cause(&self) -> NoticeCause {
1019        NoticeCause {
1020            stage: "hold".into(),
1021            target: None,
1022            run: self.runs.last().cloned(),
1023            pass: None,
1024        }
1025    }
1026
1027    /// Exact old delivery identity, only for reconciling an existing record.
1028    pub(crate) fn legacy_chat_notice_key(&self) -> Option<String> {
1029        let key = match self.status {
1030            TaskStatus::Held if self.hold_notice_cause.is_none() => Some(format!(
1031                "held#{}",
1032                self.held_at.map(|t| t.to_string()).unwrap_or_default()
1033            )),
1034            TaskStatus::Blocked => Some(format!(
1035                "blocked#{}#{}",
1036                self.blocked_by.join(","),
1037                self.block_reason.as_deref().unwrap_or_default()
1038            )),
1039            _ => None,
1040        };
1041        key.filter(|key| self.legacy_notice_matches(key))
1042    }
1043
1044    pub(crate) fn legacy_notice_matches(&self, key: &str) -> bool {
1045        match self.status {
1046            TaskStatus::Held if self.hold_notice_cause.is_none() => key
1047                .strip_prefix("held#")
1048                .is_some_and(|time| time.is_empty() || time.parse::<Timestamp>().is_ok()),
1049            TaskStatus::Blocked
1050                if self
1051                    .hold_notice_cause
1052                    .as_ref()
1053                    .is_none_or(|c| !c.stage.starts_with("conductor-evidence:")) =>
1054            {
1055                key.strip_prefix("blocked#").is_some_and(|tail| {
1056                    let Some((ids, _)) = tail.split_once('#') else {
1057                        return false;
1058                    };
1059                    let old: std::collections::BTreeSet<_> =
1060                        ids.split(',').filter(|id| !id.is_empty()).collect();
1061                    let current: std::collections::BTreeSet<_> =
1062                        self.blocked_by.iter().map(String::as_str).collect();
1063                    old == current
1064                })
1065            }
1066            _ => false,
1067        }
1068    }
1069
1070    fn import_chat_notice(&mut self) {
1071        if let Some(n) = &self.chat_notice {
1072            let key = if is_done_notice_key(&n.key) {
1073                "done".to_owned()
1074            } else if self.legacy_notice_matches(&n.key) {
1075                self.chat_notice_key().unwrap_or_else(|| n.key.clone())
1076            } else {
1077                n.key.clone()
1078            };
1079            if !self.chat_notices.contains(&key) {
1080                self.chat_notices.push(key);
1081            }
1082        }
1083    }
1084
1085    /// A producer records the processing stage and actual run/pass, never the
1086    /// error's rendered prose. No run means startup made no implementation progress.
1087    pub(crate) fn notice_cause(&mut self, stage: &str, run: Option<&str>, pass: Option<usize>) {
1088        self.import_chat_notice();
1089        self.hold_notice_cause = Some(NoticeCause {
1090            stage: stage.to_owned(),
1091            target: None,
1092            run: run.map(str::to_owned),
1093            pass,
1094        });
1095    }
1096
1097    /// Startup retries cannot replace an unresolved run outcome without doing
1098    /// work. Distinct startup stages can still explain a newly encountered blocker.
1099    pub(crate) fn startup_notice_cause(&mut self, stage: &str) {
1100        let recovery_only = matches!(
1101            stage,
1102            "startup-source" | "startup-candidates" | "startup-public-head"
1103        );
1104        match self.hold_notice_cause.as_ref() {
1105            Some(cause) if recovery_only && !cause.stage.starts_with("startup-") => return,
1106            None if recovery_only
1107                && self
1108                    .chat_notice
1109                    .as_ref()
1110                    .is_some_and(|n| !is_done_notice_key(&n.key)) =>
1111            {
1112                return;
1113            }
1114            _ => {}
1115        }
1116        self.notice_cause(stage, None, None);
1117    }
1118
1119    /// Apply a preflight failure without allowing Running's generic failure
1120    /// identity to overwrite the producer's stage or its unresolved recovery anchor.
1121    pub(crate) fn fail_startup(
1122        &mut self,
1123        stage: &str,
1124        why: impl Into<String>,
1125        max_attempts: usize,
1126    ) {
1127        let unresolved = self.hold_notice_cause.clone();
1128        self.fail(why, max_attempts);
1129        self.hold_notice_cause = unresolved;
1130        self.startup_notice_cause(stage);
1131    }
1132
1133    /// A startup failure tied to a concrete object, such as a missing attachment.
1134    pub(crate) fn fail_startup_target(
1135        &mut self,
1136        stage: &str,
1137        target: String,
1138        why: impl Into<String>,
1139        max_attempts: usize,
1140    ) {
1141        self.fail_startup(stage, why, max_attempts);
1142        if let Some(cause) = self.hold_notice_cause.as_mut() {
1143            cause.target = Some(target);
1144        }
1145    }
1146
1147    /// A chat-filed task owes each material final outcome at most one notice.
1148    pub fn chat_notice_due(&self) -> Option<String> {
1149        self.filed_by_chat()?;
1150        if self.schema < 13 {
1151            return None;
1152        }
1153        let key = self.chat_notice_key()?;
1154        if self.chat_notices.contains(&key) {
1155            return None;
1156        }
1157        match &self.chat_notice {
1158            Some(n)
1159                if n.key == key
1160                    || (key == "done" && is_done_notice_key(&n.key))
1161                    || self.legacy_notice_matches(&n.key) =>
1162            {
1163                None
1164            }
1165            _ => Some(key),
1166        }
1167    }
1168
1169    /// Short form used in reports, matching a run's short id.
1170    pub fn short(&self) -> &str {
1171        short(&self.id)
1172    }
1173
1174    /// Record that triage question `question_id`'s answer has been applied.
1175    pub fn mark_triage_applied(&mut self, question_id: &str) {
1176        if !self.triage_applied(question_id) {
1177            self.triage_applied.push(question_id.to_owned());
1178        }
1179    }
1180
1181    /// Has the action of `magi ask` question `question_id` already been
1182    /// applied, or is it being applied now? See [`Task::actions_applied`].
1183    pub fn action_applied(&self, question_id: &str) -> bool {
1184        self.actions_applied.iter().any(|id| id == question_id)
1185    }
1186
1187    /// Record that `magi ask` question `question_id`'s action was applied.
1188    pub fn mark_action_applied(&mut self, question_id: &str) {
1189        if !self.action_applied(question_id) {
1190            self.actions_applied.push(question_id.to_owned());
1191        }
1192    }
1193
1194    /// Has triage question `question_id`'s answer already been applied?
1195    pub fn triage_applied(&self, question_id: &str) -> bool {
1196        self.triage_applied.iter().any(|id| id == question_id)
1197    }
1198
1199    /// Record that a run has started for this task.
1200    /// Move an already linked run to the end so it remains the current attempt,
1201    /// with one history entry per run and separate pass records for each resume.
1202    ///
1203    /// Clears [`Task::interrupt`]: a mark to run ahead of whatever else is
1204    /// in flight is fulfilled the moment this task actually gets its turn,
1205    /// dispatched same as any other. Without this, a task whose run fails
1206    /// and requeues - still `runnable`, still carrying the mark from its
1207    /// first attempt - would keep re-triggering `crate::daemon`'s interrupt
1208    /// scheduler and re-parking whatever it interrupted on every later
1209    /// boundary, for as long as its attempts hold out, instead of the
1210    /// one-shot "let this go next" the mark is meant to be.
1211    pub fn start(&mut self, run: String) {
1212        self.stamp_schema();
1213        self.status = TaskStatus::Running;
1214        self.attempts += 1;
1215        self.runs.retain(|id| id != &run);
1216        self.runs.push(run);
1217        self.last_error = None;
1218        self.park_reason = None;
1219        self.fresh_start = false;
1220        self.interrupt = false;
1221        // The answer has been honoured: the task got its turn.
1222        self.resume_override = None;
1223    }
1224
1225    /// Note that `run` serves this task without otherwise changing its life:
1226    /// no status, attempt or interrupt change, unlike [`Task::start`]. For a
1227    /// run somebody else started on the task's behalf (`magi run --task`);
1228    /// idempotent. Returns whether anything was added.
1229    pub fn link_run(&mut self, run: &str) -> bool {
1230        if self.runs.iter().any(|r| r == run) {
1231            return false;
1232        }
1233        self.runs.push(run.to_owned());
1234        true
1235    }
1236
1237    /// Record a successful run.
1238    ///
1239    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
1240    /// *or blocked* task directly, with no release in between, so this clears
1241    /// `hold_reason` and `blocked_by`/`block_reason` the same way
1242    /// [`Task::release`] does. Otherwise a task held for "waiting on 3ed9", or
1243    /// blocked on a dependency that never actually finished, and then closed
1244    /// as done without ever being released would still read as waiting on
1245    /// something in `magi task show` and on its card, after it no longer is.
1246    pub fn succeed(&mut self) {
1247        self.stamp_schema();
1248        self.status = TaskStatus::Done;
1249        self.resume_override = None;
1250        self.last_error = None;
1251        self.park_reason = None;
1252        self.hold_reason = None;
1253        self.hold_source = None;
1254        self.diagnostic = None;
1255        self.blocked_by.clear();
1256        self.block_reason = None;
1257        self.blocked_from = None;
1258    }
1259
1260    /// Finish the task because the run's whole change turned out to be on the
1261    /// base already ([`crate::run::RunStatus::AlreadyInBase`]): `Done`, with
1262    /// `note` kept in [`Task::last_error`] so `magi task show` says why, and
1263    /// the attempt refunded. Nothing went wrong and nothing was spent
1264    /// misbehaving, so this is neither [`Task::fail`] nor
1265    /// [`Task::handed_off`]; and unlike [`Task::succeed`] it does not pretend
1266    /// the run landed anything.
1267    pub fn already_landed(&mut self, note: impl Into<String>) {
1268        self.succeed();
1269        self.attempts = self.attempts.saturating_sub(1);
1270        self.last_error = Some(note.into());
1271    }
1272
1273    /// Earlier attempts at this task that a later one has since made moot —
1274    /// empty unless the task is [`TaskStatus::Done`] *and* `last_run_succeeded`
1275    /// says `runs.last()` is actually why.
1276    ///
1277    /// `runs` is oldest first, and [`Task::start`] is the only thing that
1278    /// pushes to it, always right before the attempt it names either succeeds
1279    /// or fails; `succeed` itself never touches `runs`. So whenever `status`
1280    /// is `Done` *because* the loop itself saw that last attempt land
1281    /// (`Merged`/`Ready`), everything before it in the same list is a retry
1282    /// this task no longer needs. But `succeed` is also reachable directly —
1283    /// `magi task done`, the web UI's equivalent, and the conductor's
1284    /// `Recovery::Done` all call it on a task in *any* status, including one
1285    /// whose last recorded attempt never landed at all (closed by hand after
1286    /// a merge magi's own loop never saw). `runs.last()` alone cannot tell
1287    /// those two cases apart — that requires the caller to have actually
1288    /// looked at that run's own `status`, which this module has no way to
1289    /// do — so `last_run_succeeded` is the caller's answer to exactly that
1290    /// question, not something this function can derive from `Task` alone.
1291    ///
1292    /// Deliberately narrower than [`Queue::superseded`]'s "every earlier
1293    /// attempt has a later one" walk, which fires the moment a retry starts
1294    /// even though the retry itself might still fail: that reading is right
1295    /// for the web UI's "a newer attempt exists, go look at that one
1296    /// instead" note, but wrong for deciding a run no longer needs a human's
1297    /// attention, which is only true once the task's story has actually
1298    /// ended well. Two Blocked runs sitting side by side while a third
1299    /// attempt is still in flight must not be touched by this — see
1300    /// `daemon::supersede_prior_runs`, the caller that turns this list into
1301    /// rewritten `run.json` files.
1302    pub fn superseded_attempts(&self, last_run_succeeded: bool) -> &[String] {
1303        if self.status != TaskStatus::Done || !last_run_succeeded || self.runs.len() < 2 {
1304            return &[];
1305        }
1306        &self.runs[..self.runs.len() - 1]
1307    }
1308
1309    /// The attempt right after the last occurrence of `run`, if there is one.
1310    /// Resuming records the same run again; it cannot supersede itself.
1311    ///
1312    /// The one place "a later attempt replaced this run" is decided:
1313    /// [`Queue::superseded`] and [`Queue::superseded_by`] (the web UI's
1314    /// note) read it, and so does [`Task::earlier_attempts`], so what is shown
1315    /// and what `crate::handover` acts on cannot disagree.
1316    pub fn successor_of(&self, run: &str) -> Option<&String> {
1317        let pos = self.runs.iter().rposition(|r| r == run)?;
1318        self.runs.get(pos + 1)
1319    }
1320
1321    /// Every run already recorded for this task, i.e. every run that an
1322    /// attempt being minted *right now* supersedes.
1323    ///
1324    /// The new run is not in [`Task::runs`] yet when it decides what to take
1325    /// over (`Task::start` pushes it afterwards), so "has a later attempt" is
1326    /// true of everything listed here by the time the new run exists.
1327    pub fn earlier_attempts(&self) -> &[String] {
1328        &self.runs
1329    }
1330
1331    /// Stamp [`Task::held_at`] on the way into [`TaskStatus::Held`]; a task
1332    /// that is already held keeps the time its hold began.
1333    /// A transition this build makes (start, hold, block, done) makes the task
1334    /// eligible for a chat notice even when it was filed under an older
1335    /// schema; only records no build has touched since stay silent
1336    /// ([`Task::chat_notice_due`]).
1337    fn stamp_schema(&mut self) {
1338        self.schema = self.schema.max(SCHEMA);
1339    }
1340
1341    fn note_held(&mut self) {
1342        self.stamp_schema();
1343        let continuing = self.status == TaskStatus::Held
1344            || (self.status == TaskStatus::Blocked
1345                && (self.blocked_from == Some(TaskStatus::Held)
1346                    || (self.blocked_from.is_none()
1347                        && (self.hold_reason.is_some() || self.hold_source.is_some()))));
1348        if !continuing || self.held_at.is_none() {
1349            self.held_at = Some(Timestamp::now());
1350        }
1351    }
1352
1353    /// Record a failed attempt. Out of attempts means held for a human, rather
1354    /// than retried until the money runs out.
1355    ///
1356    /// Clears [`Task::diagnostic`] unconditionally: it belongs to whatever run
1357    /// produced it, and a caller that has one for *this* attempt sets it
1358    /// itself right after calling this, once it knows the task actually ended
1359    /// up [`TaskStatus::Held`] - see `daemon::diagnostic`. Without the clear, a
1360    /// task released after a diagnosed hold and then failed again for an
1361    /// unrelated, undiagnosed reason (a config error, say) would go on
1362    /// showing the previous run's diagnostic as if it explained the new one.
1363    ///
1364    /// Also records `why` into [`Task::hold_reason`] when this ends up
1365    /// [`TaskStatus::Held`], so the notification and `magi task show` say the
1366    /// same thing as [`Task::last_error`] instead of leaving the hold's own
1367    /// reason blank.
1368    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
1369        let why = why.into();
1370        if self.status == TaskStatus::Running {
1371            let run = self.runs.last().cloned();
1372            self.notice_cause("run-failed", run.as_deref(), Some(self.attempts));
1373        }
1374        self.diagnostic = None;
1375        self.park_reason = None;
1376        self.status = if self.attempts >= self.effective_attempt_limit(max_attempts) {
1377            self.note_held();
1378            self.hold_source = Some(HoldSource::Machine);
1379            self.hold_reason = Some(why.clone());
1380            TaskStatus::Held
1381        } else {
1382            TaskStatus::Failed
1383        };
1384        self.last_error = Some(why);
1385    }
1386
1387    /// Record an attempt that failed for a reason the task is not responsible
1388    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
1389    ///
1390    /// This refunds the attempt on purpose. A quota window closing at 4am must
1391    /// not spend the backlog's retry budget: the operator would come back to a
1392    /// queue of held tasks that were never actually judged, and would have to
1393    /// release every one by hand to find out which had a real problem. The task
1394    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
1395    /// work up where it stopped.
1396    pub fn stall(&mut self, why: impl Into<String>) {
1397        self.last_error = Some(why.into());
1398        self.diagnostic = None;
1399        self.attempts = self.attempts.saturating_sub(1);
1400        self.status = TaskStatus::Failed;
1401    }
1402
1403    /// Record a run that parked at a node boundary. Nothing failed: the attempt
1404    /// is refunded, [`Task::last_error`] is left as it was, the run history and
1405    /// `fresh_start` are kept so the next loop resumes the same run, and the
1406    /// reason goes to [`Task::park_reason`]. Runnable like a requeued task and
1407    /// never counted against `max_attempts`.
1408    pub fn park(&mut self, why: impl Into<String>) {
1409        self.diagnostic = None;
1410        self.attempts = self.attempts.saturating_sub(1);
1411        self.status = TaskStatus::Parked;
1412        self.park_reason = Some(why.into());
1413        // The variant and field are schema 11; stamp it so an older build
1414        // refuses the record instead of half-reading it.
1415        self.schema = self.schema.max(SCHEMA);
1416    }
1417
1418    /// Record a settled pass whose start time is unknown (see [`Self::record_pass_at`]).
1419    pub fn record_pass(
1420        &mut self,
1421        run: &str,
1422        pass_index: usize,
1423        attempts_before: usize,
1424        parked: Option<crate::run::ParkCause>,
1425    ) {
1426        self.record_pass_at(run, pass_index, None, attempts_before, parked);
1427    }
1428
1429    /// Record a settled pass. `attempts_before` is the count just before the
1430    /// settle, so a refund is read off what actually changed, not inferred.
1431    pub fn record_pass_at(
1432        &mut self,
1433        run: &str,
1434        pass_index: usize,
1435        started_at: Option<Timestamp>,
1436        attempts_before: usize,
1437        parked: Option<crate::run::ParkCause>,
1438    ) {
1439        if self
1440            .passes
1441            .iter()
1442            .any(|p| p.run == run && p.pass_index == pass_index)
1443        {
1444            return;
1445        }
1446        let cost = if self.attempts < attempts_before {
1447            PassCost::Refunded
1448        } else {
1449            PassCost::Spent
1450        };
1451        self.passes.push(TaskPass {
1452            run: run.to_owned(),
1453            pass_index,
1454            started_at: self.dispatched_at.or(started_at),
1455            cost,
1456            attempts_after: self.attempts,
1457            settled_at: Timestamp::now(),
1458            parked,
1459        });
1460        self.schema = self.schema.max(SCHEMA);
1461    }
1462
1463    /// Only the latest pass of the current, resumable upgrade-parked run
1464    /// retains its place. Explicit fresh starts and other park causes do not.
1465    pub(crate) fn upgrade_resume_key(&self) -> Option<(Timestamp, &str)> {
1466        if self.status != TaskStatus::Parked || self.fresh_start {
1467            return None;
1468        }
1469        let pass = self.passes.last()?;
1470        if self.runs.last() != Some(&pass.run)
1471            || !matches!(pass.parked, Some(crate::run::ParkCause::Upgrade { .. }))
1472        {
1473            return None;
1474        }
1475        // Before schema 19 the dispatch start was not copied to the task.
1476        // Settlement time is only an approximation for those old records.
1477        Some((pass.started_at.unwrap_or(pass.settled_at), &self.id))
1478    }
1479
1480    /// Whether this held task may only be released by an operator.
1481    ///
1482    /// Old files did not record a source. Preserve every such hold rather
1483    /// than guessing that it was automatic and risking duplicate work. New
1484    /// automatic holds record [`HoldSource::Machine`] and remain recoverable.
1485    pub fn operator_held(&self) -> bool {
1486        self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
1487    }
1488
1489    /// Take this task out of the loop's reach by an operator action.
1490    ///
1491    /// Clears `blocked_by`/`block_reason` unconditionally, the same as
1492    /// [`Task::release`] and for the same reason its own comment already
1493    /// gives: a human choosing to hold a *blocked* task overrides its wait
1494    /// outright, the same as it overrides an ordinary hold. Without this, a
1495    /// task held straight out of [`TaskStatus::Blocked`] - the web UI's "Hold"
1496    /// button is reachable on a blocked task, same as "Mark done" - kept
1497    /// reading as still waiting on a dependency it no longer had any claim on.
1498    pub fn hold_manual(&mut self, reason: Option<String>) {
1499        // A later explicit hold supersedes outstanding triage answers.
1500        self.operator_decided_at = Some(Timestamp::now());
1501        self.hold_unrequested_retry(reason);
1502    }
1503
1504    /// Keep a hand-started run from retrying automatically, without recording
1505    /// an operator decision that could invalidate its outstanding questions.
1506    pub(crate) fn hold_unrequested_retry(&mut self, reason: Option<String>) {
1507        self.park_reason = None;
1508        self.note_held();
1509        self.status = TaskStatus::Held;
1510        if reason.is_some() {
1511            self.hold_reason = reason;
1512        }
1513        self.hold_source = Some(HoldSource::Manual);
1514        self.blocked_by.clear();
1515        self.block_reason = None;
1516        self.blocked_from = None;
1517    }
1518
1519    /// Take this task out of the loop's reach during automatic recovery.
1520    ///
1521    /// Clears `blocked_by`/`block_reason` for the same reason
1522    /// [`Task::hold_manual`] does.
1523    pub fn hold_machine(&mut self, reason: Option<String>) {
1524        self.park_reason = None;
1525        self.note_held();
1526        self.status = TaskStatus::Held;
1527        if reason.is_some() {
1528            self.hold_reason = reason;
1529        }
1530        self.hold_source = Some(HoldSource::Machine);
1531        self.blocked_by.clear();
1532        self.block_reason = None;
1533        self.blocked_from = None;
1534    }
1535
1536    /// Block this task on other task ids and/or open question ids, chosen by
1537    /// `crate::conduct`. Pure: the caller still owns writing it back with
1538    /// [`Queue::put`].
1539    ///
1540    /// Records [`Task::blocked_from`] the first time this moves the task into
1541    /// [`TaskStatus::Blocked`], and leaves it alone on a later call that adds
1542    /// or replaces `blocked_by` while the task is already `Blocked` - a
1543    /// second question about an already-blocked task must not overwrite the
1544    /// status it should eventually return to with `Blocked` itself.
1545    pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
1546        self.stamp_schema();
1547        let before: std::collections::BTreeSet<_> = self.blocked_by.iter().collect();
1548        let after: std::collections::BTreeSet<_> = blocked_by.iter().collect();
1549        if self.status != TaskStatus::Blocked || before != after {
1550            self.blocked_at = Some(Timestamp::now());
1551        }
1552        if self.status != TaskStatus::Blocked {
1553            self.blocked_from = Some(self.status);
1554        }
1555        self.status = TaskStatus::Blocked;
1556        self.blocked_by = blocked_by;
1557        self.block_reason = reason;
1558    }
1559
1560    /// Remove one resolved dependency (a task id that became [`TaskStatus::Done`],
1561    /// or a question id that became [`crate::ask::QuestionStatus::Answered`]).
1562    /// Once nothing is left in [`Task::blocked_by`], the task returns to
1563    /// whatever [`Task::blocked_from`] recorded - deciding *why* a task was
1564    /// blocked was `crate::conduct`'s job, but noticing a dependency resolved
1565    /// needs no model at all, and restoring the status it interrupted needs
1566    /// nothing more than what `block` already wrote down.
1567    ///
1568    /// A task blocked while `Running` restores to [`TaskStatus::Queued`]
1569    /// instead: whatever process was running it is gone by the time this
1570    /// runs, so there is nothing left to resume. A task with no recorded
1571    /// `blocked_from` - a pre-schema-4 record, or one blocked before this
1572    /// field existed - falls back to [`TaskStatus::Held`] when it still
1573    /// carries hold evidence ([`Task::hold_reason`] or [`Task::hold_source`],
1574    /// neither ever cleared by `block`), and to `Queued` otherwise: the same
1575    /// choice `block` itself would have recorded, reconstructed from what
1576    /// survived.
1577    ///
1578    /// A no-op, on purpose, for a task that is not [`TaskStatus::Blocked`]:
1579    /// `crate::daemon`'s deterministic resolver runs over every task on every
1580    /// poll, and a task that moved on for some other reason must not be
1581    /// dragged back by a stale id it still happens to carry.
1582    pub fn unblock(&mut self, resolved_id: &str) {
1583        if self.status != TaskStatus::Blocked {
1584            return;
1585        }
1586        self.blocked_by.retain(|id| id != resolved_id);
1587        self.restore_if_unblocked();
1588    }
1589
1590    /// A dependency this task waited on was deleted on purpose
1591    /// ([`Queue::remove`]): drop just that id from [`Task::blocked_by`] and,
1592    /// once nothing is left, return to the status the block interrupted, the
1593    /// same way [`Task::unblock`] does. Returns whether anything changed.
1594    ///
1595    /// Never a hold: the operator chose the deletion, so the dependent is not
1596    /// in trouble. Touches nothing but a `Blocked` task that actually names
1597    /// `deleted_id`, so a `Held` task (or a manual hold) is left exactly as it
1598    /// was. A dependency that vanished *without* going through a deletion is
1599    /// not this method's case - that stays a machine hold.
1600    pub fn dependency_deleted(&mut self, deleted_id: &str) -> bool {
1601        if self.status != TaskStatus::Blocked || !self.blocked_by.iter().any(|b| b == deleted_id) {
1602            return false;
1603        }
1604        self.blocked_by.retain(|id| id != deleted_id);
1605        self.restore_if_unblocked();
1606        true
1607    }
1608
1609    /// The shared tail of [`Task::unblock`] and [`Task::dependency_deleted`]:
1610    /// with nothing left in `blocked_by`, restore the interrupted status and
1611    /// clear the now-stale block bookkeeping.
1612    fn restore_if_unblocked(&mut self) {
1613        if self.blocked_by.is_empty() {
1614            self.status = match self.blocked_from {
1615                Some(TaskStatus::Running) => TaskStatus::Queued,
1616                Some(other) => other,
1617                None if self.hold_reason.is_some() || self.hold_source.is_some() => {
1618                    TaskStatus::Held
1619                }
1620                None => TaskStatus::Queued,
1621            };
1622            self.block_reason = None;
1623            self.blocked_from = None;
1624        }
1625    }
1626
1627    /// Record that a question `crate::conduct` asked about this task has been
1628    /// answered, so the answer's content — not just the fact that the
1629    /// question is gone — reaches the next conductor prompt and the next
1630    /// run's instruction. See [`Task::answers`].
1631    pub fn record_answer(&mut self, question: String, answer: String) {
1632        self.answers.push(AnsweredQuestion { question, answer });
1633    }
1634
1635    /// Requeue this task to reopen its last run as a review-only pass against
1636    /// `branch` (`crate::graph::Runner::review`) rather than competing from
1637    /// scratch. See [`Task::review_branch`].
1638    pub fn request_review(&mut self, branch: String) {
1639        self.release();
1640        self.review_branch = Some(branch);
1641        self.review_instruction = Some(self.instruction.clone());
1642    }
1643
1644    /// Requeue after a conductor chose a new competition. Unlike an ordinary
1645    /// operator release, this deliberately does not resume the old run.
1646    pub fn requeue(&mut self) {
1647        self.release();
1648        // A fresh competition is the opposite of a review of the old branch.
1649        self.review_branch = None;
1650        self.review_instruction = None;
1651        self.fresh_start = true;
1652    }
1653
1654    /// Hold this task because a takeover of `branch` was refused, keeping
1655    /// [`Task::review_branch`] so that once the operator has cleaned up and
1656    /// released it, the retry is a review of the same branch rather than a
1657    /// resume or a fresh competition. Spends no attempt.
1658    ///
1659    /// This is the only way a machine hold keeps a `review_branch`: the
1660    /// daemon takes it at the start of every attempt, so [`Task::release`]
1661    /// relies on that to tell this hold from every other.
1662    pub fn hold_for_handover(&mut self, branch: Option<String>, reason: String) {
1663        if branch.is_some() {
1664            self.review_branch = branch;
1665            self.review_instruction = Some(self.instruction.clone());
1666        }
1667        self.hold_machine(Some(reason));
1668    }
1669
1670    /// Change the urgent lane setting without releasing a held task.
1671    pub fn set_urgent(&mut self, urgent: bool) -> Result<()> {
1672        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1673            bail!(
1674                "task {} is {}; urgent can only change while queued or held; \
1675                 wait for the active attempt to finish and requeue or hold the task first",
1676                self.short(),
1677                self.status.as_str()
1678            );
1679        }
1680        self.urgent = urgent;
1681        Ok(())
1682    }
1683
1684    /// Change how urgently this task should run next.
1685    ///
1686    /// Refused once the task is `running`: priority only feeds the sort
1687    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
1688    /// running task has already left that pool. Accepting the write anyway
1689    /// would look like it worked while changing nothing until - and unless -
1690    /// this attempt fails and the task becomes runnable again, which is a
1691    /// surprise the phone should not hand back as a success.
1692    pub fn set_priority(&mut self, priority: i32) -> Result<()> {
1693        if self.status == TaskStatus::Running {
1694            bail!(
1695                "task {} is running; its priority cannot be changed until \
1696                 this attempt finishes",
1697                self.short()
1698            );
1699        }
1700        self.priority = priority;
1701        Ok(())
1702    }
1703
1704    /// Mark (or unmark) this task to interrupt whatever `magi serve` already
1705    /// has in flight, once `[daemon] pause_for_interrupts` is on. See
1706    /// [`Task::interrupt`].
1707    ///
1708    /// Setting it is restricted to a task the loop could pick up on its own
1709    /// right now - [`TaskStatus::runnable`] - for the same reason as
1710    /// [`Task::set_priority`]: a task already `running` has been claimed, and
1711    /// a task that is `done`, `held`, or `blocked` is not going to compete
1712    /// for the daemon's attention regardless of this flag. Unlike priority,
1713    /// this is never silently inert while `running` - it is refused outright,
1714    /// because the entire feature this flag drives (`crate::daemon`'s
1715    /// interrupt scheduler) is scoped to tasks still waiting to be claimed.
1716    /// Clearing it back to `false` carries no such risk and is always
1717    /// allowed, including on a task that moved on since it was set.
1718    pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
1719        if interrupt && !self.status.runnable() {
1720            bail!(
1721                "task {} is {}; only a queued or failed task can be marked \
1722                 to interrupt",
1723                self.short(),
1724                self.status.as_str()
1725            );
1726        }
1727        self.interrupt = interrupt;
1728        Ok(())
1729    }
1730
1731    /// Resolve the task override against the running daemon's limit.
1732    pub fn effective_attempt_limit(&self, daemon_limit: usize) -> usize {
1733        self.attempt_limit.map_or(daemon_limit, NonZeroUsize::get)
1734    }
1735
1736    /// Change the budget without resetting attempts or releasing a hold.
1737    /// Callers must hold the queue claim and reload the task before calling.
1738    pub fn change_attempt_limit(&mut self, limit: Option<NonZeroUsize>) -> Result<()> {
1739        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1740            bail!("only queued or held tasks can change the attempt limit");
1741        }
1742        self.stamp_schema();
1743        self.attempt_limit = limit;
1744        Ok(())
1745    }
1746
1747    /// Explicit implementer choice, with Solo taking precedence for old records.
1748    pub fn implementers(&self) -> Option<usize> {
1749        if self.solo {
1750            Some(1)
1751        } else {
1752            self.overrides.as_ref().and_then(|o| o.candidates)
1753        }
1754    }
1755
1756    /// Validate against this task's configuration and change only its seat count.
1757    /// Callers must hold the queue claim and reload the task before calling.
1758    pub fn change_implementers(&mut self, count: u8) -> Result<()> {
1759        self.check_implementers_editable()?;
1760        let explicit = self.overrides.as_ref().and_then(|o| o.config.as_deref());
1761        let (mut config, _) = crate::config::Config::discover(&self.repo, explicit)?;
1762        if let Some(o) = &self.overrides {
1763            o.apply(&mut config);
1764        }
1765        self.set_implementers(count, &config)
1766    }
1767
1768    fn check_implementers_editable(&self) -> Result<()> {
1769        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1770            bail!(
1771                "task {} is {}; only queued or held tasks can change implementers",
1772                self.short(),
1773                self.status.as_str()
1774            );
1775        }
1776        Ok(())
1777    }
1778
1779    /// Normalize Solo and candidate overrides without altering task history.
1780    pub fn set_implementers(&mut self, count: u8, config: &crate::config::Config) -> Result<()> {
1781        self.check_implementers_editable()?;
1782        crate::talk::check_implementers(count, config).map_err(anyhow::Error::msg)?;
1783        self.stamp_schema();
1784        self.solo = count == 1;
1785        let mut overrides = self.overrides.clone().unwrap_or_else(|| RunOverrides {
1786            inherit_daemon_options: true,
1787            ..Default::default()
1788        });
1789        overrides.candidates = (count > 1).then_some(usize::from(count));
1790        let mut settings = overrides.clone();
1791        settings.inherit_daemon_options = false;
1792        // Some(empty) is a direct-run setting too: retain its isolation on Solo.
1793        self.overrides = if overrides.inherit_daemon_options && settings == RunOverrides::default()
1794        {
1795            None
1796        } else {
1797            Some(overrides)
1798        };
1799        Ok(())
1800    }
1801
1802    /// Replace this task's title and instruction wholesale.
1803    ///
1804    /// Restricted to `queued` and `held`. A `running` task's instruction has
1805    /// already been handed to the graph, so a run in flight and the file on
1806    /// disk must not be allowed to disagree about what was asked; a `done` or
1807    /// `failed` task is a record of what actually happened and editing it
1808    /// after the fact would falsify that record. `id`, `created_at`,
1809    /// `source`, and `runs` are left untouched on purpose - an edit stands in
1810    /// for "delete and refile", and keeping the id, the timestamp, the
1811    /// attribution, and the run history is the entire reason it exists
1812    /// instead.
1813    pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
1814        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
1815            bail!(
1816                "task {} is {}; only a queued or held task's instruction can \
1817                 be edited",
1818                self.short(),
1819                self.status.as_str()
1820            );
1821        }
1822        // Older retained reviews have no criteria snapshot yet. Capture the
1823        // old instruction before this supported edit can change the target.
1824        if self.review_branch.is_some() && self.review_instruction.is_none() {
1825            self.review_instruction = Some(self.instruction.clone());
1826        }
1827        if self.instruction != instruction {
1828            // A run selected for earlier criteria is no longer authorized by
1829            // this revised instruction. Title-only edits preserve the pin.
1830            self.resume_override = None;
1831        }
1832        self.title = title;
1833        self.instruction = instruction;
1834        Ok(())
1835    }
1836
1837    /// Record a run that produced a pull request without merging it.
1838    ///
1839    /// The task is held rather than retried, and it costs no further attempt
1840    /// either way. The work the task asked for exists: it is sitting on a
1841    /// branch, in a pull request, waiting for CI or for a person. Retrying
1842    /// would spend the whole competition budget a second time and then race a
1843    /// second branch against the pull request the first one opened - which is
1844    /// exactly what happened to run 01c2, whose finished and green pull request
1845    /// was re-competed from scratch four seconds after it opened.
1846    ///
1847    /// A pull request nobody merged is a request for a person, not a failure.
1848    ///
1849    /// Records `why` into [`Task::hold_reason`] as well as
1850    /// [`Task::last_error`], so the notification centre and `magi task show`
1851    /// say why the task is held rather than "no reason recorded". This is
1852    /// also the path a verified no-op with an outstanding `magi ask` question
1853    /// settles through - see `daemon::settle` and `daemon::settle_and_diagnose`,
1854    /// which append the question id to `hold_reason` when one is still open
1855    /// for the run.
1856    pub fn handed_off(&mut self, why: impl Into<String>) {
1857        let why = why.into();
1858        self.diagnostic = None;
1859        self.note_held();
1860        self.status = TaskStatus::Held;
1861        self.hold_source = Some(HoldSource::Machine);
1862        self.hold_reason = Some(why.clone());
1863        self.last_error = Some(why);
1864    }
1865
1866    /// Release on explicit operator authority, rather than automatic recovery.
1867    pub fn release_operator(&mut self, source: &str, question_id: Option<&str>) {
1868        self.recovery_budget = None;
1869        self.recovery_generations.clear();
1870        self.recovery_admission = None;
1871        if self
1872            .resumption
1873            .as_ref()
1874            .is_some_and(|r| r.instruction != self.instruction)
1875        {
1876            // Also cover persisted revisions whose earlier approval still
1877            // carries a transient pin when this release is applied.
1878            self.resume_override = None;
1879        }
1880        let approval = Resumption {
1881            generation: self.resumption.as_ref().map_or(1, |r| r.generation + 1),
1882            source: source.to_owned(),
1883            question_id: question_id.map(str::to_owned),
1884            at: Timestamp::now(),
1885            instruction: self.instruction.clone(),
1886            answers_through: self.answers.len(),
1887            runs_through: self.runs.len(),
1888            blocking_evidence: Vec::new(),
1889            cleared_reason: self
1890                .hold_reason
1891                .clone()
1892                .or_else(|| self.block_reason.clone()),
1893        };
1894        self.release();
1895        self.operator_decided_at = Some(approval.at);
1896        if let Some(previous) = self.resumption.replace(approval) {
1897            self.resumption_history.push(previous);
1898        }
1899    }
1900
1901    /// Record a failed graph pass without allowing automatic requeues to refund it.
1902    pub(crate) fn recovery_failed(&mut self, state: &crate::run::RunState) {
1903        let (base, inputs) = self.recovery_admission.as_ref().map_or_else(
1904            || (state.base_commit.clone(), recovery_inputs(&state.sources)),
1905            |a| (a.base.clone(), a.inputs.clone()),
1906        );
1907        let progress = recovery_progress(state);
1908        let stage = if state.prep_failure.is_some() {
1909            "preparation"
1910        } else {
1911            "execution"
1912        };
1913        let matches = |b: &RecoveryBudget| {
1914            b.instruction == self.instruction
1915                && b.base == base
1916                && b.inputs == inputs
1917                && (progress.is_none() || b.progress == progress)
1918        };
1919        if !self.recovery_budget.as_ref().is_some_and(matches) {
1920            let previous = self
1921                .recovery_generations
1922                .iter()
1923                .position(matches)
1924                .map(|index| self.recovery_generations.remove(index));
1925            if let Some(old) = self.recovery_budget.take() {
1926                self.recovery_generations.push(old);
1927            }
1928            self.recovery_budget = previous;
1929        }
1930        if let Some(budget) = &mut self.recovery_budget {
1931            budget.failures = budget.failures.saturating_add(1);
1932            budget.stage = stage.to_owned();
1933        } else {
1934            self.recovery_budget = Some(RecoveryBudget {
1935                instruction: self.instruction.clone(),
1936                base,
1937                inputs,
1938                stage: stage.to_owned(),
1939                failures: 1,
1940                progress,
1941            });
1942        }
1943    }
1944
1945    pub(crate) fn recovery_exhausted(
1946        &self,
1947        base: &str,
1948        inputs: &str,
1949        limit: usize,
1950    ) -> Option<String> {
1951        let same_input = |b: &&RecoveryBudget| {
1952            b.instruction == self.instruction && b.base == base && b.inputs == inputs
1953        };
1954        let budget = self
1955            .recovery_budget
1956            .as_ref()
1957            .filter(same_input)
1958            .or_else(|| self.recovery_generations.iter().rev().find(same_input))?;
1959        (budget.instruction == self.instruction && budget.base == base && budget.inputs == inputs
1960            && budget.failures >= limit.max(1)).then(|| format!(
1961                "[recovery-budget] {} failed passes against unchanged inputs ({}). Inspect the retained recovery sources and correct the cause, change the inputs, or explicitly release the task to approve another generation. Automatic requeue does not reset this budget.",
1962                budget.failures, budget.stage))
1963    }
1964
1965    /// Put a held or finished task back in line, with its attempt count reset
1966    /// so a release is a real second chance rather than an instant re-hold.
1967    /// The run history is kept: attempts reset, evidence does not.
1968    pub fn release(&mut self) {
1969        // Freeze the unresolved cause before a recovery can mint a run without
1970        // doing work. Approval and timestamps alone are not new outcomes.
1971        self.import_chat_notice();
1972        if self.hold_notice_cause.is_none()
1973            && matches!(self.status, TaskStatus::Held | TaskStatus::Blocked)
1974        {
1975            self.hold_notice_cause = Some(self.unresolved_hold_cause());
1976        }
1977        let refused_handover = self.status == TaskStatus::Held
1978            && self.hold_source == Some(HoldSource::Machine)
1979            && self.review_branch.is_some()
1980            && self
1981                .review_instruction
1982                .as_ref()
1983                .is_none_or(|i| *i == self.instruction);
1984        self.status = TaskStatus::Queued;
1985        self.held_at = None;
1986        self.attempts = 0;
1987        self.last_error = None;
1988        self.park_reason = None;
1989        // Otherwise the next person who holds this task reads a reason that
1990        // belonged to whatever it was waiting on last time.
1991        self.hold_reason = None;
1992        self.hold_source = None;
1993        self.diagnostic = None;
1994        // A release also un-blocks: the dependency or question `blocked_by`
1995        // named may still be unresolved, but a human (or `crate::conduct`)
1996        // choosing to release the task overrides that wait outright, the same
1997        // as it overrides an ordinary hold.
1998        self.blocked_by.clear();
1999        self.block_reason = None;
2000        self.blocked_from = None;
2001        // A machine hold that still names a review branch is a refused
2002        // handover (see [`Task::hold_for_handover`]): the retry must review
2003        // that same branch. Any other release drops it.
2004        if !refused_handover {
2005            self.review_branch = None;
2006            self.review_instruction = None;
2007        }
2008        self.fresh_start = false;
2009    }
2010}
2011
2012/// Copy `src` into `dir` as `name`, or `stem-2.ext`, `stem-3.ext`, ... when
2013/// that is taken. `create_new` makes the collision check atomic and also
2014/// catches a case-insensitive filesystem. Returns the stored name and path.
2015fn copy_new(dir: &Path, src: &Path, name: &str) -> Result<(String, PathBuf)> {
2016    use std::io::ErrorKind;
2017    let (stem, ext) = match name.rfind('.') {
2018        Some(i) if i > 0 => (&name[..i], &name[i..]),
2019        _ => (name, ""),
2020    };
2021    for n in 1u32.. {
2022        let candidate = if n == 1 {
2023            name.to_owned()
2024        } else {
2025            let suffix = format!("-{n}");
2026            let room = 64usize.saturating_sub(suffix.len() + ext.len());
2027            let stem: String = stem.chars().take(room).collect();
2028            format!("{stem}{suffix}{ext}")
2029        };
2030        if !crate::ask::valid_asset_name(&candidate) {
2031            bail!("no valid attachment name is left for `{name}`");
2032        }
2033        let path = dir.join(&candidate);
2034        match std::fs::OpenOptions::new()
2035            .write(true)
2036            .create_new(true)
2037            .open(&path)
2038        {
2039            Ok(mut out) => {
2040                let copied = std::fs::File::open(src)
2041                    .and_then(|mut input| std::io::copy(&mut input, &mut out));
2042                if let Err(e) = copied {
2043                    drop(out);
2044                    let _ = std::fs::remove_file(&path);
2045                    return Err(e).with_context(|| format!("copy {}", src.display()));
2046                }
2047                return Ok((candidate, path));
2048            }
2049            Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
2050            Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
2051        }
2052    }
2053    unreachable!("the counter never runs out")
2054}
2055
2056/// Minimum age before a dead writer's lock may be recovered, and maximum wait
2057/// for a contending writer. Age alone never revokes a live writer's ownership.
2058const TASK_LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
2059
2060/// Guard for [`Queue::lock_task`]; removes the lock file on drop, but only
2061/// while the file still carries this guard's owner token.
2062pub(crate) struct TaskLock {
2063    path: PathBuf,
2064    token: String,
2065}
2066
2067/// A token no other holder shares: pid, then a mix of entropy and a process
2068/// counter so threads born in the same nanosecond still differ.
2069fn owner_token() -> String {
2070    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2071    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2072    let mut r = crate::rng::SplitMix64::new(crate::rng::entropy() ^ n.rotate_left(32));
2073    format!("{}-{:016x}", std::process::id(), r.next_u64())
2074}
2075
2076/// The marker that serializes everyone removing the lock that carries `token`.
2077/// Hashed (FNV-1a) so an empty or foreign token is still a valid file name.
2078fn break_marker(path: &Path, token: &str) -> PathBuf {
2079    let mut h: u64 = 0xcbf29ce484222325;
2080    for b in token.bytes() {
2081        h ^= u64::from(b);
2082        h = h.wrapping_mul(0x100000001b3);
2083    }
2084    let mut name = path.as_os_str().to_owned();
2085    name.push(format!(".break-{h:016x}"));
2086    PathBuf::from(name)
2087}
2088
2089fn older_than_stale(path: &Path) -> bool {
2090    std::fs::metadata(path)
2091        .and_then(|m| m.modified())
2092        .ok()
2093        .and_then(|t| t.elapsed().ok())
2094        .is_some_and(|age| age > TASK_LOCK_STALE)
2095}
2096
2097fn writer_may_be_gone(token: &str) -> bool {
2098    let Some(pid) = token
2099        .split_once('-')
2100        .and_then(|(pid, _)| pid.parse::<u32>().ok())
2101    else {
2102        // Legacy/incomplete tokens have no valid writer identity. They remain
2103        // subject to the age and compare/remove checks.
2104        return true;
2105    };
2106    if pid == std::process::id() {
2107        return false;
2108    }
2109    // Unknown process-table access must not steal a long-running retirement.
2110    crate::proc::pid_status(pid) == Some(false)
2111}
2112
2113/// How many levels of "a stale marker guarding a stale marker" are recovered
2114/// before giving up. Each level needs its own crashed remover, so the cap is
2115/// never reached without several independent crashes.
2116const MAX_MARKER_DEPTH: u8 = 3;
2117
2118/// Take the break marker for `token`, or `None` if another remover holds it.
2119/// The marker carries its own owner token. One older than [`TASK_LOCK_STALE`]
2120/// was left by a remover that died; it is cleared by the same compare-and-
2121/// remove discipline as a lock, guarded by a marker of its own, so a recoverer
2122/// that judged it stale cannot delete a fresh marker that replaced it.
2123fn take_marker(
2124    path: &Path,
2125    token: &str,
2126    depth: u8,
2127    retry: crate::lock_file::Retry,
2128) -> Option<(PathBuf, String)> {
2129    use std::io::Write;
2130    let marker = break_marker(path, token);
2131    for _ in 0..2 {
2132        match retry.create(&marker) {
2133            Ok(mut f) => {
2134                let mine = owner_token();
2135                if f.write_all(mine.as_bytes()).is_err() {
2136                    drop(f);
2137                    let _ = std::fs::remove_file(&marker);
2138                    return None;
2139                }
2140                return Some((marker, mine));
2141            }
2142            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2143                let judged = std::fs::read_to_string(&marker).ok();
2144                match judged.filter(|_| older_than_stale(&marker)) {
2145                    Some(judged) if depth < MAX_MARKER_DEPTH => {
2146                        remove_lock_if(&marker, &judged, true, depth + 1, retry)?;
2147                    }
2148                    _ => return None,
2149                }
2150            }
2151            Err(_) => return None,
2152        }
2153    }
2154    None
2155}
2156
2157/// Remove the lock at `path` if it still carries `token` (and, when
2158/// `require_stale`, is still stale). Removal is serialized per token by the
2159/// break marker, so a remover that judged an old lock cannot delete the fresh
2160/// one that replaced it. `None` means the marker was busy: try again.
2161fn remove_lock_if(
2162    path: &Path,
2163    token: &str,
2164    require_stale: bool,
2165    depth: u8,
2166    retry: crate::lock_file::Retry,
2167) -> Option<bool> {
2168    let (marker, mine) = take_marker(path, token, depth, retry)?;
2169    let still = std::fs::read_to_string(path).is_ok_and(|c| c == token)
2170        && (!require_stale || older_than_stale(path) && writer_may_be_gone(token));
2171    if still {
2172        let _ = std::fs::remove_file(path);
2173    }
2174    // Release the marker only if it is still ours.
2175    if depth >= MAX_MARKER_DEPTH {
2176        if std::fs::read_to_string(&marker).is_ok_and(|c| c == mine) {
2177            let _ = std::fs::remove_file(&marker);
2178        }
2179    } else {
2180        let _ = remove_lock_if(&marker, &mine, false, depth + 1, retry);
2181    }
2182    Some(still)
2183}
2184
2185/// Break the lock judged stale while it held `judged`. Returns whether it was
2186/// removed; a lock that has since been replaced is left alone.
2187#[cfg(test)]
2188fn break_stale(path: &Path, judged: &str) -> bool {
2189    break_stale_with(path, judged, crate::lock_file::Retry::new(None))
2190}
2191
2192fn break_stale_with(path: &Path, judged: &str, retry: crate::lock_file::Retry) -> bool {
2193    remove_lock_if(path, judged, true, 0, retry).unwrap_or(false)
2194}
2195
2196impl Drop for TaskLock {
2197    fn drop(&mut self) {
2198        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2199        let retry = crate::lock_file::Retry::new(Some(deadline));
2200        loop {
2201            match remove_lock_if(&self.path, &self.token, false, 0, retry) {
2202                Some(_) => return,
2203                None if std::time::Instant::now() > deadline => return,
2204                None => std::thread::sleep(std::time::Duration::from_millis(5)),
2205            }
2206        }
2207    }
2208}
2209
2210/// A queue on disk.
2211#[derive(Debug, Clone)]
2212pub struct Queue {
2213    root: PathBuf,
2214}
2215
2216impl Queue {
2217    /// The operator's queue, `<home>/queue`.
2218    pub fn open() -> Self {
2219        Self::at(crate::run::home().join("queue"))
2220    }
2221
2222    /// A queue at an explicit root. Tests use this; so could an operator who
2223    /// wants a queue per project.
2224    pub fn at(root: PathBuf) -> Self {
2225        Self { root }
2226    }
2227
2228    /// Directory holding the task files.
2229    pub fn root(&self) -> &Path {
2230        &self.root
2231    }
2232
2233    /// Path for one task id.
2234    pub fn path_of(&self, id: &str) -> PathBuf {
2235        self.root.join(format!("{id}.json"))
2236    }
2237
2238    /// Directory holding a task's attachments. A sibling of the task file,
2239    /// not a `*.json`, so [`Queue::list`] and [`Queue::revision`] never see it.
2240    pub fn attachments_dir(&self, id: &str) -> PathBuf {
2241        self.root.join(format!("{id}.attachments"))
2242    }
2243
2244    /// Copy each of `sources` into `task`'s attachment directory and record
2245    /// the stored names on `task` (persist with [`Queue::put`]). Returns the
2246    /// names, in order.
2247    ///
2248    /// Every source is checked (a readable file, a name passing
2249    /// [`crate::ask::valid_asset_name`]) before anything is copied, and a
2250    /// copy that fails part-way removes only what this call created. A name
2251    /// already taken is never overwritten: it becomes `stem-2.ext`,
2252    /// `stem-3.ext`, ... A name that does not validate is refused rather than
2253    /// sanitised - the operator renames the file.
2254    pub fn attach(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2255        let mut wanted = Vec::new();
2256        for src in sources {
2257            let name = src
2258                .file_name()
2259                .and_then(|n| n.to_str())
2260                .with_context(|| format!("`{}` has no usable file name", src.display()))?;
2261            if !crate::ask::valid_asset_name(name) {
2262                bail!(
2263                    "attachment name `{name}` must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ \
2264                     with no `..`; rename the file and try again"
2265                );
2266            }
2267            if !src.is_file() {
2268                bail!("attachment `{}` is not a file", src.display());
2269            }
2270            wanted.push((src, name));
2271        }
2272        let dir = self.attachments_dir(&task.id);
2273        let existed = dir.is_dir();
2274        std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2275        let mut created: Vec<PathBuf> = Vec::new();
2276        let mut names = Vec::new();
2277        let mut copy_all = || -> Result<()> {
2278            for (src, name) in &wanted {
2279                let (stored, path) = copy_new(&dir, src, name)?;
2280                created.push(path);
2281                names.push(stored);
2282            }
2283            Ok(())
2284        };
2285        if let Err(e) = copy_all() {
2286            for path in &created {
2287                let _ = std::fs::remove_file(path);
2288            }
2289            if !existed {
2290                let _ = std::fs::remove_dir(&dir);
2291            }
2292            return Err(e);
2293        }
2294        task.attachments.extend(names.iter().cloned());
2295        Ok(names)
2296    }
2297
2298    /// [`Queue::attach`] then [`Queue::put`], undoing the copies when the
2299    /// record cannot be written. Without the undo the files sit in
2300    /// `<id>.attachments/` with no task pointing at them.
2301    ///
2302    /// Only what this call copied is removed (the names `attach` returned,
2303    /// and the directory itself only when it did not exist before and is
2304    /// empty), never an attachment an earlier save recorded. `task` is rolled
2305    /// back too, and the error the caller sees is `put`'s own. This relies on
2306    /// a failed `put` leaving the stored record untouched, which its
2307    /// write-then-rename guarantees.
2308    pub fn attach_and_put(&self, task: &mut Task, sources: &[PathBuf]) -> Result<Vec<String>> {
2309        let dir = self.attachments_dir(&task.id);
2310        let existed = dir.is_dir();
2311        let before = task.attachments.len();
2312        let names = self.attach(task, sources)?;
2313        if let Err(e) = self.put(task) {
2314            for name in &names {
2315                let _ = std::fs::remove_file(dir.join(name));
2316            }
2317            if !existed {
2318                let _ = std::fs::remove_dir(&dir);
2319            }
2320            task.attachments.truncate(before);
2321            return Err(e);
2322        }
2323        Ok(names)
2324    }
2325
2326    /// Absolute paths of `task`'s attachments, whatever shape this queue's
2327    /// root has. Absolute because the prompt hands them to an agent whose
2328    /// working directory is somewhere else entirely.
2329    pub fn attachment_paths(&self, task: &Task) -> Vec<PathBuf> {
2330        let dir = self.attachments_dir(&task.id);
2331        task.attachments
2332            .iter()
2333            .map(|n| {
2334                let p = dir.join(n);
2335                std::path::absolute(&p).unwrap_or(p)
2336            })
2337            .collect()
2338    }
2339
2340    /// Write a task, atomically, so a daemon killed mid-write leaves the
2341    /// previous state readable rather than a truncated file.
2342    ///
2343    /// Runs already recorded on disk that `task` does not carry are kept, not
2344    /// dropped: `magi run --task` links a run into a task from another
2345    /// process ([`Queue::link_run`]) while a daemon may hold a snapshot taken
2346    /// before it, and writing that snapshot back whole would erase the link.
2347    /// Nothing ever removes a run from a task, so the union loses nothing.
2348    pub fn put(&self, task: &mut Task) -> Result<()> {
2349        let _lock = self.lock_task(&task.id)?;
2350        self.put_unlocked(task)
2351    }
2352
2353    /// Write `task` only if no task with its id exists yet; `Ok(false)` when
2354    /// one does. Unlike [`Queue::put`] it never replaces a record, so a
2355    /// producer with a deterministic id cannot rewind a task that has since
2356    /// started running or finished.
2357    pub fn create_new(&self, task: &mut Task) -> Result<bool> {
2358        let _lock = self.lock_task(&task.id)?;
2359        if self.path_of(&task.id).exists() {
2360            return Ok(false);
2361        }
2362        self.put_unlocked(task)?;
2363        Ok(true)
2364    }
2365
2366    /// [`Queue::put`] for a caller already holding [`Queue::lock_task`].
2367    fn put_unlocked(&self, task: &mut Task) -> Result<()> {
2368        if let Ok(mut stored) = read_path(&self.path_of(&task.id)) {
2369            stored.import_chat_notice();
2370            for key in stored.chat_notices {
2371                if !task.chat_notices.contains(&key) {
2372                    task.chat_notices.push(key);
2373                }
2374            }
2375            for run in stored.runs {
2376                if !task.runs.contains(&run) {
2377                    task.runs.push(run);
2378                }
2379            }
2380            for p in stored.passes {
2381                if !task
2382                    .passes
2383                    .iter()
2384                    .any(|q| q.run == p.run && q.pass_index == p.pass_index)
2385                {
2386                    task.passes.push(p);
2387                }
2388            }
2389            // A completion notice already recorded is never rewound by an
2390            // older snapshot: that would report the same state twice.
2391            if let Some(n) = stored.chat_notice
2392                && task.chat_notice.as_ref().is_none_or(|mine| mine.at < n.at)
2393            {
2394                task.chat_notice = Some(n);
2395            }
2396        }
2397        task.import_chat_notice();
2398        task.updated_at = Timestamp::now();
2399        std::fs::create_dir_all(&self.root)
2400            .with_context(|| format!("create {}", self.root.display()))?;
2401        let body = serde_json::to_string_pretty(task).context("serialize task")?;
2402        let path = self.path_of(&task.id);
2403        let tmp = path.with_extension("json.tmp");
2404        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
2405        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
2406        // A machine hold is news for the notification centre, filed beside
2407        // this queue (`<home>/queue` -> `<home>/notifications`) rather than
2408        // through a process-global, so a queue in a temp directory notifies
2409        // into that directory.
2410        if let (Some(notice), Some(home)) = (
2411            crate::notices::task_held(task),
2412            self.root.parent().filter(|p| !p.as_os_str().is_empty()),
2413        ) {
2414            crate::notices::raise_in(home, notice);
2415        }
2416        Ok(())
2417    }
2418
2419    /// Change the task `id` in place: read it inside its write lock, let `f`
2420    /// edit it, and write it back only when `f` returns `true`. The check a
2421    /// caller makes in `f` therefore sees the record as stored now, not a
2422    /// snapshot taken before another writer got in. Returns whether it wrote.
2423    pub fn modify(&self, id: &str, f: impl FnOnce(&mut Task) -> bool) -> Result<bool> {
2424        let id = self.resolve_id(id)?;
2425        let _lock = self.lock_task(&id)?;
2426        let mut task = self.get(&id)?;
2427        if !f(&mut task) {
2428            return Ok(false);
2429        }
2430        self.put_unlocked(&mut task)?;
2431        Ok(true)
2432    }
2433
2434    /// Append `run` to the runs of the task `id` (or an unambiguous prefix),
2435    /// leaving everything else about it alone — see [`Task::link_run`]. Read
2436    /// and written back in one breath, because [`Queue::put`] replaces the
2437    /// whole record. Returns the task as stored.
2438    pub fn link_run(&self, id: &str, run: &str) -> Result<Task> {
2439        let id = self.resolve_id(id)?;
2440        // Read inside the lock, so the record written back is the current one
2441        // and a daemon's write cannot slip between the read and the write.
2442        let _lock = self.lock_task(&id)?;
2443        let mut task = self.get(&id)?;
2444        if task.link_run(run) {
2445            self.put_unlocked(&mut task)?;
2446        }
2447        Ok(task)
2448    }
2449
2450    /// Exclusive right to rewrite task `id`'s record, held until the guard
2451    /// drops. Every writer ([`Queue::put`], [`Queue::link_run`]) takes it, so
2452    /// a process linking a run and the daemon saving a transition are
2453    /// serialized instead of last-writer-wins. An old lock may be broken only
2454    /// after its writer is confirmed dead (legacy tokens without a PID retain
2455    /// age-based recovery). Not the claim lock (`<id>.lock`), held for a
2456    /// whole run.
2457    pub(crate) fn lock_task(&self, id: &str) -> Result<TaskLock> {
2458        std::fs::create_dir_all(&self.root)
2459            .with_context(|| format!("create {}", self.root.display()))?;
2460        let path = self.root.join(format!("{id}.write-lock"));
2461        let started = std::time::Instant::now();
2462        let retry = crate::lock_file::Retry::task_write(started + TASK_LOCK_STALE);
2463        let marker_retry = crate::lock_file::Retry::new(Some(started + TASK_LOCK_STALE));
2464        loop {
2465            match retry.create(&path) {
2466                Ok(mut file) => {
2467                    use std::io::Write;
2468                    let token = owner_token();
2469                    if let Err(e) = file.write_all(token.as_bytes()) {
2470                        drop(file);
2471                        let _ = std::fs::remove_file(&path);
2472                        return Err(e).with_context(|| format!("lock {}", path.display()));
2473                    }
2474                    return Ok(TaskLock { path, token });
2475                }
2476                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2477                    // Content first, then age: a lock replaced in between
2478                    // reads fresh, never stale.
2479                    let judged = std::fs::read_to_string(&path).ok();
2480                    let broken = judged
2481                        .filter(|_| older_than_stale(&path))
2482                        .is_some_and(|judged| break_stale_with(&path, &judged, marker_retry));
2483                    // A break that does not happen (marker busy, recovery
2484                    // exhausted) is a wait like any other, so it meets the
2485                    // same deadline instead of spinning forever.
2486                    if !broken {
2487                        if started.elapsed() > TASK_LOCK_STALE {
2488                            bail!("could not lock task {id}");
2489                        }
2490                        std::thread::sleep(std::time::Duration::from_millis(15));
2491                    }
2492                }
2493                Err(e) => return Err(e).with_context(|| format!("lock {}", path.display())),
2494            }
2495        }
2496    }
2497
2498    /// Load a task by id or unambiguous id prefix.
2499    pub fn get(&self, id: &str) -> Result<Task> {
2500        let resolved = self.resolve_id(id)?;
2501        read_path(&self.path_of(&resolved))
2502    }
2503
2504    /// Remove a task, and the claim lock that belongs to it.
2505    ///
2506    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
2507    /// this task — because the task's own `running` status cannot answer the
2508    /// question. A daemon killed mid-competition leaves the status at
2509    /// `running` and an orphaned `.lock` behind, and a guard that trusted
2510    /// either would make the task undeletable for good: the phone showed
2511    /// exactly that, refusing a task whose daemon had been gone for an hour.
2512    ///
2513    /// So the lock is removed with the task rather than respected. Any lock
2514    /// still there once no live daemon claims the task is by definition stale,
2515    /// and leaving it would make a deleted task look claimed to
2516    /// [`Queue::claim`] and to whoever reads the directory.
2517    ///
2518    /// A deletion is deliberate, so whatever was `blocked` on the id is
2519    /// resolved, not held: a `<id>.removed` tombstone is written first (it
2520    /// is what tells [`missing_blockers`] and the daemon's resolver that this
2521    /// id was deleted on purpose, so a pass that races this call cannot
2522    /// mistake it for a vanished dependency), the record is deleted (the
2523    /// tombstone is taken back if that fails), and then each dependent is
2524    /// rewritten under its claim - see [`Queue::release_dependents_of`].
2525    /// Best-effort for the dependents: one claimed by something else right
2526    /// now, or whose write fails, is released silently by
2527    /// `crate::daemon::resolve_blockers` (or `crate::triage::run_once`) on
2528    /// its next pass, because the tombstone is still there. It does not fail
2529    /// this removal.
2530    ///
2531    /// `questions` is the store [`missing_blockers`] checks a `blocked_by` id
2532    /// against - the same store the caller already resolves `id`'s own home
2533    /// from, passed in rather than reopened here so a test queue at an
2534    /// explicit root never reads the operator's real questions directory.
2535    pub fn remove(&self, id: &str, in_flight: bool, questions: &Questions) -> Result<Removal> {
2536        let resolved = self.resolve_id(id)?;
2537        if in_flight {
2538            bail!("task {resolved} is being run by a live daemon right now");
2539        }
2540        self.write_tombstone(&resolved)?;
2541        if let Err(e) = self.remove_record_with_attachments(&resolved, |p| std::fs::remove_file(p))
2542        {
2543            // A record that is still there was not deleted: take the marker
2544            // back. One already gone keeps it, so the deletion still reads
2545            // as deliberate.
2546            if self.path_of(&resolved).exists() {
2547                let _ = std::fs::remove_file(self.tombstone_path(&resolved));
2548            }
2549            return Err(e);
2550        }
2551        let (released, still_blocked) = self.release_dependents_of(&resolved, questions);
2552        Ok(Removal {
2553            id: resolved,
2554            released,
2555            still_blocked,
2556        })
2557    }
2558
2559    /// Path of the marker saying task `id` was deleted on purpose. Not a
2560    /// `*.json`, so [`Queue::list`] and [`Queue::revision`] never see it.
2561    fn tombstone_path(&self, id: &str) -> PathBuf {
2562        self.root.join(format!("{id}.removed"))
2563    }
2564
2565    fn write_tombstone(&self, id: &str) -> Result<()> {
2566        let path = self.tombstone_path(id);
2567        let tmp = self.root.join(format!("{id}.removed.tmp"));
2568        std::fs::write(&tmp, b"")
2569            .and_then(|()| std::fs::rename(&tmp, &path))
2570            .with_context(|| format!("write {}", path.display()))
2571    }
2572
2573    /// Was `id` deleted through [`Queue::remove`] (and is its record gone)?
2574    /// A live record always wins: a tombstone never makes an existing task
2575    /// look deleted.
2576    fn was_deleted(&self, id: &str) -> bool {
2577        self.tombstone_path(id).is_file() && !self.path_of(id).exists()
2578    }
2579
2580    /// Apply [`Task::dependency_deleted`] to `task` for every `blocked_by` id
2581    /// that [`Queue::remove`] deleted. Returns those ids. The caller holds
2582    /// the task's claim and saves it.
2583    pub fn apply_deleted_blockers(&self, task: &mut Task, questions: &Questions) -> Vec<String> {
2584        let deleted = deleted_blockers(self, questions, &task.blocked_by);
2585        deleted
2586            .into_iter()
2587            .filter(|id| task.dependency_deleted(id))
2588            .collect()
2589    }
2590
2591    /// Record, as a fixed-wording info notice, that `dependent` stopped
2592    /// waiting on the deleted task `deleted`. Best-effort and beside the
2593    /// queue, like the hold notice [`Queue::put`] files.
2594    pub fn note_dependency_deleted(&self, dependent: &Task, deleted: &str) {
2595        let Some(home) = self.root.parent().filter(|p| !p.as_os_str().is_empty()) else {
2596            return;
2597        };
2598        let ja = crate::lang::is_japanese(&crate::lang::of_repo(&dependent.repo));
2599        let waiting = dependent.status == TaskStatus::Blocked;
2600        let message = match (ja, waiting) {
2601            (true, false) => format!(
2602                "タスク {} は、待っていた {} が削除されたため待機を解除し、元の状態に戻しました",
2603                dependent.short(),
2604                short(deleted)
2605            ),
2606            (true, true) => format!(
2607                "タスク {} は、待っていた {} が削除されたため、残りの依存を待っています",
2608                dependent.short(),
2609                short(deleted)
2610            ),
2611            (false, false) => format!(
2612                "Task {} stopped waiting on {} because it was deleted, and returned to its previous state",
2613                dependent.short(),
2614                short(deleted)
2615            ),
2616            (false, true) => format!(
2617                "Task {} stopped waiting on {} because it was deleted, and is still waiting on its other dependencies",
2618                dependent.short(),
2619                short(deleted)
2620            ),
2621        };
2622        crate::notices::raise_in(
2623            home,
2624            crate::notices::Notice::info(
2625                &format!("unblocked:{}:{}", dependent.id, deleted),
2626                message,
2627            )
2628            .link(crate::notices::Link::Task {
2629                id: dependent.id.clone(),
2630            }),
2631        );
2632    }
2633
2634    /// Resolve every `blocked` task naming `dependency` (just deleted) with
2635    /// [`Task::dependency_deleted`]. Returns the ids released to another
2636    /// status and those that remain `blocked` on something else. Each
2637    /// dependent is re-read inside its claim, never written from a stale copy.
2638    fn release_dependents_of(
2639        &self,
2640        dependency: &str,
2641        questions: &Questions,
2642    ) -> (Vec<String>, Vec<String>) {
2643        let (mut released, mut still_blocked) = (Vec::new(), Vec::new());
2644        for listed in self.list() {
2645            if listed.status != TaskStatus::Blocked || !listed.blocked_by.iter().any(|b| {
2646                matches!(crate::queue::dependency(self, questions, b), Dependency::Deleted(id) if id == dependency)
2647            }) {
2648                continue;
2649            }
2650            let Ok(_claim) = self.claim(&listed.id) else {
2651                continue;
2652            };
2653            let Ok(mut task) = self.get(&listed.id) else {
2654                continue;
2655            };
2656            let references: Vec<String> = task.blocked_by.iter().filter(|id| {
2657                matches!(crate::queue::dependency(self, questions, id), Dependency::Deleted(full) if full == dependency)
2658            }).cloned().collect();
2659            if references.is_empty() {
2660                continue;
2661            }
2662            for reference in references {
2663                task.dependency_deleted(&reference);
2664            }
2665            if self.put(&mut task).is_err() {
2666                continue;
2667            }
2668            self.note_dependency_deleted(&task, dependency);
2669            if task.status == TaskStatus::Blocked {
2670                still_blocked.push(task.id.clone());
2671            } else {
2672                released.push(task.id.clone());
2673            }
2674        }
2675        (released, still_blocked)
2676    }
2677
2678    /// The attachment-and-record half of [`Queue::remove`]. `remove_record` is
2679    /// how the record file is deleted; production passes `remove_file`, and a
2680    /// test passes a failing one to prove the attachments come back.
2681    fn remove_record_with_attachments(
2682        &self,
2683        resolved: &str,
2684        remove_record: impl FnOnce(&Path) -> std::io::Result<()>,
2685    ) -> Result<()> {
2686        // Neither step may strand the other. The attachments are set aside by a
2687        // rename (cheap, and undone if the record cannot be removed), then the
2688        // record goes, and only then is the set-aside directory deleted for
2689        // real. A failure before the record is gone loses nothing; a failure
2690        // after it leaves at most an orphan `.removing` directory, which the
2691        // next removal sweeps.
2692        self.sweep_removed_attachments();
2693        let attachments = self.attachments_dir(resolved);
2694        let aside = self.root.join(format!("{resolved}.attachments.removing"));
2695        let moved = match std::fs::rename(&attachments, &aside) {
2696            Ok(()) => true,
2697            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
2698            Err(e) => {
2699                return Err(e).with_context(|| format!("remove {}", attachments.display()));
2700            }
2701        };
2702        let path = self.path_of(resolved);
2703        if let Err(e) = remove_record(&path) {
2704            if moved {
2705                let _ = std::fs::rename(&aside, &attachments);
2706            }
2707            return Err(e).with_context(|| format!("remove {}", path.display()));
2708        }
2709        if moved {
2710            if let Err(e) = std::fs::remove_dir_all(&aside) {
2711                tracing::warn!("leftover attachments {}: {e}", aside.display());
2712            }
2713        }
2714        let lock = self.lock_path(resolved);
2715        if let Err(e) = std::fs::remove_file(&lock) {
2716            if e.kind() != std::io::ErrorKind::NotFound {
2717                return Err(e).with_context(|| format!("remove {}", lock.display()));
2718            }
2719        }
2720        Ok(())
2721    }
2722
2723    /// Delete `*.attachments.removing` directories a previous [`Queue::remove`]
2724    /// could not finish deleting. The task record is already gone by then, so
2725    /// the id no longer resolves and the removal cannot be retried by name;
2726    /// every later removal sweeps them instead. A directory whose record still
2727    /// exists belongs to a removal in progress and is left alone. Best-effort.
2728    fn sweep_removed_attachments(&self) {
2729        let Ok(entries) = std::fs::read_dir(&self.root) else {
2730            return;
2731        };
2732        for entry in entries.flatten() {
2733            let name = entry.file_name();
2734            let name = name.to_string_lossy();
2735            let Some(id) = name.strip_suffix(".attachments.removing") else {
2736                continue;
2737            };
2738            // A record still on disk means a removal is mid-flight (or about
2739            // to roll back): the directory is its to delete or restore.
2740            if !self.path_of(id).exists() {
2741                if let Err(e) = std::fs::remove_dir_all(entry.path()) {
2742                    tracing::warn!("leftover attachments {}: {e}", entry.path().display());
2743                }
2744            }
2745        }
2746        self.sweep_tombstones();
2747    }
2748
2749    /// Delete `*.removed` markers that are no longer needed: the record is
2750    /// gone, no task's `blocked_by` names the id, and the marker is older than
2751    /// [`TOMBSTONE_GRACE`]. The age is what makes this safe against a
2752    /// concurrent writer: the task list read here can be stale, so a dependent
2753    /// that was just blocked on an id (a conductor applying a decision while a
2754    /// removal is under way) would otherwise lose the marker and be held as a
2755    /// vanished dependency. A marker whose record still exists belongs to a
2756    /// removal in progress and is never touched.
2757    fn sweep_tombstones(&self) {
2758        let Ok(entries) = std::fs::read_dir(&self.root) else {
2759            return;
2760        };
2761        let tasks = self.list();
2762        for entry in entries.flatten() {
2763            let name = entry.file_name();
2764            let name = name.to_string_lossy();
2765            let Some(id) = name.strip_suffix(".removed") else {
2766                continue;
2767            };
2768            if self.path_of(id).exists()
2769                || tasks.iter().any(|t| {
2770                    t.blocked_by
2771                        .iter()
2772                        .any(|b| id.starts_with(b) || id.ends_with(b))
2773                })
2774            {
2775                continue;
2776            }
2777            let old = entry
2778                .metadata()
2779                .and_then(|m| m.modified())
2780                .ok()
2781                .and_then(|m| m.elapsed().ok())
2782                .is_some_and(|age| age >= TOMBSTONE_GRACE);
2783            if old {
2784                let _ = std::fs::remove_file(entry.path());
2785            }
2786        }
2787    }
2788
2789    /// Path of the claim lock for a task. One definition, so `claim` and
2790    /// `remove` cannot end up naming different files.
2791    fn lock_path(&self, id: &str) -> PathBuf {
2792        self.root.join(format!("{id}.lock"))
2793    }
2794
2795    /// Every task on disk, highest priority first and newest first within a
2796    /// priority. This is what `magi task list` and `GET /api/queue` print, so
2797    /// a raised priority has to move a task here the moment it is saved, not
2798    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
2799    /// the backlog and the loop about to drain it must agree on what "first"
2800    /// means. Every existing task defaults to priority 0, so this is a no-op
2801    /// change from the old newest-first order for a queue nobody has
2802    /// reprioritised.
2803    ///
2804    /// Unreadable files are skipped rather than fatal: one corrupt task must
2805    /// not take the queue - or the web UI, or an unattended daemon - down
2806    /// with it.
2807    pub fn list(&self) -> Vec<Task> {
2808        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
2809            .into_iter()
2810            .flatten()
2811            .flatten()
2812            .map(|e| e.path())
2813            .filter(|p| p.extension().is_some_and(|x| x == "json"))
2814            .filter_map(|p| read_path(&p).ok())
2815            .collect();
2816        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
2817        tasks
2818    }
2819
2820    /// Task files [`Queue::list`] skips because they cannot be parsed (a
2821    /// newer schema, a torn write). A guard that looks a task up by scanning
2822    /// the list must treat any of these as "the task might be there".
2823    pub fn unreadable(&self) -> Vec<PathBuf> {
2824        std::fs::read_dir(&self.root)
2825            .into_iter()
2826            .flatten()
2827            .flatten()
2828            .map(|e| e.path())
2829            .filter(|p| p.extension().is_some_and(|x| x == "json"))
2830            .filter(|p| read_path(p).is_err())
2831            .collect()
2832    }
2833
2834    /// Runs that a later attempt at the same task replaced, mapped to the id
2835    /// of the attempt that replaced them.
2836    ///
2837    /// A task keeps its attempts in order, and the deck showed them as two
2838    /// cards with the same title and no hint which was which: yukimemi asked
2839    /// why `stalled` and `blocked` appeared twice for one task, and the
2840    /// answer - "those are two tries, and the second one exists because of a
2841    /// bug since fixed" - was not on the screen anywhere.
2842    ///
2843    /// Read from the queue rather than stored on the run, because the
2844    /// ordering is the queue's fact: a `RunState` has no idea another attempt
2845    /// happened after it.
2846    pub fn superseded(&self) -> HashMap<String, String> {
2847        let mut by = HashMap::new();
2848        for task in self.list() {
2849            for earlier in &task.runs {
2850                if let Some(later) = task.successor_of(earlier) {
2851                    by.insert(earlier.clone(), later.clone());
2852                }
2853            }
2854        }
2855        by
2856    }
2857
2858    /// Whether `run` is an earlier attempt a later one replaced, and if so
2859    /// the id of that later attempt.
2860    ///
2861    /// Same walk as [`Queue::superseded`], narrowed to one run: a run detail
2862    /// page asks about exactly one run at a time, and this keeps that call
2863    /// site from building (and discarding) the whole map's `HashMap` just to
2864    /// read one entry out of it.
2865    pub fn superseded_by(&self, run: &str) -> Option<String> {
2866        for task in self.list() {
2867            if task.runs.iter().any(|r| r == run) {
2868                return task.successor_of(run).cloned();
2869            }
2870        }
2871        None
2872    }
2873
2874    /// The task's own most recent attempt, when `run` belongs to that task
2875    /// but is not already that attempt.
2876    ///
2877    /// Distinct from [`Queue::superseded_by`], which names only the very
2878    /// next attempt: a chain of retries (A superseded by B superseded by C)
2879    /// leaves an older run pointing at an intermediate one that may itself
2880    /// be unresolved, and a run's own detail page needs to know where the
2881    /// task's story currently stands - the chain's current head, C - not an
2882    /// attempt in the middle of it that a client would otherwise have to
2883    /// walk to by hand.
2884    pub fn latest_attempt(&self, run: &str) -> Option<String> {
2885        for task in self.list() {
2886            if task.runs.iter().any(|r| r == run) {
2887                return task.runs.last().filter(|last| **last != run).cloned();
2888            }
2889        }
2890        None
2891    }
2892
2893    /// The task a daemon should run next, or `None` when the queue is idle.
2894    ///
2895    /// Upgrade resumes first, then highest priority and oldest id first.
2896    /// See [`dispatch_order`] for the durable ordering rule.
2897    pub fn next_runnable(&self) -> Option<Task> {
2898        let mut runnable: Vec<Task> = self
2899            .list()
2900            .into_iter()
2901            .filter(|t| t.status.runnable())
2902            .collect();
2903        runnable.sort_unstable_by(dispatch_order);
2904        runnable.into_iter().next()
2905    }
2906
2907    /// Take exclusive ownership of a task.
2908    ///
2909    /// The lock is a `create_new` file next to the task, which is atomic on
2910    /// every platform magi targets. It exists so two daemons - or a daemon and
2911    /// a human running `magi run` - cannot drive one task into two competing
2912    /// runs. The returned guard releases on drop, including on panic.
2913    pub fn claim(&self, id: &str) -> Result<Claim> {
2914        std::fs::create_dir_all(&self.root)
2915            .with_context(|| format!("create {}", self.root.display()))?;
2916        let path = self.lock_path(id);
2917        let retry = crate::lock_file::Retry::new(None);
2918        match retry.create(&path) {
2919            Ok(mut f) => {
2920                use std::io::Write as _;
2921                // Best effort: the pid is for the human looking at a stale lock.
2922                let _ = writeln!(f, "{}", std::process::id());
2923                Ok(Claim { path })
2924            }
2925            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2926                bail!("task {id} is already claimed ({} exists)", path.display())
2927            }
2928            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
2929        }
2930    }
2931
2932    /// Expand an id prefix to exactly one task id.
2933    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
2934        if !prefix.is_empty()
2935            && prefix != "."
2936            && prefix != ".."
2937            && !prefix.contains(['/', '\\'])
2938            && self.path_of(prefix).is_file()
2939        {
2940            return Ok(prefix.to_owned());
2941        }
2942        let ids = record_ids(&self.root, "json")?;
2943        match resolve_reference(prefix, ids.iter().map(String::as_str)) {
2944            Reference::Found(id) => Ok(id),
2945            Reference::Missing => bail!("no task matches `{prefix}`"),
2946            Reference::Ambiguous => bail!("`{prefix}` matches multiple tasks"),
2947        }
2948    }
2949
2950    /// Change detection token for the queue.
2951    ///
2952    /// Combines file names and modification times of all task files in the
2953    /// queue, so adding, modifying, or deleting any task — even an older one —
2954    /// moves the revision and notifies connected clients via the change stream.
2955    /// Returns 0 when the queue is completely empty.
2956    pub fn revision(&self) -> u64 {
2957        self.revision_excluding(&std::collections::BTreeSet::new())
2958    }
2959
2960    /// [`Queue::revision`] ignoring the files of the named task ids, for a
2961    /// caller that tracks those tasks' content some other way.
2962    pub fn revision_excluding(&self, skip: &std::collections::BTreeSet<String>) -> u64 {
2963        use std::hash::{Hash as _, Hasher as _};
2964
2965        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
2966            .into_iter()
2967            .flatten()
2968            .flatten()
2969            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
2970            .filter(|e| {
2971                let path = e.path();
2972                !path
2973                    .file_stem()
2974                    .is_some_and(|stem| skip.contains(stem.to_string_lossy().as_ref()))
2975            })
2976            .filter_map(|e| {
2977                let name = e.file_name().to_string_lossy().into_owned();
2978                let mtime = e
2979                    .metadata()
2980                    .ok()?
2981                    .modified()
2982                    .ok()?
2983                    .duration_since(std::time::UNIX_EPOCH)
2984                    .ok()?
2985                    .as_millis() as u64;
2986                Some((name, mtime))
2987            })
2988            .collect();
2989
2990        if entries.is_empty() {
2991            return 0;
2992        }
2993
2994        entries.sort_unstable();
2995        let mut hasher = std::hash::DefaultHasher::new();
2996        for (name, mtime) in &entries {
2997            name.hash(&mut hasher);
2998            mtime.hash(&mut hasher);
2999        }
3000        let h = hasher.finish();
3001        if h == 0 { 1 } else { h }
3002    }
3003}
3004
3005/// How long a deletion marker is kept after its record is gone, even with
3006/// nothing referencing it. See [`Queue::sweep_tombstones`].
3007const TOMBSTONE_GRACE: std::time::Duration = std::time::Duration::from_secs(3600);
3008
3009/// What [`Queue::remove`] did, beyond deleting the named task's own file.
3010#[derive(Debug, Clone)]
3011pub struct Removal {
3012    /// The id actually removed - `id` expanded from a prefix, if it was one.
3013    pub id: String,
3014    /// Every task that was `blocked` on [`Removal::id`] and left the block
3015    /// (back to the status it interrupted) because that was its only
3016    /// remaining dependency.
3017    pub released: Vec<String>,
3018    /// Every task that named [`Removal::id`] and is still `blocked` on
3019    /// another dependency.
3020    pub still_blocked: Vec<String>,
3021}
3022
3023/// Exclusive ownership of a task, released on drop.
3024#[derive(Debug)]
3025pub struct Claim {
3026    path: PathBuf,
3027}
3028
3029impl Drop for Claim {
3030    fn drop(&mut self) {
3031        let _ = std::fs::remove_file(&self.path);
3032    }
3033}
3034
3035/// The first line of a task, trimmed to a title. Used when the caller gives a
3036/// body but no title, which is the normal case for an agent piping a file in.
3037pub fn title_from(instruction: &str, max: usize) -> String {
3038    let Some(line) = first_line(instruction) else {
3039        return "(empty task)".to_owned();
3040    };
3041    if line.chars().count() <= max {
3042        return line.to_owned();
3043    }
3044    let head: String = line.chars().take(max.saturating_sub(1)).collect();
3045    format!("{head}…")
3046}
3047
3048/// The first non-blank line, whatever it is. A markdown heading is the
3049/// task's own summary - agents pipe in `# Rework the config loader` and mean
3050/// exactly that - so it is preferred over the prose beneath it rather than
3051/// skipped as decoration. Leading list and heading markers are stripped
3052/// because they are syntax, not words. `None` when nothing is left.
3053pub(crate) fn first_line(instruction: &str) -> Option<&str> {
3054    let line = instruction
3055        .lines()
3056        .map(str::trim)
3057        .find(|l| !l.is_empty())?
3058        .trim_start_matches(['#', '-', '*', '>', ' '])
3059        .trim();
3060    (!line.is_empty()).then_some(line)
3061}
3062
3063fn read_path(path: &Path) -> Result<Task> {
3064    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
3065    let task: Task =
3066        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3067    // Greater-than, not not-equal: every field added since schema 1 carries
3068    // `#[serde(default)]`, so an older task has nothing to say about it and
3069    // defaulting is exactly as good a reading as a value that build never had
3070    // a chance to write. Only a schema *ahead* of this build - a meaning it
3071    // cannot possibly know - is refused rather than guessed at.
3072    if task.schema > SCHEMA {
3073        bail!(
3074            "task {} was written by a different magi (schema {}, this build \
3075             speaks {SCHEMA})",
3076            task.id,
3077            task.schema
3078        );
3079    }
3080    Ok(task)
3081}
3082
3083/// Resolution shared by queue lookups and dependency inventories. Exact ids
3084/// win; otherwise both the CLI prefix and displayed suffix are supported.
3085#[derive(Debug, PartialEq, Eq)]
3086pub(crate) enum Reference {
3087    Found(String),
3088    Missing,
3089    Ambiguous,
3090}
3091
3092pub(crate) fn resolve_reference<'a>(id: &str, ids: impl Iterator<Item = &'a str>) -> Reference {
3093    let mut hits = Vec::new();
3094    for candidate in ids {
3095        if candidate == id {
3096            return Reference::Found(candidate.to_owned());
3097        }
3098        if candidate.starts_with(id) || candidate.ends_with(id) {
3099            hits.push(candidate);
3100        }
3101    }
3102    match hits.as_slice() {
3103        [] => Reference::Missing,
3104        [one] => Reference::Found((*one).to_owned()),
3105        _ => Reference::Ambiguous,
3106    }
3107}
3108
3109/// Read names, not deserialized records: list() intentionally skips corrupt
3110/// and future-schema records, which still exist and must keep their waiters.
3111pub(crate) fn record_ids(root: &Path, extension: &str) -> Result<Vec<String>> {
3112    let entries = match std::fs::read_dir(root) {
3113        Ok(entries) => entries,
3114        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
3115        Err(e) => return Err(e.into()),
3116    };
3117    let mut ids = Vec::new();
3118    for entry in entries {
3119        let path = entry?.path();
3120        if path.extension().is_some_and(|e| e == extension)
3121            && let Some(id) = path.file_stem().and_then(|s| s.to_str())
3122        {
3123            ids.push(id.to_owned());
3124        }
3125    }
3126    Ok(ids)
3127}
3128
3129/// Unknown and ambiguous dependencies stay waiting; neither is evidence that
3130/// the operator deleted a prerequisite or needs to supply its replacement.
3131#[derive(Debug, PartialEq, Eq)]
3132pub enum Dependency {
3133    /// Existing task record, with its canonical id; its contents may be unreadable.
3134    Task(String),
3135    /// Existing question record, with its canonical id.
3136    Question(String),
3137    /// Explicitly removed task, identified by its tombstone.
3138    Deleted(String),
3139    /// No matching record or tombstone after a successful inventory read.
3140    Missing,
3141    /// More than one record matches the reference.
3142    Ambiguous,
3143    /// The inventory cannot currently be read, so existence is unknown.
3144    Unknown,
3145}
3146
3147/// Resolve against authoritative task, question and deletion record names.
3148pub fn dependency(queue: &Queue, questions: &Questions, id: &str) -> Dependency {
3149    let (Ok(tasks), Ok(qs), Ok(removed)) = (
3150        record_ids(queue.root(), "json"),
3151        record_ids(questions.root(), "json"),
3152        record_ids(queue.root(), "removed"),
3153    ) else {
3154        return Dependency::Unknown;
3155    };
3156    // Include tombstones in ambiguity checks: a reused suffix must never
3157    // release a waiter on a different, still-live task.
3158    let mut ids = tasks
3159        .iter()
3160        .chain(qs.iter())
3161        .chain(removed.iter())
3162        .cloned()
3163        .collect::<Vec<_>>();
3164    ids.sort();
3165    ids.dedup();
3166    match resolve_reference(id, ids.iter().map(String::as_str)) {
3167        Reference::Found(id) if tasks.contains(&id) => Dependency::Task(id),
3168        Reference::Found(id) if qs.contains(&id) => Dependency::Question(id),
3169        Reference::Found(id) if queue.was_deleted(&id) => Dependency::Deleted(id),
3170        Reference::Found(_) => Dependency::Unknown,
3171        Reference::Missing => Dependency::Missing,
3172        Reference::Ambiguous => Dependency::Ambiguous,
3173    }
3174}
3175
3176/// Canonicalize only proven dependencies, preserving unknown legacy references.
3177pub fn normalize_blockers(queue: &Queue, questions: &Questions, ids: &mut [String]) {
3178    for id in ids {
3179        if let Dependency::Task(full) | Dependency::Question(full) | Dependency::Deleted(full) =
3180            dependency(queue, questions, id)
3181        {
3182            *id = full;
3183        }
3184    }
3185}
3186
3187/// Live queue facts for decision and deputy prompts. Historical hold reasons
3188/// and question text are claims, never an inventory of existing prerequisites.
3189pub fn dependency_facts(queue: &Queue, questions: &Questions) -> String {
3190    let inventory = crate::blockers::Inventory::load(queue, questions);
3191    let mut text = String::from(
3192        "Current authoritative queue (historical missing-task claims may be stale; unreadable or ambiguous references must keep waiting):\n",
3193    );
3194    match record_ids(queue.root(), "json") {
3195        Ok(ids) => {
3196            for id in ids {
3197                match queue.get(&id) {
3198                    Ok(task) => text.push_str(&format!(
3199                        "{}: {}; blocked_by={:?}; chain={:?}\n",
3200                        task.id,
3201                        task.status.as_str(),
3202                        task.blocked_by,
3203                        inventory.waits_on(&task)
3204                    )),
3205                    Err(_) => text.push_str(&format!("{id}: exists, state unreadable\n")),
3206                }
3207            }
3208        }
3209        Err(_) => text.push_str("Queue inventory unreadable: existence is unknown.\n"),
3210    }
3211    text
3212}
3213
3214/// Only a successful directory scan with no matching record proves absence.
3215pub fn missing_blockers(
3216    queue: &Queue,
3217    questions: &Questions,
3218    blocked_by: &[String],
3219) -> Vec<String> {
3220    blocked_by
3221        .iter()
3222        .filter(|id| dependency(queue, questions, id) == Dependency::Missing)
3223        .cloned()
3224        .collect()
3225}
3226
3227/// Return the original references, so legacy short ids are removed by the
3228/// same Task transition as full ids.
3229pub fn deleted_blockers(
3230    queue: &Queue,
3231    questions: &Questions,
3232    blocked_by: &[String],
3233) -> Vec<String> {
3234    blocked_by
3235        .iter()
3236        .filter(|id| matches!(dependency(queue, questions, id), Dependency::Deleted(_)))
3237        .cloned()
3238        .collect()
3239}
3240
3241/// The `hold_reason` text for a task quarantined because one or more of its
3242/// `blocked_by` ids no longer exist. Shared by `crate::daemon::resolve_blockers`,
3243/// `crate::triage::run_once`, and [`Queue::remove`]'s own dependent
3244/// quarantine, so the three call sites read as the same event to an operator
3245/// looking at `magi task show` rather than three different wordings for it.
3246///
3247/// Names the full original `blocked_by` list, not just `missing` - a task
3248/// quarantined here can also have named a dependency that was still
3249/// perfectly valid, and [`Task::hold_machine`] clears `blocked_by` on the way
3250/// in, so this text is the only place that information survives for an
3251/// operator deciding whether to release the task outright.
3252pub fn missing_blocker_hold_reason(blocked_by: &[String], missing: &[String]) -> String {
3253    missing_blocker_hold_reason_in(blocked_by, missing, "en")
3254}
3255
3256/// [`missing_blocker_hold_reason`] in `language` (Japanese, else English).
3257pub fn missing_blocker_hold_reason_in(
3258    blocked_by: &[String],
3259    missing: &[String],
3260    language: &str,
3261) -> String {
3262    if crate::lang::is_japanese(language) {
3263        format!(
3264            "{} を待っていましたが、{} はディスク上に存在しません - `magi task triage` を参照",
3265            blocked_by.join(", "),
3266            missing.join(", "),
3267        )
3268    } else {
3269        format!(
3270            "blocked on {} but {} no longer exist(s) on disk - see `magi task triage`",
3271            blocked_by.join(", "),
3272            missing.join(", "),
3273        )
3274    }
3275}
3276
3277/// The last dash-separated part of an id, the form people say aloud.
3278pub fn short(id: &str) -> &str {
3279    id.split('-').next_back().unwrap_or(id)
3280}
3281
3282fn new_id() -> String {
3283    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
3284    let seed = crate::rng::entropy();
3285    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
3286}
3287
3288#[cfg(test)]
3289mod tests {
3290    #[test]
3291    fn explicit_resumption_is_not_invented_for_legacy_or_automatic_release() {
3292        let t = Task::new(
3293            "retry".into(),
3294            "do it".into(),
3295            PathBuf::from("."),
3296            Source::Human,
3297        );
3298        let mut value = serde_json::to_value(&t).unwrap();
3299        for field in ["resumption", "resumption_history", "operator_decided_at"] {
3300            value.as_object_mut().unwrap().remove(field);
3301        }
3302        value["schema"] = serde_json::json!(15);
3303        let mut legacy: Task = serde_json::from_value(value).unwrap();
3304        legacy.hold_machine(Some("automatic retry".into()));
3305        legacy.release();
3306        assert!(legacy.resumption.is_none());
3307        assert!(legacy.resumption_history.is_empty());
3308        assert!(legacy.operator_decided_at.is_none());
3309        legacy.release_operator("cli", None);
3310        legacy.start("run".into());
3311        let reloaded: Task =
3312            serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
3313        assert!(reloaded.resume_override.is_none());
3314        assert_eq!(reloaded.resumption.as_ref().unwrap().generation, 1);
3315    }
3316
3317    #[test]
3318    fn merge_over_lets_the_later_choice_win_and_keeps_the_rest() {
3319        let mut base = RunOverrides {
3320            merge: Some("pr".to_owned()),
3321            candidates: Some(3),
3322            ..RunOverrides::default()
3323        };
3324        base.merge_over(&RunOverrides {
3325            merge: Some("none".to_owned()),
3326            seed: Some(7),
3327            ..RunOverrides::default()
3328        });
3329        assert_eq!(base.merge.as_deref(), Some("none"));
3330        assert_eq!(base.candidates, Some(3));
3331        assert_eq!(base.seed, Some(7));
3332    }
3333
3334    #[test]
3335    fn an_already_landed_task_is_done_with_its_attempt_refunded() {
3336        let mut t = task("relanded");
3337        t.attempts = 1;
3338        t.status = TaskStatus::Running;
3339        t.already_landed("already in main as 0e368de");
3340        assert_eq!(t.status, TaskStatus::Done);
3341        assert_eq!(t.attempts, 0);
3342        assert!(t.hold_reason.is_none());
3343        assert_eq!(t.last_error.as_deref(), Some("already in main as 0e368de"));
3344    }
3345
3346    #[test]
3347    fn missing_blocker_reason_follows_the_language() {
3348        let b = vec!["a".to_owned()];
3349        let en = missing_blocker_hold_reason_in(&b, &b, "en");
3350        assert_eq!(en, missing_blocker_hold_reason(&b, &b));
3351        assert!(en.starts_with("blocked on a"));
3352        assert!(missing_blocker_hold_reason_in(&b, &b, "ja").contains("存在しません"));
3353        assert_eq!(missing_blocker_hold_reason_in(&b, &b, "de"), en);
3354    }
3355
3356    use super::*;
3357
3358    #[test]
3359    fn triage_applied_survives_release_and_old_records_read_as_empty() {
3360        let mut t = Task::new(
3361            "t".to_owned(),
3362            "i".to_owned(),
3363            PathBuf::from("r"),
3364            Source::Human,
3365        );
3366        t.mark_triage_applied("q1");
3367        t.mark_triage_applied("q1");
3368        t.hold_machine(Some("x".to_owned()));
3369        t.release();
3370        assert_eq!(t.triage_applied, ["q1"]);
3371        assert!(t.triage_applied("q1") && !t.triage_applied("q2"));
3372
3373        let mut v = serde_json::to_value(&t).unwrap();
3374        v.as_object_mut().unwrap().remove("triage_applied");
3375        let old: Task = serde_json::from_value(v).unwrap();
3376        assert!(old.triage_applied.is_empty());
3377    }
3378
3379    #[test]
3380    fn task_counts_of_empty_is_all_zero() {
3381        assert_eq!(TaskCounts::of(&[]), TaskCounts::default());
3382    }
3383
3384    #[test]
3385    fn task_counts_of_tallies_every_status() {
3386        let mut queued = Task::new(
3387            "q".to_owned(),
3388            "i".to_owned(),
3389            PathBuf::from("."),
3390            Source::Human,
3391        );
3392        queued.status = TaskStatus::Queued;
3393        let mut running = queued.clone();
3394        running.status = TaskStatus::Running;
3395        let mut done = queued.clone();
3396        done.status = TaskStatus::Done;
3397        let mut failed = queued.clone();
3398        failed.status = TaskStatus::Failed;
3399        let mut held = queued.clone();
3400        held.status = TaskStatus::Held;
3401        let mut blocked = queued.clone();
3402        blocked.status = TaskStatus::Blocked;
3403
3404        let mut parked = queued.clone();
3405        parked.status = TaskStatus::Parked;
3406
3407        let counts = TaskCounts::of(&[
3408            queued,
3409            running,
3410            done.clone(),
3411            done,
3412            failed,
3413            held,
3414            blocked,
3415            parked,
3416        ]);
3417        assert_eq!(
3418            counts,
3419            TaskCounts {
3420                queued: 1,
3421                running: 1,
3422                done: 2,
3423                failed: 1,
3424                held: 1,
3425                blocked: 1,
3426                parked: 1,
3427            }
3428        );
3429    }
3430
3431    #[test]
3432    fn task_implementers_persist_normalized_without_losing_history() {
3433        let (_dir, q) = queue();
3434        let mut t = task("keep me");
3435        t.hold_manual(Some("waiting".into()));
3436        t.runs.push("earlier-run".into());
3437        t.attachments.push("reference.txt".into());
3438        let cfg: crate::config::Config =
3439            toml::from_str("[[agents]]\nid = 'mock'\nkind = 'command'\ncommand = ['true']\n")
3440                .unwrap();
3441        q.put(&mut t).unwrap();
3442        let original = serde_json::to_value(&t).unwrap();
3443        for n in [2, 1, 2] {
3444            t.set_implementers(n, &cfg).unwrap();
3445            q.put(&mut t).unwrap();
3446            t = q.get(&t.id).unwrap();
3447            assert_eq!(t.implementers(), Some(usize::from(n)));
3448            assert_eq!(t.solo, n == 1);
3449            if n == 1 {
3450                assert!(t.overrides.is_none());
3451            }
3452            let mut actual = serde_json::to_value(&t).unwrap();
3453            for field in ["solo", "overrides", "updated_at"] {
3454                actual[field] = original[field].clone();
3455            }
3456            assert_eq!(actual, original);
3457        }
3458        t.overrides.as_mut().unwrap().judges = Some(2);
3459        t.set_implementers(1, &cfg).unwrap();
3460        assert_eq!(t.overrides.as_ref().unwrap().judges, Some(2));
3461        assert_eq!(t.overrides.as_ref().unwrap().candidates, None);
3462    }
3463
3464    #[test]
3465    fn task_implementers_old_records_keep_inheritance_until_changed() {
3466        let mut value = serde_json::to_value(task("old task")).unwrap();
3467        value.as_object_mut().unwrap().remove("solo");
3468        value.as_object_mut().unwrap().remove("overrides");
3469        let mut old: Task = serde_json::from_value(value).unwrap();
3470        assert_eq!(old.implementers(), None);
3471        old.set_implementers(1, &crate::config::Config::default())
3472            .unwrap();
3473        assert!(old.solo);
3474        assert!(old.overrides.is_none());
3475    }
3476
3477    #[test]
3478    fn task_implementers_reject_invalid_counts_and_states() {
3479        let cfg = crate::config::Config::default();
3480        let mut t = task("unchanged");
3481        for n in [0, 4, 255] {
3482            assert!(t.set_implementers(n, &cfg).is_err());
3483        }
3484        for status in [
3485            TaskStatus::Running,
3486            TaskStatus::Done,
3487            TaskStatus::Failed,
3488            TaskStatus::Blocked,
3489            TaskStatus::Parked,
3490        ] {
3491            t.status = status;
3492            let before = serde_json::to_value(&t).unwrap();
3493            assert!(t.set_implementers(1, &cfg).is_err());
3494            assert_eq!(serde_json::to_value(&t).unwrap(), before);
3495        }
3496    }
3497
3498    /// A queue of its own, with no process-global state - which is the point of
3499    /// `Queue::at`, and why these can run in parallel.
3500    fn queue() -> (tempfile::TempDir, Queue) {
3501        let dir = tempfile::tempdir().unwrap();
3502        let q = Queue::at(dir.path().join("queue"));
3503        (dir, q)
3504    }
3505
3506    #[test]
3507    fn putting_a_machine_held_task_files_a_notification_beside_the_queue() {
3508        let dir = tempfile::tempdir().unwrap();
3509        let q = Queue::at(dir.path().join("queue"));
3510        let mut t = task("held");
3511        q.put(&mut t).unwrap();
3512        assert_eq!(
3513            crate::notices::Notices::at(dir.path().join("notifications"))
3514                .list()
3515                .len(),
3516            0
3517        );
3518        t.hold_machine(Some("out of attempts".to_owned()));
3519        q.put(&mut t).unwrap();
3520        let listed = crate::notices::Notices::at(dir.path().join("notifications")).list();
3521        assert_eq!(listed.len(), 1);
3522        assert!(listed[0].message.contains("out of attempts"));
3523    }
3524
3525    fn task(title: &str) -> Task {
3526        Task::new(
3527            title.to_owned(),
3528            format!("do {title}"),
3529            PathBuf::from("."),
3530            Source::Human,
3531        )
3532    }
3533
3534    #[test]
3535    fn attempt_limit_inherits_old_records_and_controls_failure() {
3536        let mut t = task("budget");
3537        let mut old = serde_json::to_value(&t).unwrap();
3538        old.as_object_mut().unwrap().remove("attempt_limit");
3539        old["schema"] = serde_json::json!(15);
3540        let legacy: Task = serde_json::from_value(old).unwrap();
3541        assert_eq!(legacy.attempt_limit, None);
3542        assert_eq!(legacy.effective_attempt_limit(2), 2);
3543        t.attempts = 2;
3544        t.change_attempt_limit(NonZeroUsize::new(3)).unwrap();
3545        t.fail("retry", 1);
3546        assert_eq!(t.status, TaskStatus::Failed);
3547        t.attempts = 3;
3548        t.fail("stop", 10);
3549        assert_eq!(t.status, TaskStatus::Held);
3550        t.change_attempt_limit(None).unwrap();
3551        assert_eq!(t.status, TaskStatus::Held);
3552        assert_eq!(t.attempts, 3);
3553        t.fail("inherit", 4);
3554        assert_eq!(t.status, TaskStatus::Failed);
3555        for status in [
3556            TaskStatus::Running,
3557            TaskStatus::Done,
3558            TaskStatus::Failed,
3559            TaskStatus::Blocked,
3560            TaskStatus::Parked,
3561        ] {
3562            t.status = status;
3563            assert!(t.change_attempt_limit(NonZeroUsize::new(1)).is_err());
3564            assert_eq!(t.attempt_limit, None);
3565        }
3566    }
3567
3568    #[test]
3569    fn earlier_attempts_is_every_recorded_run_and_agrees_with_the_display() {
3570        let mut t = task("retried");
3571        assert!(
3572            t.earlier_attempts().is_empty(),
3573            "a first attempt takes nothing over"
3574        );
3575        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3576        assert_eq!(t.earlier_attempts(), ["aaaa", "bbbb"]);
3577        // What the web note shows and what a takeover acts on are one rule.
3578        assert_eq!(t.successor_of("aaaa"), Some(&"bbbb".to_owned()));
3579        assert_eq!(t.successor_of("bbbb"), None);
3580        assert_eq!(t.successor_of("zzzz"), None);
3581    }
3582
3583    #[test]
3584    fn resumed_runs_are_superseded_only_after_their_last_occurrence() {
3585        let (_dir, q) = queue();
3586        let mut t = task("resumed");
3587        for (runs, successor) in [
3588            (vec!["aaaa", "aaaa"], None),
3589            (vec!["aaaa", "bbbb", "aaaa"], None),
3590            (vec!["aaaa", "aaaa", "bbbb"], Some("bbbb")),
3591            (vec!["aaaa", "bbbb", "aaaa", "cccc"], Some("cccc")),
3592        ] {
3593            t.runs = runs.into_iter().map(str::to_owned).collect();
3594            q.put(&mut t).unwrap();
3595
3596            assert_eq!(t.successor_of("aaaa").map(String::as_str), successor);
3597            assert_eq!(q.superseded_by("aaaa").as_deref(), successor);
3598            assert_eq!(q.superseded().get("aaaa").map(String::as_str), successor);
3599        }
3600    }
3601
3602    #[test]
3603    fn superseded_by_names_the_next_attempt_and_none_for_the_last() {
3604        let (_dir, q) = queue();
3605        let mut t = task("retried");
3606        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3607        q.put(&mut t).unwrap();
3608
3609        assert_eq!(q.superseded_by("aaaa"), Some("bbbb".to_owned()));
3610        assert_eq!(q.superseded_by("bbbb"), Some("cccc".to_owned()));
3611        assert_eq!(
3612            q.superseded_by("cccc"),
3613            None,
3614            "the latest attempt replaces nothing"
3615        );
3616        assert_eq!(
3617            q.superseded_by("never-heard-of-it"),
3618            None,
3619            "a run belonging to no task on this queue is not superseded"
3620        );
3621
3622        let mut by = HashMap::new();
3623        by.insert("aaaa".to_owned(), "bbbb".to_owned());
3624        by.insert("bbbb".to_owned(), "cccc".to_owned());
3625        assert_eq!(
3626            q.superseded(),
3627            by,
3628            "the whole-map and single-run forms must agree"
3629        );
3630    }
3631
3632    #[test]
3633    fn superseded_attempts_is_empty_until_the_task_is_done() {
3634        let mut t = task("retried");
3635        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3636        t.status = TaskStatus::Failed;
3637        assert_eq!(
3638            t.superseded_attempts(true),
3639            &[] as &[String],
3640            "a task still retrying has no attempt yet that a later one made moot"
3641        );
3642
3643        t.status = TaskStatus::Running;
3644        assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3645    }
3646
3647    #[test]
3648    fn superseded_attempts_names_every_run_before_the_one_that_succeeded() {
3649        let mut t = task("retried");
3650        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3651        t.status = TaskStatus::Done;
3652        assert_eq!(
3653            t.superseded_attempts(true),
3654            &["aaaa".to_owned(), "bbbb".to_owned()],
3655            "cccc is the attempt whose success made the task done, and stays out"
3656        );
3657    }
3658
3659    #[test]
3660    fn superseded_attempts_is_empty_for_a_done_task_with_only_one_attempt() {
3661        let mut t = task("first try landed");
3662        t.runs = vec!["aaaa".to_owned()];
3663        t.status = TaskStatus::Done;
3664        assert_eq!(t.superseded_attempts(true), &[] as &[String]);
3665    }
3666
3667    #[test]
3668    fn superseded_attempts_is_empty_when_the_last_run_never_actually_succeeded() {
3669        // `succeed` is reachable directly - `magi task done`, its web
3670        // equivalent, and the conductor's `Recovery::Done` - on a task in
3671        // any status, including one whose last recorded attempt is itself
3672        // `Blocked`/`Failed`/anything but `Merged`/`Ready`. `runs.last()`
3673        // alone cannot tell that apart from the loop's own settle path, so
3674        // the caller's own read of that run's status is what decides this.
3675        let mut t = task("closed by hand after a manual merge");
3676        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned()];
3677        t.status = TaskStatus::Done;
3678        assert_eq!(
3679            t.superseded_attempts(false),
3680            &[] as &[String],
3681            "nothing here is provably why the task is done, so nothing is superseded"
3682        );
3683    }
3684
3685    #[test]
3686    fn latest_attempt_names_the_chain_s_current_head_not_just_the_next_one() {
3687        let (_dir, q) = queue();
3688        let mut t = task("retried twice");
3689        t.runs = vec!["aaaa".to_owned(), "bbbb".to_owned(), "cccc".to_owned()];
3690        q.put(&mut t).unwrap();
3691
3692        assert_eq!(
3693            q.latest_attempt("aaaa"),
3694            Some("cccc".to_owned()),
3695            "an old attempt points straight at the chain's current head, not the \
3696             next attempt in the middle of it"
3697        );
3698        assert_eq!(q.latest_attempt("bbbb"), Some("cccc".to_owned()));
3699        assert_eq!(
3700            q.latest_attempt("cccc"),
3701            None,
3702            "the latest attempt is not superseded by anything"
3703        );
3704        assert_eq!(
3705            q.latest_attempt("never-heard-of-it"),
3706            None,
3707            "a run belonging to no task on this queue is not superseded"
3708        );
3709    }
3710
3711    #[test]
3712    fn a_markdown_heading_is_the_title_not_decoration() {
3713        // A task file's heading is the summary its author already wrote, so it
3714        // beats the prose underneath. Getting this backwards was visible in the
3715        // first smoke test: a task titled "# Rework the config loader" listed
3716        // as "It re-reads the file on every lookup".
3717        assert_eq!(
3718            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
3719            "Rework the config loader"
3720        );
3721        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
3722        assert_eq!(title_from("> quoted task", 40), "quoted task");
3723        // Nothing usable at all still has to produce something printable.
3724        assert_eq!(title_from("   \n\n", 40), "(empty task)");
3725        assert_eq!(title_from("###\n", 40), "(empty task)");
3726    }
3727
3728    #[test]
3729    fn a_long_title_is_elided_by_characters_not_bytes() {
3730        // Byte truncation would split a multi-byte character and panic.
3731        let long = "課題".repeat(30);
3732        let title = title_from(&long, 10);
3733        assert_eq!(title.chars().count(), 10);
3734        assert!(title.ends_with('…'));
3735    }
3736
3737    #[test]
3738    fn priority_wins_and_ties_break_oldest_first() {
3739        let (_dir, q) = queue();
3740        let mut a = task("first");
3741        let mut b = task("second");
3742        let mut c = task("urgent");
3743        // Ids carry a timestamp, so force a known order.
3744        a.id = "20260101-000001-aaaa".to_owned();
3745        b.id = "20260101-000002-bbbb".to_owned();
3746        c.id = "20260101-000003-cccc".to_owned();
3747        c.priority = 5;
3748        for t in [&mut a, &mut b, &mut c] {
3749            q.put(t).unwrap();
3750        }
3751
3752        // Priority first...
3753        assert_eq!(q.next_runnable().unwrap().id, c.id);
3754        c.hold_machine(None);
3755        q.put(&mut c).unwrap();
3756        // ...then oldest, so a burst of new work cannot starve older work.
3757        assert_eq!(q.next_runnable().unwrap().id, a.id);
3758        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
3759    }
3760
3761    #[test]
3762    fn a_blocked_task_never_starves_another_runnable_one() {
3763        let (_dir, q) = queue();
3764        let mut blocked = task("blocked");
3765        blocked.block(vec!["something".to_owned()], None);
3766        q.put(&mut blocked).unwrap();
3767
3768        let mut runnable = task("free to go");
3769        q.put(&mut runnable).unwrap();
3770
3771        let next = q.next_runnable().expect("a runnable task is still offered");
3772        assert_eq!(next.id, runnable.id);
3773    }
3774
3775    #[test]
3776    fn a_held_task_is_never_offered_to_the_loop() {
3777        let (_dir, q) = queue();
3778        let mut t = task("held");
3779        q.put(&mut t).unwrap();
3780        assert!(q.next_runnable().is_some());
3781
3782        t.hold_machine(None);
3783        q.put(&mut t).unwrap();
3784        assert!(
3785            q.next_runnable().is_none(),
3786            "a held task must wait for a human"
3787        );
3788
3789        // A failed task, by contrast, is exactly what the loop should retry.
3790        t.status = TaskStatus::Failed;
3791        q.put(&mut t).unwrap();
3792        assert!(q.next_runnable().is_some());
3793    }
3794
3795    #[test]
3796    fn attempts_are_capped_and_then_the_task_is_held() {
3797        let mut t = task("doomed");
3798
3799        t.start("run-1".to_owned());
3800        t.fail("gate red", 2);
3801        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
3802
3803        t.start("run-2".to_owned());
3804        t.fail("gate red", 2);
3805        assert_eq!(
3806            t.status,
3807            TaskStatus::Held,
3808            "out of attempts: stop spending money on it"
3809        );
3810        assert_eq!(t.runs, ["run-1", "run-2"]);
3811        assert_eq!(t.last_error.as_deref(), Some("gate red"));
3812        assert_eq!(
3813            t.hold_reason.as_deref(),
3814            Some("gate red"),
3815            "the hold must say why, not leave hold_reason null next to a \
3816             populated last_error"
3817        );
3818    }
3819
3820    #[test]
3821    fn handing_off_a_task_records_a_hold_reason_too() {
3822        let mut t = task("left a pull request");
3823        t.start("run-1".to_owned());
3824        t.handed_off("run ended with a pull request open [run run-1]");
3825        assert_eq!(t.status, TaskStatus::Held);
3826        assert_eq!(t.hold_source, Some(HoldSource::Machine));
3827        assert_eq!(
3828            t.hold_reason.as_deref(),
3829            Some("run ended with a pull request open [run run-1]")
3830        );
3831        assert_eq!(t.hold_reason, t.last_error);
3832    }
3833
3834    #[test]
3835    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
3836        let mut t = task("stalled by quota");
3837
3838        t.start("run-1".to_owned());
3839        assert_eq!(t.attempts, 1);
3840        t.stall("judge-1, judge-2 out of quota");
3841        assert_eq!(
3842            t.attempts, 0,
3843            "a closed quota window must not spend the task's retry budget"
3844        );
3845        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
3846        assert_eq!(
3847            t.last_error.as_deref(),
3848            Some("judge-1, judge-2 out of quota")
3849        );
3850
3851        // A task can therefore stall all night and still get its real attempts
3852        // once the quota resets - which is the whole point.
3853        for _ in 0..20 {
3854            t.start("run-n".to_owned());
3855            t.stall("still out of quota");
3856        }
3857        t.start("run-real".to_owned());
3858        t.fail("gate red", 2);
3859        assert_eq!(
3860            t.status,
3861            TaskStatus::Failed,
3862            "the first attempt that was really judged is attempt one"
3863        );
3864    }
3865
3866    #[test]
3867    fn releasing_a_held_task_gives_it_a_real_second_chance() {
3868        let mut t = task("retry me");
3869        t.start("run-1".to_owned());
3870        t.fail("gate red", 1);
3871        assert_eq!(t.status, TaskStatus::Held);
3872
3873        t.release();
3874        assert_eq!(t.status, TaskStatus::Queued);
3875        // Without resetting attempts the next failure would re-hold at once,
3876        // and a release would be a no-op the operator cannot see.
3877        assert_eq!(t.attempts, 0);
3878        assert!(t.last_error.is_none());
3879        assert_eq!(
3880            t.runs.len(),
3881            1,
3882            "history is kept: attempts reset, evidence does not"
3883        );
3884    }
3885
3886    #[test]
3887    fn a_hold_reason_survives_and_a_release_clears_it() {
3888        let mut t = task("waiting on something else");
3889        t.hold_manual(Some(
3890            "waiting for 20260101-000000-aaaa to land first".to_owned(),
3891        ));
3892        assert_eq!(t.status, TaskStatus::Held);
3893        assert_eq!(
3894            t.hold_reason.as_deref(),
3895            Some("waiting for 20260101-000000-aaaa to land first")
3896        );
3897
3898        // Holding again with no reason must not erase the one already there.
3899        t.hold_manual(None);
3900        assert_eq!(
3901            t.hold_reason.as_deref(),
3902            Some("waiting for 20260101-000000-aaaa to land first"),
3903            "a bare re-hold keeps whatever a human already wrote down"
3904        );
3905
3906        // A hold with no reason at all is still an ordinary, allowed hold.
3907        let mut plain = task("no reason given");
3908        plain.hold_manual(None);
3909        assert_eq!(plain.status, TaskStatus::Held);
3910        assert!(plain.hold_reason.is_none());
3911
3912        t.release();
3913        assert_eq!(t.status, TaskStatus::Queued);
3914        assert!(
3915            t.hold_reason.is_none(),
3916            "a stale reason must not greet the next person who holds this task"
3917        );
3918    }
3919
3920    #[test]
3921    fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
3922        // `done` can close a held task directly - neither `magi task done`
3923        // nor `POST /api/queue/{id}/done` requires a release first - so a
3924        // task held for "waiting on 3ed9" and then closed without ever being
3925        // released must not still read as waiting on it afterwards.
3926        let mut t = task("landed by hand while held");
3927        t.hold_manual(Some("waiting on 3ed9".to_owned()));
3928        assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
3929
3930        t.succeed();
3931        assert_eq!(t.status, TaskStatus::Done);
3932        assert!(
3933            t.hold_reason.is_none(),
3934            "a done task cannot still be waiting on something"
3935        );
3936    }
3937
3938    #[test]
3939    fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
3940        // The web UI's "Hold" and "Mark done" buttons are both reachable on a
3941        // `blocked` task, not just on `queued`/`held` ones - neither requires
3942        // a release first. A task moved off `Blocked` that way must not still
3943        // carry the dependency it was waiting on: a dependency graph built
3944        // from `blocked_by` would otherwise keep drawing an edge for a task
3945        // that is not blocked on anything any more.
3946        let mut held = task("held straight out of blocked");
3947        held.block(
3948            vec!["20260101-000000-dead".to_owned()],
3949            Some("waiting on the migration script".to_owned()),
3950        );
3951        assert_eq!(held.status, TaskStatus::Blocked);
3952
3953        held.hold_manual(None);
3954        assert_eq!(held.status, TaskStatus::Held);
3955        assert!(
3956            held.blocked_by.is_empty(),
3957            "hold overrides the wait, same as release"
3958        );
3959        assert!(held.block_reason.is_none());
3960
3961        let mut done = task("closed straight out of blocked");
3962        done.block(
3963            vec!["20260101-000000-dead".to_owned()],
3964            Some("waiting on the migration script".to_owned()),
3965        );
3966        done.succeed();
3967        assert_eq!(done.status, TaskStatus::Done);
3968        assert!(
3969            done.blocked_by.is_empty(),
3970            "a done task cannot still be waiting on a dependency"
3971        );
3972        assert!(done.block_reason.is_none());
3973    }
3974
3975    #[test]
3976    fn a_blocked_task_is_never_offered_to_the_loop() {
3977        let mut t = task("blocked");
3978        assert!(t.status.runnable());
3979        t.block(
3980            vec!["dep-id".to_owned()],
3981            Some("waits on dep-id".to_owned()),
3982        );
3983        assert_eq!(t.status, TaskStatus::Blocked);
3984        assert!(!t.status.runnable());
3985        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
3986    }
3987
3988    #[test]
3989    fn unblocking_the_last_dependency_returns_the_task_to_queued() {
3990        let mut t = task("blocked on two");
3991        t.block(
3992            vec!["a".to_owned(), "b".to_owned()],
3993            Some("waits on a and b".to_owned()),
3994        );
3995
3996        t.unblock("a");
3997        assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
3998        assert_eq!(t.blocked_by, ["b"]);
3999
4000        t.unblock("b");
4001        assert_eq!(t.status, TaskStatus::Queued);
4002        assert!(t.blocked_by.is_empty());
4003        assert!(t.block_reason.is_none());
4004    }
4005
4006    #[test]
4007    fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
4008        let mut t = task("never blocked");
4009        t.unblock("whatever");
4010        assert_eq!(t.status, TaskStatus::Queued);
4011    }
4012
4013    #[test]
4014    fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
4015        // The bug this guards: a task an operator (or `crate::triage`) has
4016        // deliberately held, once `crate::conduct` blocks it on a follow-up
4017        // question, must not silently re-enter the competition queue the
4018        // moment that question is answered - whatever the answer said.
4019        let mut t = task("held, then asked about");
4020        t.hold_machine(Some("out of attempts".to_owned()));
4021        assert_eq!(t.status, TaskStatus::Held);
4022
4023        t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
4024        assert_eq!(t.status, TaskStatus::Blocked);
4025
4026        t.record_answer("what now?".to_owned(), "leave it held".to_owned());
4027        t.unblock("q1");
4028        assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
4029        assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
4030        assert_eq!(t.hold_source, Some(HoldSource::Machine));
4031        assert!(t.blocked_from.is_none(), "consumed once restored");
4032    }
4033
4034    #[test]
4035    fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
4036        let mut t = task("manually held, then asked about");
4037        t.hold_manual(Some("waiting on a dependency".to_owned()));
4038
4039        t.block(vec!["q1".to_owned()], None);
4040        t.unblock("q1");
4041
4042        assert_eq!(t.status, TaskStatus::Held);
4043        assert_eq!(t.hold_source, Some(HoldSource::Manual));
4044    }
4045
4046    #[test]
4047    fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
4048        // A second `Task::block` call - `crate::conduct` adding a question on
4049        // top of an existing block - must not overwrite `blocked_from` with
4050        // `Blocked` itself, or the task would restore into itself.
4051        let mut t = task("held, blocked twice");
4052        t.hold_machine(None);
4053        t.block(vec!["q1".to_owned()], Some("first".to_owned()));
4054        t.block(
4055            vec!["q1".to_owned(), "q2".to_owned()],
4056            Some("second".to_owned()),
4057        );
4058
4059        t.unblock("q1");
4060        assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
4061        t.unblock("q2");
4062        assert_eq!(t.status, TaskStatus::Held);
4063    }
4064
4065    #[test]
4066    fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
4067        // Whatever process was driving the run is gone by the time a
4068        // conductor's question about it gets answered - there is nothing left
4069        // to resume into.
4070        let mut t = task("blocked mid-run");
4071        t.start("run-1".to_owned());
4072        assert_eq!(t.status, TaskStatus::Running);
4073
4074        t.block(vec!["q1".to_owned()], None);
4075        t.unblock("q1");
4076        assert_eq!(t.status, TaskStatus::Queued);
4077    }
4078
4079    #[test]
4080    fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
4081        // `blocked_from` is `None` for a record written before schema 4 (or,
4082        // equivalently, deserialized straight from an on-disk file that never
4083        // had the field). Held evidence surviving on the task - never cleared
4084        // by `block` - is the only way left to tell such a record apart from
4085        // one blocked straight out of `Queued`.
4086        let mut t = task("legacy record, held before it was blocked");
4087        t.hold_source = Some(HoldSource::Machine);
4088        t.hold_reason = Some("legacy hold reason".to_owned());
4089        t.status = TaskStatus::Blocked;
4090        t.blocked_by = vec!["q1".to_owned()];
4091        t.blocked_from = None;
4092
4093        t.unblock("q1");
4094        assert_eq!(t.status, TaskStatus::Held);
4095    }
4096
4097    #[test]
4098    fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
4099        let mut t = task("legacy record, ordinary dependency block");
4100        t.status = TaskStatus::Blocked;
4101        t.blocked_by = vec!["dep".to_owned()];
4102        t.blocked_from = None;
4103
4104        t.unblock("dep");
4105        assert_eq!(t.status, TaskStatus::Queued);
4106    }
4107
4108    #[test]
4109    fn answering_a_question_is_recorded_and_survives_a_release() {
4110        let mut t = task("asked something");
4111        t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
4112        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
4113        t.unblock("q1");
4114        assert_eq!(t.status, TaskStatus::Queued);
4115        assert_eq!(t.answers.len(), 1);
4116        assert_eq!(t.answers[0].answer, "SQLite");
4117
4118        // A release resets attempts, not evidence - the same rule
4119        // `releasing_a_held_task_gives_it_a_real_second_chance` asserts for
4120        // `runs`.
4121        t.release();
4122        assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
4123    }
4124
4125    #[test]
4126    fn resumption_review_branch_requires_unchanged_authored_criteria() {
4127        for source in ["cli", "web"] {
4128            let mut t = task("review old candidate");
4129            t.request_review("candidate-A".into());
4130            t.hold_for_handover(Some("candidate-A".into()), "handover refused".into());
4131            let mut unchanged = t.clone();
4132            unchanged.release_operator(source, None);
4133            assert_eq!(unchanged.review_branch.as_deref(), Some("candidate-A"));
4134            t.edit(
4135                "review approved head".into(),
4136                "Reuse measured PR head B; fix Clippy; review".into(),
4137            )
4138            .unwrap();
4139            t.release_operator(source, None);
4140            assert!(t.review_branch.is_none());
4141            assert!(t.review_instruction.is_none());
4142            // Legacy records capture their old criteria at the supported edit.
4143            let mut legacy = unchanged;
4144            legacy.review_instruction = None;
4145            legacy.hold_for_handover(None, "still checked out".into());
4146            legacy
4147                .edit("new plan".into(), "Reuse measured PR head B".into())
4148                .unwrap();
4149            legacy.release_operator(source, None);
4150            assert!(legacy.review_branch.is_none());
4151        }
4152    }
4153
4154    #[test]
4155    fn a_refused_handover_keeps_the_review_branch_across_release() {
4156        let mut t = task("refused takeover");
4157        t.start("run-1".to_owned());
4158        t.hold_for_handover(Some("magi/eba2/A".to_owned()), "checked out".to_owned());
4159        assert_eq!(t.status, TaskStatus::Held);
4160        assert_eq!(t.attempts, 1);
4161        t.release();
4162        assert_eq!(t.status, TaskStatus::Queued);
4163        assert_eq!(t.attempts, 0);
4164        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4165
4166        // A fresh competition chosen afterwards is not a review.
4167        t.hold_for_handover(None, "again".to_owned());
4168        t.requeue();
4169        assert!(t.review_branch.is_none());
4170
4171        // A manual hold never keeps one.
4172        let mut m = task("manual");
4173        m.review_branch = Some("magi/x/A".to_owned());
4174        m.hold_manual(None);
4175        m.release();
4176        assert!(m.review_branch.is_none());
4177    }
4178
4179    #[test]
4180    fn requesting_review_requeues_the_task_and_remembers_the_branch() {
4181        let mut t = task("blocked run with a surviving branch");
4182        t.start("run-1".to_owned());
4183        t.fail("blocked with major findings", 5);
4184        assert_eq!(t.status, TaskStatus::Failed);
4185
4186        t.request_review("magi/eba2/A".to_owned());
4187        assert_eq!(t.status, TaskStatus::Queued);
4188        assert_eq!(t.attempts, 0);
4189        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
4190
4191        // An ordinary release (a human overriding the choice) drops it again.
4192        t.release();
4193        assert!(t.review_branch.is_none());
4194    }
4195
4196    #[test]
4197    fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
4198        let mut t = task("retry");
4199        t.start("run-1".to_owned());
4200        t.requeue();
4201        assert!(t.fresh_start);
4202
4203        t.release();
4204        assert!(!t.fresh_start);
4205    }
4206
4207    #[test]
4208    fn urgent_only_changes_queued_or_held_and_preserves_settings() {
4209        for status in [
4210            TaskStatus::Queued,
4211            TaskStatus::Held,
4212            TaskStatus::Running,
4213            TaskStatus::Done,
4214            TaskStatus::Failed,
4215            TaskStatus::Blocked,
4216            TaskStatus::Parked,
4217        ] {
4218            let mut t = task("urgent settings");
4219            t.status = status;
4220            t.attempts = 2;
4221            t.hold_reason = Some("waiting".into());
4222            t.priority = 7;
4223            t.interrupt = true;
4224            let before = serde_json::to_value(&t).unwrap();
4225            for urgent in [true, true, false, false] {
4226                let result = t.set_urgent(urgent);
4227                let mut expected = before.clone();
4228                if matches!(status, TaskStatus::Queued | TaskStatus::Held) {
4229                    result.unwrap();
4230                    expected["urgent"] = urgent.into();
4231                } else {
4232                    let error = result.unwrap_err().to_string();
4233                    assert!(error.contains(status.as_str()), "{error}");
4234                    assert!(error.contains("queued or held"), "{error}");
4235                }
4236                assert_eq!(serde_json::to_value(&t).unwrap(), expected);
4237            }
4238        }
4239    }
4240
4241    #[test]
4242    fn priority_can_be_changed_while_queued_but_not_while_running() {
4243        let mut t = task("reprioritise me");
4244        t.set_priority(5).unwrap();
4245        assert_eq!(t.priority, 5);
4246
4247        t.start("run-1".to_owned());
4248        let err = t.set_priority(9).unwrap_err().to_string();
4249        assert!(err.contains("running"), "{err}");
4250        assert_eq!(t.priority, 5, "the rejected write must not partially apply");
4251    }
4252
4253    #[test]
4254    fn interrupt_can_be_marked_while_queued_but_not_while_running() {
4255        let mut t = task("interrupt me");
4256        assert!(!t.interrupt, "off unless asked, same as any other task");
4257
4258        t.set_interrupt(true).unwrap();
4259        assert!(t.interrupt);
4260
4261        t.start("run-1".to_owned());
4262        assert!(
4263            !t.interrupt,
4264            "the mark is one-shot: dispatching the task fulfils it, \
4265             whatever the run that follows ends up doing"
4266        );
4267        let err = t.set_interrupt(true).unwrap_err().to_string();
4268        assert!(err.contains("running"), "{err}");
4269        // Clearing is always allowed, even on a running task - there is
4270        // nothing left for it to interrupt once it has been claimed.
4271        t.set_interrupt(false).unwrap();
4272        assert!(!t.interrupt);
4273    }
4274
4275    /// R2-1-1: a task whose run fails and requeues must not go on
4276    /// re-triggering `crate::daemon`'s interrupt scheduler on every later
4277    /// boundary, attempt after attempt, until it exhausts its budget.
4278    #[test]
4279    fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
4280        let mut t = task("interrupt me");
4281        t.set_interrupt(true).unwrap();
4282        t.start("run-1".to_owned());
4283        t.fail("mock failure", 5);
4284        assert_eq!(t.status, TaskStatus::Failed);
4285        assert!(
4286            !t.interrupt,
4287            "one attempt already spent the mark; a retry is an ordinary \
4288             requeue, not a fresh interrupt request"
4289        );
4290    }
4291
4292    #[test]
4293    fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
4294        let (_dir, q) = queue();
4295        let mut a = task("first filed");
4296        let mut b = task("second filed");
4297        a.id = "20260101-000001-aaaa".to_owned();
4298        b.id = "20260101-000002-bbbb".to_owned();
4299        q.put(&mut a).unwrap();
4300        q.put(&mut b).unwrap();
4301
4302        assert_eq!(
4303            q.next_runnable().unwrap().id,
4304            a.id,
4305            "with equal priority the older task goes first, so a burst of \
4306             new work cannot starve it"
4307        );
4308        assert_eq!(
4309            q.list()[0].id,
4310            b.id,
4311            "but the list an operator reads is newest first, the same as \
4312             before priority existed - a's turn to run does not make it the \
4313             newest task"
4314        );
4315
4316        let mut a = q.get(&a.id).unwrap();
4317        a.set_priority(10).unwrap();
4318        q.put(&mut a).unwrap();
4319
4320        assert_eq!(
4321            q.next_runnable().unwrap().id,
4322            a.id,
4323            "a raised priority must be reflected the moment it is saved"
4324        );
4325        // `magi task list` and `GET /api/queue` both print `Queue::list()`
4326        // directly, so the raised task has to lead there too - not only in
4327        // what the loop would claim next.
4328        assert_eq!(
4329            q.list()[0].id,
4330            a.id,
4331            "the raised task must sort first in the list an operator reads, \
4332             not only in next_runnable's own ordering"
4333        );
4334    }
4335
4336    #[test]
4337    fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
4338        let mut t = Task::new(
4339            "old title".to_owned(),
4340            "old instruction".to_owned(),
4341            PathBuf::from("/repo"),
4342            Source::Agent {
4343                run: "20260101-000000-beef".to_owned(),
4344                node: "implement".to_owned(),
4345            },
4346        );
4347        let id = t.id.clone();
4348        let created_at = t.created_at;
4349        t.runs.push("20260101-000000-beef".to_owned());
4350
4351        t.edit("new title".to_owned(), "new instruction".to_owned())
4352            .unwrap();
4353
4354        assert_eq!(t.title, "new title");
4355        assert_eq!(t.instruction, "new instruction");
4356        assert_eq!(t.id, id, "editing must not mint a new id");
4357        assert_eq!(t.created_at, created_at);
4358        assert_eq!(
4359            t.source,
4360            Source::Agent {
4361                run: "20260101-000000-beef".to_owned(),
4362                node: "implement".to_owned(),
4363            },
4364            "editing must not turn agent attribution into human"
4365        );
4366        assert_eq!(t.runs, ["20260101-000000-beef"]);
4367    }
4368
4369    #[test]
4370    fn editing_is_refused_once_a_task_is_running_or_finished() {
4371        let mut running = task("in flight");
4372        running.start("run-1".to_owned());
4373        let err = running
4374            .edit("x".to_owned(), "y".to_owned())
4375            .unwrap_err()
4376            .to_string();
4377        assert!(err.contains("running"), "{err}");
4378
4379        let mut done = task("finished");
4380        done.succeed();
4381        let err = done
4382            .edit("x".to_owned(), "y".to_owned())
4383            .unwrap_err()
4384            .to_string();
4385        assert!(err.contains("done"), "{err}");
4386
4387        // Both queued and held are the point of the feature and must work.
4388        let mut queued = task("waiting");
4389        queued.edit("x".to_owned(), "y".to_owned()).unwrap();
4390        let mut held = task("parked");
4391        held.hold_machine(None);
4392        held.edit("x".to_owned(), "y".to_owned()).unwrap();
4393    }
4394
4395    #[test]
4396    fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
4397        let (_dir, q) = queue();
4398        let path = q.path_of("20260101-000000-aaaa");
4399        std::fs::create_dir_all(q.root()).unwrap();
4400        std::fs::write(
4401            &path,
4402            serde_json::json!({
4403                "schema": SCHEMA,
4404                "id": "20260101-000000-aaaa",
4405                "title": "from before hold reasons existed",
4406                "instruction": "from before hold reasons existed",
4407                "repo": ".",
4408                "source": { "kind": "human" },
4409                "status": "held",
4410                "created_at": Timestamp::now().to_string(),
4411                "updated_at": Timestamp::now().to_string(),
4412            })
4413            .to_string(),
4414        )
4415        .unwrap();
4416
4417        let task = q.get("20260101-000000-aaaa").expect("must still read");
4418        assert!(task.hold_reason.is_none());
4419        assert!(task.operator_held());
4420    }
4421
4422    #[test]
4423    fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
4424        let (_dir, q) = queue();
4425        let path = q.path_of("20260101-000000-bbbb");
4426        std::fs::create_dir_all(q.root()).unwrap();
4427        std::fs::write(
4428            &path,
4429            serde_json::json!({
4430                "schema": 2,
4431                "id": "20260101-000000-bbbb",
4432                "title": "old manual recovery",
4433                "instruction": "old manual recovery",
4434                "repo": ".",
4435                "source": { "kind": "human" },
4436                "status": "held",
4437                "hold_reason": "active manual recovery run20260912-224242-daf5",
4438                "created_at": Timestamp::now().to_string(),
4439                "updated_at": Timestamp::now().to_string(),
4440            })
4441            .to_string(),
4442        )
4443        .unwrap();
4444
4445        let task = q.get("20260101-000000-bbbb").expect("must still read");
4446        assert_eq!(task.hold_source, None);
4447        assert!(task.operator_held());
4448    }
4449
4450    #[test]
4451    fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
4452        let (_dir, q) = queue();
4453        let path = q.path_of("20260101-000000-aaaa");
4454        std::fs::create_dir_all(q.root()).unwrap();
4455        std::fs::write(
4456            &path,
4457            serde_json::json!({
4458                "schema": SCHEMA,
4459                "id": "20260101-000000-aaaa",
4460                "title": "from before diagnostics existed",
4461                "instruction": "from before diagnostics existed",
4462                "repo": ".",
4463                "source": { "kind": "human" },
4464                "status": "held",
4465                "created_at": Timestamp::now().to_string(),
4466                "updated_at": Timestamp::now().to_string(),
4467            })
4468            .to_string(),
4469        )
4470        .unwrap();
4471
4472        let task = q.get("20260101-000000-aaaa").expect("must still read");
4473        assert!(task.diagnostic.is_none());
4474    }
4475
4476    #[test]
4477    fn a_schema_1_task_with_no_blocking_fields_still_reads() {
4478        // Written by a build that predates `blocked_by`, `block_reason`,
4479        // `answers` and `review_branch` entirely - literal `"schema": 1`,
4480        // not `SCHEMA`, since the whole point is a build older than this one.
4481        let (_dir, q) = queue();
4482        let path = q.path_of("20260101-000000-aaaa");
4483        std::fs::create_dir_all(q.root()).unwrap();
4484        std::fs::write(
4485            &path,
4486            serde_json::json!({
4487                "schema": 1,
4488                "id": "20260101-000000-aaaa",
4489                "title": "from before blocking existed",
4490                "instruction": "from before blocking existed",
4491                "repo": ".",
4492                "source": { "kind": "human" },
4493                "status": "queued",
4494                "created_at": Timestamp::now().to_string(),
4495                "updated_at": Timestamp::now().to_string(),
4496            })
4497            .to_string(),
4498        )
4499        .unwrap();
4500
4501        let task = q.get("20260101-000000-aaaa").expect("must still read");
4502        assert!(task.blocked_by.is_empty());
4503        assert!(task.block_reason.is_none());
4504        assert!(task.answers.is_empty());
4505        assert!(task.review_branch.is_none());
4506    }
4507
4508    #[test]
4509    fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
4510        // A diagnostic belongs to the run that produced it. Left in place
4511        // across a release, an unrelated later failure - a config error, say -
4512        // would go on showing evidence for a problem that is no longer why the
4513        // task is stuck.
4514        let mut held = task("diagnosed");
4515        held.start("run-1".to_owned());
4516        held.fail("gate red", 1);
4517        held.diagnostic = Some("cargo test failed: ...".to_owned());
4518        assert_eq!(held.status, TaskStatus::Held);
4519
4520        held.release();
4521        assert!(held.diagnostic.is_none());
4522
4523        held.diagnostic = Some("cargo test failed: ...".to_owned());
4524        held.succeed();
4525        assert!(held.diagnostic.is_none());
4526    }
4527
4528    #[test]
4529    fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
4530        let mut t = task("retried");
4531        t.start("run-1".to_owned());
4532        t.diagnostic = Some("stale evidence from a previous hold".to_owned());
4533        t.fail("unrelated config error", 5);
4534        assert_eq!(t.status, TaskStatus::Failed);
4535        assert!(
4536            t.diagnostic.is_none(),
4537            "fail() must not let an old diagnostic outlive the run that produced it"
4538        );
4539    }
4540
4541    #[test]
4542    fn a_claim_is_exclusive_and_releases_on_drop() {
4543        let (_dir, q) = queue();
4544        let mut t = task("contended");
4545        q.put(&mut t).unwrap();
4546
4547        let held = q.claim(&t.id).unwrap();
4548        assert!(
4549            q.claim(&t.id).is_err(),
4550            "two daemons must not drive one task into two runs"
4551        );
4552        drop(held);
4553        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
4554    }
4555
4556    #[test]
4557    fn a_round_trip_survives_disk() {
4558        let (_dir, q) = queue();
4559        let mut t = Task::new(
4560            "titled".to_owned(),
4561            "body".to_owned(),
4562            PathBuf::from("/repo"),
4563            Source::Agent {
4564                run: "20260101-000000-beef".to_owned(),
4565                node: "implement".to_owned(),
4566            },
4567        );
4568        t.priority = 3;
4569        q.put(&mut t).unwrap();
4570
4571        let back = q.get(&t.id).unwrap();
4572        assert_eq!(back.id, t.id);
4573        assert_eq!(back.priority, 3);
4574        assert_eq!(back.source.label(), "implement@beef");
4575        // A prefix is enough, the way run ids work everywhere else.
4576        assert_eq!(q.get(t.short()).unwrap().id, t.id);
4577    }
4578
4579    #[test]
4580    fn an_unreadable_task_does_not_take_the_queue_down() {
4581        let (_dir, q) = queue();
4582        let mut t = task("fine");
4583        q.put(&mut t).unwrap();
4584        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
4585
4586        let listed = q.list();
4587        assert_eq!(listed.len(), 1, "the readable task still lists");
4588        assert_eq!(listed[0].id, t.id);
4589    }
4590
4591    #[test]
4592    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
4593        let (_dir, q) = queue();
4594        let path = q.path_of("20260101-000000-aaaa");
4595        std::fs::create_dir_all(q.root()).unwrap();
4596        std::fs::write(
4597            &path,
4598            serde_json::json!({
4599                "schema": SCHEMA,
4600                "id": "20260101-000000-aaaa",
4601                "title": "from before solo existed",
4602                "instruction": "from before solo existed",
4603                "repo": ".",
4604                "source": { "kind": "human" },
4605                "status": "queued",
4606                "created_at": Timestamp::now().to_string(),
4607                "updated_at": Timestamp::now().to_string(),
4608            })
4609            .to_string(),
4610        )
4611        .unwrap();
4612
4613        let task = q.get("20260101-000000-aaaa").expect("must still read");
4614        assert!(!task.solo, "a queue file with no `solo` field means false");
4615    }
4616
4617    #[test]
4618    fn a_task_recorded_without_an_urgent_field_still_reads_as_not_urgent() {
4619        let (_dir, q) = queue();
4620        let path = q.path_of("20260101-000000-bbbb");
4621        std::fs::create_dir_all(q.root()).unwrap();
4622        std::fs::write(
4623            &path,
4624            serde_json::json!({
4625                "schema": SCHEMA,
4626                "id": "20260101-000000-bbbb",
4627                "title": "from before urgent existed",
4628                "instruction": "from before urgent existed",
4629                "repo": ".",
4630                "source": { "kind": "human" },
4631                "status": "queued",
4632                "created_at": Timestamp::now().to_string(),
4633                "updated_at": Timestamp::now().to_string(),
4634            })
4635            .to_string(),
4636        )
4637        .unwrap();
4638
4639        let task = q.get("20260101-000000-bbbb").expect("must still read");
4640        assert!(
4641            !task.urgent,
4642            "a queue file with no `urgent` field means false, same as `solo`"
4643        );
4644    }
4645
4646    #[test]
4647    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
4648        let (_dir, q) = queue();
4649        let mut t = task("from the future");
4650        q.put(&mut t).unwrap();
4651        let path = q.path_of(&t.id);
4652        let body = std::fs::read_to_string(&path)
4653            .unwrap()
4654            .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
4655        std::fs::write(&path, body).unwrap();
4656
4657        let err = q.get(&t.id).unwrap_err().to_string();
4658        assert!(err.contains("schema 99"), "{err}");
4659    }
4660
4661    #[test]
4662    fn revision_moves_when_the_queue_changes() {
4663        let (_dir, q) = queue();
4664        assert_eq!(q.revision(), 0, "an empty queue has no revision");
4665        let mut t = task("first");
4666        q.put(&mut t).unwrap();
4667        assert!(q.revision() > 0, "a written task moves the revision");
4668    }
4669
4670    #[test]
4671    fn revision_moves_when_deleting_an_older_task() {
4672        let (dir, q) = queue();
4673        let questions = Questions::at(dir.path().join("questions"));
4674        let mut t1 = task("older");
4675        q.put(&mut t1).unwrap();
4676        // Ensure mtime ticks forward.
4677        std::thread::sleep(std::time::Duration::from_millis(10));
4678        let mut t2 = task("newer");
4679        q.put(&mut t2).unwrap();
4680
4681        let rev_before = q.revision();
4682        q.remove(&t1.id, false, &questions).unwrap();
4683        let rev_after = q.revision();
4684
4685        assert_ne!(
4686            rev_before, rev_after,
4687            "deleting an older task must change the revision so other clients see the deletion"
4688        );
4689    }
4690
4691    #[test]
4692    fn removing_a_task_takes_it_out_of_the_listing() {
4693        let (dir, q) = queue();
4694        let questions = Questions::at(dir.path().join("questions"));
4695        let mut t = task("delete me");
4696        q.put(&mut t).unwrap();
4697        let removed = q.remove(t.short(), false, &questions).unwrap();
4698        assert_eq!(removed.id, t.id, "a prefix resolves before deleting");
4699        assert!(removed.released.is_empty() && removed.still_blocked.is_empty());
4700        assert!(q.list().is_empty());
4701        assert!(
4702            q.remove(&t.id, false, &questions).is_err(),
4703            "removing twice is an error"
4704        );
4705    }
4706
4707    #[test]
4708    fn removing_a_task_takes_its_stale_lock_with_it() {
4709        let (dir, q) = queue();
4710        let questions = Questions::at(dir.path().join("questions"));
4711        let mut t = task("interrupted");
4712        q.put(&mut t).unwrap();
4713
4714        // A daemon killed mid-run leaves this behind. Nothing holds it: the
4715        // process that would have dropped the guard is gone.
4716        let claim = q.claim(&t.id).unwrap();
4717        std::mem::forget(claim);
4718        assert!(
4719            q.claim(&t.id).is_err(),
4720            "the orphaned lock is what makes the task look claimed"
4721        );
4722
4723        // A live daemon on this task is refused, whatever the lock says.
4724        let err = q.remove(&t.id, true, &questions).unwrap_err().to_string();
4725        assert!(err.contains("live daemon"), "{err}");
4726        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
4727
4728        // With no daemon behind it, the lock is stale and goes with the task.
4729        q.remove(&t.id, false, &questions).unwrap();
4730        assert!(q.list().is_empty());
4731        let mut again = task("interrupted");
4732        again.id = t.id.clone();
4733        q.put(&mut again).unwrap();
4734        assert!(
4735            q.claim(&t.id).is_ok(),
4736            "a task that comes back must be claimable, which a left-behind lock would prevent"
4737        );
4738    }
4739
4740    fn notices_of(dir: &Path) -> Vec<crate::notices::Notice> {
4741        crate::notices::Notices::at(dir.join("notifications")).list()
4742    }
4743
4744    #[test]
4745    fn removing_a_sole_dependency_releases_the_dependent_without_a_hold() {
4746        let (dir, q) = queue();
4747        let questions = Questions::at(dir.path().join("questions"));
4748        let mut dep = task("dependency");
4749        q.put(&mut dep).unwrap();
4750        let mut blocked = task("waiting");
4751        blocked.block(vec![dep.id.clone()], Some("waits".to_owned()));
4752        q.put(&mut blocked).unwrap();
4753
4754        let removed = q.remove(&dep.id, false, &questions).unwrap();
4755        assert_eq!(removed.released, [blocked.id.clone()]);
4756        assert!(removed.still_blocked.is_empty());
4757
4758        let after = q.get(&blocked.id).unwrap();
4759        assert_eq!(after.status, TaskStatus::Queued);
4760        assert!(after.blocked_by.is_empty());
4761        assert!(after.block_reason.is_none());
4762        let notes = notices_of(dir.path());
4763        assert_eq!(notes.len(), 1, "{notes:?}");
4764        assert_eq!(notes[0].severity, crate::notices::Severity::Info);
4765        assert_eq!(
4766            notes[0].link,
4767            Some(crate::notices::Link::Task {
4768                id: blocked.id.clone(),
4769            })
4770        );
4771    }
4772
4773    #[test]
4774    fn removing_one_of_two_dependencies_keeps_the_other() {
4775        let (dir, q) = queue();
4776        let questions = Questions::at(dir.path().join("questions"));
4777        let mut dep = task("dependency");
4778        q.put(&mut dep).unwrap();
4779        let mut other = task("other");
4780        q.put(&mut other).unwrap();
4781        let mut blocked = task("waiting");
4782        blocked.block(
4783            vec![dep.id.clone(), other.id.clone()],
4784            Some("waits on both".to_owned()),
4785        );
4786        q.put(&mut blocked).unwrap();
4787
4788        let removed = q.remove(&dep.id, false, &questions).unwrap();
4789        assert!(removed.released.is_empty());
4790        assert_eq!(removed.still_blocked, [blocked.id.clone()]);
4791
4792        let after = q.get(&blocked.id).unwrap();
4793        assert_eq!(after.status, TaskStatus::Blocked);
4794        assert_eq!(after.blocked_by, [other.id.clone()]);
4795        // The tombstone stays while a task still names the id... only this
4796        // one's dependency is gone, so nothing references it and it may go.
4797        assert!(missing_blockers(&q, &questions, &after.blocked_by).is_empty());
4798    }
4799
4800    #[test]
4801    fn a_dependency_deleted_before_its_dependents_were_rewritten_is_released_later() {
4802        let (dir, q) = queue();
4803        let questions = Questions::at(dir.path().join("questions"));
4804        let mut dep = task("dependency");
4805        q.put(&mut dep).unwrap();
4806        let mut blocked = task("waiting");
4807        blocked.block(vec![dep.id.clone()], None);
4808        q.put(&mut blocked).unwrap();
4809
4810        // The dependent is claimed elsewhere, so `remove` has to skip it.
4811        let claim = q.claim(&blocked.id).unwrap();
4812        let removed = q.remove(&dep.id, false, &questions).unwrap();
4813        assert!(removed.released.is_empty());
4814        drop(claim);
4815
4816        let mut task = q.get(&blocked.id).unwrap();
4817        assert_eq!(task.status, TaskStatus::Blocked);
4818        assert!(missing_blockers(&q, &questions, &task.blocked_by).is_empty());
4819        assert_eq!(
4820            q.apply_deleted_blockers(&mut task, &questions),
4821            [dep.id.clone()]
4822        );
4823        assert_eq!(task.status, TaskStatus::Queued);
4824    }
4825
4826    #[test]
4827    fn dependency_resolution_short_deleted_reference_releases_only_its_waiters() {
4828        let (dir, q) = queue();
4829        let questions = Questions::at(dir.path().join("questions"));
4830        let mut dep = task("dependency");
4831        dep.id = "20261013-193611-ca5b".into();
4832        q.put(&mut dep).unwrap();
4833        let mut waiting = task("waiting");
4834        waiting.block(vec!["ca5b".into()], None);
4835        q.put(&mut waiting).unwrap();
4836        q.remove(&dep.id, false, &questions).unwrap();
4837        assert_eq!(q.get(&waiting.id).unwrap().status, TaskStatus::Queued);
4838        assert_eq!(
4839            dependency(&q, &questions, "ca5b"),
4840            Dependency::Deleted(dep.id)
4841        );
4842    }
4843
4844    #[test]
4845    fn dependency_resolution_preserves_cross_store_ambiguity_and_exact_ids() {
4846        let (dir, queue) = queue();
4847        let questions = Questions::at(dir.path().join("questions"));
4848        let mut dep = task("dependency");
4849        dep.id = "20261013-193611-ca5b".into();
4850        queue.put(&mut dep).unwrap();
4851        std::fs::create_dir_all(questions.root()).unwrap();
4852        let question_id = "20261014-193611-ca5b";
4853        std::fs::write(questions.path_of(question_id), "unreadable question").unwrap();
4854        assert_eq!(questions.resolve_id("ca5b").unwrap(), question_id);
4855        assert_eq!(
4856            dependency(&queue, &questions, "ca5b"),
4857            Dependency::Ambiguous
4858        );
4859        assert_eq!(
4860            dependency(&queue, &questions, &dep.id),
4861            Dependency::Task(dep.id)
4862        );
4863        assert_eq!(
4864            dependency(&queue, &questions, question_id),
4865            Dependency::Question(question_id.into())
4866        );
4867        assert_eq!(
4868            dependency(&queue, &questions, "missing"),
4869            Dependency::Missing
4870        );
4871    }
4872
4873    #[test]
4874    fn dependency_resolution_directory_errors_are_unknown_not_missing() {
4875        let dir = tempfile::tempdir().unwrap();
4876        let q = Queue::at(dir.path().join("not-a-directory"));
4877        std::fs::write(q.root(), "file").unwrap();
4878        let questions = Questions::at(dir.path().join("questions"));
4879        assert_eq!(dependency(&q, &questions, "ca5b"), Dependency::Unknown);
4880        assert!(missing_blockers(&q, &questions, &["ca5b".into()]).is_empty());
4881    }
4882
4883    #[test]
4884    fn a_dependency_without_a_tombstone_is_still_missing() {
4885        let (dir, q) = queue();
4886        let questions = Questions::at(dir.path().join("questions"));
4887        let mut dep = task("dependency");
4888        q.put(&mut dep).unwrap();
4889        std::fs::remove_file(q.path_of(&dep.id)).unwrap();
4890        assert_eq!(
4891            missing_blockers(&q, &questions, std::slice::from_ref(&dep.id)),
4892            [dep.id.clone()]
4893        );
4894        assert!(deleted_blockers(&q, &questions, std::slice::from_ref(&dep.id)).is_empty());
4895    }
4896
4897    #[test]
4898    fn a_held_dependent_is_left_alone_by_a_dependency_deletion() {
4899        let mut t = task("held");
4900        t.hold_machine(Some("because".to_owned()));
4901        t.blocked_by = vec!["gone".to_owned()];
4902        assert!(!t.dependency_deleted("gone"));
4903        assert_eq!(t.status, TaskStatus::Held);
4904    }
4905
4906    #[test]
4907    fn a_failed_record_removal_takes_the_tombstone_back() {
4908        let (_dir, q) = queue();
4909        let mut t = task("stays");
4910        q.put(&mut t).unwrap();
4911        q.write_tombstone(&t.id).unwrap();
4912        let err = q.remove_record_with_attachments(&t.id, |_| Err(std::io::Error::other("nope")));
4913        assert!(err.is_err());
4914        // `remove` is what withdraws the marker; a live record is never
4915        // treated as deleted whatever is on disk.
4916        assert!(!q.was_deleted(&t.id));
4917    }
4918
4919    fn source_file(dir: &Path, name: &str, body: &str) -> PathBuf {
4920        let p = dir.join(name);
4921        std::fs::write(&p, body).unwrap();
4922        p
4923    }
4924
4925    #[test]
4926    fn an_attachment_copy_survives_deleting_its_source() {
4927        let (dir, q) = queue();
4928        let src = source_file(dir.path(), "shot.png", "pixels");
4929        let mut t = task("with a picture");
4930        let names = q.attach(&mut t, std::slice::from_ref(&src)).unwrap();
4931        q.put(&mut t).unwrap();
4932        std::fs::remove_file(&src).unwrap();
4933        assert_eq!(names, ["shot.png"]);
4934        let loaded = q.get(&t.id).unwrap();
4935        let paths = q.attachment_paths(&loaded);
4936        assert_eq!(paths.len(), 1);
4937        assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "pixels");
4938    }
4939
4940    #[test]
4941    fn attachment_names_that_could_traverse_or_are_odd_are_refused() {
4942        let (dir, q) = queue();
4943        let mut t = task("bad names");
4944        for name in ["a..b.png", ".hidden", "with space.png", "-x.png"] {
4945            let src = source_file(dir.path(), name, "x");
4946            assert!(
4947                q.attach(&mut t, &[src]).is_err(),
4948                "`{name}` must be refused"
4949            );
4950        }
4951        // A drive-qualified name cannot exist as a file on Windows (joining it
4952        // to a directory yields a drive-relative path to `foo.png` instead),
4953        // so the rule is asserted on the name itself, everywhere.
4954        assert!(!crate::ask::valid_asset_name("C:foo.png"));
4955        #[cfg(not(windows))]
4956        {
4957            let src = source_file(dir.path(), "C:foo.png", "x");
4958            assert!(q.attach(&mut t, &[src]).is_err());
4959        }
4960        let long = format!("{}.png", "a".repeat(70));
4961        let src = source_file(dir.path(), &long, "x");
4962        assert!(q.attach(&mut t, &[src]).is_err());
4963        assert!(t.attachments.is_empty());
4964        assert!(!q.attachments_dir(&t.id).exists());
4965    }
4966
4967    #[test]
4968    fn a_taken_attachment_name_is_numbered_not_overwritten() {
4969        let (dir, q) = queue();
4970        let a = source_file(dir.path(), "shot.png", "one");
4971        let sub = dir.path().join("other");
4972        std::fs::create_dir_all(&sub).unwrap();
4973        let b = source_file(&sub, "shot.png", "two");
4974        let mut t = task("collision");
4975        q.attach(&mut t, &[a]).unwrap();
4976        q.attach(&mut t, &[b]).unwrap();
4977        assert_eq!(t.attachments, ["shot.png", "shot-2.png"]);
4978        let paths = q.attachment_paths(&t);
4979        assert_eq!(std::fs::read_to_string(&paths[0]).unwrap(), "one");
4980        assert_eq!(std::fs::read_to_string(&paths[1]).unwrap(), "two");
4981    }
4982
4983    #[test]
4984    fn a_renumbered_name_stays_inside_the_length_bound() {
4985        let (dir, q) = queue();
4986        let name = format!("{}.png", "a".repeat(60));
4987        assert_eq!(name.len(), 64);
4988        let a = source_file(dir.path(), &name, "one");
4989        let sub = dir.path().join("other");
4990        std::fs::create_dir_all(&sub).unwrap();
4991        let b = source_file(&sub, &name, "two");
4992        let mut t = task("long");
4993        q.attach(&mut t, &[a, b]).unwrap();
4994        assert_eq!(t.attachments.len(), 2);
4995        assert!(
4996            t.attachments
4997                .iter()
4998                .all(|n| crate::ask::valid_asset_name(n))
4999        );
5000        assert!(t.attachments[1].ends_with("-2.png"));
5001    }
5002
5003    #[test]
5004    fn a_failed_attach_keeps_existing_attachments_and_leaves_no_partial_copy() {
5005        let (dir, q) = queue();
5006        let good = source_file(dir.path(), "good.png", "ok");
5007        let mut t = task("partial");
5008        q.attach(&mut t, &[good]).unwrap();
5009        let more = source_file(dir.path(), "more.png", "ok");
5010        let missing = dir.path().join("missing.png");
5011        assert!(q.attach(&mut t, &[more, missing]).is_err());
5012        assert_eq!(t.attachments, ["good.png"]);
5013        let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5014            .unwrap()
5015            .flatten()
5016            .collect();
5017        assert_eq!(on_disk.len(), 1);
5018    }
5019
5020    /// Make the next `put` of `t` fail: its temp file's path is a directory.
5021    fn block_put(q: &Queue, t: &Task) -> PathBuf {
5022        let tmp = q.path_of(&t.id).with_extension("json.tmp");
5023        std::fs::create_dir_all(&tmp).unwrap();
5024        tmp
5025    }
5026
5027    #[test]
5028    fn a_failed_put_leaves_no_new_attachment_directory() {
5029        let (dir, q) = queue();
5030        let mut t = task("fresh");
5031        let tmp = block_put(&q, &t);
5032        let src = source_file(dir.path(), "shot.png", "x");
5033        assert!(q.attach_and_put(&mut t, &[src]).is_err());
5034        assert!(t.attachments.is_empty());
5035        assert!(!q.attachments_dir(&t.id).exists());
5036        assert!(!q.path_of(&t.id).exists());
5037        std::fs::remove_dir(tmp).unwrap();
5038    }
5039
5040    #[test]
5041    fn a_failed_put_removes_only_the_copy_it_just_made() {
5042        let (dir, q) = queue();
5043        let mut t = task("edited");
5044        let first = source_file(dir.path(), "first.png", "1");
5045        q.attach_and_put(&mut t, &[first]).unwrap();
5046        block_put(&q, &t);
5047        let second = source_file(dir.path(), "second.png", "2");
5048        assert!(q.attach_and_put(&mut t, &[second]).is_err());
5049        assert_eq!(t.attachments, ["first.png"]);
5050        let on_disk: Vec<_> = std::fs::read_dir(q.attachments_dir(&t.id))
5051            .unwrap()
5052            .flatten()
5053            .map(|e| e.file_name().to_string_lossy().into_owned())
5054            .collect();
5055        assert_eq!(on_disk, ["first.png"]);
5056        assert_eq!(q.get(&t.id).unwrap().attachments, ["first.png"]);
5057    }
5058
5059    #[test]
5060    fn a_leftover_removing_directory_is_swept_by_the_next_removal() {
5061        let (dir, q) = queue();
5062        let questions = Questions::at(dir.path().join("questions"));
5063        let gone = task("gone");
5064        let mut other = task("other");
5065        let mut live = task("live");
5066        q.put(&mut other).unwrap();
5067        q.put(&mut live).unwrap();
5068        // `gone` has no record: its removal finished deleting the record but
5069        // not the set-aside attachments.
5070        let orphan = q.root.join(format!("{}.attachments.removing", gone.id));
5071        std::fs::create_dir_all(&orphan).unwrap();
5072        std::fs::write(orphan.join("shot.png"), "x").unwrap();
5073        // `live` still has a record, so its `.removing` belongs to a removal
5074        // in progress and must be left alone.
5075        let busy = q.root.join(format!("{}.attachments.removing", live.id));
5076        std::fs::create_dir_all(&busy).unwrap();
5077
5078        q.remove(&other.id, false, &questions).unwrap();
5079        assert!(!orphan.exists(), "an orphan is swept");
5080        assert!(busy.exists(), "a removal in progress is left alone");
5081    }
5082
5083    #[test]
5084    fn a_blocked_aside_rename_fails_the_removal_and_loses_nothing() {
5085        let (dir, q) = queue();
5086        let questions = Questions::at(dir.path().join("questions"));
5087        let mut t = task("stuck");
5088        let src = source_file(dir.path(), "shot.png", "x");
5089        q.attach_and_put(&mut t, &[src]).unwrap();
5090        // A non-empty directory already at the set-aside name makes the rename
5091        // fail; its record is present, so the sweep leaves it in place.
5092        let aside = q.root.join(format!("{}.attachments.removing", t.id));
5093        std::fs::create_dir_all(&aside).unwrap();
5094        std::fs::write(aside.join("old.png"), "o").unwrap();
5095        assert!(q.remove(&t.id, false, &questions).is_err());
5096        assert!(q.path_of(&t.id).exists());
5097        assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5098    }
5099
5100    #[test]
5101    fn a_failed_record_removal_puts_the_attachments_back() {
5102        let (dir, q) = queue();
5103        let mut t = task("rollback");
5104        let src = source_file(dir.path(), "shot.png", "x");
5105        q.attach_and_put(&mut t, &[src]).unwrap();
5106        let err = q
5107            .remove_record_with_attachments(&t.id, |_| {
5108                Err(std::io::Error::other("injected failure"))
5109            })
5110            .unwrap_err();
5111        assert!(format!("{err:#}").contains("injected failure"));
5112        assert!(q.path_of(&t.id).exists());
5113        assert!(q.attachments_dir(&t.id).join("shot.png").is_file());
5114        assert!(
5115            !q.root
5116                .join(format!("{}.attachments.removing", t.id))
5117                .exists()
5118        );
5119    }
5120
5121    #[test]
5122    fn editing_a_task_keeps_its_attachments() {
5123        let (dir, q) = queue();
5124        let src = source_file(dir.path(), "shot.png", "x");
5125        let mut t = task("editable");
5126        q.attach(&mut t, &[src]).unwrap();
5127        t.edit("new".to_owned(), "new text".to_owned()).unwrap();
5128        q.put(&mut t).unwrap();
5129        assert_eq!(q.get(&t.id).unwrap().attachments, ["shot.png"]);
5130    }
5131
5132    #[test]
5133    fn removing_a_task_deletes_its_attachments() {
5134        let (dir, q) = queue();
5135        let questions = Questions::at(dir.path().join("questions"));
5136        let src = source_file(dir.path(), "shot.png", "x");
5137        let mut t = task("doomed");
5138        q.attach(&mut t, &[src]).unwrap();
5139        q.put(&mut t).unwrap();
5140        assert!(q.attachments_dir(&t.id).is_dir());
5141        q.remove(&t.id, false, &questions).unwrap();
5142        assert!(!q.attachments_dir(&t.id).exists());
5143        assert!(q.list().is_empty());
5144    }
5145
5146    #[test]
5147    fn a_task_written_before_attachments_still_reads() {
5148        let (_dir, q) = queue();
5149        let mut t = task("old");
5150        q.put(&mut t).unwrap();
5151        let path = q.path_of(&t.id);
5152        let mut v: serde_json::Value =
5153            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
5154        v.as_object_mut().unwrap().remove("attachments");
5155        std::fs::write(&path, v.to_string()).unwrap();
5156        assert!(q.get(&t.id).unwrap().attachments.is_empty());
5157    }
5158
5159    #[test]
5160    fn attachment_paths_are_absolute_even_when_the_root_is_relative() {
5161        let q = Queue::at(PathBuf::from("relative-queue"));
5162        let mut t = task("rel");
5163        t.attachments.push("shot.png".to_owned());
5164        let paths = q.attachment_paths(&t);
5165        assert!(paths[0].is_absolute(), "{}", paths[0].display());
5166        assert!(paths[0].ends_with(format!("{}.attachments/shot.png", t.id)));
5167    }
5168
5169    fn upgrade_parked(id: &str, started: &str) -> Task {
5170        let mut t = task(id);
5171        t.id = id.into();
5172        t.start(format!("run-{id}"));
5173        t.park("upgrade");
5174        t.record_pass_at(
5175            &format!("run-{id}"),
5176            0,
5177            Some(started.parse().unwrap()),
5178            1,
5179            Some(crate::run::ParkCause::Upgrade {
5180                from: "old".into(),
5181                to: None,
5182            }),
5183        );
5184        t
5185    }
5186
5187    #[test]
5188    fn upgrade_resume_order_follows_dispatch_claim_not_graph_start() {
5189        // A was claimed first but its graph pass started later (slow start-up).
5190        let stamp = |id: &str, dispatched: &str, started: &str| {
5191            let mut t = task(id);
5192            t.id = id.into();
5193            t.start(format!("run-{id}"));
5194            t.park("upgrade");
5195            t.dispatched_at = Some(dispatched.parse().unwrap());
5196            t.record_pass_at(
5197                &format!("run-{id}"),
5198                0,
5199                Some(started.parse().unwrap()),
5200                1,
5201                Some(crate::run::ParkCause::Upgrade {
5202                    from: "old".into(),
5203                    to: None,
5204                }),
5205            );
5206            t
5207        };
5208        let a = stamp("a", "2026-10-22T00:00:01Z", "2026-10-22T00:00:09Z");
5209        let b = stamp("b", "2026-10-22T00:00:02Z", "2026-10-22T00:00:03Z");
5210        assert!(dispatch_order(&a, &b).is_lt());
5211    }
5212
5213    #[test]
5214    fn upgrade_resume_order_survives_queue_reload_and_reversed_settlement() {
5215        let (_dir, q) = queue();
5216        let mut later = upgrade_parked("later", "2026-10-22T00:00:02Z");
5217        let mut earlier = upgrade_parked("earlier", "2026-10-22T00:00:01Z");
5218        earlier.priority = -10;
5219        let mut fresh = task("fresh");
5220        fresh.id = "newer".into();
5221        fresh.priority = 100;
5222        for t in [&mut fresh, &mut later, &mut earlier] {
5223            q.put(t).unwrap();
5224        }
5225        let reloaded = Queue::at(q.root().to_owned());
5226        let mut tasks = reloaded.list();
5227        tasks.sort_unstable_by(dispatch_order);
5228        assert_eq!(
5229            tasks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
5230            vec!["earlier", "later", "newer"]
5231        );
5232        assert_eq!(reloaded.next_runnable().unwrap().id, "earlier");
5233        assert_eq!(reloaded.get("earlier").unwrap().attempts, 0);
5234    }
5235
5236    #[test]
5237    fn upgrade_resume_legacy_order_uses_settlement_and_id() {
5238        let mut a = upgrade_parked("a", "2026-10-22T00:00:02Z");
5239        let mut b = upgrade_parked("b", "2026-10-22T00:00:01Z");
5240        for t in [&mut a, &mut b] {
5241            let mut value = serde_json::to_value(&*t).unwrap();
5242            value["passes"][0]
5243                .as_object_mut()
5244                .unwrap()
5245                .remove("started_at");
5246            *t = serde_json::from_value(value).unwrap();
5247        }
5248        assert!(dispatch_order(&a, &b).is_lt());
5249        b.passes[0].settled_at = a.passes[0].settled_at;
5250        assert!(dispatch_order(&a, &b).is_lt());
5251    }
5252
5253    #[test]
5254    fn upgrade_resume_key_excludes_other_parks_and_explicit_task_changes() {
5255        let original = upgrade_parked("a", "2026-10-22T00:00:01Z");
5256        assert!(original.upgrade_resume_key().is_some());
5257        for cause in [
5258            crate::run::ParkCause::Operator,
5259            crate::run::ParkCause::Question,
5260            crate::run::ParkCause::Interrupt {
5261                reason: "interrupt".into(),
5262            },
5263            crate::run::ParkCause::Unspecified,
5264        ] {
5265            let mut t = original.clone();
5266            t.passes[0].parked = Some(cause);
5267            assert!(t.upgrade_resume_key().is_none());
5268            let mut fresh = task("fresh");
5269            fresh.priority = 1;
5270            assert!(dispatch_order(&fresh, &t).is_lt());
5271        }
5272        let mut t = original.clone();
5273        t.release();
5274        assert!(t.upgrade_resume_key().is_none());
5275        t.edit("edited".into(), "new instruction".into()).unwrap();
5276        assert!(t.upgrade_resume_key().is_none());
5277        let mut t = original.clone();
5278        t.requeue();
5279        t.status = TaskStatus::Parked;
5280        assert!(t.upgrade_resume_key().is_none());
5281        let mut t = original.clone();
5282        t.start("different-run".into());
5283        t.park("no upgrade");
5284        assert!(t.upgrade_resume_key().is_none());
5285    }
5286
5287    #[test]
5288    fn explicitly_resumed_older_run_becomes_current_without_duplicate_history() {
5289        let dir = tempfile::tempdir().unwrap();
5290        let q = Queue::at(dir.path().join("queue"));
5291        let mut t = task("resume");
5292        t.start("A".into());
5293        t.record_pass("A", 0, 1, None);
5294        t.start("B".into());
5295        q.put(&mut t).unwrap();
5296        assert_eq!(t.runs, vec!["A", "B"]);
5297        t.start("A".into());
5298        t.record_pass("A", 1, 3, None);
5299        q.put(&mut t).unwrap();
5300        let saved = q.get(&t.id).unwrap();
5301        assert_eq!(saved.runs, vec!["B", "A"]);
5302        assert_eq!(saved.runs.last().map(String::as_str), Some("A"));
5303        assert_eq!(saved.attempts, 3);
5304        assert_eq!(saved.passes.len(), 2);
5305        assert_eq!(saved.passes[0].run, "A");
5306        assert_eq!(saved.passes[1].run, "A");
5307    }
5308
5309    #[test]
5310    fn repeated_start_records_one_run_and_separate_passes() {
5311        let mut t = task("resume");
5312        t.start("same-run".into());
5313        t.record_pass("same-run", 0, 1, None);
5314        t.start("same-run".into());
5315        t.record_pass("same-run", 1, 2, None);
5316        assert_eq!(t.runs, vec!["same-run"]);
5317        assert_eq!(t.attempts, 2);
5318        assert_eq!(t.passes.len(), 2);
5319    }
5320
5321    #[test]
5322    fn link_run_adds_a_run_once_and_touches_nothing_else() {
5323        let dir = tempfile::tempdir().unwrap();
5324        let queue = Queue::at(dir.path().join("queue"));
5325        let mut t = Task::new(
5326            "t".to_owned(),
5327            "do it".to_owned(),
5328            PathBuf::from("."),
5329            Source::Human,
5330        );
5331        queue.put(&mut t).unwrap();
5332        let before = queue.get(&t.id).unwrap();
5333
5334        let linked = queue.link_run(&t.id[..4], "20260930-092817-ec34").unwrap();
5335        assert_eq!(linked.runs, vec!["20260930-092817-ec34".to_owned()]);
5336        assert_eq!(linked.status, before.status);
5337        assert_eq!(linked.attempts, before.attempts);
5338        assert_eq!(linked.interrupt, before.interrupt);
5339
5340        let again = queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5341        assert_eq!(again.runs.len(), 1, "linking twice must not duplicate");
5342        assert_eq!(queue.get(&t.id).unwrap().runs.len(), 1);
5343        assert!(queue.link_run("no-such-task", "r").is_err());
5344    }
5345
5346    #[test]
5347    fn put_keeps_a_run_linked_after_the_writer_took_its_snapshot() {
5348        let dir = tempfile::tempdir().unwrap();
5349        let queue = Queue::at(dir.path().join("queue"));
5350        let mut t = Task::new(
5351            "t".to_owned(),
5352            "do it".to_owned(),
5353            PathBuf::from("."),
5354            Source::Human,
5355        );
5356        queue.put(&mut t).unwrap();
5357        // The daemon's copy, taken before the link.
5358        let mut snapshot = queue.get(&t.id).unwrap();
5359        queue.link_run(&t.id, "20260930-092817-ec34").unwrap();
5360
5361        snapshot.start("20260930-000000-aaaa".to_owned());
5362        queue.put(&mut snapshot).unwrap();
5363
5364        let stored = queue.get(&t.id).unwrap();
5365        assert!(stored.runs.contains(&"20260930-092817-ec34".to_owned()));
5366        assert!(stored.runs.contains(&"20260930-000000-aaaa".to_owned()));
5367        assert_eq!(stored.attempts, 1);
5368    }
5369
5370    #[test]
5371    fn concurrent_links_and_daemon_saves_lose_nothing() {
5372        let dir = tempfile::tempdir().unwrap();
5373        let queue = Queue::at(dir.path().join("queue"));
5374        let mut t = Task::new(
5375            "t".to_owned(),
5376            "do it".to_owned(),
5377            PathBuf::from("."),
5378            Source::Human,
5379        );
5380        queue.put(&mut t).unwrap();
5381        let id = t.id.clone();
5382
5383        let linkers: Vec<_> = (0..4)
5384            .map(|n| {
5385                let (queue, id) = (queue.clone(), id.clone());
5386                std::thread::spawn(move || {
5387                    for k in 0..10 {
5388                        queue
5389                            .link_run(&id, &format!("20260930-00000{n}-l{k:03}"))
5390                            .unwrap();
5391                    }
5392                })
5393            })
5394            .collect();
5395        // The daemon's side: transitions saved from its own, ever staler,
5396        // copy of the task.
5397        let mut mine = queue.get(&id).unwrap();
5398        for k in 0..10 {
5399            mine.start(format!("20260930-000009-d{k:03}"));
5400            queue.put(&mut mine).unwrap();
5401        }
5402        for l in linkers {
5403            l.join().unwrap();
5404        }
5405
5406        let stored = queue.get(&id).unwrap();
5407        assert_eq!(stored.runs.len(), 50, "{:?}", stored.runs);
5408        assert_eq!(
5409            stored.attempts, 10,
5410            "linking never rewinds the daemon's work"
5411        );
5412    }
5413
5414    fn age_lock(path: &Path) {
5415        let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
5416        f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60))
5417            .unwrap();
5418    }
5419
5420    #[test]
5421    fn concurrent_stale_takeover_yields_one_holder() {
5422        use std::sync::atomic::{AtomicUsize, Ordering};
5423        use std::sync::{Arc, Barrier};
5424        for _ in 0..5 {
5425            let dir = tempfile::tempdir().unwrap();
5426            let q = Queue::at(dir.path().to_path_buf());
5427            let lock = dir.path().join("t.write-lock");
5428            std::fs::write(&lock, "dead-0000").unwrap();
5429            age_lock(&lock);
5430            let n = 6;
5431            let barrier = Arc::new(Barrier::new(n));
5432            let (now, max) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
5433            let handles: Vec<_> = (0..n)
5434                .map(|_| {
5435                    let (q, b, now, max) = (q.clone(), barrier.clone(), now.clone(), max.clone());
5436                    std::thread::spawn(move || {
5437                        b.wait();
5438                        let g = q.lock_task("t").unwrap();
5439                        let held = now.fetch_add(1, Ordering::SeqCst) + 1;
5440                        max.fetch_max(held, Ordering::SeqCst);
5441                        std::thread::sleep(std::time::Duration::from_millis(20));
5442                        now.fetch_sub(1, Ordering::SeqCst);
5443                        drop(g);
5444                    })
5445                })
5446                .collect();
5447            for h in handles {
5448                h.join().unwrap();
5449            }
5450            assert_eq!(max.load(Ordering::SeqCst), 1);
5451            assert!(!lock.exists());
5452        }
5453    }
5454
5455    #[test]
5456    fn dropping_a_replaced_lock_leaves_the_new_holders_token() {
5457        let dir = tempfile::tempdir().unwrap();
5458        let q = Queue::at(dir.path().to_path_buf());
5459        let lock = dir.path().join("t.write-lock");
5460        let a = q.lock_task("t").unwrap();
5461        // Simulate a foreign/legacy writer replacing the file. A modern
5462        // writer cannot acquire it while this guard's process is alive.
5463        let replacement = owner_token();
5464        std::fs::write(&lock, &replacement).unwrap();
5465        drop(a);
5466        assert_eq!(std::fs::read_to_string(&lock).unwrap(), replacement);
5467    }
5468
5469    #[test]
5470    fn aged_live_task_lock_excludes_updates_until_retirement_releases_it() {
5471        let dir = tempfile::tempdir().unwrap();
5472        let q = Queue::at(dir.path().to_path_buf());
5473        let held = q.lock_task("t").unwrap();
5474        let path = dir.path().join("t.write-lock");
5475        age_lock(&path);
5476        assert!(!break_stale(&path, &held.token));
5477        let (started_tx, started_rx) = std::sync::mpsc::channel();
5478        let (acquired_tx, acquired_rx) = std::sync::mpsc::channel();
5479        let updater = std::thread::spawn(move || {
5480            started_tx.send(()).unwrap();
5481            let _write = q.lock_task("t").unwrap();
5482            acquired_tx.send(()).unwrap();
5483        });
5484        started_rx.recv().unwrap();
5485        assert!(
5486            acquired_rx
5487                .recv_timeout(std::time::Duration::from_millis(100))
5488                .is_err()
5489        );
5490        assert_eq!(std::fs::read_to_string(&path).unwrap(), held.token);
5491        drop(held);
5492        acquired_rx
5493            .recv_timeout(std::time::Duration::from_secs(2))
5494            .unwrap();
5495        updater.join().unwrap();
5496        assert!(!path.exists());
5497    }
5498
5499    #[test]
5500    fn break_stale_leaves_a_lock_that_replaced_the_one_judged() {
5501        let dir = tempfile::tempdir().unwrap();
5502        let lock = dir.path().join("t.write-lock");
5503        std::fs::write(&lock, "new-token").unwrap();
5504        assert!(!break_stale(&lock, "old-token"));
5505        assert_eq!(std::fs::read_to_string(&lock).unwrap(), "new-token");
5506        // Same token but fresh: not stale any more, so also left alone.
5507        assert!(!break_stale(&lock, "new-token"));
5508        assert!(lock.exists());
5509        age_lock(&lock);
5510        assert!(break_stale(&lock, "new-token"));
5511        assert!(!lock.exists());
5512    }
5513
5514    #[test]
5515    fn a_stale_break_marker_is_recovered_and_a_fresh_one_is_respected() {
5516        let dir = tempfile::tempdir().unwrap();
5517        let lock = dir.path().join("t.write-lock");
5518        std::fs::write(&lock, "dead-1").unwrap();
5519        age_lock(&lock);
5520        let marker = break_marker(&lock, "dead-1");
5521        std::fs::write(&marker, "crashed-remover").unwrap();
5522        // Fresh marker: another remover is working, so nothing is broken.
5523        assert!(!break_stale(&lock, "dead-1"));
5524        assert!(lock.exists());
5525        // Stale marker: recovered, then the lock is broken.
5526        age_lock(&marker);
5527        assert!(break_stale(&lock, "dead-1"));
5528        assert!(!lock.exists());
5529        assert!(!marker.exists());
5530    }
5531
5532    fn chat_task() -> Task {
5533        Task::new(
5534            "t".to_owned(),
5535            "do t".to_owned(),
5536            PathBuf::from("."),
5537            Source::Agent {
5538                run: "20260902-000000-beef".to_owned(),
5539                node: CHAT_NODE.to_owned(),
5540            },
5541        )
5542    }
5543
5544    #[test]
5545    fn a_blocked_hold_keeps_its_anchor_but_a_new_run_starts_a_new_hold() {
5546        let mut t = chat_task();
5547        t.hold_machine(Some("waiting".into()));
5548        let old: Timestamp = "2020-01-01T00:00:00Z".parse().unwrap();
5549        t.held_at = Some(old);
5550        t.block(vec!["decision".into()], None);
5551        t.hold_machine(Some("same waiting decision".into()));
5552        assert_eq!(t.held_at, Some(old));
5553        t.start("real-new-run".into());
5554        t.fail("new failure", 1);
5555        assert_ne!(
5556            t.held_at,
5557            Some(old),
5558            "old answers cannot approve a new hold"
5559        );
5560    }
5561
5562    #[test]
5563    fn legacy_delivery_survives_upgrade_and_a_no_progress_run_link() {
5564        let (_dir, queue) = queue();
5565        let mut t = chat_task();
5566        t.start("old-run".into());
5567        t.hold_machine(Some("operator is considering recovery".into()));
5568        t.chat_notice = Some(ChatNotice {
5569            key: format!("held#{}", t.held_at.unwrap()),
5570            at: Timestamp::now(),
5571            outcome: ChatNoticeOutcome::Posted,
5572        });
5573        let mut raw = serde_json::to_value(&t).unwrap();
5574        raw.as_object_mut().unwrap().remove("chat_notices");
5575        raw.as_object_mut().unwrap().remove("hold_notice_cause");
5576        std::fs::create_dir_all(queue.path_of(&t.id).parent().unwrap()).unwrap();
5577        std::fs::write(queue.path_of(&t.id), serde_json::to_string(&raw).unwrap()).unwrap();
5578        assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5579        queue
5580            .modify(&t.id, |task| {
5581                task.requeue();
5582                task.link_run("unprepared-run");
5583                task.startup_notice_cause("startup-source");
5584                task.fail("same unresolved preflight", 0);
5585                true
5586            })
5587            .unwrap();
5588        assert_eq!(queue.get(&t.id).unwrap().chat_notice_due(), None);
5589        queue
5590            .modify(&t.id, |task| {
5591                task.succeed();
5592                true
5593            })
5594            .unwrap();
5595        assert_eq!(
5596            queue.get(&t.id).unwrap().chat_notice_due().as_deref(),
5597            Some("done")
5598        );
5599    }
5600
5601    #[test]
5602    fn a_dependency_wait_does_not_change_when_released_and_blocked_again() {
5603        let mut t = chat_task();
5604        t.block(vec!["a".into(), "b".into()], Some("waiting".into()));
5605        let key = t.chat_notice_key().unwrap();
5606        t.chat_notices.push(key);
5607        t.release();
5608        t.block(
5609            vec!["b".into(), "a".into(), "a".into()],
5610            Some("still waiting on prerequisites".into()),
5611        );
5612        assert_eq!(t.chat_notice_due(), None);
5613        t.block(
5614            vec!["new-question".into()],
5615            Some("different decision".into()),
5616        );
5617        assert!(t.chat_notice_due().is_some());
5618    }
5619
5620    #[test]
5621    fn running_startup_failures_preserve_stage_and_report_distinct_blockers() {
5622        let mut t = chat_task();
5623        t.start("existing-run".into());
5624        t.fail("implementation failed", 1);
5625        let failed = t.chat_notice_key().unwrap();
5626        t.chat_notices.push(failed.clone());
5627        t.release_operator("test", None);
5628        t.start("existing-run".into());
5629        t.fail_startup("startup-source", "same unresolved recovery source", 1);
5630        assert_eq!(
5631            t.chat_notice_due(),
5632            None,
5633            "no-progress recovery stays quiet"
5634        );
5635        t.release_operator("test", None);
5636        t.start("existing-run".into());
5637        t.fail_startup("startup-attachments", "attachment missing", 1);
5638        let attachments = t.chat_notice_due().expect("new attachment blocker");
5639        assert_ne!(attachments, failed);
5640        assert_eq!(
5641            t.hold_notice_cause.as_ref().unwrap().stage,
5642            "startup-attachments"
5643        );
5644        t.chat_notices.push(attachments);
5645        t.release_operator("test", None);
5646        t.start("existing-run".into());
5647        t.fail_startup(
5648            "startup-attachments",
5649            "same missing attachment, reworded",
5650            1,
5651        );
5652        assert_eq!(
5653            t.chat_notice_due(),
5654            None,
5655            "same stage after attempt counter reset"
5656        );
5657        t.release_operator("test", None);
5658        t.start("existing-run".into());
5659        t.fail_startup("startup-config", "different preflight stage", 1);
5660        assert!(t.chat_notice_due().is_some());
5661        assert_eq!(
5662            t.hold_notice_cause.as_ref().unwrap().stage,
5663            "startup-config"
5664        );
5665    }
5666
5667    #[test]
5668    fn structured_causes_preserve_no_progress_retries_and_report_new_passes() {
5669        let mut t = chat_task();
5670        t.hold_machine(Some("startup source missing".into()));
5671        t.startup_notice_cause("startup-source");
5672        let first = t.chat_notice_key().unwrap();
5673        t.chat_notices.push(first.clone());
5674        t.release_operator("test", None);
5675        t.startup_notice_cause("startup-source");
5676        t.hold_machine(Some("same source cannot be read".into()));
5677        assert_eq!(t.chat_notice_due(), None);
5678        t.startup_notice_cause("startup-config");
5679        assert!(t.chat_notice_due().is_some(), "different startup stage");
5680        t.notice_cause("failed", Some("existing-run"), Some(0));
5681        let run_failure = t.chat_notice_key().unwrap();
5682        t.chat_notices.push(run_failure.clone());
5683        t.release_operator("test", None);
5684        t.startup_notice_cause("startup-source");
5685        t.hold_machine(Some("no new work happened".into()));
5686        assert_eq!(t.chat_notice_due(), None);
5687        t.notice_cause("failed", Some("existing-run"), Some(1));
5688        assert!(t.chat_notice_due().is_some(), "a real resumed pass failed");
5689        assert_ne!(t.chat_notice_key().unwrap(), run_failure);
5690    }
5691
5692    #[test]
5693    fn legacy_hold_baseline_does_not_replay_or_hide_new_failures_and_completion() {
5694        let mut t = chat_task();
5695        t.hold_machine(Some("still waiting".into()));
5696        t.chat_notice = Some(ChatNotice {
5697            key: "held#2026-10-14T00:00:00Z".into(),
5698            at: Timestamp::now(),
5699            outcome: ChatNoticeOutcome::Posted,
5700        });
5701        assert_eq!(t.chat_notice_due(), None, "old hold baseline");
5702        t.import_chat_notice();
5703        t.notice_cause("failed", Some("new-run"), Some(0));
5704        assert!(t.chat_notice_due().is_some(), "new producer evidence");
5705        t.succeed();
5706        assert_eq!(t.chat_notice_due().as_deref(), Some("done"));
5707    }
5708
5709    #[test]
5710    fn a_chat_notice_is_due_only_for_a_final_state_and_only_once() {
5711        let mut t = chat_task();
5712        assert_eq!(t.chat_notice_due(), None, "queued");
5713        t.start("r1".to_owned());
5714        assert_eq!(t.chat_notice_due(), None, "running");
5715        t.fail("boom", 3);
5716        assert_eq!(t.status, TaskStatus::Failed);
5717        assert_eq!(t.chat_notice_due(), None, "a retried failure is not final");
5718        t.start("r2".to_owned());
5719        t.park("upgrade");
5720        assert_eq!(t.chat_notice_due(), None, "parked");
5721        t.fail("boom", 1);
5722        assert_eq!(t.status, TaskStatus::Held);
5723        let key = t
5724            .chat_notice_due()
5725            .expect("held on the last attempt is final");
5726        t.chat_notice = Some(ChatNotice {
5727            key,
5728            at: Timestamp::now(),
5729            outcome: ChatNoticeOutcome::Posted,
5730        });
5731        assert_eq!(t.chat_notice_due(), None, "same hold, no second notice");
5732        t.release();
5733        assert_eq!(t.chat_notice_due(), None, "requeued after a release");
5734        t.start("r3".to_owned());
5735        t.succeed();
5736        assert!(t.chat_notice_due().is_some(), "done is a new final state");
5737    }
5738
5739    #[test]
5740    fn completed_chat_notices_keep_current_and_legacy_keys_after_requeue() {
5741        for key in ["done", "done#0", "done#1", "done#12"] {
5742            let mut t = chat_task();
5743            t.start("r1".to_owned());
5744            t.succeed();
5745            t.chat_notice = Some(ChatNotice {
5746                key: key.to_owned(),
5747                at: Timestamp::now(),
5748                outcome: ChatNoticeOutcome::Posted,
5749            });
5750            t.requeue();
5751            t.start("r2".to_owned());
5752            t.succeed();
5753            assert_eq!(t.chat_notice_key().as_deref(), Some("done"));
5754            assert_eq!(t.chat_notice_due(), None, "recorded key: {key}");
5755        }
5756    }
5757
5758    #[test]
5759    fn a_release_without_progress_keeps_the_unresolved_notice_cause() {
5760        let mut t = chat_task();
5761        t.hold_manual(Some("wait".to_owned()));
5762        let first = t.chat_notice_due().expect("held");
5763        t.chat_notice = Some(ChatNotice {
5764            key: first.clone(),
5765            at: Timestamp::now(),
5766            outcome: ChatNoticeOutcome::Posted,
5767        });
5768        t.hold_manual(Some("still waiting".to_owned()));
5769        assert_eq!(t.chat_notice_due(), None, "a re-hold keeps its start");
5770        t.release();
5771        std::thread::sleep(std::time::Duration::from_millis(5));
5772        t.hold_manual(None);
5773        assert_eq!(t.chat_notice_due(), None, "no progress, same cause");
5774    }
5775
5776    #[test]
5777    fn a_chat_notice_needs_a_chat_source_and_a_current_schema() {
5778        let mut human = task("h");
5779        human.succeed();
5780        assert_eq!(human.chat_notice_due(), None);
5781        // A follow-up only inherits the chat; it did not file the task.
5782        let mut inherited = task("i");
5783        inherited.origin_chat = Some("20260902-000000-beef".to_owned());
5784        inherited.succeed();
5785        assert_eq!(inherited.chat_notice_due(), None);
5786        let mut old = chat_task();
5787        old.schema = 12;
5788        old.status = TaskStatus::Done;
5789        assert_eq!(old.chat_notice_due(), None, "history is not replayed");
5790        let mut live = chat_task();
5791        live.schema = 12;
5792        live.start("r1".to_owned());
5793        live.succeed();
5794        assert!(live.chat_notice_due().is_some(), "started by this build");
5795        let mut held = chat_task();
5796        held.schema = 12;
5797        held.hold_machine(Some("config".to_owned()));
5798        assert!(held.chat_notice_due().is_some(), "held before any start");
5799    }
5800
5801    #[test]
5802    fn a_stale_snapshot_does_not_rewind_a_recorded_chat_notice() {
5803        let tmp = tempfile::tempdir().expect("tempdir");
5804        let q = Queue::at(tmp.path().join("queue"));
5805        let mut t = chat_task();
5806        t.succeed();
5807        q.put(&mut t).expect("put");
5808        let mut stale = q.get(&t.id).expect("get");
5809        let key = t.chat_notice_due().expect("due");
5810        q.modify(&t.id, |x| {
5811            x.chat_notice = Some(ChatNotice {
5812                key: key.clone(),
5813                at: Timestamp::now(),
5814                outcome: ChatNoticeOutcome::Posted,
5815            });
5816            true
5817        })
5818        .expect("modify");
5819        q.put(&mut stale).expect("stale put");
5820        assert_eq!(q.get(&t.id).expect("get").chat_notice_due(), None);
5821    }
5822
5823    #[test]
5824    fn empty_sources_are_omitted_so_older_builds_still_read_the_task() {
5825        // `Task` rejects unknown fields, so a build without `sources` cannot
5826        // read a record that carries the key, even an empty one.
5827        let mut t = task("plain");
5828        let value = serde_json::to_value(&t).unwrap();
5829        assert!(value.get("sources").is_none(), "{value}");
5830        t.sources.pick_commits.push(crate::refs::Input {
5831            token: "abc1234".into(),
5832            sha: "a".repeat(40),
5833            provenance: crate::refs::Provenance::ExplicitCli,
5834        });
5835        let value = serde_json::to_value(&t).unwrap();
5836        assert_eq!(value["sources"]["pick_commits"][0]["sha"], "a".repeat(40));
5837        let restored: Task = serde_json::from_value(value).unwrap();
5838        assert_eq!(restored.sources, t.sources);
5839    }
5840}