use std::path::Path;
use std::time::Duration;
use crate::paths::workbenches_dir;
use crate::utils::claude_json::prune_project;
use crate::utils::time::now_secs;
use super::model::{RoutineStore, RunStatus};
use super::run_history::{append_persisted_run, has_persisted_run, read_exit_code, PersistedRun};
mod counters;
mod disk_cap;
mod log_cap;
mod runtime;
mod session;
mod snapshot;
mod ttl;
use session::{note_forced_kill, tmux_kill_session, tmux_session_alive};
pub(crate) use counters::totals as cleanup_sweep_totals;
pub(crate) use runtime::max_runtime_ceiling_secs;
pub(crate) use session::tmux_session_alive as run_session_alive;
pub(crate) use session::tmux_session_count;
pub(crate) use session::tmux_session_prefix_alive;
pub(crate) use ttl::ttl_ceiling_secs;
pub(crate) fn workbenches_total_bytes() -> u64 {
dir_size(&workbenches_dir())
}
pub const CLEANUP_INTERVAL: Duration = Duration::from_secs(5 * 60);
pub const WATCHDOG_INTERVAL: Duration = Duration::from_secs(30);
pub(super) fn parse_workbench_name(name: &str) -> Option<(&str, u64)> {
let (slug, rest) = name.rsplit_once('-')?;
let ts = rest.split_once('_').map_or(rest, |(secs, _pid)| secs);
if slug.is_empty() || ts.is_empty() || !ts.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some((slug, ts.parse().ok()?))
}
const fn is_expired(now: u64, ts: u64, ttl: u64) -> bool {
now.saturating_sub(ts) > ttl
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReapStats {
pub removed: usize,
pub freed_bytes: u64,
}
fn dir_size(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
let mut total = 0;
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
total += dir_size(&entry.path());
} else {
total += entry.metadata().map_or(0, |meta| meta.len());
}
}
total
}
fn agent_log_finish_time(dir: &Path, trigger_ts: u64) -> u64 {
std::fs::metadata(dir.join("agent.log"))
.and_then(|meta| meta.modified())
.ok()
.and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(trigger_ts, |elapsed| elapsed.as_secs().max(trigger_ts))
}
fn kill_if_hung(
path: &Path,
session: &str,
ts: u64,
now: u64,
max_runtime: u64,
is_alive: &dyn Fn(&str) -> bool,
kill: &dyn Fn(&str),
) -> bool {
if !is_alive(session) {
return false;
}
if is_expired(now, ts, max_runtime) {
kill(session);
note_forced_kill(path);
log::warn!("cleanup: killed routine session {session:?} exceeding max runtime");
return false;
}
true
}
fn watchdog_dir(
dir: &Path,
now: u64,
max_runtime_for: &dyn Fn(&str) -> u64,
is_alive: &dyn Fn(&str) -> bool,
kill: &dyn Fn(&str),
) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let killed = std::cell::Cell::new(0_usize);
let counting_kill = |session: &str| {
killed.set(killed.get() + 1);
kill(session);
};
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let Some((slug, ts)) = parse_workbench_name(&name) else {
continue;
};
log_cap::cap_agent_log_or_warn(&entry.path().join("agent.log"));
let session = format!("moadim-{name}");
kill_if_hung(
&entry.path(),
&session,
ts,
now,
max_runtime_for(slug),
is_alive,
&counting_kill,
);
}
killed.get()
}
fn prune_claude_json(path: &Path, name: &str) {
match prune_project(path) {
Ok(true) => log::info!("cleanup: pruned stale ~/.claude.json entry for {name:?}"),
Ok(false) => {}
Err(err) => {
log::warn!("cleanup: failed to prune ~/.claude.json entry for {name:?}: {err}");
}
}
}
#[allow(
clippy::too_many_arguments,
reason = "each parameter is an independently injected test seam with no natural grouping"
)]
fn reap_dir(
dir: &Path,
now: u64,
ttl_for: &dyn Fn(&str) -> u64,
max_runtime_for: &dyn Fn(&str) -> u64,
is_alive: &dyn Fn(&str) -> bool,
kill: &dyn Fn(&str),
finished_at: &dyn Fn(&Path, u64) -> u64,
persist: &dyn Fn(&str, &str, &Path, u64, u64),
) -> ReapStats {
let Ok(entries) = std::fs::read_dir(dir) else {
return ReapStats::default();
};
let mut stats = ReapStats::default();
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let Some((slug, ts)) = parse_workbench_name(&name) else {
continue;
};
let finish_ts = finished_at(&entry.path(), ts);
let session = format!("moadim-{name}");
let alive = kill_if_hung(
&entry.path(),
&session,
ts,
now,
max_runtime_for(slug),
is_alive,
kill,
);
if alive {
continue;
}
if !is_expired(now, finish_ts, ttl_for(slug)) {
continue;
}
persist(slug, &name, &entry.path(), ts, finish_ts);
let size = dir_size(&entry.path());
match std::fs::remove_dir_all(entry.path()) {
Ok(()) => {
stats.removed += 1;
stats.freed_bytes += size;
log::info!("cleanup: removed expired workbench {name:?} (freed {size} bytes)");
prune_claude_json(&entry.path(), &name);
}
Err(err) => log::warn!("cleanup: failed to remove workbench {name:?}: {err}"),
}
}
stats
}
pub fn cleanup_expired_workbenches(store: &RoutineStore) -> ReapStats {
let ttls = snapshot::snapshot_ttls(store);
let max_runtimes = snapshot::snapshot_max_runtimes(store);
let routine_ids = snapshot::snapshot_routine_ids(store);
let ttl_for = |slug: &str| snapshot::ttl_for(&ttls, slug);
let max_runtime_for = |slug: &str| snapshot::max_runtime_for(&max_runtimes, slug);
let persist =
|slug: &str, name: &str, workbench_path: &Path, started_at: u64, finished_at: u64| {
let Some(routine_id) = routine_ids.get(slug) else {
return;
};
if has_persisted_run(routine_id, name) {
return;
}
let exit_code = read_exit_code(workbench_path);
let status = match exit_code {
Some(0) => RunStatus::Success,
Some(_) => RunStatus::Failed,
None => RunStatus::Unknown,
};
append_persisted_run(
routine_id,
&PersistedRun {
workbench: name.to_string(),
started_at,
finished_at,
status,
exit_code,
},
);
};
let ttl_stats = reap_dir(
&workbenches_dir(),
now_secs(),
&ttl_for,
&max_runtime_for,
&tmux_session_alive,
&tmux_kill_session,
&agent_log_finish_time,
&persist,
);
let cap_stats = disk_cap::enforce(
&workbenches_dir(),
disk_cap::max_disk_bytes(),
&tmux_session_alive,
&agent_log_finish_time,
);
let stats = ReapStats {
removed: ttl_stats.removed + cap_stats.removed,
freed_bytes: ttl_stats.freed_bytes + cap_stats.freed_bytes,
};
counters::record_sweep(stats.removed as u64, stats.freed_bytes);
stats
}
pub fn kill_hung_sessions(store: &RoutineStore) -> usize {
let max_runtimes = snapshot::snapshot_max_runtimes(store);
let max_runtime_for = |slug: &str| snapshot::max_runtime_for(&max_runtimes, slug);
watchdog_dir(
&workbenches_dir(),
now_secs(),
&max_runtime_for,
&tmux_session_alive,
&tmux_kill_session,
)
}
fn kill_sessions_for_slug(
dir: &Path,
slug: &str,
is_alive: &dyn Fn(&str) -> bool,
kill: &dyn Fn(&str),
) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut killed = 0;
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let Some((dir_slug, _ts)) = parse_workbench_name(&name) else {
continue;
};
if dir_slug != slug {
continue;
}
let session = format!("moadim-{name}");
if is_alive(&session) {
kill(&session);
killed += 1;
}
}
killed
}
pub fn kill_sessions_for_deleted_routine(slug: &str) -> usize {
kill_sessions_for_slug(
&workbenches_dir(),
slug,
&tmux_session_alive,
&tmux_kill_session,
)
}
#[cfg(test)]
#[path = "cleanup_tests.rs"]
mod cleanup_tests;
#[cfg(test)]
#[path = "cleanup_tmux_tests.rs"]
mod cleanup_tmux_tests;
#[cfg(test)]
#[path = "cleanup_watchdog_tests.rs"]
mod cleanup_watchdog_tests;
#[cfg(test)]
#[path = "cleanup_claude_json_tests.rs"]
mod cleanup_claude_json_tests;
#[cfg(test)]
#[path = "cleanup_freed_bytes_tests.rs"]
mod cleanup_freed_bytes_tests;
#[cfg(test)]
#[path = "cleanup_run_history_tests.rs"]
mod cleanup_run_history_tests;