use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
pub const SCHEMA: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HoldSource {
Manual,
Machine,
}
impl HoldSource {
pub fn label(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Machine => "machine",
}
}
}
#[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,
Blocked,
}
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",
Self::Blocked => "blocked",
}
}
}
#[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>,
#[serde(default)]
pub hold_reason: Option<String>,
#[serde(default)]
pub hold_source: Option<HoldSource>,
#[serde(default)]
pub diagnostic: Option<String>,
#[serde(default)]
pub blocked_by: Vec<String>,
#[serde(default)]
pub block_reason: Option<String>,
#[serde(default)]
pub answers: Vec<AnsweredQuestion>,
#[serde(default)]
pub review_branch: Option<String>,
#[serde(default)]
pub fresh_start: bool,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnsweredQuestion {
pub question: String,
pub answer: String,
}
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,
hold_reason: None,
hold_source: None,
diagnostic: None,
blocked_by: Vec::new(),
block_reason: None,
answers: Vec::new(),
review_branch: None,
fresh_start: false,
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;
self.fresh_start = false;
}
pub fn succeed(&mut self) {
self.status = TaskStatus::Done;
self.last_error = None;
self.hold_reason = None;
self.hold_source = None;
self.diagnostic = None;
}
pub fn fail(&mut self, why: impl Into<String>, max_attempts: usize) {
self.last_error = Some(why.into());
self.diagnostic = None;
self.status = if self.attempts >= max_attempts {
self.hold_source = Some(HoldSource::Machine);
TaskStatus::Held
} else {
TaskStatus::Failed
};
}
pub fn stall(&mut self, why: impl Into<String>) {
self.last_error = Some(why.into());
self.diagnostic = None;
self.attempts = self.attempts.saturating_sub(1);
self.status = TaskStatus::Failed;
}
pub fn operator_held(&self) -> bool {
self.status == TaskStatus::Held && !matches!(self.hold_source, Some(HoldSource::Machine))
}
pub fn hold_manual(&mut self, reason: Option<String>) {
self.status = TaskStatus::Held;
if reason.is_some() {
self.hold_reason = reason;
}
self.hold_source = Some(HoldSource::Manual);
}
pub fn hold_machine(&mut self, reason: Option<String>) {
self.status = TaskStatus::Held;
if reason.is_some() {
self.hold_reason = reason;
}
self.hold_source = Some(HoldSource::Machine);
}
pub fn block(&mut self, blocked_by: Vec<String>, reason: Option<String>) {
self.status = TaskStatus::Blocked;
self.blocked_by = blocked_by;
self.block_reason = reason;
}
pub fn unblock(&mut self, resolved_id: &str) {
if self.status != TaskStatus::Blocked {
return;
}
self.blocked_by.retain(|id| id != resolved_id);
if self.blocked_by.is_empty() {
self.status = TaskStatus::Queued;
self.block_reason = None;
}
}
pub fn record_answer(&mut self, question: String, answer: String) {
self.answers.push(AnsweredQuestion { question, answer });
}
pub fn request_review(&mut self, branch: String) {
self.release();
self.review_branch = Some(branch);
}
pub fn requeue(&mut self) {
self.release();
self.fresh_start = true;
}
pub fn set_priority(&mut self, priority: i32) -> Result<()> {
if self.status == TaskStatus::Running {
bail!(
"task {} is running; its priority cannot be changed until \
this attempt finishes",
self.short()
);
}
self.priority = priority;
Ok(())
}
pub fn edit(&mut self, title: String, instruction: String) -> Result<()> {
if !matches!(self.status, TaskStatus::Queued | TaskStatus::Held) {
bail!(
"task {} is {}; only a queued or held task's instruction can \
be edited",
self.short(),
self.status.as_str()
);
}
self.title = title;
self.instruction = instruction;
Ok(())
}
pub fn handed_off(&mut self, why: impl Into<String>) {
self.last_error = Some(why.into());
self.diagnostic = None;
self.status = TaskStatus::Held;
self.hold_source = Some(HoldSource::Machine);
}
pub fn release(&mut self) {
self.status = TaskStatus::Queued;
self.attempts = 0;
self.last_error = None;
self.hold_reason = None;
self.hold_source = None;
self.diagnostic = None;
self.blocked_by.clear();
self.block_reason = None;
self.review_branch = None;
self.fresh_start = false;
}
}
#[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.priority.cmp(&a.priority).then_with(|| 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_machine(None);
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_blocked_task_never_starves_another_runnable_one() {
let (_dir, q) = queue();
let mut blocked = task("blocked");
blocked.block(vec!["something".to_owned()], None);
q.put(&mut blocked).unwrap();
let mut runnable = task("free to go");
q.put(&mut runnable).unwrap();
let next = q.next_runnable().expect("a runnable task is still offered");
assert_eq!(next.id, runnable.id);
}
#[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_machine(None);
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_hold_reason_survives_and_a_release_clears_it() {
let mut t = task("waiting on something else");
t.hold_manual(Some(
"waiting for 20260101-000000-aaaa to land first".to_owned(),
));
assert_eq!(t.status, TaskStatus::Held);
assert_eq!(
t.hold_reason.as_deref(),
Some("waiting for 20260101-000000-aaaa to land first")
);
t.hold_manual(None);
assert_eq!(
t.hold_reason.as_deref(),
Some("waiting for 20260101-000000-aaaa to land first"),
"a bare re-hold keeps whatever a human already wrote down"
);
let mut plain = task("no reason given");
plain.hold_manual(None);
assert_eq!(plain.status, TaskStatus::Held);
assert!(plain.hold_reason.is_none());
t.release();
assert_eq!(t.status, TaskStatus::Queued);
assert!(
t.hold_reason.is_none(),
"a stale reason must not greet the next person who holds this task"
);
}
#[test]
fn closing_a_held_task_as_done_clears_its_hold_reason_too() {
let mut t = task("landed by hand while held");
t.hold_manual(Some("waiting on 3ed9".to_owned()));
assert_eq!(t.hold_reason.as_deref(), Some("waiting on 3ed9"));
t.succeed();
assert_eq!(t.status, TaskStatus::Done);
assert!(
t.hold_reason.is_none(),
"a done task cannot still be waiting on something"
);
}
#[test]
fn a_blocked_task_is_never_offered_to_the_loop() {
let mut t = task("blocked");
assert!(t.status.runnable());
t.block(
vec!["dep-id".to_owned()],
Some("waits on dep-id".to_owned()),
);
assert_eq!(t.status, TaskStatus::Blocked);
assert!(!t.status.runnable());
assert_eq!(TaskStatus::Blocked.as_str(), "blocked");
}
#[test]
fn unblocking_the_last_dependency_returns_the_task_to_queued() {
let mut t = task("blocked on two");
t.block(
vec!["a".to_owned(), "b".to_owned()],
Some("waits on a and b".to_owned()),
);
t.unblock("a");
assert_eq!(t.status, TaskStatus::Blocked, "b is still outstanding");
assert_eq!(t.blocked_by, ["b"]);
t.unblock("b");
assert_eq!(t.status, TaskStatus::Queued);
assert!(t.blocked_by.is_empty());
assert!(t.block_reason.is_none());
}
#[test]
fn unblocking_an_id_on_a_task_that_is_not_blocked_is_a_no_op() {
let mut t = task("never blocked");
t.unblock("whatever");
assert_eq!(t.status, TaskStatus::Queued);
}
#[test]
fn answering_a_question_is_recorded_and_survives_a_release() {
let mut t = task("asked something");
t.block(vec!["q1".to_owned()], Some("which backend?".to_owned()));
t.record_answer("Which backend?".to_owned(), "SQLite".to_owned());
t.unblock("q1");
assert_eq!(t.status, TaskStatus::Queued);
assert_eq!(t.answers.len(), 1);
assert_eq!(t.answers[0].answer, "SQLite");
t.release();
assert_eq!(t.answers.len(), 1, "the answer is not lost on release");
}
#[test]
fn requesting_review_requeues_the_task_and_remembers_the_branch() {
let mut t = task("blocked run with a surviving branch");
t.start("run-1".to_owned());
t.fail("blocked with major findings", 5);
assert_eq!(t.status, TaskStatus::Failed);
t.request_review("magi/eba2/A".to_owned());
assert_eq!(t.status, TaskStatus::Queued);
assert_eq!(t.attempts, 0);
assert_eq!(t.review_branch.as_deref(), Some("magi/eba2/A"));
t.release();
assert!(t.review_branch.is_none());
}
#[test]
fn conductor_requeue_but_not_an_ordinary_release_forces_a_fresh_start() {
let mut t = task("retry");
t.start("run-1".to_owned());
t.requeue();
assert!(t.fresh_start);
t.release();
assert!(!t.fresh_start);
}
#[test]
fn priority_can_be_changed_while_queued_but_not_while_running() {
let mut t = task("reprioritise me");
t.set_priority(5).unwrap();
assert_eq!(t.priority, 5);
t.start("run-1".to_owned());
let err = t.set_priority(9).unwrap_err().to_string();
assert!(err.contains("running"), "{err}");
assert_eq!(t.priority, 5, "the rejected write must not partially apply");
}
#[test]
fn changing_priority_moves_a_task_ahead_in_the_real_queue_order() {
let (_dir, q) = queue();
let mut a = task("first filed");
let mut b = task("second filed");
a.id = "20260101-000001-aaaa".to_owned();
b.id = "20260101-000002-bbbb".to_owned();
q.put(&mut a).unwrap();
q.put(&mut b).unwrap();
assert_eq!(
q.next_runnable().unwrap().id,
a.id,
"with equal priority the older task goes first, so a burst of \
new work cannot starve it"
);
assert_eq!(
q.list()[0].id,
b.id,
"but the list an operator reads is newest first, the same as \
before priority existed - a's turn to run does not make it the \
newest task"
);
let mut a = q.get(&a.id).unwrap();
a.set_priority(10).unwrap();
q.put(&mut a).unwrap();
assert_eq!(
q.next_runnable().unwrap().id,
a.id,
"a raised priority must be reflected the moment it is saved"
);
assert_eq!(
q.list()[0].id,
a.id,
"the raised task must sort first in the list an operator reads, \
not only in next_runnable's own ordering"
);
}
#[test]
fn editing_replaces_title_and_instruction_but_keeps_identity_and_history() {
let mut t = Task::new(
"old title".to_owned(),
"old instruction".to_owned(),
PathBuf::from("/repo"),
Source::Agent {
run: "20260101-000000-beef".to_owned(),
node: "implement".to_owned(),
},
);
let id = t.id.clone();
let created_at = t.created_at;
t.runs.push("20260101-000000-beef".to_owned());
t.edit("new title".to_owned(), "new instruction".to_owned())
.unwrap();
assert_eq!(t.title, "new title");
assert_eq!(t.instruction, "new instruction");
assert_eq!(t.id, id, "editing must not mint a new id");
assert_eq!(t.created_at, created_at);
assert_eq!(
t.source,
Source::Agent {
run: "20260101-000000-beef".to_owned(),
node: "implement".to_owned(),
},
"editing must not turn agent attribution into human"
);
assert_eq!(t.runs, ["20260101-000000-beef"]);
}
#[test]
fn editing_is_refused_once_a_task_is_running_or_finished() {
let mut running = task("in flight");
running.start("run-1".to_owned());
let err = running
.edit("x".to_owned(), "y".to_owned())
.unwrap_err()
.to_string();
assert!(err.contains("running"), "{err}");
let mut done = task("finished");
done.succeed();
let err = done
.edit("x".to_owned(), "y".to_owned())
.unwrap_err()
.to_string();
assert!(err.contains("done"), "{err}");
let mut queued = task("waiting");
queued.edit("x".to_owned(), "y".to_owned()).unwrap();
let mut held = task("parked");
held.hold_machine(None);
held.edit("x".to_owned(), "y".to_owned()).unwrap();
}
#[test]
fn a_task_recorded_without_a_hold_reason_still_reads_as_none() {
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 hold reasons existed",
"instruction": "from before hold reasons existed",
"repo": ".",
"source": { "kind": "human" },
"status": "held",
"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.hold_reason.is_none());
assert!(task.operator_held());
}
#[test]
fn a_legacy_reasoned_hold_defaults_to_operator_protection() {
let (_dir, q) = queue();
let path = q.path_of("20260101-000000-bbbb");
std::fs::create_dir_all(q.root()).unwrap();
std::fs::write(
&path,
serde_json::json!({
"schema": 2,
"id": "20260101-000000-bbbb",
"title": "old manual recovery",
"instruction": "old manual recovery",
"repo": ".",
"source": { "kind": "human" },
"status": "held",
"hold_reason": "active manual recovery run20260912-224242-daf5",
"created_at": Timestamp::now().to_string(),
"updated_at": Timestamp::now().to_string(),
})
.to_string(),
)
.unwrap();
let task = q.get("20260101-000000-bbbb").expect("must still read");
assert_eq!(task.hold_source, None);
assert!(task.operator_held());
}
#[test]
fn a_task_recorded_without_a_diagnostic_still_reads_as_none() {
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 diagnostics existed",
"instruction": "from before diagnostics existed",
"repo": ".",
"source": { "kind": "human" },
"status": "held",
"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.diagnostic.is_none());
}
#[test]
fn a_schema_1_task_with_no_blocking_fields_still_reads() {
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": 1,
"id": "20260101-000000-aaaa",
"title": "from before blocking existed",
"instruction": "from before blocking 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.blocked_by.is_empty());
assert!(task.block_reason.is_none());
assert!(task.answers.is_empty());
assert!(task.review_branch.is_none());
}
#[test]
fn releasing_or_finishing_a_task_clears_its_stale_diagnostic() {
let mut held = task("diagnosed");
held.start("run-1".to_owned());
held.fail("gate red", 1);
held.diagnostic = Some("cargo test failed: ...".to_owned());
assert_eq!(held.status, TaskStatus::Held);
held.release();
assert!(held.diagnostic.is_none());
held.diagnostic = Some("cargo test failed: ...".to_owned());
held.succeed();
assert!(held.diagnostic.is_none());
}
#[test]
fn failing_a_task_always_clears_whatever_diagnostic_it_carried() {
let mut t = task("retried");
t.start("run-1".to_owned());
t.diagnostic = Some("stale evidence from a previous hold".to_owned());
t.fail("unrelated config error", 5);
assert_eq!(t.status, TaskStatus::Failed);
assert!(
t.diagnostic.is_none(),
"fail() must not let an old diagnostic outlive the run that produced it"
);
}
#[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(&format!("\"schema\": {SCHEMA}"), "\"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"
);
}
}