sloop-daemon 0.5.0

Agentic coding scheduler — a daemon that runs background coding agents autonomously in isolated git worktrees
Documentation
mod support;

use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::Duration;

use rusqlite::Connection;

use support::{FakeAgent, World, wait_until};

fn configure(world: &World, retention: &str, agent: FakeAgent) {
    world.configure_fake_agent(agent);
    let path = world.root().join(".agents/sloop/config.yaml");
    let config = fs::read_to_string(&path).unwrap();
    fs::write(
        path,
        config.replacen(
            "version: 1\n",
            &format!("version: 1\nworktree_retention: {retention}\n"),
            1,
        ),
    )
    .unwrap();
}

fn post_and_run(world: &World, name: &str) -> String {
    let ticket_path = world.write_ticket(name, "# Retention test\n");
    let posted = world.sloop(&["post", ticket_path.to_str().unwrap(), "--manual"]);
    assert!(
        posted.status.success(),
        "{}",
        String::from_utf8_lossy(&posted.stderr)
    );
    let ticket = World::json_stdout(&posted)["data"]["ticket"]["id"]
        .as_str()
        .unwrap()
        .to_owned();
    let run = world.sloop(&["run", &ticket]);
    assert!(
        run.status.success(),
        "{}",
        String::from_utf8_lossy(&run.stderr)
    );
    ticket
}

fn run_value<T: rusqlite::types::FromSql>(world: &World, column: &str) -> T {
    let connection = Connection::open(world.db_path()).unwrap();
    connection
        .query_row(
            &format!("SELECT {column} FROM runs ORDER BY created_at_ms LIMIT 1"),
            [],
            |row| row.get(0),
        )
        .unwrap()
}

fn branch_exists(root: &Path, branch: &str) -> bool {
    Command::new("git")
        .args([
            "show-ref",
            "--verify",
            "--quiet",
            &format!("refs/heads/{branch}"),
        ])
        .current_dir(root)
        .status()
        .unwrap()
        .success()
}

fn trigger_reconcile(world: &World) {
    assert!(world.sloop(&["status"]).status.success());
}

fn wait_for_merged(world: &World) {
    wait_until("run settles as merged", || {
        let connection = Connection::open(world.db_path()).unwrap();
        connection
            .query_row("SELECT state = 'merged' FROM runs LIMIT 1", [], |row| {
                row.get(0)
            })
            .unwrap_or(false)
    });
}

#[test]
fn merged_worktree_and_branch_expire_after_retention() {
    let world = World::configured();
    configure(&world, "1h", FakeAgent::new().commit("work").exit(0));
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "expire.md");
    wait_for_merged(&world);
    let worktree = world.run_worktree(1);
    let branch: String = run_value(&world, "branch");

    world.tick(Duration::from_secs(59 * 60));
    trigger_reconcile(&world);
    assert!(worktree.exists());
    assert!(branch_exists(world.root(), &branch));

    world.tick(Duration::from_secs(2 * 60));
    // `cleaned_at_ms` is recorded only after both the worktree and the branch
    // are removed; waiting on the worktree alone races the branch deletion.
    wait_until("expired worktree cleanup is recorded", || {
        run_value::<Option<i64>>(&world, "cleaned_at_ms").is_some()
    });
    assert!(!worktree.exists());
    assert!(!branch_exists(world.root(), &branch));
    let connection = Connection::open(world.db_path()).unwrap();
    let events: i64 = connection
        .query_row(
            "SELECT COUNT(*) FROM events WHERE kind = 'run_worktree_cleaned'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert_eq!(events, 1);
}

#[test]
fn never_retains_settled_worktrees() {
    let world = World::configured();
    configure(&world, "never", FakeAgent::new().commit("work").exit(0));
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "never.md");
    wait_for_merged(&world);
    let worktree = world.run_worktree(1);

    world.tick(Duration::from_secs(365 * 24 * 60 * 60));
    trigger_reconcile(&world);
    assert!(worktree.exists());
    assert!(run_value::<Option<i64>>(&world, "cleaned_at_ms").is_none());
}

