use std::collections::HashSet;
use std::path::PathBuf;
use chrono::{Duration, Utc};
use tempfile::TempDir;
use super::dedup::{is_resolved_existing, plan_dedup, plan_workspace_duplicates};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use super::tests::FakeTmuxDriver;
use crate::session_manager::SessionManager;
#[allow(clippy::too_many_arguments)]
fn rec(
tmux_name: &str,
source_id: Option<&str>,
ws_path: Option<&PathBuf>,
state: ManagedSessionState,
activity_offset_secs: i64,
) -> SessionRecord {
let created = Utc::now();
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.into(),
cwd: ws_path
.cloned()
.unwrap_or_else(|| PathBuf::from("/unknown")),
task: "t".into(),
state,
created_at: created,
last_activity_at: Some(created + Duration::seconds(activity_offset_secs)),
workspace_path: ws_path.cloned(),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: source_id.map(Into::into),
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
}
}
#[test]
fn is_resolved_existing_true_for_real_dir() {
let dir = TempDir::new().unwrap();
let r = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
0,
);
assert!(is_resolved_existing(&r));
}
#[test]
fn is_resolved_existing_false_for_missing_dir() {
let dir = TempDir::new().unwrap();
let ghost = dir.path().join("never-existed");
let r = rec(
"tm-proj-01",
Some("proj"),
Some(&ghost),
ManagedSessionState::Stopped,
0,
);
assert!(!is_resolved_existing(&r));
}
#[test]
fn is_resolved_existing_false_for_none_and_unknown_sentinel() {
let none_ws = rec(
"tm-proj-01",
Some("proj"),
None,
ManagedSessionState::Stopped,
0,
);
assert!(!is_resolved_existing(&none_ws));
let sentinel = PathBuf::from("/unknown");
let literal_unknown = rec(
"tm-proj-02",
Some("proj"),
Some(&sentinel),
ManagedSessionState::Stopped,
0,
);
assert!(!is_resolved_existing(&literal_unknown));
}
#[test]
fn plan_dedup_two_resolved_existing_paths_untouched() {
let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap();
let older = rec(
"tm-proj-01",
Some("proj"),
Some(&dir_a.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
let newer = rec(
"tm-proj-02",
Some("proj"),
Some(&dir_b.path().to_path_buf()),
ManagedSessionState::Stopped,
99, );
let losers = plan_dedup(&[older, newer], &HashSet::new());
assert!(
losers.is_empty(),
"two resolved-existing distinct-path records must both survive: {losers:?}"
);
}
#[test]
fn plan_dedup_prefers_resolved_existing_over_unknown() {
let dir = TempDir::new().unwrap();
let resolved = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
10,
);
let unknown = rec(
"tm-proj-02",
Some("proj"),
None,
ManagedSessionState::Stopped,
99, );
let resolved_id = resolved.id;
let unknown_id = unknown.id;
let losers = plan_dedup(&[resolved, unknown], &HashSet::new());
assert_eq!(
losers,
vec![unknown_id],
"the /unknown stub must be the loser"
);
assert!(
!losers.contains(&resolved_id),
"the resolved-existing record must survive"
);
}
#[test]
fn plan_dedup_dead_path_loses_to_resolved_existing() {
let dir = TempDir::new().unwrap();
let ghost_dir = TempDir::new().unwrap();
let ghost_path = ghost_dir.path().join("removed-worktree");
let existing = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
let dead = rec(
"tm-proj-02",
Some("proj"),
Some(&ghost_path),
ManagedSessionState::Stopped,
99, );
let existing_id = existing.id;
let dead_id = dead.id;
let losers = plan_dedup(&[existing, dead], &HashSet::new());
assert_eq!(
losers,
vec![dead_id],
"the dead-path record must be the loser"
);
assert!(!losers.contains(&existing_id));
}
#[test]
fn plan_dedup_mixed_three_way_group_existing_survives_others_lose() {
let dir = TempDir::new().unwrap();
let ghost_dir = TempDir::new().unwrap();
let ghost_path = ghost_dir.path().join("removed-worktree");
let existing = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
let dead = rec(
"tm-proj-02",
Some("proj"),
Some(&ghost_path),
ManagedSessionState::Stopped,
50,
);
let unknown = rec(
"tm-proj-03",
Some("proj"),
None,
ManagedSessionState::Stopped,
99,
);
let existing_id = existing.id;
let dead_id = dead.id;
let unknown_id = unknown.id;
let losers = plan_dedup(&[existing, dead, unknown], &HashSet::new());
let got: HashSet<ManagedSessionId> = losers.iter().copied().collect();
let expected: HashSet<ManagedSessionId> = [dead_id, unknown_id].into_iter().collect();
assert_eq!(got, expected);
assert!(!losers.contains(&existing_id));
}
#[test]
fn plan_dedup_live_group_untouched() {
let dir = TempDir::new().unwrap();
let live = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Active,
10,
);
let stale = rec(
"tm-proj-02",
Some("proj"),
None,
ManagedSessionState::Stopped,
5,
);
let mut live_names = HashSet::new();
live_names.insert("tm-proj-01".to_string());
let losers = plan_dedup(&[live, stale], &live_names);
assert!(
losers.is_empty(),
"a group with a live member must be untouched: {losers:?}"
);
}
#[test]
fn plan_dedup_distinct_source_ids_untouched() {
let dir = TempDir::new().unwrap();
let a = rec(
"tm-a-01",
Some("proj-a"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
10,
);
let b = rec(
"tm-b-01",
Some("proj-b"),
None,
ManagedSessionState::Stopped,
10,
);
let losers = plan_dedup(&[a, b], &HashSet::new());
assert!(
losers.is_empty(),
"distinct projects must be untouched: {losers:?}"
);
}
#[test]
fn plan_dedup_unknown_only_group_keeps_most_recent() {
let older = rec(
"tm-proj-01",
Some("proj"),
None,
ManagedSessionState::Stopped,
5,
);
let newer = rec(
"tm-proj-02",
Some("proj"),
None,
ManagedSessionState::Stopped,
50,
);
let older_id = older.id;
let newer_id = newer.id;
let losers = plan_dedup(&[older, newer], &HashSet::new());
assert_eq!(
losers,
vec![older_id],
"with no resolved-existing record, the older /unknown loses"
);
assert!(!losers.contains(&newer_id));
}
#[test]
fn plan_dedup_singleton_group_untouched() {
let dir = TempDir::new().unwrap();
let only = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
10,
);
assert!(plan_dedup(&[only], &HashSet::new()).is_empty());
}
#[tokio::test]
async fn reconcile_dedup_collapses_stopped_duplicates() {
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let resolved = rec(
"tm-proj-01",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Stopped,
10,
);
let unknown = rec(
"tm-proj-02",
Some("proj"),
None,
ManagedSessionState::Stopped,
20,
);
let resolved_id = resolved.id;
let unknown_id = unknown.id;
{
let mut store = mgr.store.write().await;
store.upsert(resolved).await.unwrap();
store.upsert(unknown).await.unwrap();
}
mgr.reconcile_on_boot(false).await.expect("reconcile");
let unknown_after = mgr.get(&unknown_id).await.unwrap();
assert_eq!(
unknown_after.state,
ManagedSessionState::Decommissioned,
"the /unknown duplicate must be decommissioned by dedup"
);
let resolved_after = mgr.get(&resolved_id).await.unwrap();
assert_ne!(
resolved_after.state,
ManagedSessionState::Decommissioned,
"the resolved-existing survivor must NOT be decommissioned"
);
assert!(
ws_dir.path().exists(),
"the resolved survivor's workspace directory must remain on disk"
);
}
#[tokio::test]
async fn reconcile_dedup_leaves_two_resolved_existing_worktrees_untouched() {
let dir = TempDir::new().unwrap();
let ws_a = TempDir::new().unwrap();
let ws_b = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let a = rec(
"tm-proj-01",
Some("proj"),
Some(&ws_a.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
let b = rec(
"tm-proj-02",
Some("proj"),
Some(&ws_b.path().to_path_buf()),
ManagedSessionState::Stopped,
99,
);
let a_id = a.id;
let b_id = b.id;
{
let mut store = mgr.store.write().await;
store.upsert(a).await.unwrap();
store.upsert(b).await.unwrap();
}
mgr.reconcile_on_boot(false).await.expect("reconcile");
assert_ne!(
mgr.get(&a_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"first concurrent worktree session must survive"
);
assert_ne!(
mgr.get(&b_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"second concurrent worktree session must survive"
);
assert!(ws_a.path().exists(), "workspace A must remain on disk");
assert!(ws_b.path().exists(), "workspace B must remain on disk");
}
#[tokio::test]
async fn reconcile_dedup_skips_live_group() {
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names.lock().unwrap().push("tm-proj-01".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let live = rec(
"tm-proj-01",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Active,
10,
);
let stale = rec(
"tm-proj-02",
Some("proj"),
None,
ManagedSessionState::Stopped,
20,
);
let live_id = live.id;
let stale_id = stale.id;
{
let mut store = mgr.store.write().await;
store.upsert(live).await.unwrap();
store.upsert(stale).await.unwrap();
}
mgr.reconcile_on_boot(false).await.expect("reconcile");
assert_ne!(
mgr.get(&live_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the live record must survive"
);
assert_ne!(
mgr.get(&stale_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"a group with a live member must NOT be deduped"
);
}
fn owned(mut r: SessionRecord) -> SessionRecord {
r.workspace_owned = true;
r
}
#[test]
fn plan_workspace_duplicates_collapses_dead_sibling_of_live_record() {
let dir = TempDir::new().unwrap();
let live = rec(
"tm-tcode-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Active,
1,
);
let stale = rec(
"tm-tm-tcode-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
50, );
let live_id = live.id;
let stale_id = stale.id;
let mut live_names = HashSet::new();
live_names.insert("tm-tcode-01".to_string());
let losers = plan_workspace_duplicates(&[live, stale], &live_names);
assert_eq!(
losers,
vec![stale_id],
"the dead-tmux_name sibling at the same literal path must be the loser"
);
assert!(!losers.contains(&live_id), "the live record must survive");
}
#[test]
fn plan_workspace_duplicates_picks_most_recent_when_none_live() {
let dir = TempDir::new().unwrap();
let older = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
let newer = rec(
"tm-proj-02",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
99,
);
let older_id = older.id;
let newer_id = newer.id;
let losers = plan_workspace_duplicates(&[older, newer], &HashSet::new());
assert_eq!(losers, vec![older_id], "the older record must be the loser");
assert!(!losers.contains(&newer_id));
}
#[test]
fn plan_workspace_duplicates_leaves_two_live_records_untouched() {
let dir = TempDir::new().unwrap();
let a = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Active,
1,
);
let b = rec(
"tm-proj-02",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Active,
99,
);
let mut live_names = HashSet::new();
live_names.insert("tm-proj-01".to_string());
live_names.insert("tm-proj-02".to_string());
let losers = plan_workspace_duplicates(&[a, b], &live_names);
assert!(
losers.is_empty(),
"two simultaneously-live records at one path must be left for the operator: {losers:?}"
);
}
#[test]
fn plan_workspace_duplicates_owned_record_is_permanent_survivor() {
let dir = TempDir::new().unwrap();
let canonical = owned(rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
));
let phantom = rec(
"tm-tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
99, );
let canonical_id = canonical.id;
let phantom_id = phantom.id;
let losers = plan_workspace_duplicates(&[canonical, phantom], &HashSet::new());
assert_eq!(
losers,
vec![phantom_id],
"the unowned duplicate must be the loser"
);
assert!(
!losers.contains(&canonical_id),
"the SM-owned record must never be a loser"
);
}
#[test]
fn plan_workspace_duplicates_owned_record_never_kills_live_duplicate() {
let dir = TempDir::new().unwrap();
let canonical = owned(rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
));
let live_dupe = rec(
"tm-tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Active,
99,
);
let mut live_names = HashSet::new();
live_names.insert("tm-tm-proj-01".to_string());
let losers = plan_workspace_duplicates(&[canonical, live_dupe], &live_names);
assert!(
losers.is_empty(),
"a live duplicate must never be decommissioned, even alongside an owned record: {losers:?}"
);
}
#[test]
fn plan_workspace_duplicates_two_owned_records_untouched() {
let dir = TempDir::new().unwrap();
let a = owned(rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
));
let b = owned(rec(
"tm-proj-02",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
99,
));
let losers = plan_workspace_duplicates(&[a, b], &HashSet::new());
assert!(
losers.is_empty(),
"two owned records at one path must never be auto-collapsed: {losers:?}"
);
}
#[test]
fn plan_workspace_duplicates_distinct_paths_untouched() {
let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap();
let a = rec(
"tm-proj-01",
Some("proj"),
Some(&dir_a.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
let b = rec(
"tm-proj-02",
Some("proj"),
Some(&dir_b.path().to_path_buf()),
ManagedSessionState::Stopped,
99,
);
let losers = plan_workspace_duplicates(&[a, b], &HashSet::new());
assert!(
losers.is_empty(),
"distinct paths must never be grouped: {losers:?}"
);
}
#[test]
fn plan_workspace_duplicates_singleton_untouched() {
let dir = TempDir::new().unwrap();
let only = rec(
"tm-proj-01",
Some("proj"),
Some(&dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
);
assert!(plan_workspace_duplicates(&[only], &HashSet::new()).is_empty());
}
#[tokio::test]
async fn reconcile_dedup_collapses_exact_workspace_duplicate_of_live_record() {
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names.lock().unwrap().push("tm-tcode-01".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let live = rec(
"tm-tcode-01",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Active,
1,
);
let stale = rec(
"tm-tm-tcode-01",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Stopped,
50,
);
let live_id = live.id;
let stale_id = stale.id;
{
let mut store = mgr.store.write().await;
store.upsert(live).await.unwrap();
store.upsert(stale).await.unwrap();
}
mgr.reconcile_on_boot(false).await.expect("reconcile");
assert_eq!(
mgr.get(&stale_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the stale duplicate at the same literal path must be decommissioned"
);
assert_ne!(
mgr.get(&live_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the live record must survive"
);
assert!(
ws_dir.path().exists(),
"the workspace directory must remain on disk (loser was never SM-owned)"
);
}
#[tokio::test]
async fn reconcile_dedup_collapses_dead_duplicate_of_owned_record() {
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let canonical = owned(rec(
"tm-proj-01",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Stopped,
1,
));
let phantom = rec(
"tm-tm-proj-01",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Stopped,
99,
);
let canonical_id = canonical.id;
let phantom_id = phantom.id;
{
let mut store = mgr.store.write().await;
store.upsert(canonical).await.unwrap();
store.upsert(phantom).await.unwrap();
}
mgr.reconcile_on_boot(false).await.expect("reconcile");
assert_eq!(
mgr.get(&phantom_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the unowned phantom duplicate must be decommissioned"
);
assert_ne!(
mgr.get(&canonical_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the SM-owned record must never be decommissioned by dedup"
);
assert!(
ws_dir.path().exists(),
"the owned workspace directory must remain on disk"
);
}