use std::path::{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_needs_protection,
};
#[test]
fn retention_window_is_twenty_four_hours() {
assert_eq!(TERMINAL_RECORD_RETENTION_DAYS, 1);
}
fn window() -> Duration {
Duration::days(TERMINAL_RECORD_RETENTION_DAYS)
}
fn names() -> trusty_common::workspace_layout::WorktreeDirNames {
trusty_common::workspace_layout::WorktreeDirNames::from_configured(None)
}
fn terminal_record(state: ManagedSessionState, age_hours: i64) -> SessionRecord {
let mut r = base_record();
r.state = state;
r.terminal_at = Some(Utc::now() - Duration::hours(age_hours));
r
}
fn session_worktree(root: &Path, leaf: &str) -> PathBuf {
let wt = root.join(".worktrees").join(leaf);
std::fs::create_dir_all(&wt).expect("create worktree dir");
std::fs::write(
wt.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
b"",
)
.expect("write sentinel");
wt
}
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,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[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_worktree_still_exists() {
let mut r = terminal_record(ManagedSessionState::Decommissioned, 24 * 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, 12, 23] {
let r = terminal_record(ManagedSessionState::Decommissioned, age);
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Keep,
"{age}-hour-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(), 25);
assert_eq!(
retention_verdict(&r, false, Utc::now(), window()),
RetentionVerdict::Evict,
"{state} 25 hours 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_hours: 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::hours(age_hours));
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, 25).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_a_worktree_on_the_filesystem() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let base = TempDir::new().expect("base");
let workspace = session_worktree(base.path(), "tm-retention-01");
let canary = workspace.join("unsaved-work.txt");
std::fs::write(&canary, b"do not delete me").expect("write canary");
let id = seed_terminal(&mgr, "stale", ManagedSessionState::Decommissioned, 24 * 400).await;
let mut rec = mgr.get(&id).await.expect("get");
rec.workspace_path = Some(workspace.clone());
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 worktree is still on disk is never evicted: {outcome:?}"
);
assert!(workspace.exists(), "worktree directory survives");
assert!(canary.exists(), "file inside the worktree 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::hours(2);
legacy.last_activity_at = Some(Utc::now() - Duration::hours(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-hour-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::hours(23)).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_worktree_exists() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let base = TempDir::new().expect("base");
let workspace = session_worktree(base.path(), "tm-legacy-01");
let canary = workspace.join("unsaved-work.txt");
std::fs::write(&canary, b"do not delete me").expect("write canary");
let rec = mgr
.create("legacy-with-worktree".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.clone());
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 worktree 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 worktree");
}
#[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], &names(), 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], &names(), 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], &names(), 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_whose_worktree_appeared_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;
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");
assert_eq!(
mgr.revalidate_for_eviction(vec![id], &names(), Utc::now(), window())
.await,
vec![id]
);
std::fs::write(
workspace
.path()
.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
b"",
)
.expect("sentinel");
let survivors = mgr
.revalidate_for_eviction(vec![id], &names(), Utc::now(), window())
.await;
assert!(
survivors.is_empty(),
"phase 3 must re-read the sentinel, not reuse the snapshot's answer: {survivors:?}"
);
assert!(mgr.get(&id).await.is_ok(), "and the record is still there");
}
#[tokio::test]
async fn sweep_spares_a_record_whose_worktree_appears_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 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 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");
std::fs::write(
workspace
.path()
.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
b"",
)
.expect("sentinel");
let second = mgr
.sweep_terminal_records(Utc::now(), window(), &mut gate)
.await
.expect("second sweep");
assert!(
second.evicted.is_empty(),
"one stale observation must never be enough to delete: {second:?}"
);
assert!(mgr.get(&id).await.is_ok());
}
#[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], &names(), Utc::now(), window())
.await;
assert!(
survivors.is_empty(),
"an already-removed id is not something this sweep evicted: {survivors:?}"
);
}
#[test]
fn workspace_needs_protection_treats_an_undetermined_path_as_protected() {
let path = PathBuf::from("/definitely/unreadable/plain-checkout");
assert!(
workspace_needs_protection(Some(&path), &names(), |_| Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"EACCES"
))),
"an undetermined workspace path must count as PROTECTED so the record is kept"
);
assert!(
workspace_needs_protection(Some(&path), &names(), |p| if p == path {
Ok(true)
} else {
Err(std::io::Error::other("EIO"))
}),
"an undetermined SENTINEL probe must also count as PROTECTED"
);
assert!(
!workspace_needs_protection(Some(&path), &names(), |_| Ok(false)),
"a workspace that is gone protects nothing"
);
assert!(
!workspace_needs_protection(None, &names(), |_| Ok(true)),
"no path, nothing to protect"
);
let mut r = terminal_record(ManagedSessionState::Decommissioned, 24 * 400);
r.workspace_path = Some(path.clone());
let undetermined =
workspace_needs_protection(Some(&path), &names(), |_| Err(std::io::Error::other("EIO")));
assert_eq!(
retention_verdict(&r, undetermined, Utc::now(), window()),
RetentionVerdict::Keep,
"a stat error must never evict"
);
}
#[test]
fn workspace_needs_protection_covers_a_session_worktree() {
let base = TempDir::new().expect("base");
let wt = session_worktree(base.path(), "tm-shape-01");
std::fs::remove_file(wt.join(super::super::decommission::WORKTREE_SENTINEL_FILE))
.expect("strip sentinel");
assert!(
workspace_needs_protection(Some(&wt), &names(), |p| p.try_exists()),
"a `.worktrees/<leaf>` path is protected on its shape alone, with no sentinel to read"
);
let odd = base.path().join("not-under-a-worktree-base");
std::fs::create_dir_all(&odd).expect("create");
std::fs::write(
odd.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
b"",
)
.expect("sentinel");
assert!(
workspace_needs_protection(Some(&odd), &names(), |p| p.try_exists()),
"an ownership sentinel protects whatever the path's shape is"
);
}
#[test]
fn workspace_needs_protection_ignores_a_plain_main_checkout() {
let checkout = TempDir::new().expect("checkout");
std::fs::write(checkout.path().join("README.md"), b"a real repo").expect("write");
assert!(
!workspace_needs_protection(Some(checkout.path()), &names(), |p| p.try_exists()),
"a main checkout carries no ownership sentinel and no sweep can delete it"
);
std::fs::write(
checkout
.path()
.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
b"",
)
.expect("sentinel");
assert!(
workspace_needs_protection(Some(checkout.path()), &names(), |p| p.try_exists()),
"…and the sentinel is what decides it, not the directory's existence"
);
}
#[tokio::test]
async fn sweep_evicts_a_main_checkout_session_but_spares_a_worktree_one() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let project = TempDir::new().expect("project");
let checkout = project.path().join("owner").join("repo");
std::fs::create_dir_all(&checkout).expect("create checkout");
let worktree = session_worktree(&checkout, "tm-isolated-01");
let on_main = seed_terminal(&mgr, "on-main", ManagedSessionState::Decommissioned, 25).await;
let isolated = seed_terminal(&mgr, "isolated", ManagedSessionState::Decommissioned, 25).await;
for (id, path) in [(on_main, checkout.clone()), (isolated, worktree.clone())] {
let mut rec = mgr.get(&id).await.expect("get");
rec.workspace_path = Some(path);
mgr.store.write().await.upsert(rec).await.expect("re-seed");
}
let before = mgr.numbered_snapshot(&mgr.list().await).await;
let main_slot = before
.iter()
.find(|s| s.record.as_ref().map(|r| r.id) == Some(on_main))
.expect("observed")
.slot;
let outcome = sweep_twice(&mgr, Utc::now()).await;
assert_eq!(
outcome.evicted,
vec![on_main],
"only the main-checkout record is evicted: {outcome:?}"
);
assert!(
mgr.get(&isolated).await.is_ok(),
"`tm launch --worktree`'s record still protects its worktree"
);
assert!(checkout.exists(), "the main checkout is untouched");
assert!(worktree.exists(), "and so is the worktree");
let after = mgr.numbered_snapshot(&mgr.list().await).await;
assert!(
after.iter().all(|s| s.slot != main_slot),
"the freed NUM leaves the listing"
);
}
#[tokio::test]
async fn sweep_evicts_across_the_twenty_four_hour_boundary() {
let dir = crate::test_support::hermetic_temp_dir();
let (mgr, _fake) = make_manager(&dir).await;
let inside = seed_terminal(&mgr, "inside", ManagedSessionState::Decommissioned, 12).await;
let outside = seed_terminal(&mgr, "outside", ManagedSessionState::Decommissioned, 25).await;
let outcome = sweep_twice(&mgr, Utc::now()).await;
assert_eq!(
outcome.evicted,
vec![outside],
"12 hours survives, 25 hours does not: {outcome:?}"
);
assert!(mgr.get(&inside).await.is_ok());
}
#[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);
}