use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use jiff::{SignedDuration, Timestamp};
use serde::Deserialize;
use crate::ask::Questions;
use crate::config::Disk;
use crate::run::{RunState, RunStatus, SCHEMA, short_of};
use crate::disk::{Prune, dir_size, prune_dir};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Housekeeping {
pub folded: usize,
pub unreadable: usize,
pub orphaned_worktrees: usize,
pub cache_files: usize,
pub cache_freed: u64,
pub questions_abandoned: usize,
}
pub async fn housekeep(
cfg: &crate::config::Config,
home: &Path,
worktrees_root: &Path,
repo: &Path,
now: Timestamp,
) -> Housekeeping {
let mut out = Housekeeping::default();
if cfg.disk.auto_fold {
let runs = home.join("runs");
match fold_due(&runs, home, worktrees_root, &cfg.disk, now).await {
Ok((folded, unreadable)) => {
out.folded = folded;
out.unreadable = unreadable;
}
Err(e) => tracing::warn!("housekeep: fold due runs: {e:#}"),
}
out.orphaned_worktrees =
fold_orphaned_worktrees(&runs, worktrees_root, home, cfg.disk.fold_grace_secs, now)
.await;
if let Err(e) = crate::git::worktree_prune(repo).await {
tracing::warn!("housekeep: prune worktree registrations: {e:#}");
}
}
if cfg.disk.cache_limit_bytes > 0 {
if let Some(cache) = cfg.cache_dir() {
match prune_cache(&cache, cfg.disk.cache_limit_bytes) {
Ok(pruned) => {
out.cache_files = pruned.files;
out.cache_freed = pruned.freed;
}
Err(e) => tracing::warn!("housekeep: prune cache: {e:#}"),
}
}
}
out.questions_abandoned =
abandon_settled_questions(&Questions::at(home.join("questions")), &home.join("runs"));
out
}
pub fn abandon_settled_questions(store: &Questions, runs: &Path) -> usize {
let waiting_on: BTreeSet<String> = store
.list()
.into_iter()
.filter(|q| q.status.open())
.map(|q| q.run)
.collect();
let mut abandoned = 0;
for run in waiting_on {
let Ok(meta) = read_meta(runs, &run) else {
continue;
};
match store.settle_run(&run, meta.status) {
Ok(n) => abandoned += n,
Err(e) => tracing::warn!("housekeep: abandon questions for {run}: {e:#}"),
}
}
abandoned
}
pub async fn fold_due(
runs: &Path,
home: &Path,
_worktrees_root: &Path,
disk: &Disk,
now: Timestamp,
) -> Result<(usize, usize)> {
let mut folded = 0usize;
let mut unreadable = 0usize;
let mut ids: Vec<String> = std::fs::read_dir(runs)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().join("run.json").is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
ids.sort_unstable();
for id in ids {
if crate::daemon::is_working_on(home, &id, now) {
continue;
}
let meta = match read_meta(runs, &id) {
Ok(meta) => meta,
Err(e) => {
unreadable += 1;
tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
continue;
}
};
if meta.status.resumable() || !due(now, meta.updated_at, disk.fold_grace_secs) {
continue;
}
let mut state = match read_state(runs, &id) {
Ok(state) => state,
Err(e) => {
unreadable += 1;
tracing::warn!("housekeep: run {id} unreadable, left alone: {e:#}");
continue;
}
};
if state.schema != SCHEMA {
tracing::info!(
"housekeep: run {id} was written by schema {} (this build speaks {SCHEMA}); \
folding it anyway",
state.schema
);
}
let drop_winner = state.status == RunStatus::Merged;
match crate::graph::fold_run(&mut state, drop_winner).await {
Ok(_) => folded += 1,
Err(e) => tracing::warn!("housekeep: fold {id}: {e:#}"),
}
}
Ok((folded, unreadable))
}
pub fn due(now: Timestamp, updated: Timestamp, grace_secs: u64) -> bool {
now.duration_since(updated) > SignedDuration::new(grace_secs as i64, 0)
}
#[derive(Deserialize)]
struct Meta {
status: RunStatus,
updated_at: Timestamp,
}
fn read_meta(runs: &Path, id: &str) -> Result<Meta> {
let path = runs.join(id).join("run.json");
let body =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let meta: Meta =
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
Ok(meta)
}
fn read_state(runs: &Path, id: &str) -> Result<RunState> {
let path = runs.join(id).join("run.json");
let body =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let state: RunState =
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
Ok(state)
}
pub async fn fold_unreadable(runs: &Path, worktrees_root: &Path, id: &str) -> Result<Vec<String>> {
let resolved = resolve_id_path(runs, id)?;
let mut removed = Vec::new();
let run_dir = runs.join(&resolved);
if run_dir.exists() {
std::fs::remove_dir_all(&run_dir)
.with_context(|| format!("remove {}", run_dir.display()))?;
removed.push(format!("runs/{resolved}"));
}
let wt = worktrees_root.join(short_of(&resolved));
if wt.exists() {
crate::git::remove_worktree_from_linked(&wt).await;
for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
crate::git::remove_worktree_from_linked(&e.path()).await;
}
std::fs::remove_dir_all(&wt).with_context(|| format!("remove {}", wt.display()))?;
removed.push(wt.to_string_lossy().into_owned());
}
Ok(removed)
}
pub async fn fold_orphaned_worktrees(
runs: &Path,
worktrees_root: &Path,
home: &Path,
grace_secs: u64,
now: Timestamp,
) -> usize {
let known: std::collections::HashSet<String> = std::fs::read_dir(runs)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| crate::run::is_run_id(name))
.map(|id| short_of(&id).to_owned())
.collect();
let mut folded = 0usize;
for entry in std::fs::read_dir(worktrees_root)
.into_iter()
.flatten()
.flatten()
{
if !entry.path().is_dir() {
continue;
}
let short = entry.file_name().to_string_lossy().into_owned();
if !looks_like_a_worktree_bay(&short) {
continue;
}
if known.contains(&short) || crate::daemon::is_working_on_short(home, &short, now) {
continue;
}
let wt = entry.path();
if !stale_enough(&wt, grace_secs, now) {
continue;
}
crate::git::remove_worktree_from_linked(&wt).await;
for e in std::fs::read_dir(&wt).into_iter().flatten().flatten() {
crate::git::remove_worktree_from_linked(&e.path()).await;
}
match std::fs::remove_dir_all(&wt) {
Ok(()) => folded += 1,
Err(e) => tracing::warn!(
"housekeep: remove orphaned worktree {}: {e:#}",
wt.display()
),
}
}
folded
}
fn looks_like_a_worktree_bay(name: &str) -> bool {
name.len() == 4 && name.bytes().all(|b| b.is_ascii_alphanumeric())
}
fn stale_enough(dir: &Path, grace_secs: u64, now: Timestamp) -> bool {
let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) else {
return false;
};
let Ok(ts) = Timestamp::try_from(modified) else {
return false;
};
due(now, ts, grace_secs.max(MIN_ORPHAN_AGE_SECS))
}
const MIN_ORPHAN_AGE_SECS: u64 = 5 * 60;
fn resolve_id_path(runs: &Path, prefix: &str) -> Result<String> {
if runs.join(prefix).is_dir() && crate::run::is_run_id(prefix) {
return Ok(prefix.to_owned());
}
let mut hits: Vec<String> = Vec::new();
for e in std::fs::read_dir(runs).into_iter().flatten().flatten() {
if !e.path().is_dir() {
continue;
}
let id = e.file_name().to_string_lossy().into_owned();
if crate::run::is_run_id(&id) && (id.starts_with(prefix) || id.ends_with(prefix)) {
hits.push(id);
}
}
match hits.len() {
1 => Ok(hits.into_iter().next().expect("exactly one hit")),
0 => bail!("no run matches `{prefix}`"),
_ => bail!(
"`{prefix}` matches {} runs: {}",
hits.len(),
hits.join(", ")
),
}
}
pub fn clear_abandoned_active(state: &mut RunState, home: &Path, now: Timestamp) -> Result<bool> {
if crate::daemon::is_working_on(home, &state.id, now) || !state.active_all_overrun(now) {
return Ok(false);
}
state.abandon("fold");
state.save_under(home)?;
if let Err(e) = Questions::at(home.join("questions")).settle_run(&state.id, state.status) {
tracing::warn!("abandon questions for {}: {e:#}", state.id);
}
Ok(true)
}
pub fn prune_cache(cache: &Path, limit_bytes: u64) -> Result<Prune> {
prune_dir(cache, limit_bytes)
}
pub fn cache_report(cfg: &crate::config::Config) -> Option<(PathBuf, u64, u64)> {
let cache = cfg.cache_dir()?;
Some((
cache.clone(),
cache_size(&cache),
cfg.disk.cache_limit_bytes,
))
}
pub fn cache_size(cache: &Path) -> u64 {
dir_size(cache)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Disk;
use std::fs;
fn ts(s: &str) -> Timestamp {
s.parse().expect("rfc3339")
}
fn block_on<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Runtime::new().expect("runtime").block_on(f)
}
#[test]
fn a_run_is_due_after_its_grace_and_not_before() {
let now = ts("2026-09-05T00:00:00Z");
let grace = 600;
let old = now - SignedDuration::new(601, 0);
let fresh = now - SignedDuration::new(599, 0);
assert!(due(now, old, grace));
assert!(!due(now, fresh, grace));
let edge = now - SignedDuration::new(600, 0);
assert!(!due(now, edge, grace));
assert!(due(now, old, 0));
}
#[test]
fn the_meta_reader_is_tolerant_of_everything_except_the_deciders() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let id = "20260905-000000-abcd";
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(
runs.join(id).join("run.json"),
r#"{"schema": 99, "id": "20260905-000000-abcd", "updated_at": "2026-09-05T00:00:00Z", "status": "ready", "junk_from_another_build": [1, 2, 3]}"#,
)
.unwrap();
let meta = read_meta(&runs, id).expect("readable");
assert_eq!(meta.status, RunStatus::Ready);
assert_eq!(meta.updated_at, ts("2026-09-05T00:00:00Z"));
assert!(read_meta(&runs, "nope").is_err(), "missing file unreadable");
std::fs::write(runs.join(id).join("run.json"), "not json at all").unwrap();
assert!(read_meta(&runs, id).is_err(), "garbage unreadable");
}
#[test]
fn fold_unreadable_releases_run_dir_and_worktrees() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let id = "20260905-000000-abcd";
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
std::fs::create_dir_all(wt.join("abcd")).unwrap();
std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold");
assert_eq!(removed.len(), 2);
assert!(!runs.join(id).exists(), "run dir gone");
assert!(!wt.join("abcd").exists(), "worktrees gone");
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json"), "garbage").unwrap();
std::fs::create_dir_all(wt.join("abcd")).unwrap();
std::fs::write(wt.join("abcd").join("leftover"), b"x").unwrap();
let removed = block_on(fold_unreadable(&runs, &wt, "20260905")).expect("by prefix");
assert_eq!(removed.len(), 2);
assert!(
block_on(fold_unreadable(&runs, &wt, id)).is_err(),
"a run already gone cannot be resolved again"
);
}
#[test]
fn prune_cache_sheds_the_oldest_generation_until_it_fits() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("old"), b"xx").unwrap();
fs::write(dir.path().join("new"), b"yy").unwrap();
touch(&dir.path().join("old"), 1_000_000);
touch(&dir.path().join("new"), 2_000_000);
let out = prune_cache(dir.path(), 2).expect("prune");
assert_eq!(out.files, 1, "one deletion is enough to reach the cap");
assert_eq!(out.remaining, 2);
assert!(!dir.path().join("old").exists(), "the older file went");
assert!(dir.path().join("new").exists(), "the newer one stayed");
let tied = tempfile::tempdir().unwrap();
fs::write(tied.path().join("big"), b"xxxx").unwrap();
fs::write(tied.path().join("small"), b"yy").unwrap();
touch(&tied.path().join("big"), 1_000_000);
touch(&tied.path().join("small"), 1_000_000);
let out = prune_cache(tied.path(), 2).expect("prune");
assert_eq!(out.files, 1, "the big one alone gets under the cap");
assert_eq!(out.remaining, 2);
assert!(tied.path().join("small").exists());
}
fn touch(path: &Path, secs: u64) {
let f = fs::File::options().write(true).open(path).unwrap();
f.set_times(fs::FileTimes::new().set_modified(
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs),
))
.unwrap();
}
#[test]
fn fold_unreadable_clears_a_run_whose_state_never_landed() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let id = "20260904-014540-88c0";
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
let removed = block_on(fold_unreadable(&runs, &wt, id)).expect("fold by id");
assert_eq!(removed, vec![format!("runs/{id}")]);
assert!(!runs.join(id).exists(), "record gone");
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(runs.join(id).join("run.json.tmp"), b"").unwrap();
assert!(
block_on(fold_unreadable(&runs, &wt, "88c0")).is_ok(),
"by prefix"
);
std::fs::create_dir_all(runs.join("scratch")).unwrap();
assert!(
block_on(fold_unreadable(&runs, &wt, "scratch")).is_err(),
"a stray directory is not a run"
);
}
#[test]
fn fold_due_folds_terminal_runs_of_any_schema_but_leaves_genuinely_unreadable_ones() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let home = dir.path().to_path_buf();
let disk = Disk::default();
let now = ts("2026-09-05T00:00:00Z");
crate::run::set_home(dir.path().to_path_buf());
let judging = "20260801-000000-0001";
write_meta(&runs, judging, "judging", "2026-08-01T00:00:00Z");
let ready_fresh = "20260904-220000-0002";
write_meta(&runs, ready_fresh, "ready", "2026-09-04T22:00:00Z");
let garbage = "20260901-000000-0004";
std::fs::create_dir_all(runs.join(garbage)).unwrap();
std::fs::write(runs.join(garbage).join("run.json"), "not json").unwrap();
std::fs::create_dir_all(wt.join("0004")).unwrap();
let due_ready = due_run(&runs, "20260801-000000-ffff", SCHEMA);
let due_old_schema = due_run(&runs, "20260801-000000-eeee", SCHEMA - 1);
let (folded, unreadable) =
block_on(fold_due(&runs, &home, &wt, &disk, now)).expect("fold_due");
assert_eq!(
folded, 2,
"both due, parseable runs fold regardless of their schema number"
);
assert_eq!(
unreadable, 1,
"only the run with broken JSON counts as unreadable"
);
assert!(runs.join(judging).exists(), "runnable never folded");
assert!(runs.join(ready_fresh).exists(), "fresh never folded");
assert!(runs.join(garbage).exists(), "unreadable record kept");
assert!(wt.join("0004").exists(), "unreadable worktree kept");
assert!(
runs.join(&due_ready).exists(),
"folding drops worktrees, not the record"
);
assert!(
runs.join(&due_old_schema).exists(),
"an old-schema record survives its fold exactly like a current one"
);
}
#[test]
fn fold_orphaned_worktrees_removes_only_worktrees_no_run_claims_and_none_in_flight() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let home = dir.path().to_path_buf();
write_meta(
&runs,
"20260801-000000-aaaa",
"ready",
"2026-08-01T00:00:00Z",
);
std::fs::create_dir_all(wt.join("aaaa").join("cand-A")).unwrap();
std::fs::create_dir_all(wt.join("bbbb").join("cand-A")).unwrap();
std::fs::create_dir_all(wt.join("cccc")).unwrap();
std::fs::create_dir_all(wt.join("scratch")).unwrap();
let now = Timestamp::now() + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
let mut status = crate::daemon::Status::new();
status.current = vec![crate::daemon::Current {
task: "20260905-000000-t111".to_owned(),
run: "20260905-000000-cccc".to_owned(),
}];
status.updated_at = now;
crate::daemon::write_status_to(&home.join("daemon.json"), &status).unwrap();
let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
assert_eq!(
folded, 1,
"only the truly orphaned, idle, bay-shaped worktree is removed"
);
assert!(wt.join("aaaa").exists(), "claimed by a run record");
assert!(!wt.join("bbbb").exists(), "orphaned and idle: reclaimed");
assert!(wt.join("cccc").exists(), "a run in flight is never touched");
assert!(
wt.join("scratch").exists(),
"not shaped like a worktree bay, so never a reclaim target"
);
}
#[test]
fn fold_orphaned_worktrees_leaves_a_freshly_created_bay_alone() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let home = dir.path().to_path_buf();
std::fs::create_dir_all(wt.join("dddd").join("under-review")).unwrap();
let now = Timestamp::now();
let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 6 * 60 * 60, now));
assert_eq!(
folded, 0,
"too fresh to tell apart from a run still being set up"
);
assert!(wt.join("dddd").exists());
}
fn open_question(store: &Questions, run: &str) -> crate::ask::Question {
let mut q = crate::ask::Question::new(
run.to_owned(),
"implement".to_owned(),
"impl-A".to_owned(),
"Which storage backend should the cache use?".to_owned(),
String::new(),
vec!["SQLite".to_owned(), "Redis".to_owned()],
);
store.put(&mut q).unwrap();
q
}
#[test]
fn a_finished_runs_open_question_is_swept_up() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let store = Questions::at(dir.path().join("questions"));
let failed = "20260908-205802-c9eb";
write_meta(&runs, failed, "failed", "2026-09-08T20:58:02Z");
let failed_q = open_question(&store, failed);
let merged = "20260908-205501-ca67";
write_meta(&runs, merged, "merged", "2026-09-08T20:55:01Z");
let merged_q = open_question(&store, merged);
let n = abandon_settled_questions(&store, &runs);
assert_eq!(n, 2, "both dead runs' questions are swept in one pass");
for (id, run) in [(&failed_q.id, failed), (&merged_q.id, merged)] {
let back = store.get(id).unwrap();
assert!(!back.status.open(), "{run} is done; nobody reads an answer");
assert!(back.detail.contains(run), "{}", back.detail);
}
}
#[test]
fn a_still_alive_runs_open_question_survives_the_sweep() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let store = Questions::at(dir.path().join("questions"));
for (id, status) in [
("20260908-000000-b10c", "blocked"),
("20260908-000000-5ta1", "stalled"),
("20260908-000000-jud6", "judging"),
] {
write_meta(&runs, id, status, "2026-09-08T00:00:00Z");
let q = open_question(&store, id);
let n = abandon_settled_questions(&store, &runs);
assert_eq!(n, 0, "{status} run is not done; nothing to sweep");
assert!(
store.get(&q.id).unwrap().status.open(),
"{status} run's question must still be waiting"
);
}
}
#[test]
fn the_sweep_leaves_an_answered_question_and_an_unreadable_run_alone() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let store = Questions::at(dir.path().join("questions"));
let done = "20260908-000000-answ";
write_meta(&runs, done, "failed", "2026-09-08T00:00:00Z");
let mut answered = open_question(&store, done);
answered
.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
.unwrap();
store.put(&mut answered).unwrap();
let gone = "20260908-000000-gone";
let orphan = open_question(&store, gone);
assert_eq!(abandon_settled_questions(&store, &runs), 0);
assert_eq!(
store.get(&answered.id).unwrap().status,
crate::ask::QuestionStatus::Answered,
"a real answer is never overwritten by a sweep"
);
assert!(
store.get(&orphan.id).unwrap().status.open(),
"a run this sweep cannot read is left exactly as it was, not guessed at"
);
}
#[test]
fn fold_orphaned_worktrees_floors_a_zero_grace_at_the_race_safe_minimum() {
let dir = tempfile::tempdir().unwrap();
let runs = dir.path().join("runs");
let wt = dir.path().join("wt");
let home = dir.path().to_path_buf();
std::fs::create_dir_all(wt.join("eeee").join("under-review")).unwrap();
let now = Timestamp::now();
let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, now));
assert_eq!(
folded, 0,
"a zero grace must not defeat the race-safety floor"
);
assert!(wt.join("eeee").exists());
let later = now + SignedDuration::new((MIN_ORPHAN_AGE_SECS + 1) as i64, 0);
let folded = block_on(fold_orphaned_worktrees(&runs, &wt, &home, 0, later));
assert_eq!(folded, 1, "old enough now, regardless of the zero grace");
assert!(!wt.join("eeee").exists());
}
#[test]
fn clear_abandoned_active_only_acts_once_dead_and_overrun() {
let dir = tempfile::tempdir().unwrap();
crate::run::set_home(dir.path().to_path_buf());
let home = dir.path().to_path_buf();
let now = ts("2026-09-14T12:00:00Z");
let overrun_seat = || crate::run::ActiveSeat {
node: "implement".to_owned(),
started_at: now - SignedDuration::new(21_000, 0),
timeout_secs: 3_600,
attempt: 0,
};
let mut state = RunState::new(
PathBuf::from("/repo"),
"main".to_owned(),
"abc1234".to_owned(),
"fixture".to_owned(),
crate::config::Config::default(),
);
state.status = RunStatus::Implementing;
state.active.insert("impl-A".to_owned(), overrun_seat());
let mut fresh = state.clone();
fresh.active.insert(
"impl-B".to_owned(),
crate::run::ActiveSeat {
node: "implement".to_owned(),
started_at: now,
timeout_secs: 3_600,
attempt: 0,
},
);
assert!(!clear_abandoned_active(&mut fresh, &home, now).unwrap());
assert!(!fresh.active.is_empty());
assert_eq!(fresh.status, RunStatus::Implementing);
let store = Questions::at(home.join("questions"));
let q = open_question(&store, &state.id);
assert!(clear_abandoned_active(&mut state, &home, now).unwrap());
assert!(state.active.is_empty());
assert_eq!(state.status, RunStatus::Failed);
assert!(
!store.get(&q.id).unwrap().status.open(),
"the abandoned seat's own open question must not keep badging the \
operator until some later daemon startup notices it"
);
}
fn write_meta(runs: &Path, id: &str, status: &str, updated_at: &str) {
let day = &updated_at[..10];
std::fs::create_dir_all(runs.join(id)).unwrap();
let body = format!(
r#"{{"schema": {SCHEMA}, "id": "{id}", "repo": "/nonexistent/repo", "base_branch": "main", "base_commit": "0000000000000000000000000000000000000000", "instruction": "", "created_at": "{day}T00:00:00Z", "updated_at": "{updated_at}", "status": "{status}", "seed": 1}}"#
);
std::fs::write(runs.join(id).join("run.json"), body).unwrap();
}
fn due_run(runs: &Path, id: &str, schema: u32) -> String {
let mut state = RunState::new(
PathBuf::from("/nonexistent/repo"),
"main".to_owned(),
"0000000000000000000000000000000000000000".to_owned(),
String::new(),
crate::config::Config::default(),
);
state.id = id.to_owned();
state.status = RunStatus::Ready;
state.updated_at = ts("2026-08-01T00:00:00Z");
let mut value = serde_json::to_value(&state).unwrap();
value["schema"] = serde_json::json!(schema);
std::fs::create_dir_all(runs.join(id)).unwrap();
std::fs::write(
runs.join(id).join("run.json"),
serde_json::to_string_pretty(&value).unwrap(),
)
.unwrap();
id.to_owned()
}
}