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    /// What a human hold is waiting on.
154    ///
155    /// `None` covers both the ordinary cases: a hold the loop makes itself
156    /// (out of attempts, or the disk gate closed) explains itself through
157    /// [`Task::last_error`] instead, and a human hold nobody bothered to
158    /// explain is still a valid hold. The queue has no way to express a
159    /// dependency between two tasks, so on the occasions a hold really is
160    /// "wait for that other task first", this is the only place that reason
161    /// survives - see [`Task::hold`] and [`Task::release`].
162    ///
163    /// `#[serde(default)]` so a queue file written before this field existed
164    /// still reads, with no reason recorded rather than a parse error.
165    #[serde(default)]
166    pub hold_reason: Option<String>,
167    /// Diagnostic detail excerpted from the run that led to a hold - what a
168    /// human would have found opening `artifacts/` by hand, not the one-line
169    /// reason in [`Task::last_error`]. Set only when a run's own attempts are
170    /// exhausted and the task becomes [`TaskStatus::Held`]; `daemon` computes
171    /// it from the run's own record, since this module has no notion of a
172    /// run's internals. Bounded in length by the writer - see
173    /// `daemon::diagnostic` - so a verbose run cannot make this file grow
174    /// without limit.
175    ///
176    /// `#[serde(default)]` so a queue file written before this field existed
177    /// still reads, with no diagnostic recorded rather than a parse error.
178    #[serde(default)]
179    pub diagnostic: Option<String>,
180    /// When the task was filed.
181    pub created_at: Timestamp,
182    /// Last change to this file.
183    pub updated_at: Timestamp,
184}
185
186impl Task {
187    /// File a new task. Persist it with [`Queue::put`].
188    pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
189        let now = Timestamp::now();
190        Self {
191            schema: SCHEMA,
192            id: new_id(),
193            title,
194            instruction,
195            repo,
196            source,
197            priority: 0,
198            solo: false,
199            status: TaskStatus::Queued,
200            attempts: 0,
201            runs: Vec::new(),
202            last_error: None,
203            hold_reason: None,
204            diagnostic: None,
205            created_at: now,
206            updated_at: now,
207        }
208    }
209
210    /// Short form used in reports, matching a run's short id.
211    pub fn short(&self) -> &str {
212        short(&self.id)
213    }
214
215    /// Record that a run has started for this task.
216    pub fn start(&mut self, run: String) {
217        self.status = TaskStatus::Running;
218        self.attempts += 1;
219        self.runs.push(run);
220        self.last_error = None;
221    }
222
223    /// Record a successful run.
224    ///
225    /// Both `magi task done` and `POST /api/queue/{id}/done` can close a held
226    /// task directly, with no release in between, so this clears
227    /// `hold_reason` the same way [`Task::release`] does. Otherwise a task
228    /// held for "waiting on 3ed9" and then closed as done without ever being
229    /// released would still read as waiting on something in `magi task show`
230    /// and on its card, after it no longer is.
231    pub fn succeed(&mut self) {
232        self.status = TaskStatus::Done;
233        self.last_error = None;
234        self.hold_reason = None;
235        self.diagnostic = None;
236    }
237
238    /// Record a failed attempt. Out of attempts means held for a human, rather
239    /// than retried until the money runs out.
240    ///
241    /// Clears [`Task::diagnostic`] unconditionally: it belongs to whatever run
242    /// produced it, and a caller that has one for *this* attempt sets it
243    /// itself right after calling this, once it knows the task actually ended
244    /// up [`TaskStatus::Held`] - see `daemon::diagnostic`. Without the clear, a
245    /// task released after a diagnosed hold and then failed again for an
246    /// unrelated, undiagnosed reason (a config error, say) would go on
247    /// showing the previous run's diagnostic as if it explained the new one.
248    pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
249        self.last_error = Some(why.into());
250        self.diagnostic = None;
251        self.status = if self.attempts >= max_attempts {
252            TaskStatus::Held
253        } else {
254            TaskStatus::Failed
255        };
256    }
257
258    /// Record an attempt that failed for a reason the task is not responsible
259    /// for - the agent CLIs ran out of quota and the judging panel collapsed.
260    ///
261    /// This refunds the attempt on purpose. A quota window closing at 4am must
262    /// not spend the backlog's retry budget: the operator would come back to a
263    /// queue of held tasks that were never actually judged, and would have to
264    /// release every one by hand to find out which had a real problem. The task
265    /// goes back to `Failed`, which the loop retries, so a reset quota picks the
266    /// work up where it stopped.
267    pub fn stall(&mut self, why: impl Into<String>) {
268        self.last_error = Some(why.into());
269        self.diagnostic = None;
270        self.attempts = self.attempts.saturating_sub(1);
271        self.status = TaskStatus::Failed;
272    }
273
274    /// Take this task out of the loop's reach without deleting it.
275    ///
276    /// `reason` replaces whatever was recorded before when it is given.
277    /// Passing `None` - the loop's own holds do this - leaves any existing
278    /// reason alone, so a machine-initiated hold cannot erase what a human
279    /// wrote down about a previous one.
280    pub fn hold(&mut self, reason: Option<String>) {
281        self.status = TaskStatus::Held;
282        if reason.is_some() {
283            self.hold_reason = reason;
284        }
285    }
286
287    /// Change how urgently this task should run next.
288    ///
289    /// Refused once the task is `running`: priority only feeds the sort
290    /// [`Queue::next_runnable`] does over tasks waiting to be claimed, and a
291    /// running task has already left that pool. Accepting the write anyway
292    /// would look like it worked while changing nothing until - and unless -
293    /// this attempt fails and the task becomes runnable again, which is a
294    /// surprise the phone should not hand back as a success.
295    pub fn set_priority(&mut self, priority: i32) -> Result<()> {
296        if self.status == TaskStatus::Running {
297            bail!(
298                "task {} is running; its priority cannot be changed until \
299                 this attempt finishes",
300                self.short()
301            );
302        }
303        self.priority = priority;
304        Ok(())
305    }
306
307    /// Replace this task's title and instruction wholesale.
308    ///
309    /// Restricted to `queued` and `held`. A `running` task's instruction has
310    /// already been handed to the graph, so a run in flight and the file on
311    /// disk must not be allowed to disagree about what was asked; a `done` or
312    /// `failed` task is a record of what actually happened and editing it
313    /// after the fact would falsify that record. `id`, `created_at`,
314    /// `source`, and `runs` are left untouched on purpose - an edit stands in
315    /// for "delete and refile", and keeping the id, the timestamp, the
316    /// attribution, and the run history is the entire reason it exists
317    /// instead.
318    pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
319        if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
320            bail!(
321                "task {} is {}; only a queued or held task's instruction can \
322                 be edited",
323                self.short(),
324                self.status.as_str()
325            );
326        }
327        self.title = title;
328        self.instruction = instruction;
329        Ok(())
330    }
331
332    /// Record a run that produced a pull request without merging it.
333    ///
334    /// The task is held rather than retried, and it costs no further attempt
335    /// either way. The work the task asked for exists: it is sitting on a
336    /// branch, in a pull request, waiting for CI or for a person. Retrying
337    /// would spend the whole competition budget a second time and then race a
338    /// second branch against the pull request the first one opened - which is
339    /// exactly what happened to run 01c2, whose finished and green pull request
340    /// was re-competed from scratch four seconds after it opened.
341    ///
342    /// A pull request nobody merged is a request for a person, not a failure.
343    pub fn handed_off(&mut self, why: impl Into<String>) {
344        self.last_error = Some(why.into());
345        self.diagnostic = None;
346        self.status = TaskStatus::Held;
347    }
348
349    /// Put a held or finished task back in line, with its attempt count reset
350    /// so a release is a real second chance rather than an instant re-hold.
351    /// The run history is kept: attempts reset, evidence does not.
352    pub fn release(&mut self) {
353        self.status = TaskStatus::Queued;
354        self.attempts = 0;
355        self.last_error = None;
356        // Otherwise the next person who holds this task reads a reason that
357        // belonged to whatever it was waiting on last time.
358        self.hold_reason = None;
359        self.diagnostic = None;
360    }
361}
362
363/// A queue on disk.
364#[derive(Debug, Clone)]
365pub struct Queue {
366    root: PathBuf,
367}
368
369impl Queue {
370    /// The operator's queue, `<home>/queue`.
371    pub fn open() -> Self {
372        Self::at(crate::run::home().join("queue"))
373    }
374
375    /// A queue at an explicit root. Tests use this; so could an operator who
376    /// wants a queue per project.
377    pub fn at(root: PathBuf) -> Self {
378        Self { root }
379    }
380
381    /// Directory holding the task files.
382    pub fn root(&self) -> &Path {
383        &self.root
384    }
385
386    /// Path for one task id.
387    pub fn path_of(&self, id: &str) -> PathBuf {
388        self.root.join(format!("{id}.json"))
389    }
390
391    /// Write a task, atomically, so a daemon killed mid-write leaves the
392    /// previous state readable rather than a truncated file.
393    pub fn put(&self, task: &mut Task) -> Result<()> {
394        task.updated_at = Timestamp::now();
395        std::fs::create_dir_all(&self.root)
396            .with_context(|| format!("create {}", self.root.display()))?;
397        let body = serde_json::to_string_pretty(task).context("serialize task")?;
398        let path = self.path_of(&task.id);
399        let tmp = path.with_extension("json.tmp");
400        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
401        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
402        Ok(())
403    }
404
405    /// Load a task by id or unambiguous id prefix.
406    pub fn get(&self, id: &str) -> Result<Task> {
407        let resolved = self.resolve_id(id)?;
408        read_path(&self.path_of(&resolved))
409    }
410
411    /// Remove a task, and the claim lock that belongs to it.
412    ///
413    /// `in_flight` comes from the caller — a live daemon's heartbeat naming
414    /// this task — because the task's own `running` status cannot answer the
415    /// question. A daemon killed mid-competition leaves the status at
416    /// `running` and an orphaned `.lock` behind, and a guard that trusted
417    /// either would make the task undeletable for good: the phone showed
418    /// exactly that, refusing a task whose daemon had been gone for an hour.
419    ///
420    /// So the lock is removed with the task rather than respected. Any lock
421    /// still there once no live daemon claims the task is by definition stale,
422    /// and leaving it would make a deleted task look claimed to
423    /// [`Queue::claim`] and to whoever reads the directory.
424    pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
425        let resolved = self.resolve_id(id)?;
426        if in_flight {
427            bail!("task {resolved} is being run by a live daemon right now");
428        }
429        let path = self.path_of(&resolved);
430        std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
431        let lock = self.lock_path(&resolved);
432        if let Err(e) = std::fs::remove_file(&lock) {
433            if e.kind() != std::io::ErrorKind::NotFound {
434                return Err(e).with_context(|| format!("remove {}", lock.display()));
435            }
436        }
437        Ok(resolved)
438    }
439
440    /// Path of the claim lock for a task. One definition, so `claim` and
441    /// `remove` cannot end up naming different files.
442    fn lock_path(&self, id: &str) -> PathBuf {
443        self.root.join(format!("{id}.lock"))
444    }
445
446    /// Every task on disk, highest priority first and newest first within a
447    /// priority. This is what `magi task list` and `GET /api/queue` print, so
448    /// a raised priority has to move a task here the moment it is saved, not
449    /// only in [`Queue::next_runnable`]'s own ordering - the operator reading
450    /// the backlog and the loop about to drain it must agree on what "first"
451    /// means. Every existing task defaults to priority 0, so this is a no-op
452    /// change from the old newest-first order for a queue nobody has
453    /// reprioritised.
454    ///
455    /// Unreadable files are skipped rather than fatal: one corrupt task must
456    /// not take the queue - or the web UI, or an unattended daemon - down
457    /// with it.
458    pub fn list(&self) -> Vec<Task> {
459        let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
460            .into_iter()
461            .flatten()
462            .flatten()
463            .map(|e| e.path())
464            .filter(|p| p.extension().is_some_and(|x| x == "json"))
465            .filter_map(|p| read_path(&p).ok())
466            .collect();
467        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
468        tasks
469    }
470
471    /// The task a daemon should run next, or `None` when the queue is idle.
472    ///
473    /// Highest priority first, oldest first within a priority, so a burst of
474    /// agent-filed work cannot starve the task a human filed this morning.
475    pub fn next_runnable(&self) -> Option<Task> {
476        let mut runnable: Vec<Task> = self
477            .list()
478            .into_iter()
479            .filter(|t| t.status.runnable())
480            .collect();
481        runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
482        runnable.into_iter().next()
483    }
484
485    /// Take exclusive ownership of a task.
486    ///
487    /// The lock is a `create_new` file next to the task, which is atomic on
488    /// every platform magi targets. It exists so two daemons - or a daemon and
489    /// a human running `magi run` - cannot drive one task into two competing
490    /// runs. The returned guard releases on drop, including on panic.
491    pub fn claim(&self, id: &str) -> Result<Claim> {
492        std::fs::create_dir_all(&self.root)
493            .with_context(|| format!("create {}", self.root.display()))?;
494        let path = self.lock_path(id);
495        match std::fs::OpenOptions::new()
496            .write(true)
497            .create_new(true)
498            .open(&path)
499        {
500            Ok(mut f) => {
501                use std::io::Write as _;
502                // Best effort: the pid is for the human looking at a stale lock.
503                let _ = writeln!(f, "{}", std::process::id());
504                Ok(Claim { path })
505            }
506            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
507                bail!("task {id} is already claimed ({} exists)", path.display())
508            }
509            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
510        }
511    }
512
513    /// Expand an id prefix to exactly one task id.
514    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
515        if self.path_of(prefix).is_file() {
516            return Ok(prefix.to_owned());
517        }
518        let hits: Vec<String> = self
519            .list()
520            .into_iter()
521            .map(|t| t.id)
522            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
523            .collect();
524        match hits.len() {
525            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
526            0 => bail!("no task matches `{prefix}`"),
527            _ => bail!(
528                "`{prefix}` matches {} tasks: {}",
529                hits.len(),
530                hits.join(", ")
531            ),
532        }
533    }
534
535    /// Change detection token for the queue.
536    ///
537    /// Combines file names and modification times of all task files in the
538    /// queue, so adding, modifying, or deleting any task — even an older one —
539    /// moves the revision and notifies connected clients via the change stream.
540    /// Returns 0 when the queue is completely empty.
541    pub fn revision(&self) -> u64 {
542        use std::hash::{Hash as _, Hasher as _};
543
544        let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
545            .into_iter()
546            .flatten()
547            .flatten()
548            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
549            .filter_map(|e| {
550                let name = e.file_name().to_string_lossy().into_owned();
551                let mtime = e
552                    .metadata()
553                    .ok()?
554                    .modified()
555                    .ok()?
556                    .duration_since(std::time::UNIX_EPOCH)
557                    .ok()?
558                    .as_millis() as u64;
559                Some((name, mtime))
560            })
561            .collect();
562
563        if entries.is_empty() {
564            return 0;
565        }
566
567        entries.sort_unstable();
568        let mut hasher = std::hash::DefaultHasher::new();
569        for (name, mtime) in &entries {
570            name.hash(&mut hasher);
571            mtime.hash(&mut hasher);
572        }
573        let h = hasher.finish();
574        if h == 0 { 1 } else { h }
575    }
576}
577
578/// Exclusive ownership of a task, released on drop.
579#[derive(Debug)]
580pub struct Claim {
581    path: PathBuf,
582}
583
584impl Drop for Claim {
585    fn drop(&mut self) {
586        let _ = std::fs::remove_file(&self.path);
587    }
588}
589
590/// The first line of a task, trimmed to a title. Used when the caller gives a
591/// body but no title, which is the normal case for an agent piping a file in.
592pub fn title_from(instruction: &str, max: usize) -> String {
593    // The first non-blank line, whatever it is. A markdown heading is the
594    // task's own summary - agents pipe in `# Rework the config loader` and mean
595    // exactly that - so it is preferred over the prose beneath it rather than
596    // skipped as decoration. Leading list and heading markers are stripped
597    // because they are syntax, not words.
598    let line = instruction
599        .lines()
600        .map(str::trim)
601        .find(|l| !l.is_empty())
602        .unwrap_or("(empty task)")
603        .trim_start_matches(['#', '-', '*', '>', ' '])
604        .trim();
605    if line.is_empty() {
606        return "(empty task)".to_owned();
607    }
608    if line.chars().count() <= max {
609        return line.to_owned();
610    }
611    let head: String = line.chars().take(max.saturating_sub(1)).collect();
612    format!("{head}…")
613}
614
615fn read_path(path: &Path) -> Result<Task> {
616    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
617    let task: Task =
618        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
619    if task.schema != SCHEMA {
620        bail!(
621            "task {} was written by a different magi (schema {}, this build \
622             speaks {SCHEMA})",
623            task.id,
624            task.schema
625        );
626    }
627    Ok(task)
628}
629
630fn short(id: &str) -> &str {
631    id.split('-').next_back().unwrap_or(id)
632}
633
634fn new_id() -> String {
635    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
636    let seed = crate::rng::entropy();
637    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    /// A queue of its own, with no process-global state - which is the point of
645    /// `Queue::at`, and why these can run in parallel.
646    fn queue() -> (tempfile::TempDir, Queue) {
647        let dir = tempfile::tempdir().unwrap();
648        let q = Queue::at(dir.path().join("queue"));
649        (dir, q)
650    }
651
652    fn task(title: &str) -> Task {
653        Task::new(
654            title.to_owned(),
655            format!("do {title}"),
656            PathBuf::from("."),
657            Source::Human,
658        )
659    }
660
661    #[test]
662    fn a_markdown_heading_is_the_title_not_decoration() {
663        // A task file's heading is the summary its author already wrote, so it
664        // beats the prose underneath. Getting this backwards was visible in the
665        // first smoke test: a task titled "# Rework the config loader" listed
666        // as "It re-reads the file on every lookup".
667        assert_eq!(
668            title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
669            "Rework the config loader"
670        );
671        assert_eq!(title_from("- fix the thing", 40), "fix the thing");
672        assert_eq!(title_from("> quoted task", 40), "quoted task");
673        // Nothing usable at all still has to produce something printable.
674        assert_eq!(title_from("   \n\n", 40), "(empty task)");
675        assert_eq!(title_from("###\n", 40), "(empty task)");
676    }
677
678    #[test]
679    fn a_long_title_is_elided_by_characters_not_bytes() {
680        // Byte truncation would split a multi-byte character and panic.
681        let long = "課題".repeat(30);
682        let title = title_from(&long, 10);
683        assert_eq!(title.chars().count(), 10);
684        assert!(title.ends_with('…'));
685    }
686
687    #[test]
688    fn priority_wins_and_ties_break_oldest_first() {
689        let (_dir, q) = queue();
690        let mut a = task("first");
691        let mut b = task("second");
692        let mut c = task("urgent");
693        // Ids carry a timestamp, so force a known order.
694        a.id = "20260101-000001-aaaa".to_owned();
695        b.id = "20260101-000002-bbbb".to_owned();
696        c.id = "20260101-000003-cccc".to_owned();
697        c.priority = 5;
698        for t in [&mut a, &mut b, &mut c] {
699            q.put(t).unwrap();
700        }
701
702        // Priority first...
703        assert_eq!(q.next_runnable().unwrap().id, c.id);
704        c.hold(None);
705        q.put(&mut c).unwrap();
706        // ...then oldest, so a burst of new work cannot starve older work.
707        assert_eq!(q.next_runnable().unwrap().id, a.id);
708        assert_eq!(q.list().len(), 3, "b is still waiting its turn");
709    }
710
711    #[test]
712    fn a_held_task_is_never_offered_to_the_loop() {
713        let (_dir, q) = queue();
714        let mut t = task("held");
715        q.put(&mut t).unwrap();
716        assert!(q.next_runnable().is_some());
717
718        t.hold(None);
719        q.put(&mut t).unwrap();
720        assert!(
721            q.next_runnable().is_none(),
722            "a held task must wait for a human"
723        );
724
725        // A failed task, by contrast, is exactly what the loop should retry.
726        t.status = TaskStatus::Failed;
727        q.put(&mut t).unwrap();
728        assert!(q.next_runnable().is_some());
729    }
730
731    #[test]
732    fn attempts_are_capped_and_then_the_task_is_held() {
733        let mut t = task("doomed");
734
735        t.start("run-1".to_owned());
736        t.fail("gate red", 2);
737        assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
738
739        t.start("run-2".to_owned());
740        t.fail("gate red", 2);
741        assert_eq!(
742            t.status,
743            TaskStatus::Held,
744            "out of attempts: stop spending money on it"
745        );
746        assert_eq!(t.runs, ["run-1", "run-2"]);
747        assert_eq!(t.last_error.as_deref(), Some("gate red"));
748    }
749
750    #[test]
751    fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
752        let mut t = task("stalled by quota");
753
754        t.start("run-1".to_owned());
755        assert_eq!(t.attempts, 1);
756        t.stall("judge-1, judge-2 out of quota");
757        assert_eq!(
758            t.attempts, 0,
759            "a closed quota window must not spend the task's retry budget"
760        );
761        assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
762        assert_eq!(
763            t.last_error.as_deref(),
764            Some("judge-1, judge-2 out of quota")
765        );
766
767        // A task can therefore stall all night and still get its real attempts
768        // once the quota resets - which is the whole point.
769        for _ in 0..20 {
770            t.start("run-n".to_owned());
771            t.stall("still out of quota");
772        }
773        t.start("run-real".to_owned());
774        t.fail("gate red", 2);
775        assert_eq!(
776            t.status,
777            TaskStatus::Failed,
778            "the first attempt that was really judged is attempt one"
779        );
780    }
781
782    #[test]
783    fn releasing_a_held_task_gives_it_a_real_second_chance() {
784        let mut t = task("retry me");
785        t.start("run-1".to_owned());
786        t.fail("gate red", 1);
787        assert_eq!(t.status, TaskStatus::Held);
788
789        t.release();
790        assert_eq!(t.status, TaskStatus::Queued);
791        // Without resetting attempts the next failure would re-hold at once,
792        // and a release would be a no-op the operator cannot see.
793        assert_eq!(t.attempts, 0);
794        assert!(t.last_error.is_none());
795        assert_eq!(
796            t.runs.len(),
797            1,
798            "history is kept: attempts reset, evidence does not"
799        );
800    }
801
802    #[test]
803    fn a_hold_reason_survives_and_a_release_clears_it() {
804        let mut t = task("waiting on something else");
805        t.hold(Some(
806            "waiting for 20260101-000000-aaaa to land first".to_owned(),
807        ));
808        assert_eq!(t.status, TaskStatus::Held);
809        assert_eq!(
810            t.hold_reason.as_deref(),
811            Some("waiting for 20260101-000000-aaaa to land first")
812        );
813
814        // Holding again with no reason must not erase the one already there.
815        t.hold(None);
816        assert_eq!(
817            t.hold_reason.as_deref(),
818            Some("waiting for 20260101-000000-aaaa to land first"),
819            "a bare re-hold keeps whatever a human already wrote down"
820        );
821
822        // A hold with no reason at all is still an ordinary, allowed hold.
823        let mut plain = task("no reason given");
824        plain.hold(None);
825        assert_eq!(plain.status, TaskStatus::Held);
826        assert!(plain.hold_reason.is_none());
827
828        t.release();
829        assert_eq!(t.status, TaskStatus::Queued);
830        assert!(
831            t.hold_reason.is_none(),
832            "a stale reason must not greet the next person who holds this task"
833        );
834    }
835
836    #[test]
837    fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
838        // `done` can close a held task directly - neither `magi task done`
839        // nor `POST /api/queue/{id}/done` requires a release first - so a
840        // task held for "waiting on 3ed9" and then closed without ever being
841        // released must not still read as waiting on it afterwards.
842        let mut t = task("landed by hand while held");
843        t.hold(Some("waiting on 3ed9".to_owned()));
844        assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
845
846        t.succeed();
847        assert_eq!(t.status, TaskStatus::Done);
848        assert!(
849            t.hold_reason.is_none(),
850            "a done task cannot still be waiting on something"
851        );
852    }
853
854    #[test]
855    fn priority_can_be_changed_while_queued_but_not_while_running() {
856        let mut t = task("reprioritise me");
857        t.set_priority(5).unwrap();
858        assert_eq!(t.priority, 5);
859
860        t.start("run-1".to_owned());
861        let err = t.set_priority(9).unwrap_err().to_string();
862        assert!(err.contains("running"), "{err}");
863        assert_eq!(t.priority, 5, "the rejected write must not partially apply");
864    }
865
866    #[test]
867    fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
868        let (_dir, q) = queue();
869        let mut a = task("first filed");
870        let mut b = task("second filed");
871        a.id = "20260101-000001-aaaa".to_owned();
872        b.id = "20260101-000002-bbbb".to_owned();
873        q.put(&mut a).unwrap();
874        q.put(&mut b).unwrap();
875
876        assert_eq!(
877            q.next_runnable().unwrap().id,
878            a.id,
879            "with equal priority the older task goes first, so a burst of \
880             new work cannot starve it"
881        );
882        assert_eq!(
883            q.list()[0].id,
884            b.id,
885            "but the list an operator reads is newest first, the same as \
886             before priority existed - a's turn to run does not make it the \
887             newest task"
888        );
889
890        let mut a = q.get(&a.id).unwrap();
891        a.set_priority(10).unwrap();
892        q.put(&mut a).unwrap();
893
894        assert_eq!(
895            q.next_runnable().unwrap().id,
896            a.id,
897            "a raised priority must be reflected the moment it is saved"
898        );
899        // `magi task list` and `GET /api/queue` both print `Queue::list()`
900        // directly, so the raised task has to lead there too - not only in
901        // what the loop would claim next.
902        assert_eq!(
903            q.list()[0].id,
904            a.id,
905            "the raised task must sort first in the list an operator reads, \
906             not only in next_runnable's own ordering"
907        );
908    }
909
910    #[test]
911    fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
912        let mut t = Task::new(
913            "old title".to_owned(),
914            "old instruction".to_owned(),
915            PathBuf::from("/repo"),
916            Source::Agent {
917                run: "20260101-000000-beef".to_owned(),
918                node: "implement".to_owned(),
919            },
920        );
921        let id = t.id.clone();
922        let created_at = t.created_at;
923        t.runs.push("20260101-000000-beef".to_owned());
924
925        t.edit("new title".to_owned(), "new instruction".to_owned())
926            .unwrap();
927
928        assert_eq!(t.title, "new title");
929        assert_eq!(t.instruction, "new instruction");
930        assert_eq!(t.id, id, "editing must not mint a new id");
931        assert_eq!(t.created_at, created_at);
932        assert_eq!(
933            t.source,
934            Source::Agent {
935                run: "20260101-000000-beef".to_owned(),
936                node: "implement".to_owned(),
937            },
938            "editing must not turn agent attribution into human"
939        );
940        assert_eq!(t.runs, ["20260101-000000-beef"]);
941    }
942
943    #[test]
944    fn editing_is_refused_once_a_task_is_running_or_finished() {
945        let mut running = task("in flight");
946        running.start("run-1".to_owned());
947        let err = running
948            .edit("x".to_owned(), "y".to_owned())
949            .unwrap_err()
950            .to_string();
951        assert!(err.contains("running"), "{err}");
952
953        let mut done = task("finished");
954        done.succeed();
955        let err = done
956            .edit("x".to_owned(), "y".to_owned())
957            .unwrap_err()
958            .to_string();
959        assert!(err.contains("done"), "{err}");
960
961        // Both queued and held are the point of the feature and must work.
962        let mut queued = task("waiting");
963        queued.edit("x".to_owned(), "y".to_owned()).unwrap();
964        let mut held = task("parked");
965        held.hold(None);
966        held.edit("x".to_owned(), "y".to_owned()).unwrap();
967    }
968
969    #[test]
970    fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
971        let (_dir, q) = queue();
972        let path = q.path_of("20260101-000000-aaaa");
973        std::fs::create_dir_all(q.root()).unwrap();
974        std::fs::write(
975            &path,
976            serde_json::json!({
977                "schema": SCHEMA,
978                "id": "20260101-000000-aaaa",
979                "title": "from before hold reasons existed",
980                "instruction": "from before hold reasons existed",
981                "repo": ".",
982                "source": { "kind": "human" },
983                "status": "held",
984                "created_at": Timestamp::now().to_string(),
985                "updated_at": Timestamp::now().to_string(),
986            })
987            .to_string(),
988        )
989        .unwrap();
990
991        let task = q.get("20260101-000000-aaaa").expect("must still read");
992        assert!(task.hold_reason.is_none());
993        assert_eq!(SCHEMA, 1, "this feature must not bump the schema");
994    }
995
996    #[test]
997    fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
998        let (_dir, q) = queue();
999        let path = q.path_of("20260101-000000-aaaa");
1000        std::fs::create_dir_all(q.root()).unwrap();
1001        std::fs::write(
1002            &path,
1003            serde_json::json!({
1004                "schema": SCHEMA,
1005                "id": "20260101-000000-aaaa",
1006                "title": "from before diagnostics existed",
1007                "instruction": "from before diagnostics existed",
1008                "repo": ".",
1009                "source": { "kind": "human" },
1010                "status": "held",
1011                "created_at": Timestamp::now().to_string(),
1012                "updated_at": Timestamp::now().to_string(),
1013            })
1014            .to_string(),
1015        )
1016        .unwrap();
1017
1018        let task = q.get("20260101-000000-aaaa").expect("must still read");
1019        assert!(task.diagnostic.is_none());
1020        assert_eq!(SCHEMA, 1, "this feature must not bump the schema");
1021    }
1022
1023    #[test]
1024    fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
1025        // A diagnostic belongs to the run that produced it. Left in place
1026        // across a release, an unrelated later failure - a config error, say -
1027        // would go on showing evidence for a problem that is no longer why the
1028        // task is stuck.
1029        let mut held = task("diagnosed");
1030        held.start("run-1".to_owned());
1031        held.fail("gate red", 1);
1032        held.diagnostic = Some("cargo test failed: ...".to_owned());
1033        assert_eq!(held.status, TaskStatus::Held);
1034
1035        held.release();
1036        assert!(held.diagnostic.is_none());
1037
1038        held.diagnostic = Some("cargo test failed: ...".to_owned());
1039        held.succeed();
1040        assert!(held.diagnostic.is_none());
1041    }
1042
1043    #[test]
1044    fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
1045        let mut t = task("retried");
1046        t.start("run-1".to_owned());
1047        t.diagnostic = Some("stale evidence from a previous hold".to_owned());
1048        t.fail("unrelated config error", 5);
1049        assert_eq!(t.status, TaskStatus::Failed);
1050        assert!(
1051            t.diagnostic.is_none(),
1052            "fail() must not let an old diagnostic outlive the run that produced it"
1053        );
1054    }
1055
1056    #[test]
1057    fn a_claim_is_exclusive_and_releases_on_drop() {
1058        let (_dir, q) = queue();
1059        let mut t = task("contended");
1060        q.put(&mut t).unwrap();
1061
1062        let held = q.claim(&t.id).unwrap();
1063        assert!(
1064            q.claim(&t.id).is_err(),
1065            "two daemons must not drive one task into two runs"
1066        );
1067        drop(held);
1068        assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
1069    }
1070
1071    #[test]
1072    fn a_round_trip_survives_disk() {
1073        let (_dir, q) = queue();
1074        let mut t = Task::new(
1075            "titled".to_owned(),
1076            "body".to_owned(),
1077            PathBuf::from("/repo"),
1078            Source::Agent {
1079                run: "20260101-000000-beef".to_owned(),
1080                node: "implement".to_owned(),
1081            },
1082        );
1083        t.priority = 3;
1084        q.put(&mut t).unwrap();
1085
1086        let back = q.get(&t.id).unwrap();
1087        assert_eq!(back.id, t.id);
1088        assert_eq!(back.priority, 3);
1089        assert_eq!(back.source.label(), "implement@beef");
1090        // A prefix is enough, the way run ids work everywhere else.
1091        assert_eq!(q.get(t.short()).unwrap().id, t.id);
1092    }
1093
1094    #[test]
1095    fn an_unreadable_task_does_not_take_the_queue_down() {
1096        let (_dir, q) = queue();
1097        let mut t = task("fine");
1098        q.put(&mut t).unwrap();
1099        std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
1100
1101        let listed = q.list();
1102        assert_eq!(listed.len(), 1, "the readable task still lists");
1103        assert_eq!(listed[0].id, t.id);
1104    }
1105
1106    #[test]
1107    fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
1108        let (_dir, q) = queue();
1109        let path = q.path_of("20260101-000000-aaaa");
1110        std::fs::create_dir_all(q.root()).unwrap();
1111        std::fs::write(
1112            &path,
1113            serde_json::json!({
1114                "schema": SCHEMA,
1115                "id": "20260101-000000-aaaa",
1116                "title": "from before solo existed",
1117                "instruction": "from before solo existed",
1118                "repo": ".",
1119                "source": { "kind": "human" },
1120                "status": "queued",
1121                "created_at": Timestamp::now().to_string(),
1122                "updated_at": Timestamp::now().to_string(),
1123            })
1124            .to_string(),
1125        )
1126        .unwrap();
1127
1128        let task = q.get("20260101-000000-aaaa").expect("must still read");
1129        assert!(!task.solo, "a queue file with no `solo` field means false");
1130    }
1131
1132    #[test]
1133    fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
1134        let (_dir, q) = queue();
1135        let mut t = task("from the future");
1136        q.put(&mut t).unwrap();
1137        let path = q.path_of(&t.id);
1138        let body = std::fs::read_to_string(&path)
1139            .unwrap()
1140            .replace("\"schema\": 1", "\"schema\": 99");
1141        std::fs::write(&path, body).unwrap();
1142
1143        let err = q.get(&t.id).unwrap_err().to_string();
1144        assert!(err.contains("schema 99"), "{err}");
1145    }
1146
1147    #[test]
1148    fn revision_moves_when_the_queue_changes() {
1149        let (_dir, q) = queue();
1150        assert_eq!(q.revision(), 0, "an empty queue has no revision");
1151        let mut t = task("first");
1152        q.put(&mut t).unwrap();
1153        assert!(q.revision() > 0, "a written task moves the revision");
1154    }
1155
1156    #[test]
1157    fn revision_moves_when_deleting_an_older_task() {
1158        let (_dir, q) = queue();
1159        let mut t1 = task("older");
1160        q.put(&mut t1).unwrap();
1161        // Ensure mtime ticks forward.
1162        std::thread::sleep(std::time::Duration::from_millis(10));
1163        let mut t2 = task("newer");
1164        q.put(&mut t2).unwrap();
1165
1166        let rev_before = q.revision();
1167        q.remove(&t1.id, false).unwrap();
1168        let rev_after = q.revision();
1169
1170        assert_ne!(
1171            rev_before, rev_after,
1172            "deleting an older task must change the revision so other clients see the deletion"
1173        );
1174    }
1175
1176    #[test]
1177    fn removing_a_task_takes_it_out_of_the_listing() {
1178        let (_dir, q) = queue();
1179        let mut t = task("delete me");
1180        q.put(&mut t).unwrap();
1181        let removed = q.remove(t.short(), false).unwrap();
1182        assert_eq!(removed, t.id, "a prefix resolves before deleting");
1183        assert!(q.list().is_empty());
1184        assert!(
1185            q.remove(&t.id, false).is_err(),
1186            "removing twice is an error"
1187        );
1188    }
1189
1190    #[test]
1191    fn removing_a_task_takes_its_stale_lock_with_it() {
1192        let (_dir, q) = queue();
1193        let mut t = task("interrupted");
1194        q.put(&mut t).unwrap();
1195
1196        // A daemon killed mid-run leaves this behind. Nothing holds it: the
1197        // process that would have dropped the guard is gone.
1198        let claim = q.claim(&t.id).unwrap();
1199        std::mem::forget(claim);
1200        assert!(
1201            q.claim(&t.id).is_err(),
1202            "the orphaned lock is what makes the task look claimed"
1203        );
1204
1205        // A live daemon on this task is refused, whatever the lock says.
1206        let err = q.remove(&t.id, true).unwrap_err().to_string();
1207        assert!(err.contains("live daemon"), "{err}");
1208        assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
1209
1210        // With no daemon behind it, the lock is stale and goes with the task.
1211        q.remove(&t.id, false).unwrap();
1212        assert!(q.list().is_empty());
1213        let mut again = task("interrupted");
1214        again.id = t.id.clone();
1215        q.put(&mut again).unwrap();
1216        assert!(
1217            q.claim(&t.id).is_ok(),
1218            "a task that comes back must be claimable, which a left-behind lock would prevent"
1219        );
1220    }
1221}