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 },
}
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 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"),
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);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Startup {
AlreadyRunning,
Started { pid: u32 },
}
pub fn is_running(config: &Config) -> bool {
hook::is_listening(&config.socket)
}
pub fn ensure_running(config: &Config) -> Result<Startup> {
if is_running(config) {
return Ok(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] {
for task in self.store.list_tasks_in_state(state)? {
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)?;
match outcome {
hook::Outcome::Moved { .. } => {
let task = self.store.get_task(delivery.task_id)?;
Ok(self.announce(&task))
}
hook::Outcome::Recorded { .. } => Ok(None),
}
}
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, ..
} = self.scheduler.tick(&mut self.store, &self.launcher, now)?;
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<()> {
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_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"
);
}
}