use std::path::PathBuf;
use chrono::{Duration, Utc};
use tempfile::TempDir;
use super::super::record::{ManagedSessionState, SessionRecord};
use super::super::tests::make_manager;
use super::{
RetentionDebounce, RetentionVerdict, TERMINAL_RECORD_RETENTION_DAYS, retention_verdict,
workspace_present,
};
#[test]
fn retention_window_is_seven_days() {
assert_eq!(TERMINAL_RECORD_RETENTION_DAYS, 7);
}
fn window() -> Duration {
Duration::days(TERMINAL_RECORD_RETENTION_DAYS)
}
fn terminal_record(state: ManagedSessionState, age_days: i64) -> SessionRecord {
let mut r = base_record();
r.state = state;
r.terminal_at = Some(Utc::now() - Duration::days(age_days));
r
}
fn base_record() -> SessionRecord {
SessionRecord {
id: super::super::record::ManagedSessionId::new(),
tmux_name: "tm-retention-test".into(),
cwd: PathBuf::from("/tmp"),
task: "t".into(),
state: ManagedSessionState::Active,
created_at: Utc::now() - Duration::days(365),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
}
}
#[test]
fn retention_verdict_keeps_live_states() {
for state in [
ManagedSessionState::Provisioning,
ManagedSessionState::Active,
ManagedSessionState::Stopped,
ManagedSessionState::Errored,
] {
let mut r = base_record();
r.state = state.clone();
r.terminal_at = Some(Utc::now() - Duration::days(400));
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Keep,
"{state} must never be evicted"
);
}
}
#[test]
fn retention_verdict_keeps_record_whose_workspace_still_exists() {
let mut r = terminal_record(ManagedSessionState::Decommissioned, 400);
r.workspace_path = Some(PathBuf::from("/tmp/some-worktree"));
assert_eq!(
retention_verdict(&r, true, Utc::now(), window()),
RetentionVerdict::Keep,
"a record whose worktree is still on disk keeps protecting it"
);
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Evict
);
}
#[test]
fn retention_verdict_stamps_undated_terminal_record() {
let now = Utc::now();
for state in [
ManagedSessionState::Decommissioned,
ManagedSessionState::Deleted,
] {
let mut r = base_record();
r.state = state;
r.terminal_at = None;
r.last_activity_at = Some(now - Duration::days(90));
assert_eq!(
retention_verdict(&r, false, now, window()),
RetentionVerdict::Stamp(now - Duration::days(90)),
"the stamp carries the inferred date, not `now`"
);
}
}
#[test]
fn retention_verdict_keeps_record_inside_window() {
for age in [0, 1, 6] {
let r = terminal_record(ManagedSessionState::Decommissioned, age);
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Keep,
"{age}-day-old tombstone is inside the window"
);
}
}
#[test]
fn retention_verdict_evicts_record_outside_window() {
for state in [
ManagedSessionState::Decommissioned,
ManagedSessionState::Deleted,
] {
let mut r = terminal_record(state.clone(), 8);
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Evict,
"{state} 8 days past terminal is evictable"
);
r.terminal_at = Some(Utc::now() - window());
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Evict
);
}
}
async fn seed_terminal(
mgr: &super::super::manager::SessionManager,
task: &str,
state: ManagedSessionState,
age_days: i64,
) -> super::super::record::ManagedSessionId {
let rec = mgr
.create(task.into(), None, None, None, None, None)
.await
.expect("create");
let mut updated = mgr.get(&rec.id).await.expect("get");
updated.state = state;
updated.terminal_at = Some(Utc::now() - Duration::days(age_days));
updated.workspace_path = None;
mgr.store
.write()
.await
.upsert(updated)
.await
.expect("seed terminal record");
rec.id
}
async fn sweep_twice(
mgr: &super::super::manager::SessionManager,
now: chrono::DateTime<Utc>,
) -> super::RetentionOutcome {
let mut gate = RetentionDebounce::new();
mgr.sweep_terminal_records(now, window(), &mut gate)
.await
.expect("first sweep");
mgr.sweep_terminal_records(now, window(), &mut gate)
.await
.expect("second sweep")
}
#[tokio::test]
async fn sweep_evicts_only_records_past_the_window() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let fresh = seed_terminal(&mgr, "fresh", ManagedSessionState::Decommissioned, 1).await;
let stale = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
let deleted_stale = seed_terminal(&mgr, "gone", ManagedSessionState::Deleted, 9).await;
let live = mgr
.create("live".into(), None, None, None, None, None)
.await
.expect("create live");
let outcome = sweep_twice(&mgr, Utc::now()).await;
assert_eq!(outcome.evicted.len(), 2, "{outcome:?}");
assert!(outcome.evicted.contains(&stale));
assert!(outcome.evicted.contains(&deleted_stale));
assert!(
mgr.get(&fresh).await.is_ok(),
"in-window tombstone survives"
);
assert!(mgr.get(&live.id).await.is_ok(), "live session untouched");
assert!(mgr.get(&stale).await.is_err(), "stale record is gone");
}
#[tokio::test]
async fn sweep_preserves_the_in_window_tombstone_slot() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let fresh = seed_terminal(&mgr, "fresh", ManagedSessionState::Decommissioned, 1).await;
let snap = mgr.numbered_snapshot(&mgr.list().await).await;
let slot = snap
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(fresh))
.expect("observed")
.slot;
mgr.compact_record(&fresh).await.expect("compact");
sweep_twice(&mgr, Utc::now()).await;
let after = mgr.numbered_snapshot(&mgr.list().await).await;
let row = after
.iter()
.find(|s| s.slot == slot)
.expect("slot still present");
assert!(
row.record.is_none(),
"renders as a tombstone, not a session"
);
}
#[tokio::test]
async fn sweep_releases_the_evicted_slot() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let stale = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
let keeper = mgr
.create("keeper".into(), None, None, None, None, None)
.await
.expect("create keeper");
let before = mgr.numbered_snapshot(&mgr.list().await).await;
let stale_slot = before
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(stale))
.expect("stale observed")
.slot;
let keeper_slot = before
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(keeper.id))
.expect("keeper observed")
.slot;
sweep_twice(&mgr, Utc::now()).await;
let after = mgr.numbered_snapshot(&mgr.list().await).await;
assert!(
after.iter().all(|s| s.slot != stale_slot),
"evicted slot leaves the listing entirely — no phantom tombstone row"
);
assert_eq!(
after
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(keeper.id))
.expect("keeper still listed")
.slot,
keeper_slot,
"a surviving session's NUM never shifts"
);
let fresh = mgr
.create("fresh".into(), None, None, None, None, None)
.await
.expect("create fresh");
let reused = mgr.numbered_snapshot(&mgr.list().await).await;
assert_eq!(
reused
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(fresh.id))
.expect("fresh listed")
.slot,
stale_slot,
"the released slot is reusable"
);
let mut slots: Vec<u32> = reused.iter().map(|s| s.slot).collect();
let total = slots.len();
slots.sort_unstable();
slots.dedup();
assert_eq!(slots.len(), total, "no duplicate NUM after reallocation");
}
#[tokio::test]
async fn sweep_never_touches_the_filesystem() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let workspace = TempDir::new().expect("workspace");
let canary = workspace.path().join("unsaved-work.txt");
std::fs::write(&canary, b"do not delete me").expect("write canary");
let id = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 400).await;
let mut rec = mgr.get(&id).await.expect("get");
rec.workspace_path = Some(workspace.path().to_path_buf());
mgr.store.write().await.upsert(rec).await.expect("re-seed");
let outcome = sweep_twice(&mgr, Utc::now()).await;
assert!(
outcome.evicted.is_empty(),
"a record whose workspace is still on disk is never evicted: {outcome:?}"
);
assert!(workspace.path().exists(), "workspace directory survives");
assert!(canary.exists(), "file inside the workspace survives");
assert_eq!(
std::fs::read(&canary).expect("read canary"),
b"do not delete me",
"file contents untouched"
);
assert!(mgr.get(&id).await.is_ok(), "record itself survives too");
}
#[tokio::test]
async fn sweep_backfills_an_old_legacy_record_and_evicts_it_without_waiting() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let rec = mgr
.create("legacy".into(), None, None, None, None, None)
.await
.expect("create");
let mut legacy = mgr.get(&rec.id).await.expect("get");
legacy.state = ManagedSessionState::Decommissioned;
legacy.created_at = Utc::now() - Duration::days(400);
legacy.last_activity_at = Some(Utc::now() - Duration::days(400));
legacy.terminal_at = None;
legacy.workspace_path = None;
mgr.store.write().await.upsert(legacy).await.expect("seed");
let mut gate = RetentionDebounce::new();
let first = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("first sweep");
assert_eq!(first.stamped, 1, "{first:?}");
assert!(
first.evicted.is_empty(),
"the inferred date is written before anything acts on it: {first:?}"
);
let stamped = mgr.get(&rec.id).await.expect("still present").terminal_at;
let age = Utc::now() - stamped.expect("stamped");
assert!(
age > Duration::days(399),
"backfilled from the record's own history, not from `now`: {stamped:?}"
);
let second = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("second sweep");
assert!(second.evicted.is_empty(), "armed only: {second:?}");
let third = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("third sweep");
assert_eq!(
third.evicted,
vec![rec.id],
"evicted without waiting a window"
);
}
#[tokio::test]
async fn sweep_gives_a_recent_legacy_record_its_full_window() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let rec = mgr
.create("recent".into(), None, None, None, None, None)
.await
.expect("create");
let mut legacy = mgr.get(&rec.id).await.expect("get");
legacy.state = ManagedSessionState::Decommissioned;
legacy.created_at = Utc::now() - Duration::days(2);
legacy.last_activity_at = Some(Utc::now() - Duration::days(2));
legacy.terminal_at = None;
legacy.workspace_path = None;
mgr.store.write().await.upsert(legacy).await.expect("seed");
let mut gate = RetentionDebounce::new();
for pass in 0..3 {
let outcome = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("sweep");
assert!(
outcome.evicted.is_empty(),
"a 2-day-old tombstone must survive (pass {pass}): {outcome:?}"
);
}
assert!(mgr.get(&rec.id).await.is_ok(), "still in the store");
let later = sweep_twice(&mgr, Utc::now() + Duration::days(6)).await;
assert_eq!(later.evicted, vec![rec.id]);
}
#[tokio::test]
async fn sweep_stamps_now_when_a_legacy_record_has_no_usable_signal() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let rec = mgr
.create("skewed".into(), None, None, None, None, None)
.await
.expect("create");
let mut legacy = mgr.get(&rec.id).await.expect("get");
legacy.state = ManagedSessionState::Decommissioned;
legacy.created_at = Utc::now() + Duration::days(365);
legacy.last_activity_at = Some(Utc::now() + Duration::days(365));
legacy.terminal_at = None;
legacy.workspace_path = None;
mgr.store.write().await.upsert(legacy).await.expect("seed");
let now = Utc::now();
let mut gate = RetentionDebounce::new();
let first = mgr
.sweep_terminal_records(now, window(), &mut gate)
.await
.expect("first sweep");
assert_eq!(first.stamped, 1);
assert_eq!(
mgr.get(&rec.id).await.expect("present").terminal_at,
Some(now),
"an unusable signal falls back to `now`, not to the future value"
);
let second = mgr
.sweep_terminal_records(now, window(), &mut gate)
.await
.expect("second sweep");
assert!(second.evicted.is_empty(), "and it gets a full window");
}
#[tokio::test]
async fn sweep_backfill_still_spares_a_legacy_record_whose_workspace_exists() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let workspace = TempDir::new().expect("workspace");
let canary = workspace.path().join("unsaved-work.txt");
std::fs::write(&canary, b"do not delete me").expect("write canary");
let rec = mgr
.create("legacy-with-workspace".into(), None, None, None, None, None)
.await
.expect("create");
let mut legacy = mgr.get(&rec.id).await.expect("get");
legacy.state = ManagedSessionState::Decommissioned;
legacy.created_at = Utc::now() - Duration::days(400);
legacy.last_activity_at = Some(Utc::now() - Duration::days(400));
legacy.terminal_at = None;
legacy.workspace_path = Some(workspace.path().to_path_buf());
mgr.store.write().await.upsert(legacy).await.expect("seed");
let mut gate = RetentionDebounce::new();
for pass in 0..3 {
let outcome = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("sweep");
assert!(
outcome.is_empty(),
"the workspace guard runs BEFORE the backfill (pass {pass}): {outcome:?}"
);
}
assert!(mgr.get(&rec.id).await.is_ok(), "record survives");
assert!(canary.exists(), "and so does the work inside its workspace");
}
#[tokio::test]
async fn revalidate_keeps_a_still_evictable_record() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let stale = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
let survivors = mgr
.revalidate_for_eviction(vec![stale], Utc::now(), window())
.await;
assert_eq!(
survivors,
vec![stale],
"an unchanged candidate still evicts"
);
}
#[tokio::test]
async fn revalidate_drops_a_record_reactivated_after_the_snapshot() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let workspace = TempDir::new().expect("workspace");
let id = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
assert_eq!(
mgr.revalidate_for_eviction(vec![id], Utc::now(), window())
.await,
vec![id]
);
let mut rec = mgr.get(&id).await.expect("get");
rec.workspace_path = Some(workspace.path().to_path_buf());
mgr.store.write().await.upsert(rec).await.expect("re-seed");
mgr.mark_reactivated(&id).await.expect("reactivate");
let survivors = mgr
.revalidate_for_eviction(vec![id], Utc::now(), window())
.await;
assert!(
survivors.is_empty(),
"a candidate that went live after the snapshot must not be deleted: {survivors:?}"
);
assert!(mgr.get(&id).await.is_ok(), "and it is still in the store");
}
#[tokio::test]
async fn revalidate_drops_a_record_a_concurrent_prune_already_removed() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let id = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
mgr.compact_record(&id).await.expect("concurrent prune");
let survivors = mgr
.revalidate_for_eviction(vec![id], Utc::now(), window())
.await;
assert!(
survivors.is_empty(),
"an already-removed id is not something this sweep evicted: {survivors:?}"
);
}
#[test]
fn workspace_present_treats_an_undetermined_path_as_present() {
let path = PathBuf::from("/definitely/unreadable");
assert!(
workspace_present(Some(&path), |_| Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"EACCES"
))),
"an undetermined path must count as PRESENT so the record is kept"
);
assert!(workspace_present(Some(&path), |_| Ok(true)));
assert!(!workspace_present(Some(&path), |_| Ok(false)));
assert!(
!workspace_present(None, |_| Ok(true)),
"no path, nothing to protect"
);
let mut r = terminal_record(ManagedSessionState::Decommissioned, 400);
r.workspace_path = Some(path.clone());
let undetermined = workspace_present(Some(&path), |_| Err(std::io::Error::other("EIO")));
assert_eq!(
retention_verdict(&r, undetermined, Utc::now(), window()),
RetentionVerdict::Keep,
"a stat error must never evict"
);
}
#[tokio::test]
async fn debounce_requires_two_consecutive_observations() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let stale = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
let mut gate = RetentionDebounce::new();
let first = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("first sweep");
assert!(
first.evicted.is_empty(),
"one observation is never enough: {first:?}"
);
assert!(mgr.get(&stale).await.is_ok(), "record survives sweep one");
let second = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("second sweep");
assert_eq!(second.evicted, vec![stale]);
}
#[test]
fn debounce_disarms_a_candidate_that_lapses() {
let id = super::super::record::ManagedSessionId::new();
let other = super::super::record::ManagedSessionId::new();
let mut gate = RetentionDebounce::new();
assert!(
gate.confirm(&[id]).is_empty(),
"first observation never confirms"
);
assert!(gate.confirm(&[other]).is_empty());
assert!(
gate.confirm(&[id]).is_empty(),
"a lapse must re-arm from zero"
);
assert_eq!(gate.confirm(&[id]), vec![id], "two in a row confirms");
}
#[tokio::test]
async fn sweep_spares_a_record_reactivated_between_sweeps() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let workspace = TempDir::new().expect("workspace");
let id = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 30).await;
let mut gate = RetentionDebounce::new();
let first = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("first sweep");
assert!(first.evicted.is_empty(), "armed, not yet evicted");
let mut rec = mgr.get(&id).await.expect("get");
rec.workspace_path = Some(workspace.path().to_path_buf());
mgr.store.write().await.upsert(rec).await.expect("re-seed");
mgr.mark_reactivated(&id).await.expect("reactivate");
let second = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("second sweep");
assert!(
second.evicted.is_empty(),
"a record reactivated after the snapshot must survive: {second:?}"
);
let live = mgr.get(&id).await.expect("record still present");
assert_eq!(live.state, ManagedSessionState::Active);
assert_eq!(
live.terminal_at, None,
"reactivation clears the retention stamp"
);
}
#[test]
fn inferred_terminal_at_uses_the_latest_evidence_of_life() {
let now = Utc::now();
let mut r = base_record();
r.created_at = now - Duration::days(400);
r.last_activity_at = Some(now - Duration::days(30));
assert_eq!(
super::inferred_terminal_at(&r, now),
now - Duration::days(30),
"activity is the better signal when present"
);
r.last_activity_at = None;
assert_eq!(
super::inferred_terminal_at(&r, now),
now - Duration::days(400),
"creation is the fallback"
);
r.last_activity_at = Some(now - Duration::days(900));
assert_eq!(
super::inferred_terminal_at(&r, now),
now - Duration::days(400),
"never earlier than creation"
);
}
#[test]
fn inferred_terminal_at_falls_back_to_now_for_a_future_timestamp() {
let now = Utc::now();
let mut r = base_record();
r.created_at = now + Duration::days(365);
r.last_activity_at = Some(now + Duration::days(365));
assert_eq!(super::inferred_terminal_at(&r, now), now);
}