#[test]
fn failed_worktree_expires_only_after_retry_resolves_it() {
    let world = World::configured();
    configure(&world, "1h", FakeAgent::new().exit(1));
    world.commit_all("initial");
    world.start_daemon();
    let ticket = post_and_run(&world, "retry.md");
    wait_until("first run fails", || {
        let connection = Connection::open(world.db_path()).unwrap();
        connection
            .query_row("SELECT state = 'failed' FROM runs LIMIT 1", [], |row| {
                row.get(0)
            })
            .unwrap_or(false)
    });
    let failed_worktree = world.run_worktree(1);

    world.tick(Duration::from_secs(365 * 24 * 60 * 60));
    trigger_reconcile(&world);
    assert!(failed_worktree.exists());

    fs::write(
        world.root().join("fake-agent.sh"),
        "#!/bin/sh\nset -eu\ngit -c user.name=sloop-test-agent -c user.email=sloop-test-agent@example.invalid commit --quiet --allow-empty -m replacement\n",
    )
    .unwrap();
    assert!(world.sloop(&["retry", &ticket]).status.success());
    assert!(world.sloop(&["run", &ticket]).status.success());
    wait_until("replacement run merges", || {
        let connection = Connection::open(world.db_path()).unwrap();
        connection
            .query_row(
                "SELECT COUNT(*) = 1 FROM runs WHERE state = 'merged'",
                [],
                |row| row.get(0),
            )
            .unwrap_or(false)
    });
    world.tick(Duration::from_secs(61 * 60));
    wait_until("failed worktree expires from retry time", || {
        !failed_worktree.exists()
    });
}

#[test]
fn manually_removed_worktree_is_pruned_and_recorded_before_expiry() {
    let world = World::configured();
    configure(&world, "7d", FakeAgent::new().commit("work").exit(0));
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "manual.md");
    wait_for_merged(&world);
    let worktree = world.run_worktree(1);
    let branch: String = run_value(&world, "branch");
    fs::remove_dir_all(&worktree).unwrap();

    trigger_reconcile(&world);
    wait_until("manual cleanup is recorded", || {
        run_value::<Option<i64>>(&world, "cleaned_at_ms").is_some()
    });
    assert!(!branch_exists(world.root(), &branch));
}

#[test]
fn active_worktree_is_never_removed() {
    let world = World::configured();
    configure(
        &world,
        "1s",
        FakeAgent::new()
            .block_until_released("active")
            .commit("work")
            .exit(0),
    );
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "active.md");
    wait_until("agent is active", || world.fake_agent_reached("active"));
    let worktree = world.run_worktree(1);

    world.tick(Duration::from_secs(30 * 24 * 60 * 60));
    trigger_reconcile(&world);
    assert!(worktree.exists());
    world.release("active");
}

#[test]
fn restart_after_expiry_cleans_on_startup_reconcile() {
    let world = World::configured();
    configure(&world, "1h", FakeAgent::new().commit("work").exit(0));
    world.commit_all("initial");
    let started = world.start_daemon();
    let pid = started["data"]["pid"].as_u64().unwrap() as u32;
    post_and_run(&world, "restart.md");
    wait_for_merged(&world);
    let worktree = world.run_worktree(1);
    assert!(world.sloop(&["stop"]).status.success());
    wait_until("daemon stops", || {
        !Path::new(&world.operator_socket()).exists()
    });
    world.tick(Duration::from_secs(2 * 60 * 60));

    let restarted = world.start_daemon();
    assert_ne!(restarted["data"]["pid"].as_u64().unwrap() as u32, pid);
    wait_until("startup reconcile cleans expired worktree", || {
        !worktree.exists()
    });
}

#[test]
fn invalid_retention_prevents_daemon_startup_with_a_remedy() {
    let world = World::configured();
    let path = world.root().join(".agents/sloop/config.yaml");
    fs::write(&path, "version: 1\nworktree_retention: eventually\n").unwrap();

    let output = world.sloop(&["daemon"]);
    assert!(!output.status.success());
    let error = String::from_utf8_lossy(&output.stderr);
    assert!(error.contains("worktree_retention"), "{error}");
    assert!(error.contains("use a positive duration"), "{error}");
    assert!(error.contains("never"), "{error}");
}