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.
40pub const SCHEMA: u32 = 1;
41
42/// Where a task came from. Recorded because "who asked for this" is the first
43/// question about an autonomous run, and the answer is not recoverable later.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "kind", rename_all = "lowercase")]
46pub enum Source {
47    /// A person, at a terminal or through the web UI.
48    Human,
49    /// An agent inside a run, via `magi task add`. Both ids are recorded so a
50    /// task can be traced back to the exact seat that asked for it.
51    Agent {
52        /// Run the asking agent belonged to.
53        run: String,
54        /// Node it was working in, e.g. `implement` or `review`.
55        node: String,
56    },
57    /// A GitHub issue, imported by number.
58    Issue {
59        /// Issue number.
60        number: u64,
61        /// `owner/repo`, as `gh` reports it.
62        repo: String,
63    },
64}
65
66impl Source {
67    /// Short human-facing label, for lists and the web UI.
68    pub fn label(&self) -> String {
69        match self {
70            Self::Human => "human".to_owned(),
71            Self::Agent { run, node } => format!("{node}@{}", short(run)),
72            Self::Issue { number, .. } => format!("issue #{number}"),
73        }
74    }
75}
76
77/// Where a task is in its life.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum TaskStatus {
81    /// Waiting to be claimed.
82    Queued,
83    /// Claimed by a daemon; a run is in flight.
84    Running,
85    /// A run finished and its gate passed.
86    Done,
87    /// A run finished without passing, and attempts remain.
88    Failed,
89    /// Out of attempts, or held by hand. The loop will not pick it up.
90    Held,
91}
92
93impl TaskStatus {
94    /// Is this task eligible for a daemon to claim?
95    pub fn runnable(self) -> bool {
96        matches!(self, Self::Queued | Self::Failed)
97    }
98
99    /// Lowercase name, as it appears on disk and in the API.
100    pub fn as_str(self) -> &'static str {
101        match self {
102            Self::Queued => "queued",
103            Self::Running => "running",
104            Self::Done => "done",
105            Self::Failed => "failed",
106            Self::Held => "held",
107        }
108    }
109}
110
111/// One unit of work.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct Task {
115    /// On-disk format version.
116    pub schema: u32,
117    /// Task id, e.g. `20260902-140501-a1b2`.
118    pub id: String,
119    /// One line, for lists and notifications.
120    pub title: String,
121    /// The task itself, handed to the graph verbatim.
122    pub instruction: String,
123    /// Repository to work in.
124    pub repo: PathBuf,
125    /// Who asked.
126    pub source: Source,
127    /// Higher runs first; ties break oldest-first so nothing starves.
128    #[serde(default)]
129    pub priority: i32,
130    /// Run this task alone: one implementer, no panel of judges to convince.
131    ///
132    /// `#[serde(default)]` so a queue file written before this field existed
133    /// still reads, as `false` - the ordinary multi-candidate competition,
134    /// unchanged. A task set to `solo` still runs the whole graph; only the
135    /// candidate count the daemon builds it with changes, and
136    /// [`crate::graph::Runner`] already collapses a single-candidate run to
137    /// implement → review → gate → merge on its own (see
138    /// [`crate::graph::Runner::review`]'s doc), so nothing about judging,
139    /// deliberation or voting had to change to support this.
140    #[serde(default)]
141    pub solo: bool,
142    /// Current state.
143    pub status: TaskStatus,
144    /// How many times this task has been claimed.
145    #[serde(default)]
146    pub attempts: usize,
147    /// Runs this task has produced, oldest first.
148    #[serde(default)]
149    pub runs: Vec<String>,
150    /// Why the last attempt did not land.
151    #[serde(default)]
152    pub last_error: Option<String>,
153    /// When the task was filed.
154    pub created_at: Timestamp,
155    /// Last change to this file.
156    pub updated_at: Timestamp,
157}
158
159impl Task {
160    /// File a new task. Persist it with [`Queue::put`].
161    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
162        let now = Timestamp::now();
163        Self {
164            schema: SCHEMA,
165            id: new_id(),
166            title,
167            instruction,
168            repo,
169            source,
170            priority: 0,
171            solo: false,
172            status: TaskStatus::Queued,
173            attempts: 0,
174            runs: Vec::new(),
175            last_error: None,
176            created_at: now,
177            updated_at: now,
178        }
179    }
180
181    /// Short form used in reports, matching a run's short id.
182    pub fn short(&self) -> &str {
183        short(&self.id)
184    }
185
186    /// Record that a run has started for this task.
187    pub fn start(&mut self, run: String) {
188        self.status = TaskStatus::Running;
189        self.attempts += 1;
190        self.runs.push(run);
191        self.last_error = None;
192    }
193
194    /// Record a successful run.
195    pub fn succeed(&mut self) {
196        self.status = TaskStatus::Done;
197        self.last_error = None;
198    }
199
200    /// Record a failed attempt. Out of attempts means held for a human, rather
201    /// than retried until the money runs out.
202    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
203        self.last_error = Some(why.into());
204        self.status = if self.attempts >= max_attempts {
205            TaskStatus::Held
206        } else {
207            TaskStatus::Failed
208        };
209    }
210
211    /// Record an attempt that failed for a reason the task is not responsible
212    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
213    ///
214    /// This refunds the attempt on purpose. A quota window closing at 4am must
215    /// not spend the backlog's retry budget: the operator would come back to a
216    /// queue of held tasks that were never actually judged, and would have to
217    /// release every one by hand to find out which had a real problem. The task
218    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
219    /// work up where it stopped.
220    pub fn stall(&mut self, why: impl Into<String>) {
221        self.last_error = Some(why.into());
222        self.attempts = self.attempts.saturating_sub(1);
223        self.status = TaskStatus::Failed;
224    }
225
226    /// Take this task out of the loop's reach without deleting it.
227    pub fn hold(&mut self) {
228        self.status = TaskStatus::Held;
229    }
230
231    /// Record a run that produced a pull request without merging it.
232    ///
233    /// The task is held rather than retried, and it costs no further attempt
234    /// either way. The work the task asked for exists: it is sitting on a
235    /// branch, in a pull request, waiting for CI or for a person. Retrying
236    /// would spend the whole competition budget a second time and then race a
237    /// second branch against the pull request the first one opened - which is
238    /// exactly what happened to run 01c2, whose finished and green pull request
239    /// was re-competed from scratch four seconds after it opened.
240    ///
241    /// A pull request nobody merged is a request for a person, not a failure.
242    pub fn handed_off(&mut self, why: impl Into<String>) {
243        self.last_error = Some(why.into());
244        self.status = TaskStatus::Held;
245    }
246
247    /// Put a held or finished task back in line, with its attempt count reset
248    /// so a release is a real second chance rather than an instant re-hold.
249    /// The run history is kept: attempts reset, evidence does not.
250    pub fn release(&mut self) {
251        self.status = TaskStatus::Queued;
252        self.attempts = 0;
253        self.last_error = None;
254    }
255}
256
257/// A queue on disk.
258#[derive(Debug, Clone)]
259pub struct Queue {
260    root: PathBuf,
261}
262
263impl Queue {
264    /// The operator's queue, `<home>/queue`.
265    pub fn open() -> Self {
266        Self::at(crate::run::home().join("queue"))
267    }
268
269    /// A queue at an explicit root. Tests use this; so could an operator who
270    /// wants a queue per project.
271    pub fn at(root: PathBuf) -> Self {
272        Self { root }
273    }
274
275    /// Directory holding the task files.
276    pub fn root(&self) -> &Path {
277        &self.root
278    }
279
280    /// Path for one task id.
281    pub fn path_of(&self, id: &str) -> PathBuf {
282        self.root.join(format!("{id}.json"))
283    }
284
285    /// Write a task, atomically, so a daemon killed mid-write leaves the
286    /// previous state readable rather than a truncated file.
287    pub fn put(&self, task: &mut Task) -> Result<()> {
288        task.updated_at = Timestamp::now();
289        std::fs::create_dir_all(&self.root)
290            .with_context(|| format!("create {}", self.root.display()))?;
291        let body = serde_json::to_string_pretty(task).context("serialize task")?;
292        let path = self.path_of(&task.id);
293        let tmp = path.with_extension("json.tmp");
294        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
295        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
296        Ok(())
297    }
298
299    /// Load a task by id or unambiguous id prefix.
300    pub fn get(&self, id: &str) -> Result<Task> {
301        let resolved = self.resolve_id(id)?;
302        read_path(&self.path_of(&resolved))
303    }
304
305    /// Remove a task, and the claim lock that belongs to it.
306    ///
307    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
308    /// this task — because the task's own `running` status cannot answer the
309    /// question. A daemon killed mid-competition leaves the status at
310    /// `running` and an orphaned `.lock` behind, and a guard that trusted
311    /// either would make the task undeletable for good: the phone showed
312    /// exactly that, refusing a task whose daemon had been gone for an hour.
313    ///
314    /// So the lock is removed with the task rather than respected. Any lock
315    /// still there once no live daemon claims the task is by definition stale,
316    /// and leaving it would make a deleted task look claimed to
317    /// [`Queue::claim`] and to whoever reads the directory.
318    pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
319        let resolved = self.resolve_id(id)?;
320        if in_flight {
321            bail!("task {resolved} is being run by a live daemon right now");
322        }
323        let path = self.path_of(&resolved);
324        std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
325        let lock = self.lock_path(&resolved);
326        if let Err(e) = std::fs::remove_file(&lock) {
327            if e.kind() != std::io::ErrorKind::NotFound {
328                return Err(e).with_context(|| format!("remove {}", lock.display()));
329            }
330        }
331        Ok(resolved)
332    }
333
334    /// Path of the claim lock for a task. One definition, so `claim` and
335    /// `remove` cannot end up naming different files.
336    fn lock_path(&self, id: &str) -> PathBuf {
337        self.root.join(format!("{id}.lock"))
338    }
339
340    /// Every task on disk, newest first. Unreadable files are skipped rather
341    /// than fatal: one corrupt task must not take the queue - or the web UI,
342    /// or an unattended daemon - down with it.
343    pub fn list(&self) -> Vec<Task> {
344        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
345            .into_iter()
346            .flatten()
347            .flatten()
348            .map(|e| e.path())
349            .filter(|p| p.extension().is_some_and(|x| x == "json"))
350            .filter_map(|p| read_path(&p).ok())
351            .collect();
352        tasks.sort_unstable_by(|a, b| b.id.cmp(&a.id));
353        tasks
354    }
355
356    /// The task a daemon should run next, or `None` when the queue is idle.
357    ///
358    /// Highest priority first, oldest first within a priority, so a burst of
359    /// agent-filed work cannot starve the task a human filed this morning.
360    pub fn next_runnable(&self) -> Option<Task> {
361        let mut runnable: Vec<Task> = self
362            .list()
363            .into_iter()
364            .filter(|t| t.status.runnable())
365            .collect();
366        runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
367        runnable.into_iter().next()
368    }
369
370    /// Take exclusive ownership of a task.
371    ///
372    /// The lock is a `create_new` file next to the task, which is atomic on
373    /// every platform magi targets. It exists so two daemons - or a daemon and
374    /// a human running `magi run` - cannot drive one task into two competing
375    /// runs. The returned guard releases on drop, including on panic.
376    pub fn claim(&self, id: &str) -> Result<Claim> {
377        std::fs::create_dir_all(&self.root)
378            .with_context(|| format!("create {}", self.root.display()))?;
379        let path = self.lock_path(id);
380        match std::fs::OpenOptions::new()
381            .write(true)
382            .create_new(true)
383            .open(&path)
384        {
385            Ok(mut f) => {
386                use std::io::Write as _;
387                // Best effort: the pid is for the human looking at a stale lock.
388                let _ = writeln!(f, "{}", std::process::id());
389                Ok(Claim { path })
390            }
391            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
392                bail!("task {id} is already claimed ({} exists)", path.display())
393            }
394            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
395        }
396    }
397
398    /// Expand an id prefix to exactly one task id.
399    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
400        if self.path_of(prefix).is_file() {
401            return Ok(prefix.to_owned());
402        }
403        let hits: Vec<String> = self
404            .list()
405            .into_iter()
406            .map(|t| t.id)
407            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
408            .collect();
409        match hits.len() {
410            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
411            0 => bail!("no task matches `{prefix}`"),
412            _ => bail!(
413                "`{prefix}` matches {} tasks: {}",
414                hits.len(),
415                hits.join(", ")
416            ),
417        }
418    }
419
420    /// Change detection token for the queue.
421    ///
422    /// Combines file names and modification times of all task files in the
423    /// queue, so adding, modifying, or deleting any task — even an older one —
424    /// moves the revision and notifies connected clients via the change stream.
425    /// Returns 0 when the queue is completely empty.
426    pub fn revision(&self) -> u64 {
427        use std::hash::{Hash as _, Hasher as _};
428
429        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
430            .into_iter()
431            .flatten()
432            .flatten()
433            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
434            .filter_map(|e| {
435                let name = e.file_name().to_string_lossy().into_owned();
436                let mtime = e
437                    .metadata()
438                    .ok()?
439                    .modified()
440                    .ok()?
441                    .duration_since(std::time::UNIX_EPOCH)
442                    .ok()?
443                    .as_millis() as u64;
444                Some((name, mtime))
445            })
446            .collect();
447
448        if entries.is_empty() {
449            return 0;
450        }
451
452        entries.sort_unstable();
453        let mut hasher = std::hash::DefaultHasher::new();
454        for (name, mtime) in &entries {
455            name.hash(&mut hasher);
456            mtime.hash(&mut hasher);
457        }
458        let h = hasher.finish();
459        if h == 0 { 1 } else { h }
460    }
461}
462
463/// Exclusive ownership of a task, released on drop.
464#[derive(Debug)]
465pub struct Claim {
466    path: PathBuf,
467}
468
469impl Drop for Claim {
470    fn drop(&mut self) {
471        let _ = std::fs::remove_file(&self.path);
472    }
473}
474
475/// The first line of a task, trimmed to a title. Used when the caller gives a
476/// body but no title, which is the normal case for an agent piping a file in.
477pub fn title_from(instruction: &str, max: usize) -> String {
478    // The first non-blank line, whatever it is. A markdown heading is the
479    // task's own summary - agents pipe in `# Rework the config loader` and mean
480    // exactly that - so it is preferred over the prose beneath it rather than
481    // skipped as decoration. Leading list and heading markers are stripped
482    // because they are syntax, not words.
483    let line = instruction
484        .lines()
485        .map(str::trim)
486        .find(|l| !l.is_empty())
487        .unwrap_or("(empty task)")
488        .trim_start_matches(['#', '-', '*', '>', ' '])
489        .trim();
490    if line.is_empty() {
491        return "(empty task)".to_owned();
492    }
493    if line.chars().count() <= max {
494        return line.to_owned();
495    }
496    let head: String = line.chars().take(max.saturating_sub(1)).collect();
497    format!("{head}…")
498}
499
500fn read_path(path: &Path) -> Result<Task> {
501    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
502    let task: Task =
503        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
504    if task.schema != SCHEMA {
505        bail!(
506            "task {} was written by a different magi (schema {}, this build \
507             speaks {SCHEMA})",
508            task.id,
509            task.schema
510        );
511    }
512    Ok(task)
513}
514
515fn short(id: &str) -> &str {
516    id.split('-').next_back().unwrap_or(id)
517}
518
519fn new_id() -> String {
520    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
521    let seed = crate::rng::entropy();
522    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    /// A queue of its own, with no process-global state - which is the point of
530    /// `Queue::at`, and why these can run in parallel.
531    fn queue() -> (tempfile::TempDir, Queue) {
532        let dir = tempfile::tempdir().unwrap();
533        let q = Queue::at(dir.path().join("queue"));
534        (dir, q)
535    }
536
537    fn task(title: &str) -> Task {
538        Task::new(
539            title.to_owned(),
540            format!("do {title}"),
541            PathBuf::from("."),
542            Source::Human,
543        )
544    }
545
546    #[test]
547    fn a_markdown_heading_is_the_title_not_decoration() {
548        // A task file's heading is the summary its author already wrote, so it
549        // beats the prose underneath. Getting this backwards was visible in the
550        // first smoke test: a task titled "# Rework the config loader" listed
551        // as "It re-reads the file on every lookup".
552        assert_eq!(
553            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
554            "Rework the config loader"
555        );
556        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
557        assert_eq!(title_from("> quoted task", 40), "quoted task");
558        // Nothing usable at all still has to produce something printable.
559        assert_eq!(title_from("   \n\n", 40), "(empty task)");
560        assert_eq!(title_from("###\n", 40), "(empty task)");
561    }
562
563    #[test]
564    fn a_long_title_is_elided_by_characters_not_bytes() {
565        // Byte truncation would split a multi-byte character and panic.
566        let long = "課題".repeat(30);
567        let title = title_from(&long, 10);
568        assert_eq!(title.chars().count(), 10);
569        assert!(title.ends_with('…'));
570    }
571
572    #[test]
573    fn priority_wins_and_ties_break_oldest_first() {
574        let (_dir, q) = queue();
575        let mut a = task("first");
576        let mut b = task("second");
577        let mut c = task("urgent");
578        // Ids carry a timestamp, so force a known order.
579        a.id = "20260101-000001-aaaa".to_owned();
580        b.id = "20260101-000002-bbbb".to_owned();
581        c.id = "20260101-000003-cccc".to_owned();
582        c.priority = 5;
583        for t in [&mut a, &mut b, &mut c] {
584            q.put(t).unwrap();
585        }
586
587        // Priority first...
588        assert_eq!(q.next_runnable().unwrap().id, c.id);
589        c.hold();
590        q.put(&mut c).unwrap();
591        // ...then oldest, so a burst of new work cannot starve older work.
592        assert_eq!(q.next_runnable().unwrap().id, a.id);
593        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
594    }
595
596    #[test]
597    fn a_held_task_is_never_offered_to_the_loop() {
598        let (_dir, q) = queue();
599        let mut t = task("held");
600        q.put(&mut t).unwrap();
601        assert!(q.next_runnable().is_some());
602
603        t.hold();
604        q.put(&mut t).unwrap();
605        assert!(
606            q.next_runnable().is_none(),
607            "a held task must wait for a human"
608        );
609
610        // A failed task, by contrast, is exactly what the loop should retry.
611        t.status = TaskStatus::Failed;
612        q.put(&mut t).unwrap();
613        assert!(q.next_runnable().is_some());
614    }
615
616    #[test]
617    fn attempts_are_capped_and_then_the_task_is_held() {
618        let mut t = task("doomed");
619
620        t.start("run-1".to_owned());
621        t.fail("gate red", 2);
622        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
623
624        t.start("run-2".to_owned());
625        t.fail("gate red", 2);
626        assert_eq!(
627            t.status,
628            TaskStatus::Held,
629            "out of attempts: stop spending money on it"
630        );
631        assert_eq!(t.runs, ["run-1", "run-2"]);
632        assert_eq!(t.last_error.as_deref(), Some("gate red"));
633    }
634
635    #[test]
636    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
637        let mut t = task("stalled by quota");
638
639        t.start("run-1".to_owned());
640        assert_eq!(t.attempts, 1);
641        t.stall("judge-1, judge-2 out of quota");
642        assert_eq!(
643            t.attempts, 0,
644            "a closed quota window must not spend the task's retry budget"
645        );
646        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
647        assert_eq!(
648            t.last_error.as_deref(),
649            Some("judge-1, judge-2 out of quota")
650        );
651
652        // A task can therefore stall all night and still get its real attempts
653        // once the quota resets - which is the whole point.
654        for _ in 0..20 {
655            t.start("run-n".to_owned());
656            t.stall("still out of quota");
657        }
658        t.start("run-real".to_owned());
659        t.fail("gate red", 2);
660        assert_eq!(
661            t.status,
662            TaskStatus::Failed,
663            "the first attempt that was really judged is attempt one"
664        );
665    }
666
667    #[test]
668    fn releasing_a_held_task_gives_it_a_real_second_chance() {
669        let mut t = task("retry me");
670        t.start("run-1".to_owned());
671        t.fail("gate red", 1);
672        assert_eq!(t.status, TaskStatus::Held);
673
674        t.release();
675        assert_eq!(t.status, TaskStatus::Queued);
676        // Without resetting attempts the next failure would re-hold at once,
677        // and a release would be a no-op the operator cannot see.
678        assert_eq!(t.attempts, 0);
679        assert!(t.last_error.is_none());
680        assert_eq!(
681            t.runs.len(),
682            1,
683            "history is kept: attempts reset, evidence does not"
684        );
685    }
686
687    #[test]
688    fn a_claim_is_exclusive_and_releases_on_drop() {
689        let (_dir, q) = queue();
690        let mut t = task("contended");
691        q.put(&mut t).unwrap();
692
693        let held = q.claim(&t.id).unwrap();
694        assert!(
695            q.claim(&t.id).is_err(),
696            "two daemons must not drive one task into two runs"
697        );
698        drop(held);
699        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
700    }
701
702    #[test]
703    fn a_round_trip_survives_disk() {
704        let (_dir, q) = queue();
705        let mut t = Task::new(
706            "titled".to_owned(),
707            "body".to_owned(),
708            PathBuf::from("/repo"),
709            Source::Agent {
710                run: "20260101-000000-beef".to_owned(),
711                node: "implement".to_owned(),
712            },
713        );
714        t.priority = 3;
715        q.put(&mut t).unwrap();
716
717        let back = q.get(&t.id).unwrap();
718        assert_eq!(back.id, t.id);
719        assert_eq!(back.priority, 3);
720        assert_eq!(back.source.label(), "implement@beef");
721        // A prefix is enough, the way run ids work everywhere else.
722        assert_eq!(q.get(t.short()).unwrap().id, t.id);
723    }
724
725    #[test]
726    fn an_unreadable_task_does_not_take_the_queue_down() {
727        let (_dir, q) = queue();
728        let mut t = task("fine");
729        q.put(&mut t).unwrap();
730        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
731
732        let listed = q.list();
733        assert_eq!(listed.len(), 1, "the readable task still lists");
734        assert_eq!(listed[0].id, t.id);
735    }
736
737    #[test]
738    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
739        let (_dir, q) = queue();
740        let path = q.path_of("20260101-000000-aaaa");
741        std::fs::create_dir_all(q.root()).unwrap();
742        std::fs::write(
743            &path,
744            serde_json::json!({
745                "schema": SCHEMA,
746                "id": "20260101-000000-aaaa",
747                "title": "from before solo existed",
748                "instruction": "from before solo existed",
749                "repo": ".",
750                "source": { "kind": "human" },
751                "status": "queued",
752                "created_at": Timestamp::now().to_string(),
753                "updated_at": Timestamp::now().to_string(),
754            })
755            .to_string(),
756        )
757        .unwrap();
758
759        let task = q.get("20260101-000000-aaaa").expect("must still read");
760        assert!(!task.solo, "a queue file with no `solo` field means false");
761    }
762
763    #[test]
764    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
765        let (_dir, q) = queue();
766        let mut t = task("from the future");
767        q.put(&mut t).unwrap();
768        let path = q.path_of(&t.id);
769        let body = std::fs::read_to_string(&path)
770            .unwrap()
771            .replace("\"schema\": 1", "\"schema\": 99");
772        std::fs::write(&path, body).unwrap();
773
774        let err = q.get(&t.id).unwrap_err().to_string();
775        assert!(err.contains("schema 99"), "{err}");
776    }
777
778    #[test]
779    fn revision_moves_when_the_queue_changes() {
780        let (_dir, q) = queue();
781        assert_eq!(q.revision(), 0, "an empty queue has no revision");
782        let mut t = task("first");
783        q.put(&mut t).unwrap();
784        assert!(q.revision() > 0, "a written task moves the revision");
785    }
786
787    #[test]
788    fn revision_moves_when_deleting_an_older_task() {
789        let (_dir, q) = queue();
790        let mut t1 = task("older");
791        q.put(&mut t1).unwrap();
792        // Ensure mtime ticks forward.
793        std::thread::sleep(std::time::Duration::from_millis(10));
794        let mut t2 = task("newer");
795        q.put(&mut t2).unwrap();
796
797        let rev_before = q.revision();
798        q.remove(&t1.id, false).unwrap();
799        let rev_after = q.revision();
800
801        assert_ne!(
802            rev_before, rev_after,
803            "deleting an older task must change the revision so other clients see the deletion"
804        );
805    }
806
807    #[test]
808    fn removing_a_task_takes_it_out_of_the_listing() {
809        let (_dir, q) = queue();
810        let mut t = task("delete me");
811        q.put(&mut t).unwrap();
812        let removed = q.remove(t.short(), false).unwrap();
813        assert_eq!(removed, t.id, "a prefix resolves before deleting");
814        assert!(q.list().is_empty());
815        assert!(
816            q.remove(&t.id, false).is_err(),
817            "removing twice is an error"
818        );
819    }
820
821    #[test]
822    fn removing_a_task_takes_its_stale_lock_with_it() {
823        let (_dir, q) = queue();
824        let mut t = task("interrupted");
825        q.put(&mut t).unwrap();
826
827        // A daemon killed mid-run leaves this behind. Nothing holds it: the
828        // process that would have dropped the guard is gone.
829        let claim = q.claim(&t.id).unwrap();
830        std::mem::forget(claim);
831        assert!(
832            q.claim(&t.id).is_err(),
833            "the orphaned lock is what makes the task look claimed"
834        );
835
836        // A live daemon on this task is refused, whatever the lock says.
837        let err = q.remove(&t.id, true).unwrap_err().to_string();
838        assert!(err.contains("live daemon"), "{err}");
839        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
840
841        // With no daemon behind it, the lock is stale and goes with the task.
842        q.remove(&t.id, false).unwrap();
843        assert!(q.list().is_empty());
844        let mut again = task("interrupted");
845        again.id = t.id.clone();
846        q.put(&mut again).unwrap();
847        assert!(
848            q.claim(&t.id).is_ok(),
849            "a task that comes back must be claimable, which a left-behind lock would prevent"
850        );
851    }
852}