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