use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Source {
Human,
Agent {
run: String,
node: String,
},
Issue {
number: u64,
repo: String,
},
}
impl Source {
pub fn label(&self) -> String {
match self {
Self::Human => "human".to_owned(),
Self::Agent { run, node } => format!("{node}@{}", short(run)),
Self::Issue { number, .. } => format!("issue #{number}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Queued,
Running,
Done,
Failed,
Held,
}
impl TaskStatus {
pub fn runnable(self) -> bool {
matches!(self, Self::Queued | Self::Failed)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Done => "done",
Self::Failed => "failed",
Self::Held => "held",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Task {
pub schema: u32,
pub id: String,
pub title: String,
pub instruction: String,
pub repo: PathBuf,
pub source: Source,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub solo: bool,
pub status: TaskStatus,
#[serde(default)]
pub attempts: usize,
#[serde(default)]
pub runs: Vec<String>,
#[serde(default)]
pub last_error: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
impl Task {
pub fn new(title: String, instruction: String, repo: PathBuf, source: Source) -> Self {
let now = Timestamp::now();
Self {
schema: SCHEMA,
id: new_id(),
title,
instruction,
repo,
source,
priority: 0,
solo: false,
status: TaskStatus::Queued,
attempts: 0,
runs: Vec::new(),
last_error: None,
created_at: now,
updated_at: now,
}
}
pub fn short(&self) -> &str {
short(&self.id)
}
pub fn start(&mut self, run: String) {
self.status = TaskStatus::Running;
self.attempts += 1;
self.runs.push(run);
self.last_error = None;
}
pub fn succeed(&mut self) {
self.status = TaskStatus::Done;
self.last_error = None;
}
pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
self.last_error = Some(why.into());
self.status = if self.attempts >= max_attempts {
TaskStatus::Held
} else {
TaskStatus::Failed
};
}
pub fn stall(&mut self, why: impl Into<String>) {
self.last_error = Some(why.into());
self.attempts = self.attempts.saturating_sub(1);
self.status = TaskStatus::Failed;
}
pub fn hold(&mut self) {
self.status = TaskStatus::Held;
}
pub fn handed_off(&mut self, why: impl Into<String>) {
self.last_error = Some(why.into());
self.status = TaskStatus::Held;
}
pub fn release(&mut self) {
self.status = TaskStatus::Queued;
self.attempts = 0;
self.last_error = None;
}
}
#[derive(Debug, Clone)]
pub struct Queue {
root: PathBuf,
}
impl Queue {
pub fn open() -> Self {
Self::at(crate::run::home().join("queue"))
}
pub fn at(root: PathBuf) -> Self {
Self { root }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn path_of(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.json"))
}
pub fn put(&self, task: &mut Task) -> Result<()> {
task.updated_at = Timestamp::now();
std::fs::create_dir_all(&self.root)
.with_context(|| format!("create {}", self.root.display()))?;
let body = serde_json::to_string_pretty(task).context("serialize task")?;
let path = self.path_of(&task.id);
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
pub fn get(&self, id: &str) -> Result<Task> {
let resolved = self.resolve_id(id)?;
read_path(&self.path_of(&resolved))
}
pub fn remove(&self, id: &str, in_flight: bool) -> Result<String> {
let resolved = self.resolve_id(id)?;
if in_flight {
bail!("task {resolved} is being run by a live daemon right now");
}
let path = self.path_of(&resolved);
std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
let lock = self.lock_path(&resolved);
if let Err(e) = std::fs::remove_file(&lock) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e).with_context(|| format!("remove {}", lock.display()));
}
}
Ok(resolved)
}
fn lock_path(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.lock"))
}
pub fn list(&self) -> Vec<Task> {
let mut tasks: Vec<Task> = std::fs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter_map(|p| read_path(&p).ok())
.collect();
tasks.sort_unstable_by(|a, b| b.id.cmp(&a.id));
tasks
}
pub fn next_runnable(&self) -> Option<Task> {
let mut runnable: Vec<Task> = self
.list()
.into_iter()
.filter(|t| t.status.runnable())
.collect();
runnable.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
runnable.into_iter().next()
}
pub fn claim(&self, id: &str) -> Result<Claim> {
std::fs::create_dir_all(&self.root)
.with_context(|| format!("create {}", self.root.display()))?;
let path = self.lock_path(id);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
use std::io::Write as _;
let _ = writeln!(f, "{}", std::process::id());
Ok(Claim { path })
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
bail!("task {id} is already claimed ({} exists)", path.display())
}
Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
}
}
pub fn resolve_id(&self, prefix: &str) -> Result<String> {
if self.path_of(prefix).is_file() {
return Ok(prefix.to_owned());
}
let hits: Vec<String> = self
.list()
.into_iter()
.map(|t| t.id)
.filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
.collect();
match hits.len() {
1 => Ok(hits.into_iter().next().expect("exactly one hit")),
0 => bail!("no task matches `{prefix}`"),
_ => bail!(
"`{prefix}` matches {} tasks: {}",
hits.len(),
hits.join(", ")
),
}
}
pub fn revision(&self) -> u64 {
use std::hash::{Hash as _, Hasher as _};
let mut entries: Vec<(String, u64)> = std::fs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let mtime = e
.metadata()
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_millis() as u64;
Some((name, mtime))
})
.collect();
if entries.is_empty() {
return 0;
}
entries.sort_unstable();
let mut hasher = std::hash::DefaultHasher::new();
for (name, mtime) in &entries {
name.hash(&mut hasher);
mtime.hash(&mut hasher);
}
let h = hasher.finish();
if h == 0 { 1 } else { h }
}
}
#[derive(Debug)]
pub struct Claim {
path: PathBuf,
}
impl Drop for Claim {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn title_from(instruction: &str, max: usize) -> String {
let line = instruction
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("(empty task)")
.trim_start_matches(['#', '-', '*', '>', ' '])
.trim();
if line.is_empty() {
return "(empty task)".to_owned();
}
if line.chars().count() <= max {
return line.to_owned();
}
let head: String = line.chars().take(max.saturating_sub(1)).collect();
format!("{head}…")
}
fn read_path(path: &Path) -> Result<Task> {
let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let task: Task =
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
if task.schema != SCHEMA {
bail!(
"task {} was written by a different magi (schema {}, this build \
speaks {SCHEMA})",
task.id,
task.schema
);
}
Ok(task)
}
fn short(id: &str) -> &str {
id.split('-').next_back().unwrap_or(id)
}
fn new_id() -> String {
let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
let seed = crate::rng::entropy();
format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
}
#[cfg(test)]
mod tests {
use super::*;
fn queue() -> (tempfile::TempDir, Queue) {
let dir = tempfile::tempdir().unwrap();
let q = Queue::at(dir.path().join("queue"));
(dir, q)
}
fn task(title: &str) -> Task {
Task::new(
title.to_owned(),
format!("do {title}"),
PathBuf::from("."),
Source::Human,
)
}
#[test]
fn a_markdown_heading_is_the_title_not_decoration() {
assert_eq!(
title_from("# Rework the config loader\n\nIt re-reads it.\n", 40),
"Rework the config loader"
);
assert_eq!(title_from("- fix the thing", 40), "fix the thing");
assert_eq!(title_from("> quoted task", 40), "quoted task");
assert_eq!(title_from(" \n\n", 40), "(empty task)");
assert_eq!(title_from("###\n", 40), "(empty task)");
}
#[test]
fn a_long_title_is_elided_by_characters_not_bytes() {
let long = "課題".repeat(30);
let title = title_from(&long, 10);
assert_eq!(title.chars().count(), 10);
assert!(title.ends_with('…'));
}
#[test]
fn priority_wins_and_ties_break_oldest_first() {
let (_dir, q) = queue();
let mut a = task("first");
let mut b = task("second");
let mut c = task("urgent");
a.id = "20260101-000001-aaaa".to_owned();
b.id = "20260101-000002-bbbb".to_owned();
c.id = "20260101-000003-cccc".to_owned();
c.priority = 5;
for t in [&mut a, &mut b, &mut c] {
q.put(t).unwrap();
}
assert_eq!(q.next_runnable().unwrap().id, c.id);
c.hold();
q.put(&mut c).unwrap();
assert_eq!(q.next_runnable().unwrap().id, a.id);
assert_eq!(q.list().len(), 3, "b is still waiting its turn");
}
#[test]
fn a_held_task_is_never_offered_to_the_loop() {
let (_dir, q) = queue();
let mut t = task("held");
q.put(&mut t).unwrap();
assert!(q.next_runnable().is_some());
t.hold();
q.put(&mut t).unwrap();
assert!(
q.next_runnable().is_none(),
"a held task must wait for a human"
);
t.status = TaskStatus::Failed;
q.put(&mut t).unwrap();
assert!(q.next_runnable().is_some());
}
#[test]
fn attempts_are_capped_and_then_the_task_is_held() {
let mut t = task("doomed");
t.start("run-1".to_owned());
t.fail("gate red", 2);
assert_eq!(t.status, TaskStatus::Failed, "one attempt of two: retry");
t.start("run-2".to_owned());
t.fail("gate red", 2);
assert_eq!(
t.status,
TaskStatus::Held,
"out of attempts: stop spending money on it"
);
assert_eq!(t.runs, ["run-1", "run-2"]);
assert_eq!(t.last_error.as_deref(), Some("gate red"));
}
#[test]
fn a_quota_stall_is_refunded_so_the_backlog_survives_the_night() {
let mut t = task("stalled by quota");
t.start("run-1".to_owned());
assert_eq!(t.attempts, 1);
t.stall("judge-1, judge-2 out of quota");
assert_eq!(
t.attempts, 0,
"a closed quota window must not spend the task's retry budget"
);
assert_eq!(t.status, TaskStatus::Failed, "the loop should retry it");
assert_eq!(
t.last_error.as_deref(),
Some("judge-1, judge-2 out of quota")
);
for _ in 0..20 {
t.start("run-n".to_owned());
t.stall("still out of quota");
}
t.start("run-real".to_owned());
t.fail("gate red", 2);
assert_eq!(
t.status,
TaskStatus::Failed,
"the first attempt that was really judged is attempt one"
);
}
#[test]
fn releasing_a_held_task_gives_it_a_real_second_chance() {
let mut t = task("retry me");
t.start("run-1".to_owned());
t.fail("gate red", 1);
assert_eq!(t.status, TaskStatus::Held);
t.release();
assert_eq!(t.status, TaskStatus::Queued);
assert_eq!(t.attempts, 0);
assert!(t.last_error.is_none());
assert_eq!(
t.runs.len(),
1,
"history is kept: attempts reset, evidence does not"
);
}
#[test]
fn a_claim_is_exclusive_and_releases_on_drop() {
let (_dir, q) = queue();
let mut t = task("contended");
q.put(&mut t).unwrap();
let held = q.claim(&t.id).unwrap();
assert!(
q.claim(&t.id).is_err(),
"two daemons must not drive one task into two runs"
);
drop(held);
assert!(q.claim(&t.id).is_ok(), "a released claim is reclaimable");
}
#[test]
fn a_round_trip_survives_disk() {
let (_dir, q) = queue();
let mut t = Task::new(
"titled".to_owned(),
"body".to_owned(),
PathBuf::from("/repo"),
Source::Agent {
run: "20260101-000000-beef".to_owned(),
node: "implement".to_owned(),
},
);
t.priority = 3;
q.put(&mut t).unwrap();
let back = q.get(&t.id).unwrap();
assert_eq!(back.id, t.id);
assert_eq!(back.priority, 3);
assert_eq!(back.source.label(), "implement@beef");
assert_eq!(q.get(t.short()).unwrap().id, t.id);
}
#[test]
fn an_unreadable_task_does_not_take_the_queue_down() {
let (_dir, q) = queue();
let mut t = task("fine");
q.put(&mut t).unwrap();
std::fs::write(q.root().join("broken.json"), "{ not json").unwrap();
let listed = q.list();
assert_eq!(listed.len(), 1, "the readable task still lists");
assert_eq!(listed[0].id, t.id);
}
#[test]
fn a_task_recorded_without_a_solo_field_still_reads_as_not_solo() {
let (_dir, q) = queue();
let path = q.path_of("20260101-000000-aaaa");
std::fs::create_dir_all(q.root()).unwrap();
std::fs::write(
&path,
serde_json::json!({
"schema": SCHEMA,
"id": "20260101-000000-aaaa",
"title": "from before solo existed",
"instruction": "from before solo existed",
"repo": ".",
"source": { "kind": "human" },
"status": "queued",
"created_at": Timestamp::now().to_string(),
"updated_at": Timestamp::now().to_string(),
})
.to_string(),
)
.unwrap();
let task = q.get("20260101-000000-aaaa").expect("must still read");
assert!(!task.solo, "a queue file with no `solo` field means false");
}
#[test]
fn a_task_from_a_future_schema_is_refused_rather_than_guessed_at() {
let (_dir, q) = queue();
let mut t = task("from the future");
q.put(&mut t).unwrap();
let path = q.path_of(&t.id);
let body = std::fs::read_to_string(&path)
.unwrap()
.replace("\"schema\": 1", "\"schema\": 99");
std::fs::write(&path, body).unwrap();
let err = q.get(&t.id).unwrap_err().to_string();
assert!(err.contains("schema 99"), "{err}");
}
#[test]
fn revision_moves_when_the_queue_changes() {
let (_dir, q) = queue();
assert_eq!(q.revision(), 0, "an empty queue has no revision");
let mut t = task("first");
q.put(&mut t).unwrap();
assert!(q.revision() > 0, "a written task moves the revision");
}
#[test]
fn revision_moves_when_deleting_an_older_task() {
let (_dir, q) = queue();
let mut t1 = task("older");
q.put(&mut t1).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let mut t2 = task("newer");
q.put(&mut t2).unwrap();
let rev_before = q.revision();
q.remove(&t1.id, false).unwrap();
let rev_after = q.revision();
assert_ne!(
rev_before, rev_after,
"deleting an older task must change the revision so other clients see the deletion"
);
}
#[test]
fn removing_a_task_takes_it_out_of_the_listing() {
let (_dir, q) = queue();
let mut t = task("delete me");
q.put(&mut t).unwrap();
let removed = q.remove(t.short(), false).unwrap();
assert_eq!(removed, t.id, "a prefix resolves before deleting");
assert!(q.list().is_empty());
assert!(
q.remove(&t.id, false).is_err(),
"removing twice is an error"
);
}
#[test]
fn removing_a_task_takes_its_stale_lock_with_it() {
let (_dir, q) = queue();
let mut t = task("interrupted");
q.put(&mut t).unwrap();
let claim = q.claim(&t.id).unwrap();
std::mem::forget(claim);
assert!(
q.claim(&t.id).is_err(),
"the orphaned lock is what makes the task look claimed"
);
let err = q.remove(&t.id, true).unwrap_err().to_string();
assert!(err.contains("live daemon"), "{err}");
assert!(q.get(&t.id).is_ok(), "a refused delete keeps the task");
q.remove(&t.id, false).unwrap();
assert!(q.list().is_empty());
let mut again = task("interrupted");
again.id = t.id.clone();
q.put(&mut again).unwrap();
assert!(
q.claim(&t.id).is_ok(),
"a task that comes back must be claimable, which a left-behind lock would prevent"
);
}
}