sloop-daemon 0.5.1

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

use std::fs;
use std::time::Duration;

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

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

fn configure_process_tree_agent(world: &World) {
    let flow_directory = world.root().join(".agents/sloop/flows");
    fs::create_dir_all(&flow_directory).unwrap();
    fs::write(
        flow_directory.join("default.yaml"),
        "stages:\n  - { name: build, action: agent, result_check: { exec: ['true'] } }\n  - { name: merge, action: { builtin: merge } }\n",
    )
    .unwrap();
    let script = world.root().join("fake-agent.sh");
    fs::write(
        &script,
        "#!/bin/sh\nset -eu\nsleep 1000 &\necho $! > grandchild.pid\necho initial\nwhile true; do sleep 1; done\n",
    )
    .unwrap();
    fs::write(
        world.root().join(".agents/sloop/config.yaml"),
        format!(
            "version: 1\nscheduler:\n  stall_report_after: 10s\n  stall_after: 30s\nagent:\n  default_target: fake\n  targets:\n    fake:\n      cmd: [\"sh\", {}, \"{{prompt}}\"]\n",
            serde_json::to_string(&script.to_string_lossy()).unwrap(),
        ),
    )
    .unwrap();
}

#[test]
fn silent_agent_is_failed_with_stall_evidence_and_its_process_group_is_killed() {
    let world = World::configured();
    configure_process_tree_agent(&world);
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "silent-agent.md");

    let pid_file = world.run_worktree(1).join("grandchild.pid");
    wait_until("the agent records its grandchild", || pid_file.is_file());
    let run_id = world.run_id(1);
    let grandchild = fs::read_to_string(&pid_file)
        .unwrap()
        .trim()
        .parse::<u32>()
        .unwrap();
    assert!(process_alive(grandchild));

    world.tick(Duration::from_secs(30));
    wait_until_slow("the watchdog settles the run", || {
        world.run_state(&run_id) == "failed"
    });
    wait_until("the grandchild exits with the process group", || {
        !process_alive(grandchild)
    });

    let evidence = world
        .run_evidence(&run_id, "output_stall")
        .expect("stall evidence");
    assert_eq!(evidence["stage"], "build");
    assert_eq!(evidence["threshold_ms"], 30_000);
    assert!(evidence["last_output_at_ms"].is_i64());
    let shown = world.sloop_plain(&["show", &world.run_alias(1)]);
    assert!(shown.status.success());
    assert!(String::from_utf8_lossy(&shown.stdout).contains("failed (stalled: no output for 30s)"));
}

#[test]
fn resumed_output_moves_the_kill_deadline() {
    let world = World::configured();
    world.configure_fake_agent_with_stall_thresholds(
        FakeAgent::new()
            .output("initial\n")
            .block_until_released("first-silence")
            .output("resumed\n")
            .block_until_released("second-silence")
            .exit(0),
        "10s",
        "30s",
    );
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "resumed-output.md");
    wait_until("the first silence starts", || {
        world.fake_agent_reached("first-silence")
    });
    let run_id = world.run_id(1);

    world.tick(Duration::from_secs(20));
    world.release("first-silence");
    wait_until("the resumed output is written", || {
        world.fake_agent_reached("second-silence")
    });
    world.tick(Duration::from_secs(20));
    assert_eq!(world.run_state(&run_id), "running");
    assert!(world.run_evidence(&run_id, "output_stall").is_none());

    world.release("second-silence");
    wait_until_slow("the run merges", || world.run_state(&run_id) == "merged");
}

#[test]
fn restart_rederives_an_overdue_stall_and_fails_the_run() {
    let world = World::configured();
    world.configure_fake_agent_with_stall_thresholds(
        FakeAgent::new()
            .output("before restart\n")
            .block_until_released("restart-silence"),
        "10s",
        "30s",
    );
    world.commit_all("initial");
    let daemon = world.start_daemon();
    post_and_run(&world, "restart-stall.md");
    wait_until("the agent blocks", || {
        world.fake_agent_reached("restart-silence")
    });
    let run_id = world.run_id(1);

    world.tick(Duration::from_secs(20));
    world.kill_daemon(daemon["data"]["pid"].as_u64().unwrap() as u32);
    world.tick(Duration::from_secs(10));
    world.start_daemon();

    wait_until_slow("restart settles the stalled run", || {
        world.run_state(&run_id) == "failed"
    });
    assert!(world.run_evidence(&run_id, "output_stall").is_some());
}

#[test]
fn explicit_cancellation_remains_cancelled() {
    let world = World::configured();
    world.configure_fake_agent_with_stall_thresholds(
        FakeAgent::new().block_until_released("cancelled-agent"),
        "10s",
        "30s",
    );
    world.commit_all("initial");
    world.start_daemon();
    post_and_run(&world, "cancelled-agent.md");
    wait_until("the agent blocks", || {
        world.fake_agent_reached("cancelled-agent")
    });
    let run_id = world.run_id(1);
    assert!(world.sloop(&["cancel", &run_id]).status.success());

    wait_until_slow("the cancelled run settles", || {
        world.run_state(&run_id) == "cancelled"
    });
    assert!(world.run_evidence(&run_id, "cancel_requested").is_some());
    assert!(world.run_evidence(&run_id, "output_stall").is_none());
}