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