#![allow(
clippy::missing_docs_in_private_items,
reason = "test helpers and fixtures do not need doc comments"
)]
use super::runtime::MAX_RUNTIME_SECS;
use super::ttl::MAX_TTL_SECS;
use super::*;
use crate::utils::time::now_secs;
fn never_expires_runtime(_slug: &str) -> u64 {
u64::MAX
}
fn noop_kill(_session: &str) {}
fn finish_at_trigger(_dir: &std::path::Path, trigger_ts: u64) -> u64 {
trigger_ts
}
fn noop_persist(
_slug: &str,
_name: &str,
_path: &std::path::Path,
_started_at: u64,
_finished_at: u64,
) {
}
fn touch_dir(parent: &std::path::Path, name: &str) {
std::fs::create_dir_all(parent.join(name)).unwrap();
}
#[test]
fn watchdog_dir_kills_hung_session_without_reaping() {
let base = std::env::temp_dir().join("moadim-watchdog-kill-test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
touch_dir(&base, "hung-100"); touch_dir(&base, "fresh-900"); touch_dir(&base, "gone-100"); touch_dir(&base, "notawb"); std::fs::write(base.join("stray-50"), b"x").unwrap();
let now = 1000;
let max_runtime_for = |_slug: &str| 300_u64; let alive = |session: &str| session != "moadim-gone-100";
let killed = std::cell::RefCell::new(Vec::new());
let kill = |session: &str| killed.borrow_mut().push(session.to_string());
let count = watchdog_dir(&base, now, &max_runtime_for, &alive, &kill);
assert_eq!(count, 1, "only the hung session is killed");
assert_eq!(killed.into_inner(), vec!["moadim-hung-100".to_string()]);
assert!(base.join("hung-100").exists());
assert!(base.join("fresh-900").exists());
assert!(base.join("gone-100").exists());
let log = std::fs::read_to_string(base.join("hung-100").join("agent.log")).unwrap();
assert!(log.contains("exceeded max runtime"));
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn watchdog_dir_returns_zero_when_dir_unreadable() {
let missing =
std::env::temp_dir().join(format!("moadim-watchdog-missing-{}", uuid::Uuid::new_v4()));
assert!(!missing.exists());
let max_runtime_for = |_slug: &str| 0_u64;
let alive = |_session: &str| true;
assert_eq!(
watchdog_dir(&missing, 1000, &max_runtime_for, &alive, &noop_kill),
0
);
}
#[test]
fn kill_hung_sessions_scans_real_workbenches_dir() {
let home = std::env::temp_dir().join(format!("moadim-watchdog-{}", uuid::Uuid::new_v4()));
let previous = std::env::var_os("MOADIM_HOME_OVERRIDE");
unsafe {
std::env::set_var("MOADIM_HOME_OVERRIDE", &home);
}
let workbenches = crate::paths::workbenches_dir();
std::fs::create_dir_all(&workbenches).unwrap();
std::fs::create_dir_all(workbenches.join("orphan-1")).unwrap();
let store = super::super::model::new_store();
let killed = kill_hung_sessions(&store);
assert_eq!(killed, 0);
assert!(workbenches.join("orphan-1").exists());
unsafe {
match previous {
Some(value) => std::env::set_var("MOADIM_HOME_OVERRIDE", value),
None => std::env::remove_var("MOADIM_HOME_OVERRIDE"),
}
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn reap_dir_returns_zero_when_dir_unreadable() {
let missing =
std::env::temp_dir().join(format!("moadim-cleanup-missing-{}", uuid::Uuid::new_v4()));
assert!(!missing.exists());
let ttl_for = |_slug: &str| 0_u64;
let dead = |_session: &str| false;
assert_eq!(
reap_dir(
&missing,
1000,
&ttl_for,
&never_expires_runtime,
&dead,
&noop_kill,
&finish_at_trigger,
&noop_persist
),
ReapStats::default()
);
}
#[cfg(unix)]
#[test]
fn reap_dir_counts_zero_when_remove_fails() {
use std::os::unix::fs::PermissionsExt as _;
let base = std::env::temp_dir().join(format!(
"moadim-cleanup-removefail-{}",
uuid::Uuid::new_v4()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
touch_dir(&base, "expired-100");
std::fs::write(base.join("expired-100").join("inner"), b"x").unwrap();
let mut perms = std::fs::metadata(&base).unwrap().permissions();
perms.set_mode(0o555);
std::fs::set_permissions(&base, perms).unwrap();
let now = 1000;
let ttl_for = |_slug: &str| 500_u64; let dead = |_session: &str| false;
let stats = reap_dir(
&base,
now,
&ttl_for,
&never_expires_runtime,
&dead,
&noop_kill,
&finish_at_trigger,
&noop_persist,
);
if base.join("expired-100").exists() {
assert_eq!(stats.removed, 0);
}
let mut perms = std::fs::metadata(&base).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&base, perms).unwrap();
std::fs::remove_dir_all(&base).unwrap();
}
#[test]
fn cleanup_expired_workbenches_scans_real_workbenches_dir() {
let home = std::env::temp_dir().join(format!("moadim-cleanup-{}", uuid::Uuid::new_v4()));
let previous = std::env::var_os("MOADIM_HOME_OVERRIDE");
unsafe {
std::env::set_var("MOADIM_HOME_OVERRIDE", &home);
}
let workbenches = crate::paths::workbenches_dir();
std::fs::create_dir_all(&workbenches).unwrap();
std::fs::create_dir_all(workbenches.join("orphan-1")).unwrap();
let fresh_ts = now_secs();
std::fs::create_dir_all(workbenches.join(format!("recent-{fresh_ts}"))).unwrap();
std::fs::create_dir_all(workbenches.join("notawb")).unwrap();
let store = super::super::model::new_store();
let stats = cleanup_expired_workbenches(&store);
assert!(
stats.removed >= 1,
"expected at least the orphan to be reaped"
);
assert!(!workbenches.join("orphan-1").exists());
assert!(workbenches.join(format!("recent-{fresh_ts}")).exists());
assert!(workbenches.join("notawb").exists());
unsafe {
match previous {
Some(value) => std::env::set_var("MOADIM_HOME_OVERRIDE", value),
None => std::env::remove_var("MOADIM_HOME_OVERRIDE"),
}
}
let _ = std::fs::remove_dir_all(&home);
}
#[cfg(unix)]
#[test]
fn cleanup_expired_workbenches_kills_a_live_hung_session() {
use std::os::unix::fs::PermissionsExt as _;
let home = std::env::temp_dir().join(format!("moadim-cleanup-hung-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&home).unwrap();
let stub_tmux = home.join("stub-tmux");
std::fs::write(&stub_tmux, b"#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&stub_tmux, std::fs::Permissions::from_mode(0o755)).unwrap();
let prev_home = std::env::var_os("MOADIM_HOME_OVERRIDE");
let prev_tmux = std::env::var_os("MOADIM_TMUX_BIN");
unsafe {
std::env::set_var("MOADIM_HOME_OVERRIDE", &home);
std::env::set_var("MOADIM_TMUX_BIN", &stub_tmux);
}
let workbenches = crate::paths::workbenches_dir();
std::fs::create_dir_all(&workbenches).unwrap();
std::fs::create_dir_all(workbenches.join("hung-1")).unwrap();
let store = super::super::model::new_store();
let stats = cleanup_expired_workbenches(&store);
assert_eq!(
stats.removed, 1,
"the live-but-overrun workbench is killed then reaped"
);
assert!(!workbenches.join("hung-1").exists());
unsafe {
match prev_home {
Some(value) => std::env::set_var("MOADIM_HOME_OVERRIDE", value),
None => std::env::remove_var("MOADIM_HOME_OVERRIDE"),
}
match prev_tmux {
Some(value) => std::env::set_var("MOADIM_TMUX_BIN", value),
None => std::env::remove_var("MOADIM_TMUX_BIN"),
}
}
let _ = std::fs::remove_dir_all(&home);
}
fn routine_with(schedule: &str, ttl_secs: Option<u64>) -> super::super::model::Routine {
super::super::model::Routine {
model: None,
id: "x".into(),
schedule: schedule.into(),
title: "t".into(),
agent: "claude".into(),
prompt: "p".into(),
goal: None,
repositories: vec![],
machines: vec![crate::machine::current_machine()],
enabled: true,
source: "managed".into(),
created_at: 0,
updated_at: 0,
last_manual_trigger_at: None,
last_scheduled_trigger_at: None,
snoozed_until: None,
skip_runs: None,
power_saving: false,
tags: vec![],
ttl_secs,
max_runtime_secs: None,
env: std::collections::HashMap::new(),
}
}
#[test]
fn effective_ttl_caps_at_max_for_long_intervals() {
assert_eq!(
routine_with("@daily", None).effective_ttl_secs(),
MAX_TTL_SECS
);
}
#[test]
fn effective_ttl_follows_sub_hour_cron_interval() {
assert_eq!(
routine_with("*/10 * * * *", None).effective_ttl_secs(),
10 * 60
);
}
#[test]
fn effective_ttl_explicit_only_lowers() {
assert_eq!(routine_with("@daily", Some(42)).effective_ttl_secs(), 42);
assert_eq!(
routine_with("@daily", Some(u64::MAX)).effective_ttl_secs(),
MAX_TTL_SECS
);
assert_eq!(
routine_with("*/10 * * * *", Some(u64::MAX)).effective_ttl_secs(),
10 * 60
);
}
#[test]
fn effective_ttl_falls_back_to_cap_for_unparseable_schedule() {
assert_eq!(
routine_with("@reboot", None).effective_ttl_secs(),
MAX_TTL_SECS
);
}
#[test]
fn effective_max_runtime_defaults_to_cap_when_unset() {
assert_eq!(
routine_with("@daily", None).effective_max_runtime_secs(),
MAX_RUNTIME_SECS
);
}
#[test]
fn effective_max_runtime_follows_sub_hour_cron_interval() {
let mut routine = routine_with("*/10 * * * *", None);
assert_eq!(routine.effective_max_runtime_secs(), 10 * 60);
routine.max_runtime_secs = Some(u64::MAX);
assert_eq!(routine.effective_max_runtime_secs(), 10 * 60);
}
#[test]
fn effective_max_runtime_uses_explicit_value() {
let mut routine = routine_with("@daily", None);
routine.max_runtime_secs = Some(1234);
assert_eq!(routine.effective_max_runtime_secs(), 1234);
routine.max_runtime_secs = Some(u64::MAX);
assert_eq!(routine.effective_max_runtime_secs(), MAX_RUNTIME_SECS);
}
#[test]
fn effective_ttl_falls_back_to_cap_when_schedule_never_fires() {
assert_eq!(
routine_with("0 0 30 2 *", None).effective_ttl_secs(),
MAX_TTL_SECS
);
assert_eq!(
routine_with("0 0 30 2 *", Some(15)).effective_ttl_secs(),
15
);
}
#[test]
fn parse_workbench_name_overflowing_timestamp_returns_none() {
assert!(parse_workbench_name("slug-99999999999999999999").is_none());
}
#[test]
fn cron_interval_secs_supports_at_daily() {
assert_eq!(super::ttl::cron_interval_secs("@daily"), Some(24 * 60 * 60));
}
#[test]
fn cron_interval_secs_is_stable_regardless_of_the_current_time() {
assert_eq!(
super::ttl::cron_interval_secs("0,30 9 * * *"),
Some(30 * 60)
);
}