use std::collections::HashMap;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::core::config::IdleAutoStopConfig;
use crate::session_manager::{ManagedSessionId, ManagedSessionState, SessionManager};
pub trait IdleVerdictProvider: Send + Sync {
fn verdict(
&self,
session_name: &str,
) -> impl std::future::Future<Output = Option<String>> + Send;
}
#[derive(Debug, Default, Clone)]
pub struct SessionHitCounter {
pub idle_hits: u32,
pub done_hits: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReaperDecision {
Continue,
Stop,
Decommission,
}
pub fn apply_verdict(
counter: &mut SessionHitCounter,
verdict: Option<&str>,
cfg: &IdleAutoStopConfig,
) -> ReaperDecision {
use crate::core::sm::prune::normalize_verdict;
let normalized = verdict.map(normalize_verdict).unwrap_or_default();
match normalized.as_str() {
"idle" => {
counter.idle_hits = counter.idle_hits.saturating_add(1);
counter.done_hits = 0;
if counter.idle_hits >= cfg.idle_consecutive_threshold {
return ReaperDecision::Stop;
}
}
"done" => {
counter.done_hits = counter.done_hits.saturating_add(1);
counter.idle_hits = 0;
if counter.done_hits >= cfg.done_consecutive_threshold {
return ReaperDecision::Decommission;
}
}
_ => {
counter.idle_hits = 0;
counter.done_hits = 0;
}
}
ReaperDecision::Continue
}
#[derive(Debug, Default)]
pub struct IdleReaperState {
counters: HashMap<ManagedSessionId, SessionHitCounter>,
}
impl IdleReaperState {
pub fn new() -> Self {
Self::default()
}
pub fn counter_mut(&mut self, id: &ManagedSessionId) -> &mut SessionHitCounter {
self.counters.entry(*id).or_default()
}
pub fn remove(&mut self, id: &ManagedSessionId) {
self.counters.remove(id);
}
pub fn prune_stale(&mut self, active_ids: &std::collections::HashSet<ManagedSessionId>) {
self.counters.retain(|id, _| active_ids.contains(id));
}
}
pub async fn idle_reaper_loop<P>(
manager: Arc<SessionManager>,
cfg: IdleAutoStopConfig,
provider: Arc<P>,
cancel: CancellationToken,
) where
P: IdleVerdictProvider + 'static,
{
let interval = std::time::Duration::from_secs(cfg.poll_interval_secs);
info!(
poll_interval_secs = cfg.poll_interval_secs,
idle_threshold = cfg.idle_consecutive_threshold,
done_threshold = cfg.done_consecutive_threshold,
"idle-reaper: started"
);
let mut state = IdleReaperState::new();
let mut tick = tokio::time::interval(interval);
tick.tick().await; loop {
tokio::select! {
_ = cancel.cancelled() => {
info!("idle-reaper: cancel signal received; exiting");
break;
}
_ = tick.tick() => {
run_one_sweep(&manager, &cfg, &*provider, &mut state).await;
}
}
}
}
async fn run_one_sweep<P: IdleVerdictProvider>(
manager: &Arc<SessionManager>,
cfg: &IdleAutoStopConfig,
provider: &P,
state: &mut IdleReaperState,
) {
let sessions = manager.list().await;
let active_ids: std::collections::HashSet<ManagedSessionId> = sessions
.iter()
.filter(|r| matches!(r.state, ManagedSessionState::Active))
.map(|r| r.id)
.collect();
state.prune_stale(&active_ids);
for record in sessions.iter().filter(|r| {
matches!(r.state, ManagedSessionState::Active)
&& !crate::session_manager::supervisor::skip_protected(r, "idle-reaper")
}) {
let verdict_str = provider.verdict(&record.tmux_name).await;
let counter = state.counter_mut(&record.id);
let decision = apply_verdict(counter, verdict_str.as_deref(), cfg);
match decision {
ReaperDecision::Continue => {
debug!(
id = %record.id,
name = %record.tmux_name,
verdict = ?verdict_str,
idle_hits = counter.idle_hits,
done_hits = counter.done_hits,
"idle-reaper: no action"
);
}
ReaperDecision::Stop if cfg.dry_run => {
info!(
id = %record.id,
name = %record.tmux_name,
"idle-reaper (dry-run): would stop session (idle threshold reached); set idle_auto_stop.dry_run=false to enact"
);
}
ReaperDecision::Stop => {
info!(id = %record.id, name = %record.tmux_name, "idle-reaper: idle threshold reached; stopping session");
state.remove(&record.id);
let stop = manager.stop_proven(
&record.id,
crate::session_manager::StopCause::Deliberate,
"idle-reaper",
);
if let Err(e) = stop.await {
warn!(id = %record.id, "idle-reaper: stop skipped or failed: {e}");
}
}
ReaperDecision::Decommission if cfg.dry_run => {
info!(
id = %record.id,
name = %record.tmux_name,
"idle-reaper (dry-run): would decommission session (done threshold reached); set idle_auto_stop.dry_run=false to enact"
);
}
ReaperDecision::Decommission => {
info!(id = %record.id, name = %record.tmux_name, "idle-reaper: done threshold reached; decommissioning session");
state.remove(&record.id);
if let Err(e) = manager.decommission(&record.id, None).await {
warn!(id = %record.id, "idle-reaper: decommission failed: {e}");
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::IdleAutoStopConfig;
fn cfg(idle: u32, done: u32) -> IdleAutoStopConfig {
IdleAutoStopConfig {
enabled: true,
dry_run: false,
poll_interval_secs: 300,
idle_consecutive_threshold: idle,
done_consecutive_threshold: done,
}
}
#[test]
fn counter_increments_on_idle() {
let mut counter = SessionHitCounter::default();
let c = cfg(3, 1);
assert_eq!(
apply_verdict(&mut counter, Some("idle"), &c),
ReaperDecision::Continue
);
assert_eq!(counter.idle_hits, 1);
assert_eq!(
apply_verdict(&mut counter, Some("idle"), &c),
ReaperDecision::Continue
);
assert_eq!(counter.idle_hits, 2);
}
#[test]
fn counter_resets_on_non_idle() {
let mut counter = SessionHitCounter::default();
let c = cfg(3, 1);
apply_verdict(&mut counter, Some("idle"), &c);
apply_verdict(&mut counter, Some("idle"), &c);
assert_eq!(counter.idle_hits, 2);
apply_verdict(&mut counter, Some("working"), &c);
assert_eq!(counter.idle_hits, 0, "idle counter must reset on working");
assert_eq!(counter.done_hits, 0);
}
#[test]
fn stop_fires_at_threshold() {
let mut counter = SessionHitCounter::default();
let c = cfg(3, 5);
assert_eq!(
apply_verdict(&mut counter, Some("idle"), &c),
ReaperDecision::Continue
);
assert_eq!(
apply_verdict(&mut counter, Some("idle"), &c),
ReaperDecision::Continue
);
assert_eq!(
apply_verdict(&mut counter, Some("idle"), &c),
ReaperDecision::Stop
);
}
#[test]
fn decommission_fires_at_done_threshold() {
let mut counter = SessionHitCounter::default();
let c = cfg(3, 1);
assert_eq!(
apply_verdict(&mut counter, Some("done"), &c),
ReaperDecision::Decommission
);
}
#[test]
fn none_verdict_resets_counters() {
let mut counter = SessionHitCounter::default();
let c = cfg(3, 1);
apply_verdict(&mut counter, Some("idle"), &c);
apply_verdict(&mut counter, Some("idle"), &c);
let decision = apply_verdict(&mut counter, None, &c);
assert_eq!(decision, ReaperDecision::Continue);
assert_eq!(counter.idle_hits, 0, "None verdict must reset idle counter");
}
#[test]
fn blocked_verdict_never_stops() {
let mut counter = SessionHitCounter::default();
let c = cfg(1, 1);
let decision = apply_verdict(&mut counter, Some("blocked-on-permission"), &c);
assert_eq!(decision, ReaperDecision::Continue);
assert_eq!(counter.idle_hits, 0);
}
#[test]
fn done_resets_idle_counter() {
let mut counter = SessionHitCounter::default();
let c = cfg(5, 2);
apply_verdict(&mut counter, Some("idle"), &c);
apply_verdict(&mut counter, Some("idle"), &c);
assert_eq!(counter.idle_hits, 2);
apply_verdict(&mut counter, Some("done"), &c);
assert_eq!(counter.idle_hits, 0, "done must reset idle counter");
assert_eq!(counter.done_hits, 1);
}
#[test]
fn prune_removes_stale_counters() {
let a = ManagedSessionId::new();
let b = ManagedSessionId::new();
let mut rs = IdleReaperState::new();
rs.counter_mut(&a).idle_hits = 2;
rs.counter_mut(&b).idle_hits = 1;
let active = std::collections::HashSet::from([a]);
rs.prune_stale(&active);
assert!(rs.counters.contains_key(&a), "active counter must survive");
assert!(
!rs.counters.contains_key(&b),
"stale counter must be pruned"
);
}
#[tokio::test]
async fn cancel_token_stops_idle_reaper_cleanly() {
use crate::daemon::state::DaemonState;
struct AlwaysIdleProvider;
impl IdleVerdictProvider for AlwaysIdleProvider {
async fn verdict(&self, _: &str) -> Option<String> {
Some("idle".into())
}
}
let state = DaemonState::shared();
let mgr = state.session_manager().await;
let cancel = CancellationToken::new();
let token = cancel.clone();
let handle = tokio::spawn(idle_reaper_loop(
mgr,
IdleAutoStopConfig::default(),
Arc::new(AlwaysIdleProvider),
token,
));
cancel.cancel();
handle
.await
.expect("idle_reaper_loop must not panic on cancel");
}
#[test]
fn counter_cleared_after_stop_fires() {
let id = ManagedSessionId::new();
let mut rs = IdleReaperState::new();
let c = cfg(2, 5);
let counter = rs.counter_mut(&id);
assert_eq!(
apply_verdict(counter, Some("idle"), &c),
ReaperDecision::Continue
);
assert_eq!(
apply_verdict(counter, Some("idle"), &c),
ReaperDecision::Stop
);
rs.remove(&id);
let counter = rs.counter_mut(&id);
let decision = apply_verdict(counter, Some("idle"), &c);
assert_eq!(
decision,
ReaperDecision::Continue,
"counter must restart after remove; second idle must not immediately re-fire"
);
assert_eq!(
counter.idle_hits, 1,
"fresh counter must show exactly 1 idle hit"
);
}
use crate::session_manager::{
FakeNoopTmuxDriver, ManagedError, ManagedSessionState, SessionManager,
};
use tempfile::TempDir;
struct FixedVerdictProvider(&'static str);
impl IdleVerdictProvider for FixedVerdictProvider {
async fn verdict(&self, _: &str) -> Option<String> {
Some(self.0.to_string())
}
}
async fn manager_with_active_session(dir: &TempDir) -> (Arc<SessionManager>, ManagedSessionId) {
let mgr = SessionManager::new(dir.path(), Arc::new(FakeNoopTmuxDriver))
.await
.expect("manager");
let rec = mgr
.create(
"idle task".into(),
Some(dir.path().to_path_buf()),
None,
None,
None,
None,
)
.await
.expect("create");
mgr.stop(&rec.id).await.expect("stop");
mgr.resume(&rec.id).await.expect("resume");
(Arc::new(mgr), rec.id)
}
async fn state_of(mgr: &SessionManager, id: &ManagedSessionId) -> ManagedSessionState {
mgr.list()
.await
.into_iter()
.find(|r| &r.id == id)
.expect("session present")
.state
}
#[tokio::test]
async fn sweep_dry_run_does_not_stop() {
let dir = TempDir::new().unwrap();
let (mgr, id) = manager_with_active_session(&dir).await;
let mut c = cfg(1, 1);
c.dry_run = true;
let provider = FixedVerdictProvider("idle");
let mut rs = IdleReaperState::new();
run_one_sweep(&mgr, &c, &provider, &mut rs).await;
assert_eq!(
state_of(&mgr, &id).await,
ManagedSessionState::Active,
"dry-run sweep must NOT stop the session"
);
}
#[tokio::test]
async fn sweep_live_stops_idle_session() {
let dir = TempDir::new().unwrap();
let (mgr, id) = manager_with_active_session(&dir).await;
let c = cfg(1, 1); let provider = FixedVerdictProvider("idle");
let mut rs = IdleReaperState::new();
run_one_sweep(&mgr, &c, &provider, &mut rs).await;
assert_eq!(
state_of(&mgr, &id).await,
ManagedSessionState::Stopped,
"live sweep must stop the idle session"
);
}
#[tokio::test]
async fn sweep_live_decommissions_done_session() {
let dir = TempDir::new().unwrap();
let (mgr, id) = manager_with_active_session(&dir).await;
let c = cfg(3, 1); let provider = FixedVerdictProvider("done");
let mut rs = IdleReaperState::new();
run_one_sweep(&mgr, &c, &provider, &mut rs).await;
assert_eq!(
state_of(&mgr, &id).await,
ManagedSessionState::Decommissioned,
"live sweep must decommission the done session"
);
}
struct CountingProvider(&'static str, std::sync::atomic::AtomicUsize);
impl IdleVerdictProvider for CountingProvider {
async fn verdict(&self, _: &str) -> Option<String> {
self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(self.0.to_string())
}
}
#[derive(Default)]
struct UnlistablePanes {
kills: std::sync::Mutex<Vec<String>>,
}
impl crate::session_manager::ManagedTmuxDriver for UnlistablePanes {
fn create_session(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.kills.lock().unwrap().push(name.to_owned());
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _: &str, _: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(vec!["tmpm-test-8935-idle".to_owned()])
}
fn pane_exists_checked(&self, _: &str, _: &str) -> Option<bool> {
None
}
}
#[tokio::test]
async fn the_idle_reaper_stop_skips_an_unproven_runtime() {
let dir = TempDir::new().unwrap();
let tmux = Arc::new(UnlistablePanes::default());
let mgr = Arc::new(
SessionManager::new(dir.path(), tmux.clone())
.await
.expect("manager"),
);
let id = ManagedSessionId::new();
let record: crate::session_manager::SessionRecord =
serde_json::from_value(serde_json::json!({
"id": id.to_string(), "task": "t", "tmux_name": "tmpm-test-8935-idle",
"cwd": "/work/x", "state": "active", "pane_id": "%9",
"created_at": "2026-09-30T00:00:00Z",
}))
.expect("record");
mgr.store.write().await.upsert(record).await.expect("seed");
let mut rs = IdleReaperState::new();
for verdict in ["idle", "done"] {
run_one_sweep(&mgr, &cfg(1, 1), &FixedVerdictProvider(verdict), &mut rs).await;
assert_eq!(
state_of(&mgr, &id).await,
ManagedSessionState::Active,
"a `{verdict}` sweep moved a record whose runtime is unproven"
);
}
assert!(tmux.kills.lock().unwrap().is_empty());
}
#[tokio::test]
async fn idle_reaper_never_stops_a_supervisor_record() {
let dir = TempDir::new().unwrap();
let (mgr, id) = manager_with_active_session(&dir).await;
let mut record = mgr.get(&id).await.expect("record");
record.kind = crate::session_manager::SessionKind::Supervisor;
mgr.store.write().await.upsert(record).await.expect("seed");
let mut rs = IdleReaperState::new();
for verdict in ["idle", "done"] {
let provider = CountingProvider(verdict, Default::default());
run_one_sweep(&mgr, &cfg(1, 1), &provider, &mut rs).await;
assert_eq!(
state_of(&mgr, &id).await,
ManagedSessionState::Active,
"a `{verdict}` sweep reached the Architect's record"
);
assert_eq!(
provider.1.load(std::sync::atomic::Ordering::SeqCst),
0,
"the sweep classified the Architect's pane"
);
}
}
}