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::path::{Path, PathBuf};
34
35use anyhow::{Context, Result, bail};
36use jiff::Timestamp;
37use serde::{Deserialize, Serialize};
38
39/// On-disk format for a queued task. Bumped when a field's meaning changes.
40///
41/// 4: added [`Task::blocked_from`], the status a task had the moment it
42/// became [`TaskStatus::Blocked`], so [`Task::unblock`] restores it instead
43/// of always landing on [`TaskStatus::Queued`]. Without it, a task a human
44/// or `crate::triage` had deliberately left [`TaskStatus::Held`] — machine
45/// or manual — would lose that the instant `crate::conduct` blocked it on a
46/// follow-up question, and come back `Queued` the moment the question was
47/// answered, regardless of what the answer said: exactly the loop where a
48/// task the operator told to stay held instead re-enters the competition
49/// queue every time someone answers a question about it. `#[serde(default)]`
50/// so an older record reads as `None`; [`Task::unblock`] then falls back to
51/// inferring `Held` from surviving hold evidence ([`Task::hold_reason`] /
52/// [`Task::hold_source`], never cleared by [`Task::block`]) rather than
53/// guessing `Queued` outright — see [`Task::unblock`]'s own doc.
54///
55/// 3: added [`HoldSource`] so conductor recovery cannot release a hold an
56/// operator deliberately placed. Old records default to `None` and are
57/// protected as operator-held until an explicit release; the safe direction
58/// when their author was never recorded.
59///
60/// 2: added [`TaskStatus::Blocked`], [`Task::blocked_by`] and
61/// [`Task::block_reason`] (`crate::conduct`'s decisions) and
62/// [`Task::answers`] (operator answers carried forward to the next
63/// conductor prompt and the next run's instruction). All three are
64/// `#[serde(default)]`, so [`read_path`] accepts anything up to and
65/// including this schema rather than only an exact match — a task written
66/// by a build that only knew about schema 1 has nothing to say about
67/// blocking or answers, and defaulting those fields is exactly as good a
68/// reading as a value that build never had a chance to write.
69pub const SCHEMA: u32 = 4;
70
71/// Who placed the current hold.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum HoldSource {
75    /// An operator used the CLI or web UI.
76    Manual,
77    /// The daemon or conductor placed the hold as part of its own recovery.
78    Machine,
79}
80
81impl HoldSource {
82    /// Short human-facing label for reports and the CLI.
83    pub fn label(self) -> &'static str {
84        match self {
85            Self::Manual => "manual",
86            Self::Machine => "machine",
87        }
88    }
89}
90
91/// Where a task came from. Recorded because "who asked for this" is the first
92/// question about an autonomous run, and the answer is not recoverable later.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "kind", rename_all = "lowercase")]
95pub enum Source {
96    /// A person, at a terminal or through the web UI.
97    Human,
98    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
99    /// task can be traced back to the exact seat that asked for it.
100    Agent {
101        /// Run the asking agent belonged to.
102        run: String,
103        /// Node it was working in, e.g. `implement` or `review`.
104        node: String,
105    },
106    /// A GitHub issue, imported by number.
107    Issue {
108        /// Issue number.
109        number: u64,
110        /// `owner/repo`, as `gh` reports it.
111        repo: String,
112    },
113}
114
115impl Source {
116    /// Short human-facing label, for lists and the web UI.
117    pub fn label(&self) -> String {
118        match self {
119            Self::Human => "human".to_owned(),
120            Self::Agent { run, node } => format!("{node}@{}", short(run)),
121            Self::Issue { number, .. } => format!("issue #{number}"),
122        }
123    }
124}
125
126/// Where a task is in its life.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum TaskStatus {
130    /// Waiting to be claimed.
131    Queued,
132    /// Claimed by a daemon; a run is in flight.
133    Running,
134    /// A run finished and its gate passed.
135    Done,
136    /// A run finished without passing, and attempts remain.
137    Failed,
138    /// Out of attempts, or held by hand. The loop will not pick it up.
139    Held,
140    /// Waiting on another task or an unanswered question. See
141    /// [`Task::blocked_by`]. Set and cleared by `crate::conduct` and
142    /// `crate::daemon`'s deterministic resolver, never by hand.
143    Blocked,
144}
145
146impl TaskStatus {
147    /// Is this task eligible for a daemon to claim?
148    pub fn runnable(self) -> bool {
149        matches!(self, Self::Queued | Self::Failed)
150    }
151
152    /// Lowercase name, as it appears on disk and in the API.
153    pub fn as_str(self) -> &'static str {
154        match self {
155            Self::Queued => "queued",
156            Self::Running => "running",
157            Self::Done => "done",
158            Self::Failed => "failed",
159            Self::Held => "held",
160            Self::Blocked => "blocked",
161        }
162    }
163}
164
165/// One unit of work.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct Task {
169    /// On-disk format version.
170    pub schema: u32,
171    /// Task id, e.g. `20260902-140501-a1b2`.
172    pub id: String,
173    /// One line, for lists and notifications.
174    pub title: String,
175    /// The task itself, handed to the graph verbatim.
176    pub instruction: String,
177    /// Repository to work in.
178    pub repo: PathBuf,
179    /// Who asked.
180    pub source: Source,
181    /// Higher runs first; ties break oldest-first so nothing starves.
182    #[serde(default)]
183    pub priority: i32,
184    /// Run this task alone: one implementer, no panel of judges to convince.
185    ///
186    /// `#[serde(default)]` so a queue file written before this field existed
187    /// still reads, as `false` - the ordinary multi-candidate competition,
188    /// unchanged. A task set to `solo` still runs the whole graph; only the
189    /// candidate count the daemon builds it with changes, and
190    /// [`crate::graph::Runner`] already collapses a single-candidate run to
191    /// implement → review → gate → merge on its own (see
192    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
193    /// deliberation or voting had to change to support this.
194    #[serde(default)]
195    pub solo: bool,
196    /// Current state.
197    pub status: TaskStatus,
198    /// How many times this task has been claimed.
199    #[serde(default)]
200    pub attempts: usize,
201    /// Runs this task has produced, oldest first.
202    #[serde(default)]
203    pub runs: Vec<String>,
204    /// Why the last attempt did not land.
205    #[serde(default)]
206    pub last_error: Option<String>,
207    /// What a human hold is waiting on.
208    ///
209    /// `None` covers both the ordinary cases: a hold the loop makes itself
210    /// (out of attempts, or the disk gate closed) explains itself through
211    /// [`Task::last_error`] instead, and a human hold nobody bothered to
212    /// explain is still a valid hold. The queue has no way to express a
213    /// dependency between two tasks, so on the occasions a hold really is
214    /// "wait for that other task first", this is the only place that reason
215    /// survives - see [`Task::hold_manual`] and [`Task::release`].
216    ///
217    /// `#[serde(default)]` so a queue file written before this field existed
218    /// still reads, with no reason recorded rather than a parse error.
219    #[serde(default)]
220    pub hold_reason: Option<String>,
221    /// Who placed [`Task::hold_reason`].  `None` is a compatible old record;
222    /// see [`Task::operator_held`] for its deliberately conservative meaning.
223    #[serde(default)]
224    pub hold_source: Option<HoldSource>,
225    /// Diagnostic detail excerpted from the run that led to a hold - what a
226    /// human would have found opening `artifacts/` by hand, not the one-line
227    /// reason in [`Task::last_error`]. Set only when a run's own attempts are
228    /// exhausted and the task becomes [`TaskStatus::Held`]; `daemon` computes
229    /// it from the run's own record, since this module has no notion of a
230    /// run's internals. Bounded in length by the writer - see
231    /// `daemon::diagnostic` - so a verbose run cannot make this file grow
232    /// without limit.
233    ///
234    /// `#[serde(default)]` so a queue file written before this field existed
235    /// still reads, with no diagnostic recorded rather than a parse error.
236    #[serde(default)]
237    pub diagnostic: Option<String>,
238    /// What this task is waiting on: other task ids, unanswered
239    /// `crate::ask::Question` ids, or both. Non-empty exactly when
240    /// [`TaskStatus::Blocked`]; emptying it — see [`Task::unblock`] — is what
241    /// puts the task back at [`TaskStatus::Queued`].
242    ///
243    /// Set by `crate::conduct`'s decisions and cleared deterministically by
244    /// `crate::daemon` as each dependency resolves, never by a person. Never
245    /// `#[serde(default)]` is skipped: a queue file from before this field
246    /// existed has nothing to report here, and an empty list is exactly that.
247    #[serde(default)]
248    pub blocked_by: Vec<String>,
249    /// One line explaining the current [`Task::blocked_by`], written by
250    /// `crate::conduct`. Cleared whenever `blocked_by` empties.
251    #[serde(default)]
252    pub block_reason: Option<String>,
253    /// The status this task had the moment [`Task::block`] most recently
254    /// moved it to [`TaskStatus::Blocked`] — what [`Task::unblock`] restores
255    /// once nothing is left in `blocked_by`, instead of always landing on
256    /// [`TaskStatus::Queued`]. See [`SCHEMA`]'s doc for schema 4 on why this
257    /// exists: an answer to a question `crate::conduct` filed about a
258    /// [`TaskStatus::Held`] task must not itself be what puts the task back
259    /// in the competition queue.
260    ///
261    /// `#[serde(default)]` so a queue file written before this field existed
262    /// reads as `None`; [`Task::unblock`] treats that the same as a task
263    /// blocked straight from `Queued`, unless surviving hold evidence says
264    /// otherwise.
265    #[serde(default)]
266    pub blocked_from: Option<TaskStatus>,
267    /// Questions `crate::conduct` asked about this task that the operator has
268    /// since answered, oldest first — what was asked, and what they said.
269    ///
270    /// A blocking question's id leaves [`Task::blocked_by`] the moment
271    /// [`crate::ask::QuestionStatus::Answered`] is observed, but the id alone
272    /// tells nobody what was decided. This is what carries the answer's
273    /// *content* forward: into the next conductor prompt for this task, and
274    /// into the instruction handed to the next run — see `crate::daemon`'s
275    /// deterministic blocker resolution. Kept for the task's whole life, the
276    /// same as [`Task::runs`]: a release resets attempts, not evidence.
277    #[serde(default)]
278    pub answers: Vec<AnsweredQuestion>,
279    /// Set by `crate::conduct` when it chooses `Review` recovery for a task
280    /// whose branch survived a blocked run: the branch to reopen with
281    /// `crate::graph::Runner::review` instead of competing from scratch.
282    ///
283    /// Requeues the task the same way [`Task::release`] does, so it is
284    /// picked up by the ordinary loop; `crate::daemon` reads this field once,
285    /// when it actually starts the run, and clears it either way — consumed
286    /// on success, dropped if the branch no longer exists by then. Never set
287    /// from the conductor's own words: `crate::daemon` derives the branch
288    /// name itself from the task's last run, so a hallucinated branch can
289    /// never reach here.
290    #[serde(default)]
291    pub review_branch: Option<String>,
292    /// A release deliberately starts a new competition instead of resuming
293    /// the prior run. History remains as evidence in `runs`.
294    #[serde(default)]
295    pub fresh_start: bool,
296    /// Marked by an operator (`magi task interrupt`) to ask `magi serve` to
297    /// run this one ahead of whatever it already has in flight, once
298    /// `[daemon] pause_for_interrupts` is on - see
299    /// `crate::daemon::advance_interrupt`. Never set by the loop itself, and
300    /// deliberately a different operation from [`Task::set_priority`]: a
301    /// priority only reorders the queue a claim has not reached yet, while
302    /// this asks a run already in flight to park at its next safe boundary
303    /// and step aside. `#[serde(default)]` so a queue file written before
304    /// this field existed still reads, as `false` - no task interrupts
305    /// anything unless asked to, exactly as before.
306    #[serde(default)]
307    pub interrupt: bool,
308    /// When the task was filed.
309    pub created_at: Timestamp,
310    /// Last change to this file.
311    pub updated_at: Timestamp,
312}
313
314/// One question `crate::conduct` asked about a task, and what the operator
315/// said back. See [`Task::answers`].
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct AnsweredQuestion {
318    /// The question as asked, e.g. [`crate::ask::Question::summary`].
319    pub question: String,
320    /// What the operator answered.
321    pub answer: String,
322}
323
324impl Task {
325    /// File a new task. Persist it with [`Queue::put`].
326    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
327        let now = Timestamp::now();
328        Self {
329            schema: SCHEMA,
330            id: new_id(),
331            title,
332            instruction,
333            repo,
334            source,
335            priority: 0,
336            solo: false,
337            status: TaskStatus::Queued,
338            attempts: 0,
339            runs: Vec::new(),
340            last_error: None,
341            hold_reason: None,
342            hold_source: None,
343            diagnostic: None,
344            blocked_by: Vec::new(),
345            block_reason: None,
346            blocked_from: None,
347            answers: Vec::new(),
348            review_branch: None,
349            fresh_start: false,
350            interrupt: false,
351            created_at: now,
352            updated_at: now,
353        }
354    }
355
356    /// Short form used in reports, matching a run's short id.
357    pub fn short(&self) -> &str {
358        short(&self.id)
359    }
360
361    /// Record that a run has started for this task.
362    ///
363    /// Clears [`Task::interrupt`]: a mark to run ahead of whatever else is
364    /// in flight is fulfilled the moment this task actually gets its turn,
365    /// dispatched same as any other. Without this, a task whose run fails
366    /// and requeues - still `runnable`, still carrying the mark from its
367    /// first attempt - would keep re-triggering `crate::daemon`'s interrupt
368    /// scheduler and re-parking whatever it interrupted on every later
369    /// boundary, for as long as its attempts hold out, instead of the
370    /// one-shot "let this go next" the mark is meant to be.
371    pub fn start(&mut self, run: String) {
372        self.status = TaskStatus::Running;
373        self.attempts += 1;
374        self.runs.push(run);
375        self.last_error = None;
376        self.fresh_start = false;
377        self.interrupt = false;
378    }
379
380    /// Record a successful run.
381    ///
382    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
383    /// *or blocked* task directly, with no release in between, so this clears
384    /// `hold_reason` and `blocked_by`/`block_reason` the same way
385    /// [`Task::release`] does. Otherwise a task held for "waiting on 3ed9", or
386    /// blocked on a dependency that never actually finished, and then closed
387    /// as done without ever being released would still read as waiting on
388    /// something in `magi task show` and on its card, after it no longer is.
389    pub fn succeed(&mut self) {
390        self.status = TaskStatus::Done;
391        self.last_error = None;
392        self.hold_reason = None;
393        self.hold_source = None;
394        self.diagnostic = None;
395        self.blocked_by.clear();
396        self.block_reason = None;
397        self.blocked_from = None;
398    }
399
400    /// Record a failed attempt. Out of attempts means held for a human, rather
401    /// than retried until the money runs out.
402    ///
403    /// Clears [`Task::diagnostic`] unconditionally: it belongs to whatever run
404    /// produced it, and a caller that has one for *this* attempt sets it
405    /// itself right after calling this, once it knows the task actually ended
406    /// up [`TaskStatus::Held`] - see `daemon::diagnostic`. Without the clear, a
407    /// task released after a diagnosed hold and then failed again for an
408    /// unrelated, undiagnosed reason (a config error, say) would go on
409    /// showing the previous run's diagnostic as if it explained the new one.
410    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
411        self.last_error = Some(why.into());
412        self.diagnostic = None;
413        self.status = if self.attempts >= max_attempts {
414            self.hold_source = Some(HoldSource::Machine);
415            TaskStatus::Held
416        } else {
417            TaskStatus::Failed
418        };
419    }
420
421    /// Record an attempt that failed for a reason the task is not responsible
422    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
423    ///
424    /// This refunds the attempt on purpose. A quota window closing at 4am must
425    /// not spend the backlog's retry budget: the operator would come back to a
426    /// queue of held tasks that were never actually judged, and would have to
427    /// release every one by hand to find out which had a real problem. The task
428    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
429    /// work up where it stopped.
430    pub fn stall(&mut self, why: impl Into<String>) {
431        self.last_error = Some(why.into());
432        self.diagnostic = None;
433        self.attempts = self.attempts.saturating_sub(1);
434        self.status = TaskStatus::Failed;
435    }
436
437    /// Whether this held task may only be released by an operator.
438    ///
439    /// Old files did not record a source. Preserve every such hold rather
440    /// than guessing that it was automatic and risking duplicate work. New
441    /// automatic holds record [`HoldSource::Machine`] and remain recoverable.
442    pub fn operator_held(&self) -> bool {
443        self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
444    }
445
446    /// Take this task out of the loop's reach by an operator action.
447    ///
448    /// Clears `blocked_by`/`block_reason` unconditionally, the same as
449    /// [`Task::release`] and for the same reason its own comment already
450    /// gives: a human choosing to hold a *blocked* task overrides its wait
451    /// outright, the same as it overrides an ordinary hold. Without this, a
452    /// task held straight out of [`TaskStatus::Blocked`] - the web UI's "Hold"
453    /// button is reachable on a blocked task, same as "Mark done" - kept
454    /// reading as still waiting on a dependency it no longer had any claim on.
455    pub fn hold_manual(&mut self, reason: Option<String>) {
456        self.status = TaskStatus::Held;
457        if reason.is_some() {
458            self.hold_reason = reason;
459        }
460        self.hold_source = Some(HoldSource::Manual);
461        self.blocked_by.clear();
462        self.block_reason = None;
463        self.blocked_from = None;
464    }
465
466    /// Take this task out of the loop's reach during automatic recovery.
467    ///
468    /// Clears `blocked_by`/`block_reason` for the same reason
469    /// [`Task::hold_manual`] does.
470    pub fn hold_machine(&mut self, reason: Option<String>) {
471        self.status = TaskStatus::Held;
472        if reason.is_some() {
473            self.hold_reason = reason;
474        }
475        self.hold_source = Some(HoldSource::Machine);
476        self.blocked_by.clear();
477        self.block_reason = None;
478        self.blocked_from = None;
479    }
480
481    /// Block this task on other task ids and/or open question ids, chosen by
482    /// `crate::conduct`. Pure: the caller still owns writing it back with
483    /// [`Queue::put`].
484    ///
485    /// Records [`Task::blocked_from`] the first time this moves the task into
486    /// [`TaskStatus::Blocked`], and leaves it alone on a later call that adds
487    /// or replaces `blocked_by` while the task is already `Blocked` - a
488    /// second question about an already-blocked task must not overwrite the
489    /// status it should eventually return to with `Blocked` itself.
490    pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
491        if self.status != TaskStatus::Blocked {
492            self.blocked_from = Some(self.status);
493        }
494        self.status = TaskStatus::Blocked;
495        self.blocked_by = blocked_by;
496        self.block_reason = reason;
497    }
498
499    /// Remove one resolved dependency (a task id that became [`TaskStatus::Done`],
500    /// or a question id that became [`crate::ask::QuestionStatus::Answered`]).
501    /// Once nothing is left in [`Task::blocked_by`], the task returns to
502    /// whatever [`Task::blocked_from`] recorded - deciding *why* a task was
503    /// blocked was `crate::conduct`'s job, but noticing a dependency resolved
504    /// needs no model at all, and restoring the status it interrupted needs
505    /// nothing more than what `block` already wrote down.
506    ///
507    /// A task blocked while `Running` restores to [`TaskStatus::Queued`]
508    /// instead: whatever process was running it is gone by the time this
509    /// runs, so there is nothing left to resume. A task with no recorded
510    /// `blocked_from` - a pre-schema-4 record, or one blocked before this
511    /// field existed - falls back to [`TaskStatus::Held`] when it still
512    /// carries hold evidence ([`Task::hold_reason`] or [`Task::hold_source`],
513    /// neither ever cleared by `block`), and to `Queued` otherwise: the same
514    /// choice `block` itself would have recorded, reconstructed from what
515    /// survived.
516    ///
517    /// A no-op, on purpose, for a task that is not [`TaskStatus::Blocked`]:
518    /// `crate::daemon`'s deterministic resolver runs over every task on every
519    /// poll, and a task that moved on for some other reason must not be
520    /// dragged back by a stale id it still happens to carry.
521    pub fn unblock(&mut self, resolved_id: &str) {
522        if self.status != TaskStatus::Blocked {
523            return;
524        }
525        self.blocked_by.retain(|id| id != resolved_id);
526        if self.blocked_by.is_empty() {
527            self.status = match self.blocked_from {
528                Some(TaskStatus::Running) => TaskStatus::Queued,
529                Some(other) => other,
530                None if self.hold_reason.is_some() || self.hold_source.is_some() => {
531                    TaskStatus::Held
532                }
533                None => TaskStatus::Queued,
534            };
535            self.block_reason = None;
536            self.blocked_from = None;
537        }
538    }
539
540    /// Record that a question `crate::conduct` asked about this task has been
541    /// answered, so the answer's content — not just the fact that the
542    /// question is gone — reaches the next conductor prompt and the next
543    /// run's instruction. See [`Task::answers`].
544    pub fn record_answer(&mut self, question: String, answer: String) {
545        self.answers.push(AnsweredQuestion { question, answer });
546    }
547
548    /// Requeue this task to reopen its last run as a review-only pass against
549    /// `branch` (`crate::graph::Runner::review`) rather than competing from
550    /// scratch. See [`Task::review_branch`].
551    pub fn request_review(&mut self, branch: String) {
552        self.release();
553        self.review_branch = Some(branch);
554    }
555
556    /// Requeue after a conductor chose a new competition. Unlike an ordinary
557    /// operator release, this deliberately does not resume the old run.
558    pub fn requeue(&mut self) {
559        self.release();
560        self.fresh_start = true;
561    }
562
563    /// Change how urgently this task should run next.
564    ///
565    /// Refused once the task is `running`: priority only feeds the sort
566    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
567    /// running task has already left that pool. Accepting the write anyway
568    /// would look like it worked while changing nothing until - and unless -
569    /// this attempt fails and the task becomes runnable again, which is a
570    /// surprise the phone should not hand back as a success.
571    pub fn set_priority(&mut self, priority: i32) -> Result<()> {
572        if self.status == TaskStatus::Running {
573            bail!(
574                "task {} is running; its priority cannot be changed until \
575                 this attempt finishes",
576                self.short()
577            );
578        }
579        self.priority = priority;
580        Ok(())
581    }
582
583    /// Mark (or unmark) this task to interrupt whatever `magi serve` already
584    /// has in flight, once `[daemon] pause_for_interrupts` is on. See
585    /// [`Task::interrupt`].
586    ///
587    /// Setting it is restricted to a task the loop could pick up on its own
588    /// right now - [`TaskStatus::runnable`] - for the same reason as
589    /// [`Task::set_priority`]: a task already `running` has been claimed, and
590    /// a task that is `done`, `held`, or `blocked` is not going to compete
591    /// for the daemon's attention regardless of this flag. Unlike priority,
592    /// this is never silently inert while `running` - it is refused outright,
593    /// because the entire feature this flag drives (`crate::daemon`'s
594    /// interrupt scheduler) is scoped to tasks still waiting to be claimed.
595    /// Clearing it back to `false` carries no such risk and is always
596    /// allowed, including on a task that moved on since it was set.
597    pub fn set_interrupt(&mut self, interrupt: bool) -> Result<()> {
598        if interrupt && !self.status.runnable() {
599            bail!(
600                "task {} is {}; only a queued or failed task can be marked \
601                 to interrupt",
602                self.short(),
603                self.status.as_str()
604            );
605        }
606        self.interrupt = interrupt;
607        Ok(())
608    }
609
610    /// Replace this task's title and instruction wholesale.
611    ///
612    /// Restricted to `queued` and `held`. A `running` task's instruction has
613    /// already been handed to the graph, so a run in flight and the file on
614    /// disk must not be allowed to disagree about what was asked; a `done` or
615    /// `failed` task is a record of what actually happened and editing it
616    /// after the fact would falsify that record. `id`, `created_at`,
617    /// `source`, and `runs` are left untouched on purpose - an edit stands in
618    /// for "delete and refile", and keeping the id, the timestamp, the
619    /// attribution, and the run history is the entire reason it exists
620    /// instead.
621    pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
622        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
623            bail!(
624                "task {} is {}; only a queued or held task's instruction can \
625                 be edited",
626                self.short(),
627                self.status.as_str()
628            );
629        }
630        self.title = title;
631        self.instruction = instruction;
632        Ok(())
633    }
634
635    /// Record a run that produced a pull request without merging it.
636    ///
637    /// The task is held rather than retried, and it costs no further attempt
638    /// either way. The work the task asked for exists: it is sitting on a
639    /// branch, in a pull request, waiting for CI or for a person. Retrying
640    /// would spend the whole competition budget a second time and then race a
641    /// second branch against the pull request the first one opened - which is
642    /// exactly what happened to run 01c2, whose finished and green pull request
643    /// was re-competed from scratch four seconds after it opened.
644    ///
645    /// A pull request nobody merged is a request for a person, not a failure.
646    pub fn handed_off(&mut self, why: impl Into<String>) {
647        self.last_error = Some(why.into());
648        self.diagnostic = None;
649        self.status = TaskStatus::Held;
650        self.hold_source = Some(HoldSource::Machine);
651    }
652
653    /// Put a held or finished task back in line, with its attempt count reset
654    /// so a release is a real second chance rather than an instant re-hold.
655    /// The run history is kept: attempts reset, evidence does not.
656    pub fn release(&mut self) {
657        self.status = TaskStatus::Queued;
658        self.attempts = 0;
659        self.last_error = None;
660        // Otherwise the next person who holds this task reads a reason that
661        // belonged to whatever it was waiting on last time.
662        self.hold_reason = None;
663        self.hold_source = None;
664        self.diagnostic = None;
665        // A release also un-blocks: the dependency or question `blocked_by`
666        // named may still be unresolved, but a human (or `crate::conduct`)
667        // choosing to release the task overrides that wait outright, the same
668        // as it overrides an ordinary hold.
669        self.blocked_by.clear();
670        self.block_reason = None;
671        self.blocked_from = None;
672        self.review_branch = None;
673        self.fresh_start = false;
674    }
675}
676
677/// A queue on disk.
678#[derive(Debug, Clone)]
679pub struct Queue {
680    root: PathBuf,
681}
682
683impl Queue {
684    /// The operator's queue, `<home>/queue`.
685    pub fn open() -> Self {
686        Self::at(crate::run::home().join("queue"))
687    }
688
689    /// A queue at an explicit root. Tests use this; so could an operator who
690    /// wants a queue per project.
691    pub fn at(root: PathBuf) -> Self {
692        Self { root }
693    }
694
695    /// Directory holding the task files.
696    pub fn root(&self) -> &Path {
697        &self.root
698    }
699
700    /// Path for one task id.
701    pub fn path_of(&self, id: &str) -> PathBuf {
702        self.root.join(format!("{id}.json"))
703    }
704
705    /// Write a task, atomically, so a daemon killed mid-write leaves the
706    /// previous state readable rather than a truncated file.
707    pub fn put(&self, task: &mut Task) -> Result<()> {
708        task.updated_at = Timestamp::now();
709        std::fs::create_dir_all(&self.root)
710            .with_context(|| format!("create {}", self.root.display()))?;
711        let body = serde_json::to_string_pretty(task).context("serialize task")?;
712        let path = self.path_of(&task.id);
713        let tmp = path.with_extension("json.tmp");
714        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
715        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
716        Ok(())
717    }
718
719    /// Load a task by id or unambiguous id prefix.
720    pub fn get(&self, id: &str) -> Result<Task> {
721        let resolved = self.resolve_id(id)?;
722        read_path(&self.path_of(&resolved))
723    }
724
725    /// Remove a task, and the claim lock that belongs to it.
726    ///
727    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
728    /// this task — because the task's own `running` status cannot answer the
729    /// question. A daemon killed mid-competition leaves the status at
730    /// `running` and an orphaned `.lock` behind, and a guard that trusted
731    /// either would make the task undeletable for good: the phone showed
732    /// exactly that, refusing a task whose daemon had been gone for an hour.
733    ///
734    /// So the lock is removed with the task rather than respected. Any lock
735    /// still there once no live daemon claims the task is by definition stale,
736    /// and leaving it would make a deleted task look claimed to
737    /// [`Queue::claim`] and to whoever reads the directory.
738    pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
739        let resolved = self.resolve_id(id)?;
740        if in_flight {
741            bail!("task {resolved} is being run by a live daemon right now");
742        }
743        let path = self.path_of(&resolved);
744        std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
745        let lock = self.lock_path(&resolved);
746        if let Err(e) = std::fs::remove_file(&lock) {
747            if e.kind() != std::io::ErrorKind::NotFound {
748                return Err(e).with_context(|| format!("remove {}", lock.display()));
749            }
750        }
751        Ok(resolved)
752    }
753
754    /// Path of the claim lock for a task. One definition, so `claim` and
755    /// `remove` cannot end up naming different files.
756    fn lock_path(&self, id: &str) -> PathBuf {
757        self.root.join(format!("{id}.lock"))
758    }
759
760    /// Every task on disk, highest priority first and newest first within a
761    /// priority. This is what `magi task list` and `GET /api/queue` print, so
762    /// a raised priority has to move a task here the moment it is saved, not
763    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
764    /// the backlog and the loop about to drain it must agree on what "first"
765    /// means. Every existing task defaults to priority 0, so this is a no-op
766    /// change from the old newest-first order for a queue nobody has
767    /// reprioritised.
768    ///
769    /// Unreadable files are skipped rather than fatal: one corrupt task must
770    /// not take the queue - or the web UI, or an unattended daemon - down
771    /// with it.
772    pub fn list(&self) -> Vec<Task> {
773        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
774            .into_iter()
775            .flatten()
776            .flatten()
777            .map(|e| e.path())
778            .filter(|p| p.extension().is_some_and(|x| x == "json"))
779            .filter_map(|p| read_path(&p).ok())
780            .collect();
781        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
782        tasks
783    }
784
785    /// The task a daemon should run next, or `None` when the queue is idle.
786    ///
787    /// Highest priority first, oldest first within a priority, so a burst of
788    /// agent-filed work cannot starve the task a human filed this morning.
789    pub fn next_runnable(&self) -> Option<Task> {
790        let mut runnable: Vec<Task> = self
791            .list()
792            .into_iter()
793            .filter(|t| t.status.runnable())
794            .collect();
795        runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
796        runnable.into_iter().next()
797    }
798
799    /// Take exclusive ownership of a task.
800    ///
801    /// The lock is a `create_new` file next to the task, which is atomic on
802    /// every platform magi targets. It exists so two daemons - or a daemon and
803    /// a human running `magi run` - cannot drive one task into two competing
804    /// runs. The returned guard releases on drop, including on panic.
805    pub fn claim(&self, id: &str) -> Result<Claim> {
806        std::fs::create_dir_all(&self.root)
807            .with_context(|| format!("create {}", self.root.display()))?;
808        let path = self.lock_path(id);
809        match std::fs::OpenOptions::new()
810            .write(true)
811            .create_new(true)
812            .open(&path)
813        {
814            Ok(mut f) => {
815                use std::io::Write as _;
816                // Best effort: the pid is for the human looking at a stale lock.
817                let _ = writeln!(f, "{}", std::process::id());
818                Ok(Claim { path })
819            }
820            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
821                bail!("task {id} is already claimed ({} exists)", path.display())
822            }
823            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
824        }
825    }
826
827    /// Expand an id prefix to exactly one task id.
828    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
829        if self.path_of(prefix).is_file() {
830            return Ok(prefix.to_owned());
831        }
832        let hits: Vec<String> = self
833            .list()
834            .into_iter()
835            .map(|t| t.id)
836            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
837            .collect();
838        match hits.len() {
839            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
840            0 => bail!("no task matches `{prefix}`"),
841            _ => bail!(
842                "`{prefix}` matches {} tasks: {}",
843                hits.len(),
844                hits.join(", ")
845            ),
846        }
847    }
848
849    /// Change detection token for the queue.
850    ///
851    /// Combines file names and modification times of all task files in the
852    /// queue, so adding, modifying, or deleting any task — even an older one —
853    /// moves the revision and notifies connected clients via the change stream.
854    /// Returns 0 when the queue is completely empty.
855    pub fn revision(&self) -> u64 {
856        use std::hash::{Hash as _, Hasher as _};
857
858        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
859            .into_iter()
860            .flatten()
861            .flatten()
862            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
863            .filter_map(|e| {
864                let name = e.file_name().to_string_lossy().into_owned();
865                let mtime = e
866                    .metadata()
867                    .ok()?
868                    .modified()
869                    .ok()?
870                    .duration_since(std::time::UNIX_EPOCH)
871                    .ok()?
872                    .as_millis() as u64;
873                Some((name, mtime))
874            })
875            .collect();
876
877        if entries.is_empty() {
878            return 0;
879        }
880
881        entries.sort_unstable();
882        let mut hasher = std::hash::DefaultHasher::new();
883        for (name, mtime) in &entries {
884            name.hash(&mut hasher);
885            mtime.hash(&mut hasher);
886        }
887        let h = hasher.finish();
888        if h == 0 { 1 } else { h }
889    }
890}
891
892/// Exclusive ownership of a task, released on drop.
893#[derive(Debug)]
894pub struct Claim {
895    path: PathBuf,
896}
897
898impl Drop for Claim {
899    fn drop(&mut self) {
900        let _ = std::fs::remove_file(&self.path);
901    }
902}
903
904/// The first line of a task, trimmed to a title. Used when the caller gives a
905/// body but no title, which is the normal case for an agent piping a file in.
906pub fn title_from(instruction: &str, max: usize) -> String {
907    // The first non-blank line, whatever it is. A markdown heading is the
908    // task's own summary - agents pipe in `# Rework the config loader` and mean
909    // exactly that - so it is preferred over the prose beneath it rather than
910    // skipped as decoration. Leading list and heading markers are stripped
911    // because they are syntax, not words.
912    let line = instruction
913        .lines()
914        .map(str::trim)
915        .find(|l| !l.is_empty())
916        .unwrap_or("(empty task)")
917        .trim_start_matches(['#', '-', '*', '>', ' '])
918        .trim();
919    if line.is_empty() {
920        return "(empty task)".to_owned();
921    }
922    if line.chars().count() <= max {
923        return line.to_owned();
924    }
925    let head: String = line.chars().take(max.saturating_sub(1)).collect();
926    format!("{head}…")
927}
928
929fn read_path(path: &Path) -> Result<Task> {
930    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
931    let task: Task =
932        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
933    // Greater-than, not not-equal: every field added since schema 1 carries
934    // `#[serde(default)]`, so an older task has nothing to say about it and
935    // defaulting is exactly as good a reading as a value that build never had
936    // a chance to write. Only a schema *ahead* of this build - a meaning it
937    // cannot possibly know - is refused rather than guessed at.
938    if task.schema > SCHEMA {
939        bail!(
940            "task {} was written by a different magi (schema {}, this build \
941             speaks {SCHEMA})",
942            task.id,
943            task.schema
944        );
945    }
946    Ok(task)
947}
948
949fn short(id: &str) -> &str {
950    id.split('-').next_back().unwrap_or(id)
951}
952
953fn new_id() -> String {
954    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
955    let seed = crate::rng::entropy();
956    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    /// A queue of its own, with no process-global state - which is the point of
964    /// `Queue::at`, and why these can run in parallel.
965    fn queue() -> (tempfile::TempDir, Queue) {
966        let dir = tempfile::tempdir().unwrap();
967        let q = Queue::at(dir.path().join("queue"));
968        (dir, q)
969    }
970
971    fn task(title: &str) -> Task {
972        Task::new(
973            title.to_owned(),
974            format!("do {title}"),
975            PathBuf::from("."),
976            Source::Human,
977        )
978    }
979
980    #[test]
981    fn a_markdown_heading_is_the_title_not_decoration() {
982        // A task file's heading is the summary its author already wrote, so it
983        // beats the prose underneath. Getting this backwards was visible in the
984        // first smoke test: a task titled "# Rework the config loader" listed
985        // as "It re-reads the file on every lookup".
986        assert_eq!(
987            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
988            "Rework the config loader"
989        );
990        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
991        assert_eq!(title_from("> quoted task", 40), "quoted task");
992        // Nothing usable at all still has to produce something printable.
993        assert_eq!(title_from("   \n\n", 40), "(empty task)");
994        assert_eq!(title_from("###\n", 40), "(empty task)");
995    }
996
997    #[test]
998    fn a_long_title_is_elided_by_characters_not_bytes() {
999        // Byte truncation would split a multi-byte character and panic.
1000        let long = "課題".repeat(30);
1001        let title = title_from(&long, 10);
1002        assert_eq!(title.chars().count(), 10);
1003        assert!(title.ends_with('…'));
1004    }
1005
1006    #[test]
1007    fn priority_wins_and_ties_break_oldest_first() {
1008        let (_dir, q) = queue();
1009        let mut a = task("first");
1010        let mut b = task("second");
1011        let mut c = task("urgent");
1012        // Ids carry a timestamp, so force a known order.
1013        a.id = "20260101-000001-aaaa".to_owned();
1014        b.id = "20260101-000002-bbbb".to_owned();
1015        c.id = "20260101-000003-cccc".to_owned();
1016        c.priority = 5;
1017        for t in [&mut a, &mut b, &mut c] {
1018            q.put(t).unwrap();
1019        }
1020
1021        // Priority first...
1022        assert_eq!(q.next_runnable().unwrap().id, c.id);
1023        c.hold_machine(None);
1024        q.put(&mut c).unwrap();
1025        // ...then oldest, so a burst of new work cannot starve older work.
1026        assert_eq!(q.next_runnable().unwrap().id, a.id);
1027        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
1028    }
1029
1030    #[test]
1031    fn a_blocked_task_never_starves_another_runnable_one() {
1032        let (_dir, q) = queue();
1033        let mut blocked = task("blocked");
1034        blocked.block(vec!["something".to_owned()], None);
1035        q.put(&mut blocked).unwrap();
1036
1037        let mut runnable = task("free to go");
1038        q.put(&mut runnable).unwrap();
1039
1040        let next = q.next_runnable().expect("a runnable task is still offered");
1041        assert_eq!(next.id, runnable.id);
1042    }
1043
1044    #[test]
1045    fn a_held_task_is_never_offered_to_the_loop() {
1046        let (_dir, q) = queue();
1047        let mut t = task("held");
1048        q.put(&mut t).unwrap();
1049        assert!(q.next_runnable().is_some());
1050
1051        t.hold_machine(None);
1052        q.put(&mut t).unwrap();
1053        assert!(
1054            q.next_runnable().is_none(),
1055            "a held task must wait for a human"
1056        );
1057
1058        // A failed task, by contrast, is exactly what the loop should retry.
1059        t.status = TaskStatus::Failed;
1060        q.put(&mut t).unwrap();
1061        assert!(q.next_runnable().is_some());
1062    }
1063
1064    #[test]
1065    fn attempts_are_capped_and_then_the_task_is_held() {
1066        let mut t = task("doomed");
1067
1068        t.start("run-1".to_owned());
1069        t.fail("gate red", 2);
1070        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
1071
1072        t.start("run-2".to_owned());
1073        t.fail("gate red", 2);
1074        assert_eq!(
1075            t.status,
1076            TaskStatus::Held,
1077            "out of attempts: stop spending money on it"
1078        );
1079        assert_eq!(t.runs, ["run-1", "run-2"]);
1080        assert_eq!(t.last_error.as_deref(), Some("gate red"));
1081    }
1082
1083    #[test]
1084    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
1085        let mut t = task("stalled by quota");
1086
1087        t.start("run-1".to_owned());
1088        assert_eq!(t.attempts, 1);
1089        t.stall("judge-1, judge-2 out of quota");
1090        assert_eq!(
1091            t.attempts, 0,
1092            "a closed quota window must not spend the task's retry budget"
1093        );
1094        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
1095        assert_eq!(
1096            t.last_error.as_deref(),
1097            Some("judge-1, judge-2 out of quota")
1098        );
1099
1100        // A task can therefore stall all night and still get its real attempts
1101        // once the quota resets - which is the whole point.
1102        for _ in 0..20 {
1103            t.start("run-n".to_owned());
1104            t.stall("still out of quota");
1105        }
1106        t.start("run-real".to_owned());
1107        t.fail("gate red", 2);
1108        assert_eq!(
1109            t.status,
1110            TaskStatus::Failed,
1111            "the first attempt that was really judged is attempt one"
1112        );
1113    }
1114
1115    #[test]
1116    fn releasing_a_held_task_gives_it_a_real_second_chance() {
1117        let mut t = task("retry me");
1118        t.start("run-1".to_owned());
1119        t.fail("gate red", 1);
1120        assert_eq!(t.status, TaskStatus::Held);
1121
1122        t.release();
1123        assert_eq!(t.status, TaskStatus::Queued);
1124        // Without resetting attempts the next failure would re-hold at once,
1125        // and a release would be a no-op the operator cannot see.
1126        assert_eq!(t.attempts, 0);
1127        assert!(t.last_error.is_none());
1128        assert_eq!(
1129            t.runs.len(),
1130            1,
1131            "history is kept: attempts reset, evidence does not"
1132        );
1133    }
1134
1135    #[test]
1136    fn a_hold_reason_survives_and_a_release_clears_it() {
1137        let mut t = task("waiting on something else");
1138        t.hold_manual(Some(
1139            "waiting for 20260101-000000-aaaa to land first".to_owned(),
1140        ));
1141        assert_eq!(t.status, TaskStatus::Held);
1142        assert_eq!(
1143            t.hold_reason.as_deref(),
1144            Some("waiting for 20260101-000000-aaaa to land first")
1145        );
1146
1147        // Holding again with no reason must not erase the one already there.
1148        t.hold_manual(None);
1149        assert_eq!(
1150            t.hold_reason.as_deref(),
1151            Some("waiting for 20260101-000000-aaaa to land first"),
1152            "a bare re-hold keeps whatever a human already wrote down"
1153        );
1154
1155        // A hold with no reason at all is still an ordinary, allowed hold.
1156        let mut plain = task("no reason given");
1157        plain.hold_manual(None);
1158        assert_eq!(plain.status, TaskStatus::Held);
1159        assert!(plain.hold_reason.is_none());
1160
1161        t.release();
1162        assert_eq!(t.status, TaskStatus::Queued);
1163        assert!(
1164            t.hold_reason.is_none(),
1165            "a stale reason must not greet the next person who holds this task"
1166        );
1167    }
1168
1169    #[test]
1170    fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
1171        // `done` can close a held task directly - neither `magi task done`
1172        // nor `POST /api/queue/{id}/done` requires a release first - so a
1173        // task held for "waiting on 3ed9" and then closed without ever being
1174        // released must not still read as waiting on it afterwards.
1175        let mut t = task("landed by hand while held");
1176        t.hold_manual(Some("waiting on 3ed9".to_owned()));
1177        assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
1178
1179        t.succeed();
1180        assert_eq!(t.status, TaskStatus::Done);
1181        assert!(
1182            t.hold_reason.is_none(),
1183            "a done task cannot still be waiting on something"
1184        );
1185    }
1186
1187    #[test]
1188    fn holding_or_closing_a_blocked_task_clears_its_dependency_too() {
1189        // The web UI's "Hold" and "Mark done" buttons are both reachable on a
1190        // `blocked` task, not just on `queued`/`held` ones - neither requires
1191        // a release first. A task moved off `Blocked` that way must not still
1192        // carry the dependency it was waiting on: a dependency graph built
1193        // from `blocked_by` would otherwise keep drawing an edge for a task
1194        // that is not blocked on anything any more.
1195        let mut held = task("held straight out of blocked");
1196        held.block(
1197            vec!["20260101-000000-dead".to_owned()],
1198            Some("waiting on the migration script".to_owned()),
1199        );
1200        assert_eq!(held.status, TaskStatus::Blocked);
1201
1202        held.hold_manual(None);
1203        assert_eq!(held.status, TaskStatus::Held);
1204        assert!(
1205            held.blocked_by.is_empty(),
1206            "hold overrides the wait, same as release"
1207        );
1208        assert!(held.block_reason.is_none());
1209
1210        let mut done = task("closed straight out of blocked");
1211        done.block(
1212            vec!["20260101-000000-dead".to_owned()],
1213            Some("waiting on the migration script".to_owned()),
1214        );
1215        done.succeed();
1216        assert_eq!(done.status, TaskStatus::Done);
1217        assert!(
1218            done.blocked_by.is_empty(),
1219            "a done task cannot still be waiting on a dependency"
1220        );
1221        assert!(done.block_reason.is_none());
1222    }
1223
1224    #[test]
1225    fn a_blocked_task_is_never_offered_to_the_loop() {
1226        let mut t = task("blocked");
1227        assert!(t.status.runnable());
1228        t.block(
1229            vec!["dep-id".to_owned()],
1230            Some("waits on dep-id".to_owned()),
1231        );
1232        assert_eq!(t.status, TaskStatus::Blocked);
1233        assert!(!t.status.runnable());
1234        assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
1235    }
1236
1237    #[test]
1238    fn unblocking_the_last_dependency_returns_the_task_to_queued() {
1239        let mut t = task("blocked on two");
1240        t.block(
1241            vec!["a".to_owned(), "b".to_owned()],
1242            Some("waits on a and b".to_owned()),
1243        );
1244
1245        t.unblock("a");
1246        assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
1247        assert_eq!(t.blocked_by, ["b"]);
1248
1249        t.unblock("b");
1250        assert_eq!(t.status, TaskStatus::Queued);
1251        assert!(t.blocked_by.is_empty());
1252        assert!(t.block_reason.is_none());
1253    }
1254
1255    #[test]
1256    fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
1257        let mut t = task("never blocked");
1258        t.unblock("whatever");
1259        assert_eq!(t.status, TaskStatus::Queued);
1260    }
1261
1262    #[test]
1263    fn a_held_task_blocked_on_a_question_returns_to_held_not_queued() {
1264        // The bug this guards: a task an operator (or `crate::triage`) has
1265        // deliberately held, once `crate::conduct` blocks it on a follow-up
1266        // question, must not silently re-enter the competition queue the
1267        // moment that question is answered - whatever the answer said.
1268        let mut t = task("held, then asked about");
1269        t.hold_machine(Some("out of attempts".to_owned()));
1270        assert_eq!(t.status, TaskStatus::Held);
1271
1272        t.block(vec!["q1".to_owned()], Some("what now?".to_owned()));
1273        assert_eq!(t.status, TaskStatus::Blocked);
1274
1275        t.record_answer("what now?".to_owned(), "leave it held".to_owned());
1276        t.unblock("q1");
1277        assert_eq!(t.status, TaskStatus::Held, "must restore, not requeue");
1278        assert_eq!(t.hold_reason.as_deref(), Some("out of attempts"));
1279        assert_eq!(t.hold_source, Some(HoldSource::Machine));
1280        assert!(t.blocked_from.is_none(), "consumed once restored");
1281    }
1282
1283    #[test]
1284    fn a_manually_held_task_blocked_on_a_question_returns_to_held() {
1285        let mut t = task("manually held, then asked about");
1286        t.hold_manual(Some("waiting on a dependency".to_owned()));
1287
1288        t.block(vec!["q1".to_owned()], None);
1289        t.unblock("q1");
1290
1291        assert_eq!(t.status, TaskStatus::Held);
1292        assert_eq!(t.hold_source, Some(HoldSource::Manual));
1293    }
1294
1295    #[test]
1296    fn re_blocking_an_already_blocked_task_keeps_the_original_blocked_from() {
1297        // A second `Task::block` call - `crate::conduct` adding a question on
1298        // top of an existing block - must not overwrite `blocked_from` with
1299        // `Blocked` itself, or the task would restore into itself.
1300        let mut t = task("held, blocked twice");
1301        t.hold_machine(None);
1302        t.block(vec!["q1".to_owned()], Some("first".to_owned()));
1303        t.block(
1304            vec!["q1".to_owned(), "q2".to_owned()],
1305            Some("second".to_owned()),
1306        );
1307
1308        t.unblock("q1");
1309        assert_eq!(t.status, TaskStatus::Blocked, "q2 still outstanding");
1310        t.unblock("q2");
1311        assert_eq!(t.status, TaskStatus::Held);
1312    }
1313
1314    #[test]
1315    fn unblocking_a_task_blocked_while_running_lands_on_queued_not_running() {
1316        // Whatever process was driving the run is gone by the time a
1317        // conductor's question about it gets answered - there is nothing left
1318        // to resume into.
1319        let mut t = task("blocked mid-run");
1320        t.start("run-1".to_owned());
1321        assert_eq!(t.status, TaskStatus::Running);
1322
1323        t.block(vec!["q1".to_owned()], None);
1324        t.unblock("q1");
1325        assert_eq!(t.status, TaskStatus::Queued);
1326    }
1327
1328    #[test]
1329    fn a_pre_schema_4_blocked_record_with_hold_evidence_restores_to_held() {
1330        // `blocked_from` is `None` for a record written before schema 4 (or,
1331        // equivalently, deserialized straight from an on-disk file that never
1332        // had the field). Held evidence surviving on the task - never cleared
1333        // by `block` - is the only way left to tell such a record apart from
1334        // one blocked straight out of `Queued`.
1335        let mut t = task("legacy record, held before it was blocked");
1336        t.hold_source = Some(HoldSource::Machine);
1337        t.hold_reason = Some("legacy hold reason".to_owned());
1338        t.status = TaskStatus::Blocked;
1339        t.blocked_by = vec!["q1".to_owned()];
1340        t.blocked_from = None;
1341
1342        t.unblock("q1");
1343        assert_eq!(t.status, TaskStatus::Held);
1344    }
1345
1346    #[test]
1347    fn a_pre_schema_4_blocked_record_with_no_hold_evidence_restores_to_queued() {
1348        let mut t = task("legacy record, ordinary dependency block");
1349        t.status = TaskStatus::Blocked;
1350        t.blocked_by = vec!["dep".to_owned()];
1351        t.blocked_from = None;
1352
1353        t.unblock("dep");
1354        assert_eq!(t.status, TaskStatus::Queued);
1355    }
1356
1357    #[test]
1358    fn answering_a_question_is_recorded_and_survives_a_release() {
1359        let mut t = task("asked something");
1360        t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
1361        t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
1362        t.unblock("q1");
1363        assert_eq!(t.status, TaskStatus::Queued);
1364        assert_eq!(t.answers.len(), 1);
1365        assert_eq!(t.answers[0].answer, "SQLite");
1366
1367        // A release resets attempts, not evidence - the same rule
1368        // `releasing_a_held_task_gives_it_a_real_second_chance` asserts for
1369        // `runs`.
1370        t.release();
1371        assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
1372    }
1373
1374    #[test]
1375    fn requesting_review_requeues_the_task_and_remembers_the_branch() {
1376        let mut t = task("blocked run with a surviving branch");
1377        t.start("run-1".to_owned());
1378        t.fail("blocked with major findings", 5);
1379        assert_eq!(t.status, TaskStatus::Failed);
1380
1381        t.request_review("magi/eba2/A".to_owned());
1382        assert_eq!(t.status, TaskStatus::Queued);
1383        assert_eq!(t.attempts, 0);
1384        assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
1385
1386        // An ordinary release (a human overriding the choice) drops it again.
1387        t.release();
1388        assert!(t.review_branch.is_none());
1389    }
1390
1391    #[test]
1392    fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
1393        let mut t = task("retry");
1394        t.start("run-1".to_owned());
1395        t.requeue();
1396        assert!(t.fresh_start);
1397
1398        t.release();
1399        assert!(!t.fresh_start);
1400    }
1401
1402    #[test]
1403    fn priority_can_be_changed_while_queued_but_not_while_running() {
1404        let mut t = task("reprioritise me");
1405        t.set_priority(5).unwrap();
1406        assert_eq!(t.priority, 5);
1407
1408        t.start("run-1".to_owned());
1409        let err = t.set_priority(9).unwrap_err().to_string();
1410        assert!(err.contains("running"), "{err}");
1411        assert_eq!(t.priority, 5, "the rejected write must not partially apply");
1412    }
1413
1414    #[test]
1415    fn interrupt_can_be_marked_while_queued_but_not_while_running() {
1416        let mut t = task("interrupt me");
1417        assert!(!t.interrupt, "off unless asked, same as any other task");
1418
1419        t.set_interrupt(true).unwrap();
1420        assert!(t.interrupt);
1421
1422        t.start("run-1".to_owned());
1423        assert!(
1424            !t.interrupt,
1425            "the mark is one-shot: dispatching the task fulfils it, \
1426             whatever the run that follows ends up doing"
1427        );
1428        let err = t.set_interrupt(true).unwrap_err().to_string();
1429        assert!(err.contains("running"), "{err}");
1430        // Clearing is always allowed, even on a running task - there is
1431        // nothing left for it to interrupt once it has been claimed.
1432        t.set_interrupt(false).unwrap();
1433        assert!(!t.interrupt);
1434    }
1435
1436    /// R2-1-1: a task whose run fails and requeues must not go on
1437    /// re-triggering `crate::daemon`'s interrupt scheduler on every later
1438    /// boundary, attempt after attempt, until it exhausts its budget.
1439    #[test]
1440    fn a_failed_run_does_not_leave_the_task_still_marked_to_interrupt() {
1441        let mut t = task("interrupt me");
1442        t.set_interrupt(true).unwrap();
1443        t.start("run-1".to_owned());
1444        t.fail("mock failure", 5);
1445        assert_eq!(t.status, TaskStatus::Failed);
1446        assert!(
1447            !t.interrupt,
1448            "one attempt already spent the mark; a retry is an ordinary \
1449             requeue, not a fresh interrupt request"
1450        );
1451    }
1452
1453    #[test]
1454    fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
1455        let (_dir, q) = queue();
1456        let mut a = task("first filed");
1457        let mut b = task("second filed");
1458        a.id = "20260101-000001-aaaa".to_owned();
1459        b.id = "20260101-000002-bbbb".to_owned();
1460        q.put(&mut a).unwrap();
1461        q.put(&mut b).unwrap();
1462
1463        assert_eq!(
1464            q.next_runnable().unwrap().id,
1465            a.id,
1466            "with equal priority the older task goes first, so a burst of \
1467             new work cannot starve it"
1468        );
1469        assert_eq!(
1470            q.list()[0].id,
1471            b.id,
1472            "but the list an operator reads is newest first, the same as \
1473             before priority existed - a's turn to run does not make it the \
1474             newest task"
1475        );
1476
1477        let mut a = q.get(&a.id).unwrap();
1478        a.set_priority(10).unwrap();
1479        q.put(&mut a).unwrap();
1480
1481        assert_eq!(
1482            q.next_runnable().unwrap().id,
1483            a.id,
1484            "a raised priority must be reflected the moment it is saved"
1485        );
1486        // `magi task list` and `GET /api/queue` both print `Queue::list()`
1487        // directly, so the raised task has to lead there too - not only in
1488        // what the loop would claim next.
1489        assert_eq!(
1490            q.list()[0].id,
1491            a.id,
1492            "the raised task must sort first in the list an operator reads, \
1493             not only in next_runnable's own ordering"
1494        );
1495    }
1496
1497    #[test]
1498    fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
1499        let mut t = Task::new(
1500            "old title".to_owned(),
1501            "old instruction".to_owned(),
1502            PathBuf::from("/repo"),
1503            Source::Agent {
1504                run: "20260101-000000-beef".to_owned(),
1505                node: "implement".to_owned(),
1506            },
1507        );
1508        let id = t.id.clone();
1509        let created_at = t.created_at;
1510        t.runs.push("20260101-000000-beef".to_owned());
1511
1512        t.edit("new title".to_owned(), "new instruction".to_owned())
1513            .unwrap();
1514
1515        assert_eq!(t.title, "new title");
1516        assert_eq!(t.instruction, "new instruction");
1517        assert_eq!(t.id, id, "editing must not mint a new id");
1518        assert_eq!(t.created_at, created_at);
1519        assert_eq!(
1520            t.source,
1521            Source::Agent {
1522                run: "20260101-000000-beef".to_owned(),
1523                node: "implement".to_owned(),
1524            },
1525            "editing must not turn agent attribution into human"
1526        );
1527        assert_eq!(t.runs, ["20260101-000000-beef"]);
1528    }
1529
1530    #[test]
1531    fn editing_is_refused_once_a_task_is_running_or_finished() {
1532        let mut running = task("in flight");
1533        running.start("run-1".to_owned());
1534        let err = running
1535            .edit("x".to_owned(), "y".to_owned())
1536            .unwrap_err()
1537            .to_string();
1538        assert!(err.contains("running"), "{err}");
1539
1540        let mut done = task("finished");
1541        done.succeed();
1542        let err = done
1543            .edit("x".to_owned(), "y".to_owned())
1544            .unwrap_err()
1545            .to_string();
1546        assert!(err.contains("done"), "{err}");
1547
1548        // Both queued and held are the point of the feature and must work.
1549        let mut queued = task("waiting");
1550        queued.edit("x".to_owned(), "y".to_owned()).unwrap();
1551        let mut held = task("parked");
1552        held.hold_machine(None);
1553        held.edit("x".to_owned(), "y".to_owned()).unwrap();
1554    }
1555
1556    #[test]
1557    fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
1558        let (_dir, q) = queue();
1559        let path = q.path_of("20260101-000000-aaaa");
1560        std::fs::create_dir_all(q.root()).unwrap();
1561        std::fs::write(
1562            &path,
1563            serde_json::json!({
1564                "schema": SCHEMA,
1565                "id": "20260101-000000-aaaa",
1566                "title": "from before hold reasons existed",
1567                "instruction": "from before hold reasons existed",
1568                "repo": ".",
1569                "source": { "kind": "human" },
1570                "status": "held",
1571                "created_at": Timestamp::now().to_string(),
1572                "updated_at": Timestamp::now().to_string(),
1573            })
1574            .to_string(),
1575        )
1576        .unwrap();
1577
1578        let task = q.get("20260101-000000-aaaa").expect("must still read");
1579        assert!(task.hold_reason.is_none());
1580        assert!(task.operator_held());
1581    }
1582
1583    #[test]
1584    fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
1585        let (_dir, q) = queue();
1586        let path = q.path_of("20260101-000000-bbbb");
1587        std::fs::create_dir_all(q.root()).unwrap();
1588        std::fs::write(
1589            &path,
1590            serde_json::json!({
1591                "schema": 2,
1592                "id": "20260101-000000-bbbb",
1593                "title": "old manual recovery",
1594                "instruction": "old manual recovery",
1595                "repo": ".",
1596                "source": { "kind": "human" },
1597                "status": "held",
1598                "hold_reason": "active manual recovery run20260912-224242-daf5",
1599                "created_at": Timestamp::now().to_string(),
1600                "updated_at": Timestamp::now().to_string(),
1601            })
1602            .to_string(),
1603        )
1604        .unwrap();
1605
1606        let task = q.get("20260101-000000-bbbb").expect("must still read");
1607        assert_eq!(task.hold_source, None);
1608        assert!(task.operator_held());
1609    }
1610
1611    #[test]
1612    fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
1613        let (_dir, q) = queue();
1614        let path = q.path_of("20260101-000000-aaaa");
1615        std::fs::create_dir_all(q.root()).unwrap();
1616        std::fs::write(
1617            &path,
1618            serde_json::json!({
1619                "schema": SCHEMA,
1620                "id": "20260101-000000-aaaa",
1621                "title": "from before diagnostics existed",
1622                "instruction": "from before diagnostics existed",
1623                "repo": ".",
1624                "source": { "kind": "human" },
1625                "status": "held",
1626                "created_at": Timestamp::now().to_string(),
1627                "updated_at": Timestamp::now().to_string(),
1628            })
1629            .to_string(),
1630        )
1631        .unwrap();
1632
1633        let task = q.get("20260101-000000-aaaa").expect("must still read");
1634        assert!(task.diagnostic.is_none());
1635    }
1636
1637    #[test]
1638    fn a_schema_1_task_with_no_blocking_fields_still_reads() {
1639        // Written by a build that predates `blocked_by`, `block_reason`,
1640        // `answers` and `review_branch` entirely - literal `"schema": 1`,
1641        // not `SCHEMA`, since the whole point is a build older than this one.
1642        let (_dir, q) = queue();
1643        let path = q.path_of("20260101-000000-aaaa");
1644        std::fs::create_dir_all(q.root()).unwrap();
1645        std::fs::write(
1646            &path,
1647            serde_json::json!({
1648                "schema": 1,
1649                "id": "20260101-000000-aaaa",
1650                "title": "from before blocking existed",
1651                "instruction": "from before blocking existed",
1652                "repo": ".",
1653                "source": { "kind": "human" },
1654                "status": "queued",
1655                "created_at": Timestamp::now().to_string(),
1656                "updated_at": Timestamp::now().to_string(),
1657            })
1658            .to_string(),
1659        )
1660        .unwrap();
1661
1662        let task = q.get("20260101-000000-aaaa").expect("must still read");
1663        assert!(task.blocked_by.is_empty());
1664        assert!(task.block_reason.is_none());
1665        assert!(task.answers.is_empty());
1666        assert!(task.review_branch.is_none());
1667    }
1668
1669    #[test]
1670    fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1671        // A diagnostic belongs to the run that produced it. Left in place
1672        // across a release, an unrelated later failure - a config error, say -
1673        // would go on showing evidence for a problem that is no longer why the
1674        // task is stuck.
1675        let mut held = task("diagnosed");
1676        held.start("run-1".to_owned());
1677        held.fail("gate red", 1);
1678        held.diagnostic = Some("cargo test failed: ...".to_owned());
1679        assert_eq!(held.status, TaskStatus::Held);
1680
1681        held.release();
1682        assert!(held.diagnostic.is_none());
1683
1684        held.diagnostic = Some("cargo test failed: ...".to_owned());
1685        held.succeed();
1686        assert!(held.diagnostic.is_none());
1687    }
1688
1689    #[test]
1690    fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1691        let mut t = task("retried");
1692        t.start("run-1".to_owned());
1693        t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1694        t.fail("unrelated config error", 5);
1695        assert_eq!(t.status, TaskStatus::Failed);
1696        assert!(
1697            t.diagnostic.is_none(),
1698            "fail() must not let an old diagnostic outlive the run that produced it"
1699        );
1700    }
1701
1702    #[test]
1703    fn a_claim_is_exclusive_and_releases_on_drop() {
1704        let (_dir, q) = queue();
1705        let mut t = task("contended");
1706        q.put(&mut t).unwrap();
1707
1708        let held = q.claim(&t.id).unwrap();
1709        assert!(
1710            q.claim(&t.id).is_err(),
1711            "two daemons must not drive one task into two runs"
1712        );
1713        drop(held);
1714        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1715    }
1716
1717    #[test]
1718    fn a_round_trip_survives_disk() {
1719        let (_dir, q) = queue();
1720        let mut t = Task::new(
1721            "titled".to_owned(),
1722            "body".to_owned(),
1723            PathBuf::from("/repo"),
1724            Source::Agent {
1725                run: "20260101-000000-beef".to_owned(),
1726                node: "implement".to_owned(),
1727            },
1728        );
1729        t.priority = 3;
1730        q.put(&mut t).unwrap();
1731
1732        let back = q.get(&t.id).unwrap();
1733        assert_eq!(back.id, t.id);
1734        assert_eq!(back.priority, 3);
1735        assert_eq!(back.source.label(), "implement@beef");
1736        // A prefix is enough, the way run ids work everywhere else.
1737        assert_eq!(q.get(t.short()).unwrap().id, t.id);
1738    }
1739
1740    #[test]
1741    fn an_unreadable_task_does_not_take_the_queue_down() {
1742        let (_dir, q) = queue();
1743        let mut t = task("fine");
1744        q.put(&mut t).unwrap();
1745        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1746
1747        let listed = q.list();
1748        assert_eq!(listed.len(), 1, "the readable task still lists");
1749        assert_eq!(listed[0].id, t.id);
1750    }
1751
1752    #[test]
1753    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1754        let (_dir, q) = queue();
1755        let path = q.path_of("20260101-000000-aaaa");
1756        std::fs::create_dir_all(q.root()).unwrap();
1757        std::fs::write(
1758            &path,
1759            serde_json::json!({
1760                "schema": SCHEMA,
1761                "id": "20260101-000000-aaaa",
1762                "title": "from before solo existed",
1763                "instruction": "from before solo existed",
1764                "repo": ".",
1765                "source": { "kind": "human" },
1766                "status": "queued",
1767                "created_at": Timestamp::now().to_string(),
1768                "updated_at": Timestamp::now().to_string(),
1769            })
1770            .to_string(),
1771        )
1772        .unwrap();
1773
1774        let task = q.get("20260101-000000-aaaa").expect("must still read");
1775        assert!(!task.solo, "a queue file with no `solo` field means false");
1776    }
1777
1778    #[test]
1779    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1780        let (_dir, q) = queue();
1781        let mut t = task("from the future");
1782        q.put(&mut t).unwrap();
1783        let path = q.path_of(&t.id);
1784        let body = std::fs::read_to_string(&path)
1785            .unwrap()
1786            .replace(&format!("\"schema\": {SCHEMA}"), "\"schema\": 99");
1787        std::fs::write(&path, body).unwrap();
1788
1789        let err = q.get(&t.id).unwrap_err().to_string();
1790        assert!(err.contains("schema 99"), "{err}");
1791    }
1792
1793    #[test]
1794    fn revision_moves_when_the_queue_changes() {
1795        let (_dir, q) = queue();
1796        assert_eq!(q.revision(), 0, "an empty queue has no revision");
1797        let mut t = task("first");
1798        q.put(&mut t).unwrap();
1799        assert!(q.revision() > 0, "a written task moves the revision");
1800    }
1801
1802    #[test]
1803    fn revision_moves_when_deleting_an_older_task() {
1804        let (_dir, q) = queue();
1805        let mut t1 = task("older");
1806        q.put(&mut t1).unwrap();
1807        // Ensure mtime ticks forward.
1808        std::thread::sleep(std::time::Duration::from_millis(10));
1809        let mut t2 = task("newer");
1810        q.put(&mut t2).unwrap();
1811
1812        let rev_before = q.revision();
1813        q.remove(&t1.id, false).unwrap();
1814        let rev_after = q.revision();
1815
1816        assert_ne!(
1817            rev_before, rev_after,
1818            "deleting an older task must change the revision so other clients see the deletion"
1819        );
1820    }
1821
1822    #[test]
1823    fn removing_a_task_takes_it_out_of_the_listing() {
1824        let (_dir, q) = queue();
1825        let mut t = task("delete me");
1826        q.put(&mut t).unwrap();
1827        let removed = q.remove(t.short(), false).unwrap();
1828        assert_eq!(removed, t.id, "a prefix resolves before deleting");
1829        assert!(q.list().is_empty());
1830        assert!(
1831            q.remove(&t.id, false).is_err(),
1832            "removing twice is an error"
1833        );
1834    }
1835
1836    #[test]
1837    fn removing_a_task_takes_its_stale_lock_with_it() {
1838        let (_dir, q) = queue();
1839        let mut t = task("interrupted");
1840        q.put(&mut t).unwrap();
1841
1842        // A daemon killed mid-run leaves this behind. Nothing holds it: the
1843        // process that would have dropped the guard is gone.
1844        let claim = q.claim(&t.id).unwrap();
1845        std::mem::forget(claim);
1846        assert!(
1847            q.claim(&t.id).is_err(),
1848            "the orphaned lock is what makes the task look claimed"
1849        );
1850
1851        // A live daemon on this task is refused, whatever the lock says.
1852        let err = q.remove(&t.id, true).unwrap_err().to_string();
1853        assert!(err.contains("live daemon"), "{err}");
1854        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1855
1856        // With no daemon behind it, the lock is stale and goes with the task.
1857        q.remove(&t.id, false).unwrap();
1858        assert!(q.list().is_empty());
1859        let mut again = task("interrupted");
1860        again.id = t.id.clone();
1861        q.put(&mut again).unwrap();
1862        assert!(
1863            q.claim(&t.id).is_ok(),
1864            "a task that comes back must be claimable, which a left-behind lock would prevent"
1865        );
1866    }
1867}