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}