use std::path::PathBuf;
use chrono::Utc;
use super::summary::{reconcile_against_tmux, reconcile_live_state};
use super::{checked_summaries, record_to_json, record_to_summary};
use crate::session_manager::{
InjectionStatus, ManagedError, ManagedSessionId, ManagedSessionState, ManagedTmuxDriver,
SessionRecord,
};
struct EnumErrTmux;
impl ManagedTmuxDriver for EnumErrTmux {
fn create_session(&self, _n: &str, _w: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _n: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _n: &str, _t: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _n: &str, _l: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Err(ManagedError::TmuxUnavailable("enumeration failed".into()))
}
}
struct PaneAwareTmux {
pane_alive: Option<bool>,
}
impl ManagedTmuxDriver for PaneAwareTmux {
fn create_session(&self, _n: &str, _w: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _n: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _n: &str, _t: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _n: &str, _l: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(Vec::new())
}
fn pane_exists(&self, _name: &str, _pane_id: &str) -> bool {
self.pane_alive.unwrap_or(true)
}
fn pane_exists_checked(&self, _name: &str, _pane_id: &str) -> Option<bool> {
self.pane_alive
}
}
fn make_record(source_id: Option<&str>) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tmpm-test-session".into(),
cwd: PathBuf::from("/tmp/test"),
task: "test task".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
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: source_id.map(String::from),
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 reconcile_live_state_flips_stopped_to_active_when_alive() {
use std::collections::HashSet;
let mut rec = make_record(None);
rec.state = ManagedSessionState::Stopped;
rec.tmux_name = "tm-worker-01".into();
let records = vec![rec];
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
assert_eq!(summaries[0].state, "stopped", "persisted state is stopped");
let live: HashSet<String> = ["tm-worker-01".to_string()].into_iter().collect();
let attached: HashSet<String> = ["tm-worker-01".to_string()].into_iter().collect();
reconcile_live_state(&EnumErrTmux, &mut summaries, &records, &live, &attached);
assert_eq!(summaries[0].state, "active", "live tmux → active");
assert!(summaries[0].attached, "attached client → attached flag");
let mut summaries2: Vec<_> = records.iter().map(record_to_summary).collect();
reconcile_live_state(
&EnumErrTmux,
&mut summaries2,
&records,
&HashSet::new(),
&HashSet::new(),
);
assert_eq!(summaries2[0].state, "stopped", "no tmux → stopped");
assert!(!summaries2[0].attached);
}
#[test]
fn reconcile_live_state_leaves_persisted_state_untouched() {
use std::collections::HashSet;
let mut rec = make_record(None);
rec.state = ManagedSessionState::Active;
rec.tmux_name = "tm-zombie-01".into();
let records = vec![rec];
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
assert_eq!(summaries[0].state, "active");
assert_eq!(summaries[0].persisted_state, "active");
reconcile_live_state(
&EnumErrTmux,
&mut summaries,
&records,
&HashSet::new(),
&HashSet::new(),
);
assert_eq!(
summaries[0].state, "stopped",
"display state still reconciles to stopped when tmux is gone"
);
assert_eq!(
summaries[0].persisted_state, "active",
"persisted_state must survive display reconciliation unchanged (#3531)"
);
}
#[test]
fn reconcile_against_tmux_fails_closed_on_enumeration_error() {
let mut rec = make_record(None);
rec.state = ManagedSessionState::Active;
rec.tmux_name = "tm-live-1".into();
let records = vec![rec];
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
assert_eq!(summaries[0].state, "active");
reconcile_against_tmux(&EnumErrTmux, &mut summaries, &records);
assert_eq!(
summaries[0].state, "active",
"a tmux enumeration error must NOT flip a live record to stopped"
);
assert!(
!summaries[0].attached,
"attached stays at its default on a probe error"
);
}
#[test]
fn reconcile_live_state_leaves_terminal_states() {
use std::collections::HashSet;
let live: HashSet<String> = ["tm-x".to_string()].into_iter().collect();
let empty = HashSet::new();
for state in [
ManagedSessionState::Deleted,
ManagedSessionState::Decommissioned,
ManagedSessionState::Errored,
ManagedSessionState::Provisioning,
] {
let mut rec = make_record(None);
rec.state = state.clone();
rec.tmux_name = "tm-x".into();
let records = vec![rec];
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
let before = summaries[0].state.clone();
reconcile_live_state(&EnumErrTmux, &mut summaries, &records, &live, &empty);
assert_eq!(
summaries[0].state, before,
"{state:?} must not be reconciled by liveness"
);
}
}
#[test]
fn reconcile_live_state_prefers_pane_scoped_liveness_over_name_membership() {
use std::collections::HashSet;
let mut rec = make_record(None);
rec.state = ManagedSessionState::Stopped;
rec.tmux_name = "tm-tagents".into();
rec.pane_id = Some("%3".to_string());
let records = vec![rec];
let live: HashSet<String> = ["tm-tagents".to_string()].into_iter().collect();
let empty = HashSet::new();
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
reconcile_live_state(
&PaneAwareTmux {
pane_alive: Some(false),
},
&mut summaries,
&records,
&live,
&empty,
);
assert_eq!(
summaries[0].state, "stopped",
"a live NAME must not resurrect the record when its OWN pane is confirmed gone"
);
assert_eq!(
summaries[0].persisted_state, "stopped",
"persisted_state must never disagree with the (correctly reconciled) display state here"
);
let mut summaries2: Vec<_> = records.iter().map(record_to_summary).collect();
reconcile_live_state(
&PaneAwareTmux {
pane_alive: Some(true),
},
&mut summaries2,
&records,
&live,
&empty,
);
assert_eq!(
summaries2[0].state, "active",
"a live NAME whose own pane is confirmed present must still reconcile to active"
);
}
#[test]
fn reconcile_live_state_legacy_record_without_pane_id_uses_name_only() {
use std::collections::HashSet;
let mut rec = make_record(None);
rec.state = ManagedSessionState::Stopped;
rec.tmux_name = "tm-legacy-01".into();
rec.pane_id = None;
let records = vec![rec];
let live: HashSet<String> = ["tm-legacy-01".to_string()].into_iter().collect();
let empty = HashSet::new();
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
reconcile_live_state(
&PaneAwareTmux {
pane_alive: Some(false),
},
&mut summaries,
&records,
&live,
&empty,
);
assert_eq!(
summaries[0].state, "active",
"a legacy record with no pane_id falls back to the name-only check"
);
}
#[test]
fn reconcile_live_state_pane_query_error_does_not_flip_live_record_to_stopped() {
use std::collections::HashSet;
let mut rec = make_record(None);
rec.state = ManagedSessionState::Active;
rec.tmux_name = "tm-flaky-01".into();
rec.pane_id = Some("%7".to_string());
let records = vec![rec];
let live: HashSet<String> = ["tm-flaky-01".to_string()].into_iter().collect();
let empty = HashSet::new();
let mut summaries: Vec<_> = records.iter().map(record_to_summary).collect();
assert_eq!(summaries[0].state, "active");
assert_eq!(summaries[0].persisted_state, "active");
reconcile_live_state(
&PaneAwareTmux { pane_alive: None },
&mut summaries,
&records,
&live,
&empty,
);
assert_eq!(
summaries[0].state, "active",
"a transient pane-query failure must fall back to the name-only signal, \
never assert the pane is confirmed gone"
);
assert_eq!(
summaries[0].persisted_state, "active",
"state and persisted_state must still agree — no contradiction introduced"
);
}
#[test]
fn serializers_include_source_id() {
let r = make_record(Some("owner/repo"));
let json = record_to_json(&r);
assert_eq!(
json["source_id"].as_str(),
Some("owner/repo"),
"record_to_json must serialize source_id when present"
);
let summary = record_to_summary(&r);
assert_eq!(
summary.source_id.as_deref(),
Some("owner/repo"),
"record_to_summary must include source_id when present"
);
let r_none = make_record(None);
let json_none = record_to_json(&r_none);
assert!(
json_none["source_id"].is_null(),
"record_to_json must serialize source_id as null when absent, got {:?}",
json_none["source_id"]
);
let summary_none = record_to_summary(&r_none);
assert_eq!(
summary_none.source_id, None,
"record_to_summary must carry None source_id when absent"
);
}
#[test]
fn serializers_include_deliverable_id() {
use crate::deliverable::DeliverableId;
let mut r = make_record(None);
let did = DeliverableId::new();
r.deliverable_id = Some(did);
let json = record_to_json(&r);
assert_eq!(
json["deliverable_id"].as_str(),
Some(did.to_string().as_str()),
"record_to_json must serialize deliverable_id when present"
);
let summary = record_to_summary(&r);
assert_eq!(
summary.deliverable_id.as_deref(),
Some(did.to_string().as_str()),
"record_to_summary must include deliverable_id when present"
);
let r_none = make_record(None);
let json_none = record_to_json(&r_none);
assert!(
json_none["deliverable_id"].is_null(),
"record_to_json must serialize deliverable_id as null when absent"
);
let summary_none = record_to_summary(&r_none);
assert_eq!(
summary_none.deliverable_id, None,
"record_to_summary must carry None deliverable_id when absent"
);
}
#[test]
fn decommission_workspace_removed_reflects_ownership() {
use super::{DecommissionResponse, SessionSummary};
let owned_summary = SessionSummary {
id: "abc-123".into(),
name: "tmpm-test".into(),
state: "decommissioned".into(),
persisted_state: "decommissioned".into(),
workspace_path: None, repo_url: None,
branch: None,
created_at: "2025-06-28T00:00:00Z".into(),
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
attached: false,
slot: 0,
deleted: false,
};
let resp_owned = DecommissionResponse {
summary: owned_summary,
workspace_removed: true,
workspace_path_was: Some("/workspaces/trusty-mpm/session-abc".into()),
};
let json = serde_json::to_value(&resp_owned).unwrap();
assert_eq!(json["workspace_removed"], true, "owned: must be true");
assert_eq!(
json["workspace_path_was"].as_str(),
Some("/workspaces/trusty-mpm/session-abc"),
"owned: workspace_path_was must be present"
);
let unowned_summary = SessionSummary {
id: "xyz-456".into(),
name: "tmpm-adopted".into(),
state: "decommissioned".into(),
persisted_state: "decommissioned".into(),
workspace_path: None,
repo_url: None,
branch: None,
created_at: "2025-06-28T00:00:00Z".into(),
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
attached: false,
slot: 0,
deleted: false,
};
let resp_unowned = DecommissionResponse {
summary: unowned_summary,
workspace_removed: false,
workspace_path_was: None,
};
let json2 = serde_json::to_value(&resp_unowned).unwrap();
assert_eq!(json2["workspace_removed"], false, "unowned: must be false");
assert!(
json2.get("workspace_path_was").is_none(),
"unowned: workspace_path_was must be absent (skip_serializing_if = None)"
);
}
#[test]
fn injection_status_wire_omits_not_applicable() {
let r = make_record(None);
assert_eq!(r.injection_status, InjectionStatus::NotApplicable);
let json = record_to_json(&r);
assert!(
json["injection_status"].is_null(),
"record_to_json must serialize NotApplicable as null, got {:?}",
json["injection_status"]
);
let summary = record_to_summary(&r);
assert_eq!(
summary.injection_status, None,
"record_to_summary must carry None for NotApplicable"
);
}
#[test]
fn injection_status_wire_stringifies_other_variants() {
let cases = [
(InjectionStatus::Pending, "pending"),
(InjectionStatus::Success, "success"),
(InjectionStatus::FailedTimeout, "failed_timeout"),
(InjectionStatus::FailedSessionDied, "failed_session_died"),
];
for (status, expected) in cases {
let mut r = make_record(None);
r.injection_status = status;
let json = record_to_json(&r);
assert_eq!(
json["injection_status"].as_str(),
Some(expected),
"record_to_json must serialize {status:?} as {expected:?}"
);
let summary = record_to_summary(&r);
assert_eq!(
summary.injection_status.as_deref(),
Some(expected),
"record_to_summary must carry {expected:?} for {status:?}"
);
}
}
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn fake_home() -> (tempfile::TempDir, HomeGuard) {
let home = tempfile::TempDir::new().unwrap();
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", home.path()) };
(home, HomeGuard(prior))
}
#[tokio::test]
#[serial_test::serial]
async fn checked_summaries_preserves_input_order_and_flags_only_dead_sessions() {
let _home = fake_home();
let mut r0 = make_record(None);
r0.state = ManagedSessionState::Active;
let mut r1 = make_record(None);
r1.state = ManagedSessionState::Stopped;
r1.cwd = PathBuf::from("/nonexistent/checked-summaries-order-r1");
r1.workspace_path = None;
r1.last_cwd = None;
let real_dir = tempfile::TempDir::new().expect("tempdir for r2");
let mut r2 = make_record(None);
r2.state = ManagedSessionState::Errored;
r2.cwd = real_dir.path().to_path_buf();
r2.workspace_path = None;
r2.last_cwd = None;
let records = vec![r0.clone(), r1.clone(), r2.clone()];
let summaries = checked_summaries(&records).await;
assert_eq!(summaries.len(), 3, "one summary per input record");
assert_eq!(
summaries[0].id,
r0.id.to_string(),
"r0 must stay at index 0"
);
assert_eq!(
summaries[1].id,
r1.id.to_string(),
"r1 must stay at index 1"
);
assert_eq!(
summaries[2].id,
r2.id.to_string(),
"r2 must stay at index 2"
);
assert!(
!summaries[0].unresumable,
"an Active session must never be flagged, regardless of probe fan-out"
);
assert!(
summaries[1].unresumable,
"a stopped session with no existing workdir candidate must be flagged dead"
);
assert!(
!summaries[2].unresumable,
"an errored session with a REAL existing cwd must stay resumable"
);
}
#[tokio::test]
#[serial_test::serial]
async fn checked_summaries_flags_stale_assets_only_for_relevant_states() {
let (home, _guard) = fake_home();
let fw = crate::core::paths::FrameworkPaths::default();
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("rust-engineer.md"), "v1").unwrap();
let workspace = home.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let session_fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&workspace);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&session_fw.claude_agents_dir(),
|_| true,
)
.unwrap();
std::fs::write(bundled.join("rust-engineer.md"), "v2 — catalog moved").unwrap();
let mut active = make_record(None);
active.state = ManagedSessionState::Active;
active.workspace_path = Some(workspace);
let provisioning_ws = home.path().join("never-deployed");
std::fs::create_dir_all(&provisioning_ws).unwrap();
let mut provisioning = make_record(None);
provisioning.state = ManagedSessionState::Provisioning;
provisioning.workspace_path = Some(provisioning_ws);
let records = vec![active, provisioning];
let summaries = checked_summaries(&records).await;
assert!(
summaries[0].stale_assets,
"an Active session whose deployed agent drifted from the bundled \
source must be flagged stale_assets"
);
assert!(
!summaries[1].stale_assets,
"a Provisioning session must never be probed — it has not deployed yet"
);
}
#[tokio::test]
#[serial_test::serial]
async fn checked_summaries_stale_assets_independent_per_session_sharing_one_catalog() {
let (home, _guard) = fake_home();
let fw = crate::core::paths::FrameworkPaths::default();
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("rust-engineer.md"), "v1").unwrap();
let ws_a = home.path().join("workspace-a");
std::fs::create_dir_all(&ws_a).unwrap();
let fw_a = crate::core::paths::FrameworkPaths::for_managed_workspace(&ws_a);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&fw_a.claude_agents_dir(),
|_| true,
)
.unwrap();
std::fs::write(bundled.join("rust-engineer.md"), "v2 — catalog moved").unwrap();
let ws_b = home.path().join("workspace-b");
std::fs::create_dir_all(&ws_b).unwrap();
let fw_b = crate::core::paths::FrameworkPaths::for_managed_workspace(&ws_b);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&fw_b.claude_agents_dir(),
|_| true,
)
.unwrap();
let mut a = make_record(None);
a.state = ManagedSessionState::Active;
a.workspace_path = Some(ws_a);
let mut b = make_record(None);
b.state = ManagedSessionState::Active;
b.workspace_path = Some(ws_b);
let records = vec![a, b];
let summaries = checked_summaries(&records).await;
assert!(
summaries[0].stale_assets,
"session A deployed against v1 and the catalog moved to v2 — must be stale"
);
assert!(
!summaries[1].stale_assets,
"session B deployed against the current v2 catalog — must stay fresh, \
even though it shares the SAME cached catalog hashes as session A"
);
}