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