use std::fmt;
use chrono::{Duration, Utc};
use tracing::{info, warn};
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
pub const MAX_EPHEMERAL_AGE_HOURS: i64 = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneFilter {
Ephemeral,
Stopped,
Decommissioned,
All,
}
impl PruneFilter {
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"ephemeral" => Ok(Self::Ephemeral),
"stopped" => Ok(Self::Stopped),
"decommissioned" => Ok(Self::Decommissioned),
"all" => Ok(Self::All),
other => Err(format!(
"unknown prune filter `{other}` (expected: ephemeral | stopped | decommissioned | all)"
)),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Ephemeral => "ephemeral",
Self::Stopped => "stopped",
Self::Decommissioned => "decommissioned",
Self::All => "all",
}
}
}
impl fmt::Display for PruneFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PruneAction {
Decommissioned,
Removed,
}
impl PruneAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Decommissioned => "decommissioned",
Self::Removed => "removed",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrunedSession {
pub id: String,
pub tmux_name: String,
pub state: String,
pub action: PruneAction,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PruneOutcome {
pub dry_run: bool,
pub filter: String,
pub sessions: Vec<PrunedSession>,
}
impl PruneOutcome {
pub fn count(&self) -> usize {
self.sessions.len()
}
}
pub(super) fn is_running(state: &ManagedSessionState) -> bool {
matches!(
state,
ManagedSessionState::Active | ManagedSessionState::Provisioning
)
}
fn matches_filter(record: &SessionRecord, filter: PruneFilter) -> bool {
match filter {
PruneFilter::Ephemeral => {
record.ephemeral && record.state != ManagedSessionState::Decommissioned
}
PruneFilter::Stopped => record.state == ManagedSessionState::Stopped,
PruneFilter::Decommissioned => record.state == ManagedSessionState::Decommissioned,
PruneFilter::All => true,
}
}
pub(crate) fn find_orphaned_worktrees(
repos_root: &std::path::Path,
active_set: &std::collections::HashSet<std::path::PathBuf>,
) -> Vec<std::path::PathBuf> {
let mut orphans = Vec::new();
let Ok(owner_entries) = std::fs::read_dir(repos_root) else {
return orphans;
};
for owner_entry in owner_entries.flatten() {
let owner_path = owner_entry.path();
if !owner_path.is_dir() {
continue;
}
let Ok(repo_entries) = std::fs::read_dir(&owner_path) else {
continue;
};
for repo_entry in repo_entries.flatten() {
let wt_dir = repo_entry.path().join(".worktrees");
if !wt_dir.is_dir() {
continue;
}
let Ok(wt_entries) = std::fs::read_dir(&wt_dir) else {
continue;
};
for wt_entry in wt_entries.flatten() {
let wt_path = wt_entry.path();
if !wt_path.is_dir() {
continue;
}
let canonical_wt = match std::fs::canonicalize(&wt_path) {
Ok(c) => c,
Err(_) => continue,
};
if !active_set.contains(&canonical_wt) {
orphans.push(wt_path);
}
}
}
}
orphans
}
impl SessionManager {
pub async fn decommission_all_ephemeral(&self) -> Result<usize, ManagedError> {
let outcome = self
.prune_managed(PruneFilter::Ephemeral, false, true)
.await?;
Ok(outcome.count())
}
pub async fn prune_managed(
&self,
filter: PruneFilter,
dry_run: bool,
include_active: bool,
) -> Result<PruneOutcome, ManagedError> {
self.store.write().await.reload_if_changed().await?;
let all = self.store.read().await.cached_all();
let targets: Vec<SessionRecord> = all
.into_iter()
.filter(|r| matches_filter(r, filter))
.filter(|r| include_active || !is_running(&r.state))
.collect();
let mut sessions = Vec::with_capacity(targets.len());
for record in targets {
let is_tombstone = record.state == ManagedSessionState::Decommissioned;
let action = if is_tombstone {
PruneAction::Removed
} else {
PruneAction::Decommissioned
};
if !dry_run {
if is_tombstone {
if let Err(e) = self.store.write().await.remove(&record.id).await {
warn!(id = %record.id, "prune: compaction remove failed: {e}; skipping");
continue;
}
} else if let Err(e) = self.decommission(&record.id).await {
warn!(id = %record.id, "prune: decommission failed: {e}; skipping");
continue;
}
}
sessions.push(PrunedSession {
id: record.id.to_string(),
tmux_name: record.tmux_name.clone(),
state: record.state.to_string(),
action,
});
}
if dry_run {
info!(
filter = %filter,
count = sessions.len(),
"prune (dry-run): reporting candidates, no mutation"
);
} else {
info!(
filter = %filter,
count = sessions.len(),
"prune: applied teardown/compaction"
);
}
Ok(PruneOutcome {
dry_run,
filter: filter.as_str().to_string(),
sessions,
})
}
pub async fn compact_record(&self, id: &ManagedSessionId) -> Result<(), ManagedError> {
self.store.write().await.remove(id).await?;
Ok(())
}
pub async fn prune_orphaned_worktrees(
&self,
repos_root: &std::path::Path,
active_workspace_paths: &[std::path::PathBuf],
dry_run: bool,
) -> Result<Vec<std::path::PathBuf>, anyhow::Error> {
use super::decommission::remove_session_worktree;
use std::collections::HashSet;
let repos_root = repos_root.to_path_buf();
let initial_active: HashSet<std::path::PathBuf> = active_workspace_paths
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
let candidates = tokio::task::spawn_blocking({
let initial_active = initial_active.clone();
move || find_orphaned_worktrees(&repos_root, &initial_active)
})
.await
.map_err(|e| anyhow::anyhow!("prune-worktrees: orphan scan panicked: {e}"))?;
if dry_run {
for p in &candidates {
info!(path = %p.display(), "prune-worktrees (dry-run): would remove orphaned worktree");
}
return Ok(candidates);
}
let fresh_active: HashSet<std::path::PathBuf> = {
let mut set = HashSet::new();
for r in self.store.read().await.cached_all() {
let Some(p) = r.workspace_path else {
continue;
};
if let Ok(c) = std::fs::canonicalize(&p) {
set.insert(c);
} else {
warn!(
path = %p.display(),
"prune-worktrees: active session path failed to canonicalize; \
using raw path as protective fallback (#1845 F3)"
);
}
set.insert(p);
}
set
};
let mut removed = Vec::new();
for candidate in candidates {
let canonical_candidate = match std::fs::canonicalize(&candidate) {
Ok(c) => c,
Err(_) => {
warn!(
path = %candidate.display(),
"prune-worktrees: skipping candidate — canonicalize failed"
);
continue;
}
};
if fresh_active.contains(&canonical_candidate) || fresh_active.contains(&candidate) {
info!(
path = %candidate.display(),
"prune-worktrees: skipping — active session appeared after initial snapshot"
);
continue;
}
info!(path = %candidate.display(), "prune-worktrees: removing orphaned worktree");
let candidate_clone = candidate.clone();
let removed_ok =
tokio::task::spawn_blocking(move || remove_session_worktree(&candidate_clone))
.await
.unwrap_or_else(|e| {
tracing::error!(
"prune-worktrees: spawn_blocking panicked during removal: {e}"
);
false
});
if removed_ok {
removed.push(candidate);
}
}
Ok(removed)
}
pub async fn reap_orphaned_worktrees(
&self,
repos_root: &std::path::Path,
) -> Result<Vec<std::path::PathBuf>, anyhow::Error> {
let active: Vec<std::path::PathBuf> = self
.list()
.await
.into_iter()
.filter_map(|r| r.workspace_path)
.collect();
self.prune_orphaned_worktrees(repos_root, &active, false)
.await
}
pub async fn reap_aged_ephemeral(&self, max_age: Duration) -> Result<usize, ManagedError> {
let cutoff = Utc::now() - max_age;
self.store.write().await.reload_if_changed().await?;
let all = self.store.read().await.cached_all();
let stale: Vec<SessionRecord> = all
.into_iter()
.filter(|r| {
r.ephemeral
&& r.state != ManagedSessionState::Decommissioned
&& r.created_at < cutoff
})
.collect();
let mut reaped = 0usize;
for record in stale {
match self.decommission(&record.id).await {
Ok(_) => {
reaped += 1;
info!(
id = %record.id,
name = %record.tmux_name,
created_at = %record.created_at.to_rfc3339(),
"auto-reap: decommissioned stale ephemeral session"
);
}
Err(e) => {
warn!(id = %record.id, "auto-reap: decommission failed: {e}; skipping");
}
}
}
Ok(reaped)
}
}
#[cfg(test)]
mod orphan_tests {
use super::*;
#[test]
fn prune_orphaned_worktrees_spares_active() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("live-session");
std::fs::create_dir_all(&wt).unwrap();
let active: std::collections::HashSet<_> =
vec![std::fs::canonicalize(&wt).unwrap_or_else(|_| wt.clone())]
.into_iter()
.collect();
let orphans = find_orphaned_worktrees(root, &active);
assert!(
orphans.is_empty(),
"live session must not be listed as orphan"
);
}
#[test]
fn prune_orphaned_worktrees_fresh_active_set_blocks_deletion() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("session-xyz");
std::fs::create_dir_all(&wt).unwrap();
let empty_initial: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
let candidates = find_orphaned_worktrees(root, &empty_initial);
assert!(
candidates.contains(&wt),
"empty initial set must find the dir as a candidate"
);
let canonical = std::fs::canonicalize(&wt).unwrap_or_else(|_| wt.clone());
let fresh: std::collections::HashSet<std::path::PathBuf> =
[canonical.clone()].into_iter().collect();
assert!(
fresh.contains(&canonical),
"fresh active set must contain the canonicalized worktree path"
);
assert!(wt.exists(), "worktree must survive the TOCTOU check");
}
#[test]
fn prune_orphaned_worktrees_collects_orphan() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let wt1 = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("live");
let wt2 = root
.join("owner")
.join("repo")
.join(".worktrees")
.join("dead");
std::fs::create_dir_all(&wt1).unwrap();
std::fs::create_dir_all(&wt2).unwrap();
let active: std::collections::HashSet<_> =
vec![std::fs::canonicalize(&wt1).unwrap_or_else(|_| wt1.clone())]
.into_iter()
.collect();
let orphans = find_orphaned_worktrees(root, &active);
assert_eq!(orphans.len(), 1);
assert!(
orphans.contains(&wt2),
"expected {wt2:?} to be the orphan, got {orphans:?}"
);
}
#[tokio::test]
async fn prune_orphaned_worktrees_store_snapshot_blocks_deletion() {
use std::path::PathBuf;
use std::sync::Arc;
struct NoopDriver;
impl super::super::manager::ManagedTmuxDriver for NoopDriver {
fn create_session(
&self,
_: &str,
_: &str,
) -> Result<(), super::super::manager::ManagedError> {
Ok(())
}
fn kill_session(&self, _: &str) -> Result<(), super::super::manager::ManagedError> {
Ok(())
}
fn send_line(
&self,
_: &str,
_: &str,
) -> Result<(), super::super::manager::ManagedError> {
Ok(())
}
fn capture(
&self,
_: &str,
_: usize,
) -> Result<String, super::super::manager::ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, super::super::manager::ManagedError> {
Ok(Vec::new())
}
}
let store_dir = tempfile::tempdir().unwrap();
let repos_tmp = tempfile::tempdir().unwrap();
let session_id = super::super::record::ManagedSessionId::new();
let wt_path = repos_tmp
.path()
.join("owner")
.join("repo")
.join(".worktrees")
.join(session_id.to_string());
std::fs::create_dir_all(&wt_path).expect("create worktree dir");
let mgr =
super::super::manager::SessionManager::new(store_dir.path(), Arc::new(NoopDriver))
.await
.expect("SessionManager::new");
let canonical_wt = std::fs::canonicalize(&wt_path).unwrap_or_else(|_| wt_path.clone());
let record = super::super::record::SessionRecord {
id: session_id,
tmux_name: "test-toctou".into(),
cwd: PathBuf::from("/tmp"),
task: "toctou test".into(),
state: super::super::record::ManagedSessionState::Active,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: Some(canonical_wt),
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,
};
mgr.store
.write()
.await
.upsert(record)
.await
.expect("upsert test record");
let removed = mgr
.prune_orphaned_worktrees(repos_tmp.path(), &[], false)
.await
.expect("prune must not error");
assert!(
removed.is_empty(),
"worktree backed by a live store record must NOT be removed; got: {removed:?}"
);
assert!(wt_path.exists(), "worktree dir must survive the prune");
}
}