use std::path::PathBuf;
use chrono::Utc;
use super::summary::{
checked_summaries_with_under, reconcile_against_tmux, reconcile_live_state,
record_to_summary_checked_under, stale_assets_for_many_under,
};
use super::{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
}
}
pub(super) 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,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[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,
stale_assets_unchecked: false,
attached: false,
slot: 0,
deleted: false,
auto_resume_parked: None,
kind: Default::default(),
};
let resp_owned = DecommissionResponse {
summary: owned_summary,
workspace_removed: true,
workspace_path_was: Some("/workspaces/trusty-mpm/session-abc".into()),
workspace_kept_reason: None,
workspace_kept_by_design: None,
};
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,
stale_assets_unchecked: false,
attached: false,
slot: 0,
deleted: false,
auto_resume_parked: None,
kind: Default::default(),
};
let resp_unowned = DecommissionResponse {
summary: unowned_summary,
workspace_removed: false,
workspace_path_was: None,
workspace_kept_reason: None,
workspace_kept_by_design: 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:?}"
);
}
}
pub(super) fn fake_base() -> tempfile::TempDir {
tempfile::TempDir::new().unwrap()
}
#[tokio::test]
async fn checked_summaries_preserves_input_order_and_flags_only_dead_sessions() {
let base = fake_base();
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_with_under(&records, true, base.path().to_path_buf()).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]
async fn checked_summaries_flags_stale_assets_only_for_relevant_states() {
let base = fake_base();
let fw = crate::core::paths::FrameworkPaths::under(base.path());
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 = base.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let session_fw =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &workspace);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&session_fw.agent_deploy_dir(),
&session_fw.skill_deploy_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 = base.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_with_under(&records, true, base.path().to_path_buf()).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"
);
assert!(
!summaries[0].stale_assets_unchecked && !summaries[1].stale_assets_unchecked,
"neither an Active (probed) nor a Provisioning (nothing to check) row \
may claim an UNDETERMINED asset verdict"
);
}
fn deploy_then_drift_catalog(base: &std::path::Path, workspace: &std::path::Path) {
let fw = crate::core::paths::FrameworkPaths::under(base);
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("rust-engineer.md"), "v1").unwrap();
std::fs::create_dir_all(workspace).unwrap();
let session_fw =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base, workspace);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&session_fw.agent_deploy_dir(),
&session_fw.skill_deploy_dir(),
|_| true,
)
.unwrap();
std::fs::write(bundled.join("rust-engineer.md"), "v2 — catalog moved").unwrap();
}
#[tokio::test]
async fn checked_summaries_does_not_probe_stopped_sessions() {
let base = fake_base();
let workspace = base.path().join("stopped-drifted");
deploy_then_drift_catalog(base.path(), &workspace);
let mut stopped = make_record(None);
stopped.state = ManagedSessionState::Stopped;
stopped.workspace_path = Some(workspace);
let summaries = checked_summaries_with_under(
std::slice::from_ref(&stopped),
true,
base.path().to_path_buf(),
)
.await;
assert!(
!summaries[0].stale_assets,
"the fleet list must NOT probe a Stopped session — a `true` here means \
the per-session filesystem probe ran anyway (#4322 regression)"
);
assert!(
summaries[0].stale_assets_unchecked,
"an unprobed Stopped row must advertise that its asset verdict is \
UNDETERMINED — absence of `[stale-assets]` must never read as 'fresh'"
);
}
#[tokio::test]
async fn staleness_inputs_computes_one_catalog_per_source_pair_shared_by_arc() {
let base = fake_base();
let records: Vec<SessionRecord> = (0..3)
.map(|i| {
let ws = base.path().join(format!("shared-catalog-{i}"));
std::fs::create_dir_all(&ws).unwrap();
let mut r = make_record(None);
r.state = ManagedSessionState::Active;
r.workspace_path = Some(ws);
r
})
.collect();
let inputs = super::summary::staleness_inputs_under(records, base.path());
assert_eq!(inputs.len(), 3, "one input per record, in input order");
assert!(
std::sync::Arc::ptr_eq(&inputs[0].3, &inputs[1].3)
&& std::sync::Arc::ptr_eq(&inputs[0].3, &inputs[2].3),
"every session resolving the SAME catalog source pair must share ONE \
computed CatalogHashes — a distinct Arc per session means the catalog \
was recomposed per session (#2444 regression)"
);
}
#[tokio::test]
async fn stale_assets_for_many_computes_catalog_exactly_once_per_source_pair() {
let base = fake_base();
let fw = crate::core::paths::FrameworkPaths::under(base.path());
let agent_source = fw.agent_source_dir();
let skill_source = fw.skill_source_dir();
let records: Vec<SessionRecord> = (0..3)
.map(|i| {
let ws = base.path().join(format!("hot-path-shared-catalog-{i}"));
std::fs::create_dir_all(&ws).unwrap();
let mut r = make_record(None);
r.state = ManagedSessionState::Active;
r.workspace_path = Some(ws);
r
})
.collect();
let result = stale_assets_for_many_under(records, base.path().to_path_buf()).await;
assert_eq!(result.len(), 3, "every record gets a result");
assert_eq!(
crate::core::update_check::compute_calls_for(&agent_source, &skill_source),
1,
"three sessions sharing one (agent_source, skill_source) pair must \
share exactly ONE CatalogHashes::compute — more than one means the \
per-session recompose #2444's review removed was reinstated inside \
the fan-out (#4326 review HIGH)"
);
}
#[tokio::test]
async fn stale_assets_for_many_is_fresh_for_a_placeholder_body_deployed_with_its_root() {
use trusty_agents_common::agents::skill_root::SKILLS_ROOT_PLACEHOLDER;
let base = fake_base();
let fw = crate::core::paths::FrameworkPaths::under(base.path());
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
format!("Read `{SKILLS_ROOT_PLACEHOLDER}/self-improvement-loop/SKILL.md`."),
)
.unwrap();
let workspace = base.path().join("placeholder-fresh");
std::fs::create_dir_all(&workspace).unwrap();
let session_fw =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &workspace);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&session_fw.agent_deploy_dir(),
&session_fw.skill_deploy_dir(),
|_| true,
)
.unwrap();
let mut record = make_record(None);
record.state = ManagedSessionState::Active;
record.workspace_path = Some(workspace);
let id = record.id;
let result = stale_assets_for_many_under(vec![record], base.path().to_path_buf()).await;
assert_eq!(
result.get(&id),
Some(&false),
"a body deployed with the session's own skills root must read fresh"
);
}
#[tokio::test]
async fn record_to_summary_checked_still_flags_stale_stopped_session() {
let base = fake_base();
let workspace = base.path().join("stopped-drifted-on-demand");
deploy_then_drift_catalog(base.path(), &workspace);
let mut stopped = make_record(None);
stopped.state = ManagedSessionState::Stopped;
stopped.workspace_path = Some(workspace);
let summary = record_to_summary_checked_under(&stopped, base.path().to_path_buf()).await;
assert!(
summary.stale_assets,
"the on-demand single-session path must still detect a Stopped \
session's deployed-asset drift — a fast `tm ls` that never detects \
staleness anywhere is a regression, not a fix"
);
assert!(
!summary.stale_assets_unchecked,
"the on-demand path DID check, so its verdict is authoritative"
);
}
#[tokio::test]
async fn checked_summaries_slim_skips_stale_assets_probe() {
let base = fake_base();
let fw = crate::core::paths::FrameworkPaths::under(base.path());
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 = base.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let session_fw =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &workspace);
crate::core::agent_deployer::deploy_agents_filtered(
&bundled,
&session_fw.agent_deploy_dir(),
&session_fw.skill_deploy_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 records = vec![active];
let full = checked_summaries_with_under(&records, true, base.path().to_path_buf()).await;
assert!(
full[0].stale_assets,
"fixture must genuinely be stale, else the slim assertion below is vacuous"
);
let slim = checked_summaries_with_under(&records, false, base.path().to_path_buf()).await;
assert!(
!slim[0].stale_assets,
"slim mode must skip the staleness probe entirely (#4335)"
);
assert_eq!(
slim[0].id, full[0].id,
"slim mode must still return every session — only the probe is skipped"
);
}
#[tokio::test]
async fn checked_summaries_stale_assets_independent_per_session_sharing_one_catalog() {
let base = fake_base();
let fw = crate::core::paths::FrameworkPaths::under(base.path());
let bundled = fw.skill_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(bundled.join("tm-doctor.md"), "v1").unwrap();
let deploy_skills = |dest: &std::path::Path| {
crate::core::skill_tiers::deploy_all_skill_tiers(
&bundled,
&fw.user_skill_source_dir(),
dest,
|_| true,
)
.unwrap();
};
let ws_a = base.path().join("workspace-a");
std::fs::create_dir_all(&ws_a).unwrap();
let fw_a = crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &ws_a);
deploy_skills(&fw_a.claude_skills_dir());
std::fs::write(bundled.join("tm-doctor.md"), "v2 — catalog moved").unwrap();
let ws_b = base.path().join("workspace-b");
std::fs::create_dir_all(&ws_b).unwrap();
let fw_b = crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &ws_b);
deploy_skills(&fw_b.claude_skills_dir());
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_with_under(&records, true, base.path().to_path_buf()).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"
);
}
fn fleet_with_deployed_skills(base: &std::path::Path, count: usize) -> Vec<SessionRecord> {
let fw = crate::core::paths::FrameworkPaths::under(base);
let bundled_agents = fw.agent_source_dir();
let bundled_skills = fw.skill_source_dir();
std::fs::create_dir_all(&bundled_agents).unwrap();
std::fs::create_dir_all(&bundled_skills).unwrap();
std::fs::write(bundled_agents.join("rust-engineer.md"), "agent v1").unwrap();
std::fs::write(bundled_skills.join("tm-doctor.md"), "skill v1").unwrap();
crate::core::agent_deployer::deploy_agents_filtered(
&bundled_agents,
&fw.agent_deploy_dir(),
&fw.skill_deploy_dir(),
|_| true,
)
.unwrap();
(0..count)
.map(|i| {
let ws = base.join(format!("fleet-ws-{i}"));
std::fs::create_dir_all(&ws).unwrap();
let ws_fw = crate::core::paths::FrameworkPaths::for_managed_workspace_under(base, &ws);
crate::core::skill_tiers::deploy_all_skill_tiers(
&bundled_skills,
&fw.user_skill_source_dir(),
&ws_fw.claude_skills_dir(),
|_| true,
)
.unwrap();
let mut r = make_record(None);
r.state = ManagedSessionState::Active;
r.workspace_path = Some(ws);
r
})
.collect()
}
#[tokio::test]
async fn stale_assets_for_many_reads_shared_agent_dir_once_for_the_whole_fleet() {
let base = fake_base();
let records = fleet_with_deployed_skills(base.path(), 4);
let fw = crate::core::paths::FrameworkPaths::under(base.path());
let agents_dir = fw.agent_deploy_dir();
let result = stale_assets_for_many_under(records, base.path().to_path_buf()).await;
let agent_reads = crate::core::update_check::deployed_reads_under(&agents_dir);
assert_eq!(result.len(), 4, "every session must still get a verdict");
assert_eq!(
agent_reads, 1,
"the ONE catalog agent in the machine-global deploy dir must be read \
ONCE for the whole 4-session fleet. Observing 4 means the shared \
directory is being re-read per session — the #4322 fan-out this PR \
removed"
);
for i in 0..4 {
let ws = base.path().join(format!("fleet-ws-{i}"));
let skills_dir =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &ws)
.claude_skills_dir();
assert_eq!(
crate::core::update_check::deployed_reads_under(&skills_dir),
1,
"each session's PER-WORKSPACE skill tree must still be read for \
that session — sharing must not have collapsed the genuinely \
per-session half too"
);
}
}
#[tokio::test]
async fn stale_assets_for_many_sees_an_agent_change_on_the_very_next_call() {
let base = fake_base();
let records = fleet_with_deployed_skills(base.path(), 3);
let before = stale_assets_for_many_under(records.clone(), base.path().to_path_buf()).await;
assert!(
records.iter().all(|r| !before[&r.id]),
"a freshly deployed fleet must start out fresh, else the assertion \
below is vacuous"
);
let fw = crate::core::paths::FrameworkPaths::under(base.path());
std::fs::write(
fw.agent_source_dir().join("rust-engineer.md"),
"agent v2 — catalog moved",
)
.unwrap();
let after = stale_assets_for_many_under(records.clone(), base.path().to_path_buf()).await;
assert!(
records.iter().all(|r| after[&r.id]),
"the NEXT call must observe the change immediately — sharing the \
deployed-agent read is scoped to one listing, never retained across \
listings, so there is no invalidation window to miss (#4322)"
);
}
#[tokio::test]
async fn stale_assets_for_many_keeps_per_session_verdicts_correct_under_concurrency() {
let base = fake_base();
let fw = crate::core::paths::FrameworkPaths::under(base.path());
let bundled_skills = fw.skill_source_dir();
std::fs::create_dir_all(&bundled_skills).unwrap();
let mut ids: Vec<ManagedSessionId> = Vec::new();
let mut records = Vec::new();
for i in 0..6 {
let drifted = i % 2 == 0;
std::fs::write(bundled_skills.join("tm-doctor.md"), format!("skill v{i}")).unwrap();
let ws = base.path().join(format!("mixed-ws-{i}"));
std::fs::create_dir_all(&ws).unwrap();
let ws_fw =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &ws);
crate::core::skill_tiers::deploy_all_skill_tiers(
&bundled_skills,
&fw.user_skill_source_dir(),
&ws_fw.claude_skills_dir(),
|_| true,
)
.unwrap();
if drifted {
std::fs::write(
bundled_skills.join("tm-doctor.md"),
format!("skill v{i} — moved after deploy"),
)
.unwrap();
}
let mut r = make_record(None);
r.state = ManagedSessionState::Active;
r.workspace_path = Some(ws);
ids.push(r.id);
records.push(r);
}
let final_body = std::fs::read_to_string(bundled_skills.join("tm-doctor.md")).unwrap();
let expected: Vec<(ManagedSessionId, bool)> = ids
.iter()
.enumerate()
.map(|(i, id)| {
let ws = base.path().join(format!("mixed-ws-{i}"));
let ws_fw =
crate::core::paths::FrameworkPaths::for_managed_workspace_under(base.path(), &ws);
let deployed =
std::fs::read_to_string(ws_fw.claude_skills_dir().join("tm-doctor/SKILL.md"))
.unwrap_or_default();
(*id, deployed != final_body)
})
.collect();
let got = stale_assets_for_many_under(records, base.path().to_path_buf()).await;
assert!(
expected.iter().any(|(_, stale)| *stale) && expected.iter().any(|(_, stale)| !*stale),
"the fixture must contain BOTH stale and fresh sessions, else a \
cross-contamination bug would pass unnoticed"
);
for (id, want) in expected {
assert_eq!(
got.get(&id).copied(),
Some(want),
"session {id} must get its OWN verdict despite sharing one \
DeployedAgentHashes and one CatalogHashes across the concurrent \
fan-out (#4322)"
);
}
}
#[test]
fn spawn_request_worktree_wire_shape() {
let base = r#"{"repo_url":"https://github.invalid/o/r","ref":"main","task":"t""#;
let absent: super::SpawnRequest = serde_json::from_str(&format!("{base}}}")).expect("absent");
assert!(
!absent.worktree,
"#5274: a body with no `worktree` key must mean the main checkout, so \
every pre-existing client keeps working unchanged"
);
let asked: super::SpawnRequest =
serde_json::from_str(&format!(r#"{base},"worktree":true}}"#)).expect("true");
assert!(
asked.worktree,
"#5274: `\"worktree\": true` must arrive as an explicit worktree request"
);
assert!(
serde_json::from_str::<super::SpawnRequest>(&format!(r#"{base},"worktree":null}}"#))
.is_err(),
"an explicit null is a malformed body, not a tolerated `false` — same \
rule as `force_new` and `background`"
);
}
#[test]
fn list_response_omits_store_health_when_healthy() {
let healthy = super::ListSessionsResponse {
sessions: vec![],
store_health: None,
};
let json = serde_json::to_value(&healthy).expect("serialize");
assert!(
json.get("store_health").is_none(),
"a healthy response must not grow a field: {json}"
);
let degraded = super::ListSessionsResponse {
sessions: vec![],
store_health: Some(super::StoreHealthPayload {
message: "/x/sessions.json is corrupt".into(),
corrupt: true,
observed_at: "2026-08-06T09:01:17Z".into(),
}),
};
let json = serde_json::to_value(°raded).expect("serialize");
assert_eq!(json["store_health"]["corrupt"], serde_json::json!(true));
}