use std::path::{Path, PathBuf};
use crate::core::agent_builder::AgentBuildError;
use crate::core::agent_reset::{ResetResult, reset_project_agents};
use crate::core::manifest::{HarnessPlan, ManifestSources, resolve_manifest};
use crate::core::paths::FrameworkPaths;
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 catalog_root = crate::content::catalog_root_for(fw_root);
let mut outcomes = Vec::new();
for session in sessions {
if session.state == ManagedSessionState::Decommissioned {
continue;
}
let Some(workspace_path) = &session.workspace_path else {
continue;
};
if !workspace_path.is_dir() {
continue;
}
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 sources = ManifestSources::resolve(workspace_path, fw_root, &catalog_root);
let manifest = resolve_manifest(&sources);
let fw = FrameworkPaths::for_managed_project(fw_root, workspace_path);
let plan = HarnessPlan::from_manifest(&manifest, &fw, &catalog_root);
let result =
reset_project_agents(&plan.agent_source, &fw.claude_agents_dir(), names, |n| {
plan.agent_selected(n)
})
.map_err(|source| WorkspaceSweepError::Reset {
tmux_name: session.tmux_name.clone(),
source,
})?;
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,
}
}
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 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();
}
}
#[tokio::test]
async fn sweep_resets_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();
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.recomposed.len(), 2);
assert!(workspace.path().join(".claude/agents/engineer.md").exists());
}
#[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_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_respects_per_workspace_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();
let project_manifest_dir = workspace.path().join(".trusty-mpm");
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.recomposed,
vec!["base-agent.md".to_string()]
);
assert_eq!(outcomes[0].result.deselected, vec!["engineer".to_string()]);
assert!(
!workspace.path().join(".claude/agents/engineer.md").exists(),
"a project-excluded agent must never land in that project's workspace"
);
}
}