use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Mutex;
use chrono::{Duration, Utc};
use tempfile::TempDir;
use super::dedup::{is_resolved_existing, plan_dedup, plan_workspace_duplicates};
use super::manager::{ManagedError, ManagedTmuxDriver};
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,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[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"
);
}
async fn seed_same_path_pair(
dir: &TempDir,
tmux: std::sync::Arc<dyn super::driver::ManagedTmuxDriver>,
) -> (SessionManager, ManagedSessionId, ManagedSessionId, TempDir) {
let ws_dir = TempDir::new().unwrap();
let mgr = SessionManager::new(dir.path(), tmux).await.unwrap();
let live = rec(
"tm-dogfood",
Some("bobmatnyc/trusty-tools"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Active,
1,
);
let other = rec(
"tm-dogfood-02",
Some("bobmatnyc/trusty-tools"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Stopped,
900,
);
let live_id = live.id;
let other_id = other.id;
{
let mut store = mgr.store.write().await;
store.upsert(live).await.unwrap();
store.upsert(other).await.unwrap();
}
(mgr, live_id, other_id, ws_dir)
}
#[tokio::test]
async fn dedup_refuses_when_list_sessions_fails() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names.lock().unwrap().push("tm-dogfood".into());
let (mgr, live_id, other_id, _ws) = seed_same_path_pair(&dir, fake.clone()).await;
*fake.list_sessions_should_fail.lock().unwrap() = true;
let mut to_resume = Vec::new();
let decommissioned = mgr
.dedup_stale_duplicates(&mut to_resume)
.await
.expect("an unobservable tmux is a skip, not an error to the caller");
assert!(
decommissioned.is_empty(),
"dedup decommissioned {decommissioned:?} while tmux could not be queried"
);
for (label, id) in [("live", live_id), ("sibling", other_id)] {
assert_ne!(
mgr.get(&id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the {label} record must survive a liveness query that failed"
);
}
}
#[tokio::test]
async fn dedup_refuses_when_the_tmux_server_cannot_be_started() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names.lock().unwrap().push("tm-dogfood".into());
let (mgr, live_id, other_id, _ws) = seed_same_path_pair(&dir, fake.clone()).await;
*fake.ensure_server_up_should_fail.lock().unwrap() = true;
let mut to_resume = Vec::new();
let decommissioned = mgr.dedup_stale_duplicates(&mut to_resume).await.unwrap();
assert!(
decommissioned.is_empty(),
"dedup decommissioned {decommissioned:?} while the tmux server was unreachable"
);
assert_ne!(
mgr.get(&live_id).await.unwrap().state,
ManagedSessionState::Decommissioned
);
assert_ne!(
mgr.get(&other_id).await.unwrap().state,
ManagedSessionState::Decommissioned
);
}
#[tokio::test]
async fn dedup_refuses_on_the_noop_driver_rather_than_reading_zero_as_dead() {
let dir = TempDir::new().unwrap();
let noop: std::sync::Arc<dyn super::driver::ManagedTmuxDriver> =
std::sync::Arc::new(super::real_tmux::NoopTmuxDriver);
let (mgr, live_id, other_id, _ws) = seed_same_path_pair(&dir, noop).await;
let mut to_resume = Vec::new();
let decommissioned = mgr.dedup_stale_duplicates(&mut to_resume).await.unwrap();
assert!(
decommissioned.is_empty(),
"dedup decommissioned {decommissioned:?} on a driver that never reached tmux"
);
assert_ne!(
mgr.get(&live_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"an attached session must not be tombstoned because tmux was unreachable"
);
assert_ne!(
mgr.get(&other_id).await.unwrap().state,
ManagedSessionState::Decommissioned
);
}
#[tokio::test]
async fn dedup_collapses_the_dead_sibling_when_tmux_answers() {
let dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names.lock().unwrap().push("tm-dogfood".into());
let (mgr, live_id, other_id, _ws) = seed_same_path_pair(&dir, fake.clone()).await;
let mut to_resume = Vec::new();
let decommissioned = mgr.dedup_stale_duplicates(&mut to_resume).await.unwrap();
assert_eq!(
decommissioned,
vec![other_id],
"the dead sibling at the same literal path is still the loser"
);
assert_ne!(
mgr.get(&live_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the live record survives"
);
assert!(
*fake.ensure_server_up_calls.lock().unwrap() >= 1,
"the liveness query must run the server-up guard before listing"
);
}
struct LateArrivalTmuxDriver {
calls: Mutex<u32>,
live_name: String,
}
impl ManagedTmuxDriver for LateArrivalTmuxDriver {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
let mut calls = self.calls.lock().unwrap();
*calls += 1;
if *calls == 1 {
Ok(Vec::new())
} else {
Ok(vec![self.live_name.clone()])
}
}
}
#[tokio::test]
async fn dedup_skips_a_plan_dedup_loser_whose_tmux_went_live_before_act() {
let dir = TempDir::new().unwrap();
let fake = std::sync::Arc::new(LateArrivalTmuxDriver {
calls: Mutex::new(0),
live_name: "tm-old".to_string(),
});
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let old = rec(
"tm-old",
Some("proj"),
None,
ManagedSessionState::Stopped,
1,
);
let new = rec(
"tm-new",
Some("proj"),
None,
ManagedSessionState::Stopped,
50,
);
let old_id = old.id;
let new_id = new.id;
{
let mut store = mgr.store.write().await;
store.upsert(old).await.unwrap();
store.upsert(new).await.unwrap();
}
let mut to_resume = Vec::new();
let decommissioned = mgr.dedup_stale_duplicates(&mut to_resume).await.unwrap();
assert!(
decommissioned.is_empty(),
"the loser's tmux session went live before the destructive call and must be \
skipped: {decommissioned:?}"
);
assert_ne!(
mgr.get(&old_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"a loser whose tmux session is live at act time must survive"
);
assert_ne!(
mgr.get(&new_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"the survivor was never a loser"
);
}
#[tokio::test]
async fn reconcile_never_revives_a_terminal_record_with_a_live_session() {
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names
.lock()
.unwrap()
.push("tm-lingering".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let tombstone = rec(
"tm-lingering",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Decommissioned,
1,
);
let tombstone_id = tombstone.id;
mgr.store.write().await.upsert(tombstone).await.unwrap();
mgr.reconcile_on_boot(false).await.expect("reconcile");
assert_eq!(
mgr.get(&tombstone_id).await.unwrap().state,
ManagedSessionState::Decommissioned,
"a live tmux session must not silently revive a terminal record"
);
}
#[tokio::test]
#[serial_test::serial]
async fn reconcile_warns_that_a_terminal_record_has_a_live_tmux_session() {
use tracing::instrument::WithSubscriber;
use tracing_subscriber::layer::SubscriberExt;
crate::test_support::enable_event_capture();
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names
.lock()
.unwrap()
.push("tm-lingering".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let tombstone = rec(
"tm-lingering",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Decommissioned,
1,
);
let tombstone_id = tombstone.id;
mgr.store.write().await.upsert(tombstone).await.unwrap();
let buffer = trusty_common::log_buffer::LogBuffer::new(64);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
mgr.reconcile_on_boot(false)
.with_subscriber(subscriber)
.await
.expect("reconcile");
let lines = buffer.tail(64);
let line = lines
.iter()
.find(|l| l.contains("terminal record has a LIVE tmux session"))
.unwrap_or_else(|| {
panic!(
"reconcile left `tm-lingering` terminal while its tmux session was live and \
said nothing — the operator has no way to learn the picker is hiding a \
session they are sitting in. Captured lines: {lines:#?}"
)
});
assert!(
line.contains("WARN"),
"a hidden-but-live session must not be logged below WARN: {line}"
);
assert!(
line.contains(&tombstone_id.to_string()),
"the warning must name the record so `reactivate` can be aimed at it: {line}"
);
assert!(
line.contains("/reactivate"),
"the warning must carry the call that resolves the contradiction: {line}"
);
}
#[tokio::test]
async fn reconcile_refuses_to_stop_sessions_when_tmux_cannot_be_observed() {
let dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
let fake = FakeTmuxDriver::new();
fake.seeded_names.lock().unwrap().push("tm-attached".into());
let mgr = SessionManager::new(dir.path(), fake.clone()).await.unwrap();
let live = rec(
"tm-attached",
Some("proj"),
Some(&ws_dir.path().to_path_buf()),
ManagedSessionState::Active,
1,
);
let live_id = live.id;
mgr.store.write().await.upsert(live).await.unwrap();
*fake.list_sessions_should_fail.lock().unwrap() = true;
let report = mgr
.reconcile_on_boot(true)
.await
.expect("an unobservable tmux is a skip, not an error to the caller");
assert!(
report.stopped.is_empty(),
"an attached session was reported gone because tmux could not be queried; that list \
is also the auto-resume queue, so every entry is relaunched next: {:?}",
report.stopped
);
let created = fake.create_cwd_calls.lock().unwrap().clone();
assert!(
created.is_empty(),
"auto-resume respawned a session tmux was never asked about — the operator's own \
pane gets a fresh launch on top of the one they are sitting in: {created:?}"
);
assert_eq!(
mgr.get(&live_id).await.unwrap().state,
ManagedSessionState::Active,
"the record must be left exactly as it was found"
);
assert!(
report.adopted.is_empty() && report.external_adopted.is_empty(),
"a tmux that never answered cannot have produced a session to adopt"
);
assert!(
*fake.ensure_server_up_calls.lock().unwrap() >= 2,
"the skip must not be an early return: reconcile's own liveness query and the dedup \
pass in its tail each run the server-up guard, so the tail was never reached"
);
}