use std::path::{Path, PathBuf};
use crate::core::agent_builder::AgentBuildError;
use crate::core::agent_deployer::retract_framework_agents_filtered;
use crate::core::agent_reset::ResetResult;
use crate::core::workspace_liveness::{WorkspaceLiveness, workspace_liveness};
use crate::session_manager::decommission::is_session_worktree;
use crate::session_manager::{ManagedSessionState, SessionStore, StoreError};
#[derive(Debug, Clone)]
pub struct WorkspaceResetOutcome {
pub tmux_name: String,
pub workspace_path: PathBuf,
pub result: ResetResult,
pub skipped_reason: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceSweepError {
#[error("failed to load session store: {0}")]
Store(#[from] StoreError),
#[error("agent reset failed for session {tmux_name}: {source}")]
Reset {
tmux_name: String,
#[source]
source: AgentBuildError,
},
}
pub async fn reset_active_workspace_agents(
fw_root: &Path,
names: Option<&[String]>,
) -> Result<Vec<WorkspaceResetOutcome>, WorkspaceSweepError> {
let data_dir = fw_root.join("session-manager");
let mut store = SessionStore::load(&data_dir).await?;
let sessions = store.all().await?;
let mut outcomes = Vec::new();
for session in sessions {
if session.state == ManagedSessionState::Decommissioned {
continue;
}
let Some(workspace_path) = &session.workspace_path else {
continue;
};
let liveness = workspace_liveness(workspace_path, session.workspace_owned);
match &liveness {
WorkspaceLiveness::Absent => {
tracing::warn!(
workspace = %workspace_path.display(),
session = %session.tmux_name,
"session workspace is gone — skipping its agent reset"
);
outcomes.push(WorkspaceResetOutcome {
tmux_name: session.tmux_name,
workspace_path: workspace_path.clone(),
result: ResetResult::default(),
skipped_reason: Some(format!("skipped — {liveness}")),
});
continue;
}
WorkspaceLiveness::Gutted => {
outcomes.push(WorkspaceResetOutcome {
tmux_name: session.tmux_name,
workspace_path: workspace_path.clone(),
result: ResetResult::default(),
skipped_reason: Some(format!("skipped — {liveness}")),
});
continue;
}
WorkspaceLiveness::Indeterminate(_) => {
tracing::warn!(
workspace = %workspace_path.display(),
liveness = %liveness,
"workspace liveness unverifiable — proceeding with the reset \
rather than treating an ambiguous observation as death"
);
}
WorkspaceLiveness::Live => {}
}
if !(session.workspace_owned || is_session_worktree(workspace_path)) {
outcomes.push(WorkspaceResetOutcome {
tmux_name: session.tmux_name,
workspace_path: workspace_path.clone(),
result: ResetResult::default(),
skipped_reason: Some(
"skipped — not tm-owned (adopted/local-path session; refusing to \
force-recompose files into a real checkout)"
.to_string(),
),
});
continue;
}
let retracted = retract_framework_agents_filtered(
&workspace_path.join(".claude").join("agents"),
|stem| names.is_none_or(|requested| requested.iter().any(|n| n == stem)),
)
.map_err(|source| WorkspaceSweepError::Reset {
tmux_name: session.tmux_name.clone(),
source,
})?;
let result = ResetResult {
retracted: retracted.removed,
..ResetResult::default()
};
outcomes.push(WorkspaceResetOutcome {
tmux_name: session.tmux_name,
workspace_path: workspace_path.clone(),
result,
skipped_reason: None,
});
}
Ok(outcomes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session_manager::record::{ManagedSessionId, SessionRecord};
use chrono::Utc;
use std::fs;
use tempfile::TempDir;
fn write_sources(dir: &Path) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join("base-agent.md"),
"---\nname: base-agent\nrole: base\n---\n\n# Base\n\nBase content.\n",
)
.unwrap();
fs::write(
dir.join("engineer.md"),
"---\nname: engineer\nrole: engineer\nextends: base-agent\nmodel: sonnet\n---\n\n# Engineer\n\nEngineer content.\n",
)
.unwrap();
}
fn bare_session(tmux_name: &str) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.to_string(),
cwd: PathBuf::from("/tmp"),
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: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
}
}
fn intact_session(tmux_name: &str, workspace: &Path) -> SessionRecord {
SessionRecord {
workspace_path: Some(workspace.to_path_buf()),
state: ManagedSessionState::Active,
workspace_owned: true,
..bare_session(tmux_name)
}
}
fn make_linked_worktree(dir: &Path) {
fs::write(
dir.join(".git"),
"gitdir: /repo/.base/.git/worktrees/session\n",
)
.unwrap();
}
fn fw_root_under(base: &TempDir) -> PathBuf {
let root = base.path().join(".trusty-mpm");
fs::create_dir_all(&root).unwrap();
root
}
async fn seed_sessions(fw_root: &Path, sessions: Vec<SessionRecord>) {
let data_dir = fw_root.join("session-manager");
fs::create_dir_all(&data_dir).unwrap();
let mut store = SessionStore::load(&data_dir).await.unwrap();
for session in sessions {
store.upsert(session).await.unwrap();
}
}
fn seed_legacy_workspace_roster(fw_root: &Path, workspace: &Path) {
crate::core::agent_deployer::deploy_agents(
&fw_root.join("framework").join("agents"),
&workspace.join(".claude").join("agents"),
)
.unwrap();
}
#[tokio::test]
async fn sweep_retracts_intact_workspace() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let workspace = TempDir::new().unwrap();
make_linked_worktree(workspace.path());
seed_legacy_workspace_roster(&fw_root, workspace.path());
assert!(workspace.path().join(".claude/agents/engineer.md").exists());
seed_sessions(
&fw_root,
vec![intact_session("tm-sweep-test", workspace.path())],
)
.await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].tmux_name, "tm-sweep-test");
assert_eq!(outcomes[0].result.retracted.len(), 2);
assert!(
outcomes[0].result.recomposed.is_empty(),
"the sweep must never recompose a bundled agent into a workspace"
);
assert!(
!workspace.path().join(".claude/agents/engineer.md").exists(),
"the shadowing copy must be gone"
);
}
#[tokio::test]
async fn sweep_retraction_honors_requested_names() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let workspace = TempDir::new().unwrap();
make_linked_worktree(workspace.path());
seed_legacy_workspace_roster(&fw_root, workspace.path());
seed_sessions(
&fw_root,
vec![intact_session("tm-sweep-test", workspace.path())],
)
.await;
let names = vec!["engineer".to_string()];
let outcomes = reset_active_workspace_agents(&fw_root, Some(&names))
.await
.unwrap();
assert_eq!(
outcomes[0].result.retracted,
vec!["engineer.md".to_string()]
);
assert!(!workspace.path().join(".claude/agents/engineer.md").exists());
assert!(
workspace
.path()
.join(".claude/agents/base-agent.md")
.exists(),
"an agent outside the requested scope must survive"
);
}
#[tokio::test]
async fn sweep_never_removes_a_hand_placed_agent() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let workspace = TempDir::new().unwrap();
make_linked_worktree(workspace.path());
seed_legacy_workspace_roster(&fw_root, workspace.path());
let hand_placed = workspace.path().join(".claude/agents/my-own-agent.md");
let mine = "---\nname: my-own-agent\ndescription: mine\n---\n\nMine.\n";
fs::write(&hand_placed, mine).unwrap();
seed_sessions(
&fw_root,
vec![intact_session("tm-sweep-test", workspace.path())],
)
.await;
reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(
fs::read_to_string(&hand_placed).unwrap(),
mine,
"a hand-placed agent must survive the sweep byte-identical"
);
}
#[tokio::test]
async fn sweep_skips_decommissioned_session() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let workspace = TempDir::new().unwrap();
let mut session = intact_session("tm-gone", workspace.path());
session.state = ManagedSessionState::Decommissioned;
seed_sessions(&fw_root, vec![session]).await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert!(
outcomes.is_empty(),
"a decommissioned session has no workspace to reconcile"
);
}
#[tokio::test]
async fn sweep_reports_session_whose_workspace_vanished() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let gone = fw_base.path().join("vanished-workspace");
assert!(!gone.exists());
seed_sessions(&fw_root, vec![intact_session("tm-vanished", &gone)]).await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(outcomes.len(), 1, "a vanished workspace must be reported");
assert_eq!(outcomes[0].tmux_name, "tm-vanished");
let reason = outcomes[0]
.skipped_reason
.as_deref()
.expect("a vanished workspace must carry a skip reason");
assert!(reason.contains("no longer exists"), "{reason}");
assert!(outcomes[0].result.recomposed.is_empty());
}
#[tokio::test]
async fn sweep_skips_session_without_workspace() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let session = bare_session("tm-no-workspace");
seed_sessions(&fw_root, vec![session]).await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert!(outcomes.is_empty());
}
#[tokio::test]
async fn sweep_skips_unowned_non_worktree_session() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let real_repo = TempDir::new().unwrap();
assert_ne!(
real_repo
.path()
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str()),
Some(".worktrees"),
"fixture must not accidentally look like a session worktree"
);
let agents_dir = real_repo.path().join(".claude").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
let sentinel_path = agents_dir.join("my-custom-agent.md");
let original_bytes = b"---\nname: my-custom-agent\n---\n\nHand-authored, not bundled.\n";
fs::write(&sentinel_path, original_bytes).unwrap();
let mut session = intact_session("tm-adopted-real-repo", real_repo.path());
session.workspace_owned = false;
seed_sessions(&fw_root, vec![session]).await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(
outcomes.len(),
1,
"the skipped session must still be reported, not silently absent"
);
assert_eq!(outcomes[0].tmux_name, "tm-adopted-real-repo");
let reason = outcomes[0]
.skipped_reason
.as_deref()
.expect("unowned non-worktree session must carry a skip reason");
assert!(reason.contains("not tm-owned"), "reason = {reason:?}");
assert!(
outcomes[0].result.recomposed.is_empty(),
"a skipped session must not report any recomposed files"
);
let after_bytes = fs::read(&sentinel_path).unwrap();
assert_eq!(
after_bytes, original_bytes,
"a real, unowned checkout must never be written to"
);
assert!(
!agents_dir.join("engineer.md").exists(),
"the bundled roster must never be force-recomposed into a real checkout"
);
assert!(!agents_dir.join("base-agent.md").exists());
}
#[tokio::test]
async fn sweep_skips_gutted_worktree_whose_git_was_stripped() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let base = TempDir::new().unwrap();
let gutted = base
.path()
.join(".worktrees")
.join("f443c12d-2fb6-4ce1-9f70-2e7695306e47");
let agents_dir = gutted.join(".claude").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
assert!(
gutted.is_dir() && !gutted.join(".git").exists(),
"fixture must be exactly what the old is_dir() gate waved through"
);
seed_sessions(&fw_root, vec![intact_session("tm-gutted", &gutted)]).await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(outcomes.len(), 1, "a gutted husk must still be reported");
assert_eq!(outcomes[0].tmux_name, "tm-gutted");
let reason = outcomes[0]
.skipped_reason
.as_deref()
.expect("a gutted workspace must carry a skip reason");
assert!(reason.contains("gutted"), "reason = {reason:?}");
assert!(outcomes[0].result.recomposed.is_empty());
assert!(
!agents_dir.join("engineer.md").exists(),
"the sweep must never recompose the roster into a destroyed worktree"
);
assert!(!agents_dir.join("base-agent.md").exists());
assert_eq!(
fs::read_dir(&agents_dir).unwrap().count(),
0,
"the husk's .claude/agents/ must be left completely untouched"
);
}
#[tokio::test]
async fn sweep_serves_live_linked_worktree_with_git_file() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let base = TempDir::new().unwrap();
let live = base
.path()
.join(".worktrees")
.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
fs::create_dir_all(&live).unwrap();
make_linked_worktree(&live);
assert!(
live.join(".git").is_file() && !live.join(".git").is_dir(),
"fixture must model the linked-worktree .git FILE this test exists for"
);
seed_legacy_workspace_roster(&fw_root, &live);
seed_sessions(&fw_root, vec![intact_session("tm-live-worktree", &live)]).await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].skipped_reason, None, "must NOT be refused");
assert_eq!(outcomes[0].result.retracted.len(), 2);
assert!(
!live.join(".claude/agents/engineer.md").exists(),
"a live linked worktree must still be served (and therefore cleaned)"
);
}
#[tokio::test]
async fn sweep_ignores_the_project_manifest_exclude() {
let fw_base = TempDir::new().unwrap();
let fw_root = fw_root_under(&fw_base);
write_sources(&fw_root.join("framework").join("agents"));
let workspace = TempDir::new().unwrap();
make_linked_worktree(workspace.path());
seed_legacy_workspace_roster(&fw_root, workspace.path());
let project_manifest_dir = workspace.path().join(".trusty-mpm").join("framework");
fs::create_dir_all(&project_manifest_dir).unwrap();
fs::write(
project_manifest_dir.join("manifest.toml"),
"[agents]\nexclude = [\"engineer\"]\n",
)
.unwrap();
seed_sessions(
&fw_root,
vec![intact_session("tm-excluded", workspace.path())],
)
.await;
let outcomes = reset_active_workspace_agents(&fw_root, None).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].result.retracted.len(), 2);
assert!(
!workspace.path().join(".claude/agents/engineer.md").exists(),
"an excluded agent's shadowing copy must be retracted, not exempted"
);
assert!(
outcomes[0].result.deselected.is_empty(),
"no plan is consulted any more, so nothing can be reported deselected"
);
}
}