use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use tokio::sync::Notify;
use crate::clean;
use crate::config::{Config, MergeMode};
use crate::graph::Runner;
use crate::queue::{Queue, Task, TaskStatus};
use crate::run::{RunState, RunStatus};
pub const SCHEMA: u32 = 1;
pub const HEARTBEAT: Duration = Duration::from_secs(5);
pub const STALE_SECS: i64 = 30;
pub const POLL: Duration = Duration::from_secs(5);
pub const STALE_CLAIM: Duration = Duration::from_secs(6 * 60 * 60);
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Current {
pub task: String,
pub run: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Status {
pub schema: u32,
pub pid: u32,
pub started_at: Timestamp,
pub updated_at: Timestamp,
pub idle: bool,
pub current: Option<Current>,
pub completed: usize,
pub polls: u64,
}
impl Status {
#[must_use]
pub fn new() -> Self {
let now = Timestamp::now();
Self {
schema: SCHEMA,
pid: std::process::id(),
started_at: now,
updated_at: now,
idle: true,
current: None,
completed: 0,
polls: 0,
}
}
}
impl Default for Status {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct Opts {
pub repo: PathBuf,
pub config: Option<PathBuf>,
pub poll: Duration,
pub max_attempts: usize,
pub once: bool,
pub merge: Option<String>,
}
impl Default for Opts {
fn default() -> Self {
Self {
repo: PathBuf::from("."),
config: None,
poll: POLL,
max_attempts: 2,
once: false,
merge: None,
}
}
}
#[must_use]
pub fn status_path() -> PathBuf {
crate::run::home().join("daemon.json")
}
pub fn write_status(status: &Status) -> Result<()> {
write_status_to(&status_path(), status)
}
pub fn write_status_to(path: &Path, status: &Status) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let body = serde_json::to_string_pretty(status).context("serialize daemon status")?;
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 clear_status() {
clear_status_at(&status_path());
}
fn clear_status_at(path: &Path) {
let _ = std::fs::remove_file(path);
}
#[derive(Debug, Clone, Default)]
pub struct Stop {
stopped: Arc<AtomicBool>,
busy: Arc<AtomicBool>,
wake: Arc<Notify>,
pause: crate::graph::Pause,
}
impl Stop {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst);
self.wake.notify_one();
}
#[must_use]
pub fn stopped(&self) -> bool {
self.stopped.load(Ordering::SeqCst)
}
#[must_use]
pub fn finishing(&self) -> bool {
self.stopped() && self.busy.load(Ordering::SeqCst)
}
pub fn park(&self) {
self.pause.park();
self.stop();
}
#[must_use]
pub fn parking(&self) -> bool {
self.pause.parked()
}
#[must_use]
pub fn pause(&self) -> crate::graph::Pause {
self.pause.clone()
}
#[must_use]
pub fn busy_now(&self) -> bool {
self.busy.load(Ordering::SeqCst)
}
fn busy(&self, running: bool) {
self.busy.store(running, Ordering::SeqCst);
}
async fn idle(&self, poll: Duration) {
tokio::select! {
() = tokio::time::sleep(poll) => {}
() = self.wake.notified() => {}
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Reading {
pub schema: u32,
pub pid: Option<u32>,
pub started_at: Option<Timestamp>,
pub updated_at: Option<Timestamp>,
pub idle: bool,
pub current: Option<Current>,
pub completed: u64,
pub polls: u64,
}
impl Reading {
#[must_use]
pub fn age_secs(&self, now: Timestamp) -> Option<i64> {
self.updated_at
.map(|at| (now.as_second() - at.as_second()).max(0))
}
#[must_use]
pub fn running(&self, now: Timestamp) -> bool {
self.age_secs(now).is_some_and(|secs| secs <= STALE_SECS)
}
}
#[must_use]
pub fn read_status(home: &Path) -> Option<Reading> {
let body = std::fs::read_to_string(home.join("daemon.json")).ok()?;
serde_json::from_str(&body).ok()
}
#[must_use]
pub fn current_work(home: &Path, now: Timestamp) -> Option<Current> {
read_status(home)
.filter(|reading| reading.running(now))
.and_then(|reading| reading.current)
}
#[must_use]
pub fn is_working_on(home: &Path, run: &str, now: Timestamp) -> bool {
current_work(home, now).is_some_and(|c| c.run == run)
}
#[must_use]
pub fn is_working_on_task(home: &Path, task: &str, now: Timestamp) -> bool {
current_work(home, now).is_some_and(|c| c.task == task)
}
pub fn sweep_stale_claims(queue: &Queue, older_than: Duration) -> Vec<String> {
let mut swept: Vec<String> = std::fs::read_dir(queue.root())
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "lock"))
.filter(|p| {
p.metadata()
.and_then(|m| m.modified())
.and_then(|t| t.elapsed().map_err(std::io::Error::other))
.is_ok_and(|age| age >= older_than)
})
.filter(|p| std::fs::remove_file(p).is_ok())
.filter_map(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.map(std::borrow::ToOwned::to_owned)
})
.collect();
swept.sort_unstable();
swept
}
#[derive(Debug, Clone, Copy)]
pub struct Verdict {
pub status: RunStatus,
pub left_pr: bool,
pub quota_hit: bool,
pub parked: bool,
}
pub fn settle(task: &mut Task, verdict: Verdict, detail: &str, max_attempts: usize) {
if verdict.parked {
task.stall(detail);
return;
}
match verdict.status {
RunStatus::Merged | RunStatus::Ready => task.succeed(),
RunStatus::Stalled if verdict.quota_hit => task.stall(detail),
RunStatus::Stalled | RunStatus::Failed => task.fail(detail, max_attempts),
RunStatus::Blocked if verdict.left_pr => task.handed_off(detail),
RunStatus::Blocked => task.fail(detail, max_attempts),
other => task.fail(
format!(
"the graph stopped at `{}` without reaching a terminal status: {detail}",
label(other)
),
max_attempts,
),
}
}
fn reclaim(task: &mut Task, last_run: Option<RunState>, max_attempts: usize) {
match last_run {
Some(state) => {
let verdict = Verdict {
status: state.status,
left_pr: state.pr.is_some(),
quota_hit: !state.quota.is_empty(),
parked: state.parked,
};
let detail = format!(
"recovered a `running` task whose daemon never recorded the outcome: {}",
describe(&state)
);
settle(task, verdict, &detail, max_attempts);
}
None => {
let why = "task was `running` with no live daemon and no readable \
run to recover; held for a human to check what happened";
task.last_error = Some(why.to_owned());
task.hold(Some(why.to_owned()));
}
}
}
fn reclaim_orphaned_running(queue: &Queue, max_attempts: usize) -> Vec<String> {
let mut reclaimed = Vec::new();
for listed in queue.list() {
if listed.status != TaskStatus::Running {
continue;
}
let Ok(_claim) = queue.claim(&listed.id) else {
continue;
};
let Ok(mut task) = queue.get(&listed.id) else {
continue;
};
if task.status != TaskStatus::Running {
continue;
}
let last_run = task.runs.last().and_then(|id| RunState::load(id).ok());
reclaim(&mut task, last_run, max_attempts);
record(queue, &mut task);
reclaimed.push(task.id.clone());
}
reclaimed
}
pub async fn serve(opts: Opts) -> Result<()> {
serve_until(opts, Stop::new()).await
}
pub async fn serve_until(opts: Opts, stop: Stop) -> Result<()> {
let signal = {
let stop = stop.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
stop.stop();
tracing::info!("shutdown requested; a run in flight will be finished first");
}
})
};
let outcome = drive(
&opts,
&Queue::open(),
&status_path(),
&crate::run::home(),
&stop,
)
.await;
signal.abort();
outcome
}
async fn drive(
opts: &Opts,
queue: &Queue,
status_file: &Path,
home: &Path,
stop: &Stop,
) -> Result<()> {
janitor(&opts.repo, opts, home).await;
let status = Arc::new(Mutex::new(Status::new()));
write_status_to(status_file, &lock(&status)).context("publish the daemon status file")?;
let beat = tokio::spawn(heartbeat(Arc::clone(&status), status_file.to_path_buf()));
tracing::info!(
"magi serve: queue {} (poll {}s, {} attempts per task, one run at a time)",
queue.root().display(),
opts.poll.as_secs(),
opts.max_attempts
);
let outcome = poll(opts, queue, &status, home, stop).await;
beat.abort();
clear_status_at(status_file);
outcome
}
async fn heartbeat(status: Arc<Mutex<Status>>, path: PathBuf) {
loop {
tokio::time::sleep(HEARTBEAT).await;
let snapshot = {
let mut guard = lock(&status);
guard.updated_at = Timestamp::now();
guard.clone()
};
if let Err(e) = write_status_to(&path, &snapshot) {
tracing::warn!("could not refresh the daemon status file: {e:#}");
}
}
}
async fn poll(
opts: &Opts,
queue: &Queue,
status: &Arc<Mutex<Status>>,
home: &Path,
stop: &Stop,
) -> Result<()> {
let mut attempted: Vec<String> = Vec::new();
while !stop.stopped() {
lock(status).polls += 1;
let swept = sweep_stale_claims(queue, STALE_CLAIM);
if !swept.is_empty() {
tracing::warn!(
"swept {} stale claim(s) left behind by an earlier daemon: {}",
swept.len(),
swept.join(", ")
);
}
let reclaimed = reclaim_orphaned_running(queue, opts.max_attempts);
if !reclaimed.is_empty() {
tracing::warn!(
"reclaimed {} task(s) left `running` by a daemon that never \
recorded the outcome: {}",
reclaimed.len(),
reclaimed.join(", ")
);
}
let candidates: Vec<Task> = runnable(queue)
.into_iter()
.filter(|t| !opts.once || !attempted.contains(&t.id))
.collect();
let mut ran = false;
for candidate in candidates {
if stop.stopped() {
break;
}
let Ok(_claim) = queue.claim(&candidate.id) else {
tracing::info!("task {} is claimed elsewhere; skipping", candidate.short());
continue;
};
let mut task = match queue.get(&candidate.id) {
Ok(t) if t.status.runnable() => t,
Ok(_) => continue,
Err(e) => {
tracing::warn!("could not re-read task {}: {e:#}", candidate.short());
continue;
}
};
attempted.push(task.id.clone());
lock(status).idle = false;
stop.busy(true);
attempt(opts, queue, status, stop, &mut task).await;
stop.busy(false);
janitor(&opts.repo, opts, home).await;
{
let mut guard = lock(status);
guard.current = None;
guard.completed += 1;
}
ran = true;
break;
}
if ran {
continue;
}
lock(status).idle = true;
if opts.once {
return Ok(());
}
stop.idle(opts.poll).await;
}
Ok(())
}
async fn attempt(
opts: &Opts,
queue: &Queue,
status: &Arc<Mutex<Status>>,
stop: &Stop,
task: &mut Task,
) {
let repo = repo_for(task, &opts.repo);
tracing::info!(
"task {} — {} (repo {})",
task.short(),
task.title,
repo.display()
);
let mut config = match prepare(&repo, opts) {
Ok(c) => c,
Err(e) => {
task.attempts += 1;
task.fail(format!("config: {e:#}"), opts.max_attempts);
record(queue, task);
return;
}
};
apply_solo(&mut config, task);
if let Some(reason) = disk_gate(&repo, &config) {
task.last_error = Some(reason.clone());
task.hold(Some(reason.clone()));
record(queue, task);
tracing::warn!("holding {} for want of disk space: {reason}", task.short());
return;
}
let unfinished = task
.runs
.iter()
.rev()
.find(|id| {
RunState::load(id)
.map(|s| s.status.resumable())
.unwrap_or(false)
})
.cloned();
let started = match &unfinished {
Some(id) => {
tracing::info!("resuming run {id} rather than competing again");
Runner::resume(id)
}
None => Runner::start(&repo, task.instruction.clone(), config).await,
};
let mut runner = match started {
Ok(r) => r,
Err(e) => {
task.attempts += 1;
task.fail(format!("could not start the run: {e:#}"), opts.max_attempts);
record(queue, task);
return;
}
};
runner.on_pause(stop.pause());
let run = runner.state.id.clone();
task.start(run.clone());
record(queue, task);
lock(status).current = Some(Current {
task: task.id.clone(),
run,
});
let detail = match runner.execute().await {
Ok(()) => describe(&runner.state),
Err(e) => format!("{e:#}"),
};
let verdict = Verdict {
status: runner.state.status,
left_pr: runner.state.pr.is_some(),
quota_hit: !runner.state.quota.is_empty(),
parked: runner.state.parked,
};
settle(task, verdict, &detail, opts.max_attempts);
record(queue, task);
tracing::info!(
"task {} is {} after run {} ({})",
task.short(),
task.status.as_str(),
runner.state.short(),
label(runner.state.status)
);
}
fn apply_solo(config: &mut Config, task: &Task) {
if task.solo {
config.graph.candidates = 1;
}
}
fn prepare(repo: &Path, opts: &Opts) -> Result<Config> {
let (mut config, _layers) = Config::discover(repo, opts.config.as_deref())?;
if let Some(mode) = &opts.merge {
config.merge.mode = merge_mode(mode)?;
}
Ok(config)
}
async fn janitor(repo: &Path, opts: &Opts, home: &Path) {
let cfg = match prepare(repo, opts) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!("housekeep: no config: {e:#}");
return;
}
};
let out = clean::housekeep(
&cfg,
home,
&crate::run::default_worktree_root(),
Timestamp::now(),
)
.await;
if out.folded > 0 {
let unreadable = if out.unreadable > 0 {
format!(" ({} unreadable)", out.unreadable)
} else {
String::new()
};
tracing::info!("housekeep: folded {} run(s){unreadable}", out.folded);
}
if out.cache_files > 0 {
tracing::info!(
"housekeep: pruned {} file(s) ({} bytes) from the shared cache",
out.cache_files,
out.cache_freed
);
}
}
fn disk_gate(repo: &Path, config: &Config) -> Option<String> {
let min = config.disk.min_free_bytes;
if min == 0 {
return None;
}
match crate::disk::free_bytes(repo) {
Ok(free) => crate::disk::gate(free, min),
Err(e) => Some(format!(
"could not measure free space on {} ({e}); the disk gate refuses \
to let a run start blind",
repo.display()
)),
}
}
fn repo_for(task: &Task, fallback: &Path) -> PathBuf {
if task.repo.as_os_str().is_empty() || task.repo == Path::new(".") {
return fallback.to_path_buf();
}
task.repo.clone()
}
fn record(queue: &Queue, task: &mut Task) {
if let Err(e) = queue.put(task) {
tracing::error!("could not record task {}: {e:#}", task.short());
}
}
fn runnable(queue: &Queue) -> Vec<Task> {
let mut tasks: Vec<Task> = queue
.list()
.into_iter()
.filter(|t| t.status.runnable())
.collect();
tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id)));
tasks
}
fn describe(state: &RunState) -> String {
let mut detail = if state.status == RunStatus::Stalled {
let mut seats: Vec<&str> = state.quota.iter().map(|q| q.seat.as_str()).collect();
seats.sort_unstable();
seats.dedup();
if seats.is_empty() {
"the judging panel lost its quorum".to_owned()
} else {
format!(
"the judging panel lost its quorum; quota took out {}",
seats.join(", ")
)
}
} else {
format!("run ended {}", label(state.status))
};
if let Some(last) = state.events.last() {
detail.push_str(&format!(" ({}: {})", last.node, last.message));
}
detail.push_str(&format!(" [run {}]", state.id));
detail
}
fn label(status: RunStatus) -> &'static str {
status.as_str()
}
fn merge_mode(mode: &str) -> Result<MergeMode> {
match mode {
"none" => Ok(MergeMode::None),
"local" => Ok(MergeMode::Local),
"pr" => Ok(MergeMode::Pr),
other => bail!("unknown merge mode `{other}`; expected none, local or pr"),
}
}
fn lock(status: &Mutex<Status>) -> MutexGuard<'_, Status> {
status
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::queue::{Source, TaskStatus};
use pretty_assertions::assert_eq;
fn task() -> Task {
Task::new(
"add retries".to_owned(),
"add retries".to_owned(),
PathBuf::from("/repo"),
Source::Human,
)
}
#[test]
fn every_run_status_settles_the_task_it_came_from() {
let table = [
(RunStatus::Merged, TaskStatus::Done, 1),
(RunStatus::Ready, TaskStatus::Done, 1),
(RunStatus::Stalled, TaskStatus::Failed, 0),
(RunStatus::Blocked, TaskStatus::Failed, 1),
(RunStatus::Failed, TaskStatus::Failed, 1),
(RunStatus::Prep, TaskStatus::Failed, 1),
(RunStatus::Implementing, TaskStatus::Failed, 1),
(RunStatus::Judging, TaskStatus::Failed, 1),
(RunStatus::Deliberating, TaskStatus::Failed, 1),
(RunStatus::Voting, TaskStatus::Failed, 1),
(RunStatus::Reviewing, TaskStatus::Failed, 1),
(RunStatus::Gating, TaskStatus::Failed, 1),
];
for (run, want, attempts) in table {
let mut t = task();
t.start("20260902-000000-aaaa".to_owned());
settle(
&mut t,
Verdict {
status: run,
left_pr: false,
parked: false,
quota_hit: matches!(run, RunStatus::Stalled),
},
"why",
2,
);
assert_eq!(t.status, want, "task status after {}", label(run));
assert_eq!(t.attempts, attempts, "attempts after {}", label(run));
}
}
#[test]
fn a_quota_stall_costs_the_task_no_attempt_but_a_block_does() {
let mut stalled = task();
stalled.start("20260902-000000-aaaa".to_owned());
settle(
&mut stalled,
Verdict {
status: RunStatus::Stalled,
left_pr: false,
parked: false,
quota_hit: true,
},
"quota",
1,
);
assert_eq!(stalled.attempts, 0);
assert!(
stalled.status.runnable(),
"a machine problem must leave the task in line"
);
let mut blocked = task();
blocked.start("20260902-000000-aaaa".to_owned());
settle(
&mut blocked,
Verdict {
status: RunStatus::Blocked,
left_pr: false,
parked: false,
quota_hit: false,
},
"findings open",
1,
);
assert_eq!(blocked.attempts, 1);
assert_eq!(
blocked.status,
TaskStatus::Held,
"the last attempt hands the task to a human"
);
}
#[test]
fn a_run_that_opened_a_pull_request_is_never_re_competed() {
let mut delivered = task();
delivered.start("20260903-080619-01c2".to_owned());
settle(
&mut delivered,
Verdict {
status: RunStatus::Blocked,
left_pr: true,
parked: false,
quota_hit: false,
},
"no check status",
4,
);
assert_eq!(
delivered.status,
TaskStatus::Held,
"a pull request waiting on CI or a person is not a retryable failure"
);
assert!(
!delivered.status.runnable(),
"the loop must not pick this task up again"
);
assert_eq!(
delivered.last_error.as_deref(),
Some("no check status"),
"the operator needs to be told what the gate was waiting for"
);
let mut empty_handed = task();
empty_handed.start("20260903-080619-01c2".to_owned());
settle(
&mut empty_handed,
Verdict {
status: RunStatus::Blocked,
left_pr: false,
parked: false,
quota_hit: false,
},
"findings open",
4,
);
assert_eq!(empty_handed.status, TaskStatus::Failed);
assert!(empty_handed.status.runnable());
}
#[test]
fn parking_costs_the_task_no_attempt_and_leaves_it_in_line() {
let mut parked = task();
parked.start("20260903-183634-2d98".to_owned());
settle(
&mut parked,
Verdict {
status: RunStatus::Implementing,
left_pr: false,
quota_hit: false,
parked: true,
},
"parked after `implementing`",
2,
);
assert_eq!(parked.attempts, 0, "a park is refunded");
assert!(
parked.status.runnable(),
"and the task stays in line so the next loop resumes its run"
);
assert_eq!(
parked.last_error.as_deref(),
Some("parked after `implementing`"),
"the card says where it stopped"
);
let mut broken = task();
broken.start("20260903-183634-2d98".to_owned());
settle(
&mut broken,
Verdict {
status: RunStatus::Implementing,
left_pr: false,
quota_hit: false,
parked: false,
},
"returned mid-flight",
2,
);
assert_eq!(broken.attempts, 1);
}
#[test]
fn only_a_rate_limit_buys_the_task_its_attempt_back() {
let mut flaky = task();
flaky.start("20260903-123023-e633".to_owned());
settle(
&mut flaky,
Verdict {
status: RunStatus::Stalled,
left_pr: false,
parked: false,
quota_hit: false,
},
"verdict rests on 1 of 3 judges",
2,
);
assert_eq!(
flaky.attempts, 1,
"flakiness spends an attempt, so `max_attempts` still bounds it"
);
assert!(flaky.status.runnable(), "and it is still worth retrying");
let mut limited = task();
limited.start("20260903-123023-e633".to_owned());
settle(
&mut limited,
Verdict {
status: RunStatus::Stalled,
left_pr: false,
parked: false,
quota_hit: true,
},
"judge-2, judge-3 out of quota",
2,
);
assert_eq!(limited.attempts, 0, "a quota window is refunded");
assert!(limited.status.runnable());
let mut worn = task();
for _ in 0..2 {
worn.release();
}
worn.start("20260903-123023-e633".to_owned());
worn.attempts = 2;
settle(
&mut worn,
Verdict {
status: RunStatus::Stalled,
left_pr: false,
parked: false,
quota_hit: false,
},
"no quorum again",
2,
);
assert_eq!(worn.status, TaskStatus::Held);
assert!(!worn.status.runnable());
}
#[test]
fn a_held_task_is_never_offered_to_the_loop() {
let dir = tempfile::tempdir().unwrap();
let queue = Queue::at(dir.path().to_path_buf());
for (n, priority) in [(1, 0), (2, 5), (3, 5)] {
let mut t = task();
t.id = format!("2026090{n}-000000-000{n}");
t.priority = priority;
queue.put(&mut t).unwrap();
}
let mut held = task();
held.id = "20260909-000000-9999".to_owned();
held.priority = 99;
held.hold(None);
queue.put(&mut held).unwrap();
let order: Vec<String> = runnable(&queue).into_iter().map(|t| t.id).collect();
assert_eq!(order.len(), 3);
assert!(!order.contains(&held.id));
assert_eq!(
order.first().cloned(),
queue.next_runnable().map(|t| t.id),
"the loop's first candidate is exactly what the queue offers"
);
assert_eq!(
order,
vec![
"20260902-000000-0002".to_owned(),
"20260903-000000-0003".to_owned(),
"20260901-000000-0001".to_owned(),
],
"priority first, then oldest, so nothing starves"
);
}
#[test]
fn sweep_removes_an_abandoned_lock_and_keeps_a_live_one() {
let dir = tempfile::tempdir().unwrap();
let queue = Queue::at(dir.path().to_path_buf());
let mut old = task();
old.id = "20260101-000000-old0".to_owned();
queue.put(&mut old).unwrap();
let mut fresh = task();
fresh.id = "20260101-000000-new0".to_owned();
queue.put(&mut fresh).unwrap();
let abandoned = queue.claim(&old.id).unwrap();
std::thread::sleep(Duration::from_millis(60));
let live = queue.claim(&fresh.id).unwrap();
let swept = sweep_stale_claims(&queue, Duration::from_millis(50));
assert_eq!(swept, vec![old.id.clone()]);
assert!(
queue.claim(&old.id).is_ok(),
"a swept task is claimable again"
);
assert!(
queue.claim(&fresh.id).is_err(),
"a lock younger than the threshold still protects its task"
);
drop((abandoned, live));
}
fn run_state(status: RunStatus) -> RunState {
let mut state = RunState::new(
PathBuf::from("/repo"),
"main".to_owned(),
"abc1234def".to_owned(),
"add retries".to_owned(),
Config::default(),
);
state.status = status;
state
}
#[test]
fn reclaim_settles_a_running_task_against_its_last_run() {
let mut t = task();
t.start("20260904-000000-4043".to_owned());
reclaim(&mut t, Some(run_state(RunStatus::Ready)), 2);
assert_eq!(
t.status,
TaskStatus::Done,
"a run that actually finished must not stay `running` forever"
);
}
#[test]
fn reclaim_reuses_the_same_retry_policy_as_a_live_settle() {
let mut t = task();
t.start("20260904-000000-4043".to_owned());
reclaim(&mut t, Some(run_state(RunStatus::Blocked)), 2);
assert_eq!(t.status, TaskStatus::Failed);
assert!(t.status.runnable());
}
#[test]
fn reclaim_holds_a_running_task_whose_run_cannot_be_found() {
let mut t = task();
t.start("20260904-000000-4043".to_owned());
reclaim(&mut t, None, 2);
assert_eq!(t.status, TaskStatus::Held);
assert!(
t.last_error
.as_deref()
.is_some_and(|e| e.contains("running")),
"the operator needs to know why this task was held"
);
}
#[test]
fn orphaned_running_tasks_are_reclaimed_but_live_ones_are_left_alone() {
let dir = tempfile::tempdir().unwrap();
let queue = Queue::at(dir.path().to_path_buf());
let mut orphaned = task();
orphaned.id = "20260904-000000-orph".to_owned();
orphaned.status = TaskStatus::Running;
orphaned.attempts = 1;
queue.put(&mut orphaned).unwrap();
let mut alive = task();
alive.id = "20260904-000000-live".to_owned();
alive.status = TaskStatus::Running;
alive.attempts = 1;
queue.put(&mut alive).unwrap();
let _held_by_a_live_daemon = queue.claim(&alive.id).unwrap();
let mut queued = task();
queued.id = "20260904-000000-wait".to_owned();
queue.put(&mut queued).unwrap();
let reclaimed = reclaim_orphaned_running(&queue, 2);
assert_eq!(reclaimed, vec![orphaned.id.clone()]);
assert_eq!(
queue.get(&orphaned.id).unwrap().status,
TaskStatus::Held,
"nothing was driving it and there was no run to recover"
);
assert_eq!(
queue.get(&alive.id).unwrap().status,
TaskStatus::Running,
"a live claim must protect the task it belongs to"
);
assert_eq!(queue.get(&queued.id).unwrap().status, TaskStatus::Queued);
}
#[test]
fn an_already_claimed_task_is_skipped_rather_than_failed() {
let dir = tempfile::tempdir().unwrap();
let queue = Queue::at(dir.path().to_path_buf());
let mut only = task();
queue.put(&mut only).unwrap();
let _elsewhere = queue.claim(&only.id).unwrap();
let candidates = runnable(&queue);
assert_eq!(candidates.len(), 1, "the task is still runnable");
assert!(
queue.claim(&candidates[0].id).is_err(),
"the loop cannot take a claim somebody else holds"
);
let after = queue.get(&only.id).unwrap();
assert_eq!(after.status, TaskStatus::Queued);
assert_eq!(
after.attempts, 0,
"losing the race is not an attempt at the task"
);
assert_eq!(after.last_error, None);
}
#[test]
fn the_status_file_round_trips_and_its_heartbeat_advances() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.json");
let mut status = Status::new();
status.idle = false;
status.completed = 7;
status.current = Some(Current {
task: "20260902-000000-t111".to_owned(),
run: "20260902-000001-r111".to_owned(),
});
write_status_to(&path, &status).unwrap();
let first: Status = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(first.schema, SCHEMA);
assert_eq!(first.pid, std::process::id());
assert!(!first.idle);
assert_eq!(first.completed, 7);
assert_eq!(first.current, status.current);
assert!(
!path.with_extension("json.tmp").exists(),
"the temp file is renamed, not left behind"
);
std::thread::sleep(Duration::from_millis(5));
status.updated_at = Timestamp::now();
status.polls = 3;
write_status_to(&path, &status).unwrap();
let second: Status =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(
second.updated_at > first.updated_at,
"a reader can only detect staleness if the heartbeat moves"
);
assert_eq!(
second.started_at, first.started_at,
"the start time is not a heartbeat"
);
assert_eq!(second.polls, 3);
}
#[test]
fn reading_counts_as_running_only_while_its_heartbeat_is_fresh() {
let dir = tempfile::tempdir().unwrap();
assert!(read_status(dir.path()).is_none(), "no file, no daemon");
let mut status = Status::new();
status.updated_at = Timestamp::now() - jiff::SignedDuration::from_secs(60);
write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
let stale = read_status(dir.path()).unwrap();
assert!(
!stale.running(Timestamp::now()),
"a minute without a heartbeat is a dead daemon, not a busy one"
);
assert!(stale.age_secs(Timestamp::now()).is_some_and(|s| s >= 55));
status.updated_at = Timestamp::now();
write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
let fresh = read_status(dir.path()).unwrap();
assert!(fresh.running(Timestamp::now()));
}
#[test]
fn only_a_live_daemon_on_this_very_run_counts_as_working_on_it() {
let dir = tempfile::tempdir().unwrap();
let now = Timestamp::now();
let mine = "20260903-080619-01c2";
assert!(
!is_working_on(dir.path(), mine, now),
"no status file means nobody is working on anything"
);
let mut status = Status::new();
status.current = Some(Current {
task: "20260903-080340-0167".to_owned(),
run: mine.to_owned(),
});
status.updated_at = now;
write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
assert!(is_working_on(dir.path(), mine, now));
assert!(
!is_working_on(dir.path(), "20260903-105039-3cbf", now),
"a daemon busy with one run is not working on another"
);
status.updated_at = now - jiff::SignedDuration::from_secs(600);
write_status_to(&dir.path().join("daemon.json"), &status).unwrap();
assert!(
!is_working_on(dir.path(), mine, now),
"a stale heartbeat is a dead daemon, so its run is a leftover"
);
}
#[test]
fn a_newer_status_file_still_yields_a_reading() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("daemon.json"),
serde_json::json!({
"schema": 2,
"updated_at": Timestamp::now().to_string(),
"idle": true,
"surprise": { "nested": [1, 2, 3] },
})
.to_string(),
)
.unwrap();
let reading = read_status(dir.path()).expect("a forward-compatible read");
assert!(reading.running(Timestamp::now()));
assert!(reading.idle);
assert_eq!(reading.current, None);
}
#[test]
fn a_task_without_a_repository_runs_in_the_daemons_default() {
let fallback = Path::new("/default");
let mut blank = task();
blank.repo = PathBuf::new();
assert_eq!(repo_for(&blank, fallback), PathBuf::from("/default"));
let mut dot = task();
dot.repo = PathBuf::from(".");
assert_eq!(repo_for(&dot, fallback), PathBuf::from("/default"));
assert_eq!(
repo_for(&task(), fallback),
PathBuf::from("/repo"),
"a task that names a repository keeps it"
);
}
#[test]
fn a_solo_task_runs_with_one_candidate_and_a_plain_task_keeps_the_configs() {
let mut solo_cfg = Config::default();
solo_cfg.graph.candidates = 3;
let mut solo_task = task();
solo_task.solo = true;
apply_solo(&mut solo_cfg, &solo_task);
assert_eq!(solo_cfg.graph.candidates, 1);
let mut plain_cfg = Config::default();
plain_cfg.graph.candidates = 3;
let plain_task = task();
assert!(!plain_task.solo);
apply_solo(&mut plain_cfg, &plain_task);
assert_eq!(
plain_cfg.graph.candidates, 3,
"a task that did not ask to run alone keeps the config's candidates"
);
}
#[test]
fn merge_overrides_are_parsed_or_refused() {
assert_eq!(merge_mode("none").unwrap(), MergeMode::None);
assert_eq!(merge_mode("local").unwrap(), MergeMode::Local);
assert_eq!(merge_mode("pr").unwrap(), MergeMode::Pr);
assert!(merge_mode("squash").is_err());
}
fn idle_loop(dir: &Path) -> (Opts, Queue, PathBuf, PathBuf) {
let opts = Opts {
poll: Duration::from_secs(30),
..Opts::default()
};
let home = dir.join("home");
(
opts,
Queue::at(dir.join("queue")),
home.join("daemon.json"),
home,
)
}
#[test]
fn a_stop_is_idempotent_and_once_set_stays_set() {
let stop = Stop::new();
assert!(!stop.stopped());
stop.stop();
assert!(stop.stopped());
stop.stop();
assert!(stop.stopped(), "a second stop is not a toggle");
let shared = stop.clone();
assert!(
shared.stopped(),
"a clone is the same stop; that is how the loop and its caller share one"
);
}
#[test]
fn only_a_stop_with_a_run_in_flight_reads_as_finishing() {
let stop = Stop::new();
stop.busy(true);
assert!(
!stop.finishing(),
"a busy loop nobody has asked to stop is just running"
);
stop.stop();
assert!(
stop.finishing(),
"a stop asked for mid-run has not landed until the run is settled"
);
stop.busy(false);
assert!(
!stop.finishing(),
"once the run is settled the stop has landed and there is nothing to finish"
);
}
#[tokio::test]
async fn a_loop_already_asked_to_stop_returns_without_waiting_out_a_poll() {
let dir = tempfile::tempdir().unwrap();
let (opts, queue, status_file, home) = idle_loop(dir.path());
let stop = Stop::new();
stop.stop();
let began = std::time::Instant::now();
tokio::time::timeout(
Duration::from_secs(2),
drive(&opts, &queue, &status_file, &home, &stop),
)
.await
.expect("a stopped loop must return, not sit out its poll interval")
.expect("the loop's own setup and teardown must not fail");
assert!(
began.elapsed() < opts.poll,
"returned only after {:?}, which is a poll interval, not a stop",
began.elapsed()
);
}
#[tokio::test]
async fn a_stop_while_idle_wakes_the_wait_instead_of_sleeping_it_out() {
let dir = tempfile::tempdir().unwrap();
let (opts, queue, status_file, home) = idle_loop(dir.path());
let stop = Stop::new();
let asker = {
let stop = stop.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
stop.stop();
})
};
let began = std::time::Instant::now();
tokio::time::timeout(
Duration::from_secs(2),
drive(&opts, &queue, &status_file, &home, &stop),
)
.await
.expect("a stop asked for while idle must wake the wait")
.expect("the loop's own setup and teardown must not fail");
asker.await.unwrap();
assert!(
began.elapsed() < opts.poll,
"returned only after {:?}, so the stop waited on the sleep",
began.elapsed()
);
}
#[tokio::test]
async fn a_stopped_loop_leaves_no_status_file_claiming_it_is_running() {
let dir = tempfile::tempdir().unwrap();
let (opts, queue, status_file, home) = idle_loop(dir.path());
let stop = Stop::new();
stop.stop();
tokio::time::timeout(
Duration::from_secs(2),
drive(&opts, &queue, &status_file, &home, &stop),
)
.await
.expect("a stopped loop must return")
.expect("the loop's own setup and teardown must not fail");
assert!(
home.is_dir(),
"the loop did publish a status file, so its removal is the teardown and not an absence"
);
assert!(
!status_file.exists(),
"a stopped loop clears its status file"
);
assert!(
read_status(&home).is_none(),
"a reader must see no daemon at all, not a heartbeat that merely stopped"
);
}
}