Skip to main content

magi/
queue.rs

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