use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::domain::{Task, TaskState};
use crate::hook::{self, Delivery, Receiver};
use crate::launcher::Launcher;
use crate::notify::{Notification, Notify};
use crate::scan::Scanner;
use crate::scheduler::{Scheduler, Tick};
use crate::store::{Store, Transition};
use crate::tmux::{self, Tmux};
use crate::worktree::WorktreeManager;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Store(#[from] crate::store::Error),
#[error(transparent)]
Hook(#[from] hook::Error),
#[error(transparent)]
Scheduler(#[from] crate::scheduler::Error),
#[error(transparent)]
Scan(#[from] crate::scan::Error),
#[error(transparent)]
Tmux(#[from] tmux::Error),
#[error("could not start a daemon: {0}")]
Spawn(#[source] std::io::Error),
#[error("a daemon was started but never began listening; see {}", .log.display())]
NeverListened { log: PathBuf },
#[error("could not stop the daemon: {0}")]
Stop(String),
#[error("the daemon was signalled but still held the socket after {0:?}")]
StillRunning(Duration),
}
pub type Result<T> = std::result::Result<T, Error>;
pub const DEFAULT_TICK: Duration = Duration::from_secs(2);
pub const DEFAULT_SCAN_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct Config {
pub data_dir: PathBuf,
pub db: PathBuf,
pub socket: PathBuf,
pub log: PathBuf,
pub version_file: PathBuf,
pub pid_file: PathBuf,
pub workspace_root: PathBuf,
pub scan_root: PathBuf,
pub marver_bin: PathBuf,
pub cap: usize,
pub tick: Duration,
pub scan_interval: Duration,
}
impl Config {
pub fn new(data_dir: impl AsRef<Path>, scan_root: impl Into<PathBuf>) -> Self {
let data_dir = data_dir.as_ref();
Self {
data_dir: data_dir.to_path_buf(),
db: data_dir.join("marver.db"),
socket: data_dir.join("marverd.sock"),
log: data_dir.join("daemon.log"),
version_file: data_dir.join("daemon.version"),
pid_file: data_dir.join("daemon.pid"),
workspace_root: data_dir.join("tasks"),
scan_root: scan_root.into(),
marver_bin: std::env::current_exe().unwrap_or_else(|_| PathBuf::from("marver")),
cap: crate::scheduler::DEFAULT_CAP,
tick: DEFAULT_TICK,
scan_interval: DEFAULT_SCAN_INTERVAL,
}
}
pub fn default_data_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
return PathBuf::from(xdg).join("marver");
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".local/share/marver")
}
pub fn default_scan_root() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join("workspace")
}
}
const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
const STARTUP_POLL: Duration = Duration::from_millis(25);
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
pub const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(2);
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Startup {
AlreadyRunning,
Outdated { running: String },
Started { pid: u32 },
}
pub fn is_running(config: &Config) -> bool {
hook::is_listening(&config.socket)
}
pub fn running_version(config: &Config) -> Option<String> {
let recorded = std::fs::read_to_string(&config.version_file).ok()?;
let recorded = recorded.trim();
(!recorded.is_empty()).then(|| recorded.to_string())
}
pub fn announced_version(config: &Config, pid: u32) -> Option<String> {
let deadline = Instant::now() + ANNOUNCE_TIMEOUT;
loop {
if running_pid(config) == Some(pid) {
return running_version(config);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(STARTUP_POLL);
}
}
fn record_version(config: &Config) {
if let Some(parent) = config.version_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&config.version_file, format!("{VERSION}\n"));
let _ = std::fs::write(&config.pid_file, format!("{}\n", std::process::id()));
}
pub fn running_pid(config: &Config) -> Option<u32> {
if !is_running(config) {
return None;
}
std::fs::read_to_string(&config.pid_file)
.ok()?
.trim()
.parse()
.ok()
}
fn is_marver_daemon(pid: u32) -> bool {
let Ok(out) = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
else {
return false;
};
String::from_utf8_lossy(&out.stdout).contains("marver daemon")
}
fn search_for_daemon(config: &Config) -> Result<u32> {
let scoped = format!("marver daemon --data-dir {}", config.data_dir.display());
if let [pid] = matching(&scoped)?.as_slice() {
return Ok(*pid);
}
match matching("marver daemon")?.as_slice() {
[pid] => Ok(*pid),
[] => Err(Error::Stop(
"a daemon is listening but no process matches it; stop it by hand".to_string(),
)),
many => Err(Error::Stop(format!(
"{} marver daemons are running and none of them left a pid file; \
stop the right one by hand",
many.len()
))),
}
}
fn matching(pattern: &str) -> Result<Vec<u32>> {
let out = std::process::Command::new("pgrep")
.args(["-f", pattern])
.output()
.map_err(|err| Error::Stop(format!("could not search for the daemon: {err}")))?;
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| line.trim().parse().ok())
.filter(|&pid| pid != std::process::id())
.collect())
}
pub fn stop(config: &Config) -> Result<bool> {
if !is_running(config) {
return Ok(false);
}
let pid = match running_pid(config) {
Some(pid) => pid,
None => search_for_daemon(config)?,
};
if !is_marver_daemon(pid) {
return Err(Error::Stop(format!(
"pid {pid} is not a marver daemon; refusing to signal it"
)));
}
let killed = std::process::Command::new("kill")
.arg(pid.to_string())
.status()
.map_err(|err| Error::Stop(err.to_string()))?;
if !killed.success() {
return Err(Error::Stop(format!("could not signal pid {pid}")));
}
let deadline = Instant::now() + STOP_TIMEOUT;
while Instant::now() < deadline {
if !is_running(config) {
return Ok(true);
}
std::thread::sleep(STARTUP_POLL);
}
Err(Error::StillRunning(STOP_TIMEOUT))
}
pub fn ensure_running(config: &Config) -> Result<Startup> {
if is_running(config) {
return Ok(match running_version(config) {
Some(running) if running != VERSION => Startup::Outdated { running },
_ => Startup::AlreadyRunning,
});
}
if let Some(parent) = config.log.parent() {
std::fs::create_dir_all(parent).map_err(Error::Spawn)?;
}
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&config.log)
.map_err(Error::Spawn)?;
let child = std::process::Command::new(&config.marver_bin)
.arg("daemon")
.arg("--data-dir")
.arg(&config.data_dir)
.arg("--scan-root")
.arg(&config.scan_root)
.arg("--cap")
.arg(config.cap.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(
log.try_clone().map_err(Error::Spawn)?,
))
.stderr(std::process::Stdio::from(log))
.process_group(0)
.spawn()
.map_err(Error::Spawn)?;
let deadline = Instant::now() + STARTUP_TIMEOUT;
while Instant::now() < deadline {
if is_running(config) {
return Ok(Startup::Started { pid: child.id() });
}
std::thread::sleep(STARTUP_POLL);
}
Err(Error::NeverListened {
log: config.log.clone(),
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Pass {
pub started: Vec<i64>,
pub failed: Vec<(i64, String)>,
pub notified: Vec<Notification>,
pub reaped: Vec<i64>,
}
pub struct Daemon<N: Notify> {
store: Store,
receiver: Option<Receiver>,
scheduler: Scheduler,
launcher: Launcher,
notifier: N,
tmux: Tmux,
scanner: Scanner,
config: Config,
}
impl<N: Notify> Daemon<N> {
pub fn new(config: Config, tmux: Tmux, notifier: N) -> Result<Self> {
let store = Store::open(&config.db)?;
let receiver = Receiver::bind(&config.socket)?;
let launcher = Launcher::new(
tmux.clone(),
WorktreeManager::new(&config.workspace_root),
&config.marver_bin,
&config.socket,
);
Ok(Self {
store,
receiver: Some(receiver),
scheduler: Scheduler::new(config.cap),
launcher,
notifier,
tmux,
scanner: Scanner::new(&config.scan_root),
config,
})
}
pub fn store(&self) -> &Store {
&self.store
}
pub fn store_mut(&mut self) -> &mut Store {
&mut self.store
}
pub fn socket(&self) -> &Path {
&self.config.socket
}
pub fn reconcile(&mut self, now: DateTime<Utc>) -> Result<Vec<i64>> {
let mut orphaned = Vec::new();
for state in [TaskState::Running, TaskState::Blocked, TaskState::Paused] {
for task in self.store.list_tasks_in_state(state)? {
if state == TaskState::Paused && task.session_name.is_none() {
continue;
}
let session = task
.session_name
.clone()
.unwrap_or_else(|| tmux::session_name(task.id));
if self.tmux.session_exists(&session).map_err(|err| {
eprintln!("marverd: cannot reconcile, tmux is unavailable: {err}");
err
})? {
continue;
}
self.store.transition(
task.id,
TaskState::Failed,
Transition::Failed(
"the agent's tmux session was gone when marverd started".to_string(),
),
now,
)?;
orphaned.push(task.id);
}
}
Ok(orphaned)
}
pub fn scan(&mut self, now: DateTime<Utc>) -> Result<usize> {
Ok(self.scanner.sync(&self.store, now)?.present.len())
}
pub fn handle(
&mut self,
delivery: &Delivery,
now: DateTime<Utc>,
) -> Result<Option<Notification>> {
let outcome = hook::apply(&mut self.store, delivery, now)?;
self.absorb_usage(delivery);
match outcome {
hook::Outcome::Moved { .. } => {
let task = self.store.get_task(delivery.task_id)?;
Ok(self.announce(&task))
}
hook::Outcome::Recorded { .. } => Ok(None),
}
}
fn absorb_usage(&mut self, delivery: &Delivery) {
let Some(path) = delivery.payload.transcript_path.as_ref() else {
return;
};
let Ok(task) = self.store.get_task(delivery.task_id) else {
return;
};
if task.usage.transcript_path.as_deref() != Some(path.as_path()) {
let _ = self.store.set_transcript_path(task.id, path);
}
if let Ok(usage) = crate::usage::read_from(path, task.usage.transcript_offset) {
let _ = self.store.record_usage(task.id, &usage);
}
}
pub fn reap(&mut self, now: DateTime<Utc>) -> Result<Vec<i64>> {
let mut reaped = Vec::new();
for state in [TaskState::Cancelled, TaskState::Failed] {
for task in self.store.list_tasks_in_state(state)? {
let Some(session) = task.session_name.clone() else {
continue;
};
if !self.tmux.has_session(&session) {
continue;
}
if self.tmux.kill_session(&session).is_ok() {
self.store.clear_session_name(task.id, now)?;
reaped.push(task.id);
}
}
}
Ok(reaped)
}
pub fn tick(&mut self, now: DateTime<Utc>) -> Result<Pass> {
let reaped = self.reap(now)?;
let Tick {
started,
failed,
abandoned,
..
} = self.scheduler.tick(&mut self.store, &self.launcher, now)?;
for (id, state) in &abandoned {
eprintln!("marverd: task {id} became {state} while it was starting; its agent is live");
}
let mut notified = Vec::new();
for (id, _) in &failed {
if let Ok(task) = self.store.get_task(*id)
&& let Some(sent) = self.announce(&task)
{
notified.push(sent);
}
}
Ok(Pass {
started,
failed,
notified,
reaped,
})
}
fn announce(&self, task: &Task) -> Option<Notification> {
match crate::notify::for_transition(task) {
Some(notification) => match self.notifier.send(¬ification) {
Ok(()) => Some(notification),
Err(err) => {
eprintln!("marverd: could not notify: {err}");
None
}
},
None => None,
}
}
pub fn run(&mut self, shutdown: Arc<AtomicBool>) -> Result<()> {
record_version(&self.config);
let now = Utc::now();
let orphaned = self.reconcile(now)?;
if !orphaned.is_empty() {
eprintln!("marverd: failed {} orphaned task(s)", orphaned.len());
}
self.scan(now)?;
let receiver = self
.receiver
.take()
.expect("run may only be called once per daemon");
let (tx, rx) = channel();
std::thread::spawn(move || {
loop {
match receiver.accept() {
Ok(delivery) => {
if tx.send(delivery).is_err() {
break; }
}
Err(hook::Error::Probe) => {}
Err(err @ (hook::Error::Malformed(_) | hook::Error::Rejected(_))) => {
eprintln!("marverd: ignoring hook: {err}");
}
Err(err) => {
eprintln!("marverd: hook listener stopped: {err}");
break;
}
}
}
});
let mut last_scan = Instant::now();
while !shutdown.load(Ordering::Relaxed) {
match rx.recv_timeout(self.config.tick) {
Ok(delivery) => {
let now = Utc::now();
if let Err(err) = self.handle(&delivery, now) {
eprintln!("marverd: hook for task {}: {err}", delivery.task_id);
}
let _ = self.tick(now);
}
Err(RecvTimeoutError::Timeout) => {
let _ = self.tick(Utc::now());
}
Err(RecvTimeoutError::Disconnected) => break,
}
if last_scan.elapsed() >= self.config.scan_interval {
let _ = self.scan(Utc::now());
last_scan = Instant::now();
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{BlockedKind, Repo};
use crate::git::testing::init_repo;
use crate::hook::Payload;
use crate::notify::Notify;
use crate::tmux::testing::TestServer;
use serde_json::json;
use std::cell::RefCell;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).expect("valid timestamp")
}
#[derive(Default)]
struct Recorder {
sent: RefCell<Vec<Notification>>,
}
impl Notify for Recorder {
fn send(&self, notification: &Notification) -> std::result::Result<(), String> {
self.sent.borrow_mut().push(notification.clone());
Ok(())
}
}
struct Fixture {
_tmp: TempDir,
server: TestServer,
repos_dir: PathBuf,
daemon: Daemon<Recorder>,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let server = TestServer::new();
let repos_dir = tmp.path().join("repos");
std::fs::create_dir_all(&repos_dir).unwrap();
let mut config = Config::new(tmp.path(), &repos_dir);
config.cap = 2;
let mut daemon = Daemon::new(config, server.tmux.clone(), Recorder::default()).unwrap();
daemon.launcher = Launcher::new(
server.tmux.clone(),
WorktreeManager::new(tmp.path().join("tasks")),
PathBuf::from("/bin/marver"),
tmp.path().join("marverd.sock"),
)
.agent(crate::launcher::testing::stub_agent(tmp.path()));
Self {
repos_dir,
daemon,
server,
_tmp: tmp,
}
}
fn repo(&self, name: &str) -> Repo {
let path = self.repos_dir.join(name);
init_repo(&path, "main");
self.daemon.store.upsert_repo(&path, name, at(0)).unwrap()
}
fn queue(&mut self, title: &str, repos: &[Repo]) -> Task {
let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
let root = self.daemon.config.workspace_root.clone();
self.daemon
.store
.create_task(title, "do it", &root, &ids, at(0))
.unwrap()
}
fn notifications(&self) -> Vec<Notification> {
self.daemon.notifier.sent.borrow().clone()
}
}
fn delivery(task_id: i64, event: &str, extra: serde_json::Value) -> Delivery {
let mut raw = json!({
"session_id": "s1",
"cwd": "/tmp/w",
"hook_event_name": event,
});
if let (serde_json::Value::Object(base), serde_json::Value::Object(more)) =
(&mut raw, extra)
{
base.extend(more);
}
Delivery {
task_id,
payload: Payload::from_json(raw),
}
}
#[test]
fn the_socket_is_bound_on_startup() {
let fx = Fixture::new();
assert!(fx.daemon.socket().exists());
}
#[test]
fn a_second_daemon_cannot_take_a_live_socket() {
let fx = Fixture::new();
let config = fx.daemon.config.clone();
assert!(
Daemon::new(config, fx.server.tmux.clone(), Recorder::default()).is_err(),
"two daemons on one socket would both receive half the hooks"
);
}
#[test]
fn ticking_starts_queued_tasks_up_to_the_cap() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let a = fx.queue("first", std::slice::from_ref(&repo));
let b = fx.queue("second", std::slice::from_ref(&repo));
let c = fx.queue("third", std::slice::from_ref(&repo));
let pass = fx.daemon.tick(at(5)).unwrap();
assert_eq!(pass.started, [a.id, b.id]);
assert_eq!(
fx.daemon.store.get_task(c.id).unwrap().state,
TaskState::Queued
);
assert!(fx.server.tmux.has_session(&tmux::session_name(a.id)));
}
#[test]
fn a_cancelled_tasks_agent_is_killed() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("doomed", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let session = tmux::session_name(task.id);
assert!(fx.server.tmux.has_session(&session), "should be running");
fx.daemon
.store
.transition(task.id, TaskState::Cancelled, Transition::Plain, at(6))
.unwrap();
let pass = fx.daemon.tick(at(7)).unwrap();
assert_eq!(pass.reaped, [task.id]);
assert!(
!fx.server.tmux.has_session(&session),
"the agent must not outlive the task"
);
}
#[test]
fn reaping_leaves_the_worktrees_alone() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("doomed", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let worktree = fx.daemon.store.list_task_repos(task.id).unwrap()[0]
.worktree_path
.clone()
.expect("provisioned");
fx.daemon
.store
.transition(task.id, TaskState::Cancelled, Transition::Plain, at(6))
.unwrap();
fx.daemon.tick(at(7)).unwrap();
assert!(worktree.exists(), "the work must survive the cancel");
}
#[test]
fn reaping_is_not_retried_for_ever() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("doomed", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.daemon
.store
.transition(task.id, TaskState::Cancelled, Transition::Plain, at(6))
.unwrap();
assert_eq!(fx.daemon.tick(at(7)).unwrap().reaped, [task.id]);
assert!(
fx.daemon.tick(at(8)).unwrap().reaped.is_empty(),
"a reaped task has nothing left to kill"
);
}
#[test]
fn a_stop_hook_moves_a_task_to_review_and_notifies() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("first", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let sent = fx
.daemon
.handle(&delivery(task.id, "Stop", json!({})), at(6))
.unwrap();
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview
);
assert_eq!(sent.unwrap().title, "marver — Ready for review");
}
#[test]
fn a_permission_prompt_notifies_with_its_reason() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("first", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.daemon
.handle(
&delivery(
task.id,
"Notification",
json!({"notification_type": "permission_prompt", "message": "edit main.rs"}),
),
at(6),
)
.unwrap();
let stored = fx.daemon.store.get_task(task.id).unwrap();
assert_eq!(stored.state, TaskState::Blocked);
assert_eq!(stored.blocked_kind, Some(BlockedKind::PermissionPrompt));
assert_eq!(
fx.notifications()[0].body,
"first: edit main.rs",
"the reason is what makes the banner actionable"
);
}
#[test]
fn an_informational_notification_neither_moves_nor_notifies() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("first", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let sent = fx
.daemon
.handle(
&delivery(
task.id,
"Notification",
json!({"notification_type": "auth_success"}),
),
at(6),
)
.unwrap();
assert!(sent.is_none());
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::Running
);
}
#[test]
fn finishing_a_task_frees_its_slot_for_the_next_tick() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let a = fx.queue("first", std::slice::from_ref(&repo));
let b = fx.queue("second", std::slice::from_ref(&repo));
let c = fx.queue("third", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
assert_eq!(
fx.daemon.store.get_task(c.id).unwrap().state,
TaskState::Queued
);
fx.daemon
.handle(&delivery(a.id, "Stop", json!({})), at(6))
.unwrap();
let pass = fx.daemon.tick(at(7)).unwrap();
assert_eq!(pass.started, [c.id]);
assert_eq!(
fx.daemon.store.get_task(b.id).unwrap().state,
TaskState::Running
);
}
#[test]
fn reconcile_fails_a_task_whose_session_vanished() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("first", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.server
.tmux
.kill_session(&tmux::session_name(task.id))
.unwrap();
let orphaned = fx.daemon.reconcile(at(10)).unwrap();
assert_eq!(orphaned, [task.id]);
let stored = fx.daemon.store.get_task(task.id).unwrap();
assert_eq!(stored.state, TaskState::Failed);
assert!(
stored.failure_reason.is_some(),
"the user needs to know why it died"
);
}
#[test]
fn reconcile_does_not_fail_tasks_when_tmux_itself_is_unavailable() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("working", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.daemon.tmux = Tmux::with_binary("definitely-not-tmux");
let err = fx.daemon.reconcile(at(10));
assert!(err.is_err(), "an unusable tmux is not an answer");
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::Running,
"a live agent must not be abandoned because tmux would not run"
);
}
#[test]
fn reconcile_leaves_a_task_that_was_paused_before_it_launched() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("not yet", std::slice::from_ref(&repo));
fx.daemon
.store
.transition(task.id, TaskState::Paused, Transition::Plain, at(5))
.unwrap();
let orphaned = fx.daemon.reconcile(at(10)).unwrap();
assert!(orphaned.is_empty(), "{orphaned:?}");
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::Paused
);
}
#[test]
fn reconcile_fails_a_paused_task_whose_session_died() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("working", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.daemon
.store
.transition(task.id, TaskState::Paused, Transition::Plain, at(6))
.unwrap();
fx.server
.tmux
.kill_session(&tmux::session_name(task.id))
.unwrap();
let orphaned = fx.daemon.reconcile(at(10)).unwrap();
assert_eq!(orphaned, [task.id]);
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::Failed
);
}
#[test]
fn reconcile_leaves_live_tasks_alone() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("first", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
assert!(fx.daemon.reconcile(at(10)).unwrap().is_empty());
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::Running
);
}
#[test]
fn an_orphaned_task_stops_holding_its_slot() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let a = fx.queue("first", std::slice::from_ref(&repo));
let b = fx.queue("second", std::slice::from_ref(&repo));
let c = fx.queue("third", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.server
.tmux
.kill_session(&tmux::session_name(a.id))
.unwrap();
fx.daemon.reconcile(at(10)).unwrap();
let pass = fx.daemon.tick(at(11)).unwrap();
assert_eq!(
pass.started,
[c.id],
"without reconciliation the dead task would block the queue for ever"
);
assert_eq!(
fx.daemon.store.get_task(b.id).unwrap().state,
TaskState::Running
);
}
#[test]
fn scanning_records_repos_under_the_scan_root() {
let mut fx = Fixture::new();
fx.repo("api");
fx.repo("web");
let found = fx.daemon.scan(at(5)).unwrap();
assert_eq!(found, 2);
assert_eq!(fx.daemon.store.list_repos(false).unwrap().len(), 2);
}
#[test]
fn a_hook_for_an_unknown_task_is_an_error_not_a_panic() {
let mut fx = Fixture::new();
let result = fx.daemon.handle(&delivery(9999, "Stop", json!({})), at(5));
assert!(matches!(
result,
Err(Error::Hook(hook::Error::Store(
crate::store::Error::TaskNotFound(9999)
)))
));
}
#[test]
fn a_task_that_cannot_launch_fails_and_notifies() {
let mut fx = Fixture::new();
let broken_path = fx.repos_dir.join("broken");
std::fs::create_dir_all(&broken_path).unwrap();
let broken = fx
.daemon
.store
.upsert_repo(&broken_path, "broken", at(0))
.unwrap();
let task = fx.queue("doomed", std::slice::from_ref(&broken));
let pass = fx.daemon.tick(at(5)).unwrap();
assert!(pass.started.is_empty());
assert_eq!(pass.failed.len(), 1);
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::Failed
);
assert_eq!(pass.notified[0].title, "marver — Task failed");
}
#[test]
fn the_loop_delivers_a_hook_sent_over_the_real_socket() {
use std::sync::atomic::AtomicBool;
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("first", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let socket = fx.daemon.socket().to_path_buf();
let shutdown = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&shutdown);
std::thread::spawn(move || {
for _ in 0..50 {
if hook::send(&socket, &delivery(task.id, "Stop", json!({}))).is_ok() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Relaxed);
});
fx.daemon.config.tick = Duration::from_millis(50);
fx.daemon.run(shutdown).unwrap();
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview,
"a hook sent down the real socket must reach the store"
);
}
use std::io::Write;
fn transcript(dir: &std::path::Path, name: &str, turns: &[(u64, u64)]) -> std::path::PathBuf {
let path = dir.join(name);
let mut file = std::fs::File::create(&path).unwrap();
for (context, output) in turns {
writeln!(
file,
r#"{{"type":"assistant","message":{{"model":"claude-opus-5","usage":{{"input_tokens":{context},"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":{output}}}}}}}"#
)
.unwrap();
}
path
}
#[test]
fn a_hook_brings_the_agents_usage_with_it() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("working", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let dir = tempfile::TempDir::new().unwrap();
let path = transcript(dir.path(), "s.jsonl", &[(1_000, 200)]);
fx.daemon
.handle(
&delivery(
task.id,
"Notification",
json!({
"transcript_path": path.to_string_lossy(),
"notification_type": "idle_prompt",
}),
),
at(6),
)
.unwrap();
let usage = fx.daemon.store.get_task(task.id).unwrap().usage;
assert_eq!(usage.model.as_deref(), Some("claude-opus-5"));
assert_eq!(usage.context_tokens, Some(1_000));
assert_eq!(usage.output_tokens, Some(200));
assert_eq!(usage.transcript_path, Some(path));
}
#[test]
fn a_later_hook_reads_only_what_is_new() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("working", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
let dir = tempfile::TempDir::new().unwrap();
let path = transcript(dir.path(), "s.jsonl", &[(1_000, 200)]);
let hook = |p: &std::path::Path| {
delivery(
task.id,
"Notification",
json!({
"transcript_path": p.to_string_lossy(),
"notification_type": "idle_prompt",
}),
)
};
fx.daemon.handle(&hook(&path), at(6)).unwrap();
let path = transcript(dir.path(), "s.jsonl", &[(1_000, 200), (4_000, 50)]);
fx.daemon.handle(&hook(&path), at(7)).unwrap();
let usage = fx.daemon.store.get_task(task.id).unwrap().usage;
assert_eq!(usage.output_tokens, Some(250), "counted once each");
assert_eq!(usage.context_tokens, Some(4_000), "the latest turn's level");
}
#[test]
fn a_transcript_that_is_not_there_costs_the_hook_nothing() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("working", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.daemon
.handle(
&delivery(
task.id,
"Stop",
json!({ "transcript_path": "/no/such/transcript.jsonl" }),
),
at(6),
)
.unwrap();
let task = fx.daemon.store.get_task(task.id).unwrap();
assert_eq!(
task.state,
TaskState::AwaitingReview,
"the hook still did its job"
);
assert!(!task.usage.is_known());
}
#[test]
fn a_hook_with_no_transcript_path_is_not_a_problem() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queue("working", std::slice::from_ref(&repo));
fx.daemon.tick(at(5)).unwrap();
fx.daemon
.handle(&delivery(task.id, "Stop", json!({})), at(6))
.unwrap();
assert_eq!(
fx.daemon.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview
);
}
}