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