From 13f99cbf1c56ff35473b3cf925752b01458af4d7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:29:21 -0700 Subject: [PATCH] feat: add worktree retention cleanup policy --- ecc2/src/config/mod.rs | 7 +++ ecc2/src/main.rs | 17 +++++++- ecc2/src/session/daemon.rs | 11 +++-- ecc2/src/session/manager.rs | 79 +++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 85 ++++++++++++++++++++++++++++++++++--- 5 files changed, 186 insertions(+), 13 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index bc692582..165d8363 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -36,6 +36,7 @@ pub struct Config { pub worktree_branch_prefix: String, pub max_parallel_sessions: usize, pub max_parallel_worktrees: usize, + pub worktree_retention_secs: u64, pub session_timeout_secs: u64, pub heartbeat_interval_secs: u64, pub auto_terminate_stale_sessions: bool, @@ -97,6 +98,7 @@ impl Default for Config { worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 8, max_parallel_worktrees: 6, + worktree_retention_secs: 0, session_timeout_secs: 3600, heartbeat_interval_secs: 30, auto_terminate_stale_sessions: false, @@ -380,6 +382,7 @@ db_path = "/tmp/ecc2.db" worktree_root = "/tmp/ecc-worktrees" max_parallel_sessions = 8 max_parallel_worktrees = 6 +worktree_retention_secs = 0 session_timeout_secs = 3600 heartbeat_interval_secs = 30 auto_terminate_stale_sessions = false @@ -394,6 +397,10 @@ theme = "Dark" config.worktree_branch_prefix, defaults.worktree_branch_prefix ); + assert_eq!( + config.worktree_retention_secs, + defaults.worktree_retention_secs + ); assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7966369a..699f22dd 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -801,7 +801,7 @@ async fn main() -> Result<()> { } } Some(Commands::PruneWorktrees { json }) => { - let outcome = session::manager::prune_inactive_worktrees(&db).await?; + let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { @@ -1434,6 +1434,18 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome } } + if outcome.retained_session_ids.is_empty() { + lines.push("No inactive worktrees are being retained".to_string()); + } else { + lines.push(format!( + "Deferred {} inactive worktree(s) still within retention", + outcome.retained_session_ids.len() + )); + for session_id in &outcome.retained_session_ids { + lines.push(format!("- retained {}", short_session(session_id))); + } + } + lines.join("\n") } @@ -2105,12 +2117,15 @@ mod tests { let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { cleaned_session_ids: vec!["deadbeefcafefeed".to_string()], active_with_worktree_ids: vec!["facefeed12345678".to_string()], + retained_session_ids: vec!["retain1234567890".to_string()], }); assert!(text.contains("Pruned 1 inactive worktree(s)")); assert!(text.contains("- cleaned deadbeef")); assert!(text.contains("Skipped 1 active session(s) still holding worktrees")); assert!(text.contains("- active facefeed")); + assert!(text.contains("Deferred 1 inactive worktree(s) still within retention")); + assert!(text.contains("- retained retain12")); } #[test] diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index e88a92bb..f8fc7c6d 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -35,7 +35,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Worktree auto-merge pass failed: {e}"); } - if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await { + if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).await { tracing::error!("Worktree auto-prune pass failed: {e}"); } @@ -393,9 +393,9 @@ where Ok(merged) } -async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result { +async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result { maybe_auto_prune_inactive_worktrees_with_recorder( - || manager::prune_inactive_worktrees(db), + || manager::prune_inactive_worktrees(db, cfg), |pruned, active| db.record_daemon_auto_prune_pass(pruned, active), ) .await @@ -421,6 +421,7 @@ where let outcome = prune().await?; let pruned = outcome.cleaned_session_ids.len(); let active = outcome.active_with_worktree_ids.len(); + let retained = outcome.retained_session_ids.len(); record(pruned, active)?; if pruned > 0 { @@ -429,6 +430,9 @@ where if active > 0 { tracing::info!("Skipped {active} active worktree(s) during auto-prune"); } + if retained > 0 { + tracing::info!("Deferred {retained} inactive worktree(s) within retention"); + } Ok(pruned) } @@ -1255,6 +1259,7 @@ mod tests { Ok(manager::WorktreePruneOutcome { cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()], active_with_worktree_ids: vec!["running-a".to_string()], + retained_session_ids: vec!["retained-a".to_string()], }) }, move |pruned, active| { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 964e16d2..aeb903c4 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -862,12 +862,19 @@ pub async fn merge_ready_worktrees( pub struct WorktreePruneOutcome { pub cleaned_session_ids: Vec, pub active_with_worktree_ids: Vec, + pub retained_session_ids: Vec, } -pub async fn prune_inactive_worktrees(db: &StateStore) -> Result { +pub async fn prune_inactive_worktrees( + db: &StateStore, + cfg: &Config, +) -> Result { let sessions = db.list_sessions()?; let mut cleaned_session_ids = Vec::new(); let mut active_with_worktree_ids = Vec::new(); + let mut retained_session_ids = Vec::new(); + let retention = chrono::Duration::seconds(cfg.worktree_retention_secs as i64); + let now = chrono::Utc::now(); for session in sessions { let Some(_) = session.worktree.as_ref() else { @@ -882,6 +889,13 @@ pub async fn prune_inactive_worktrees(db: &StateStore) -> Result chrono::Duration::zero() + && now.signed_duration_since(session.last_heartbeat_at) < retention + { + retained_session_ids.push(session.id); + continue; + } + cleanup_session_worktree(db, &session.id).await?; cleaned_session_ids.push(session.id); } @@ -889,6 +903,7 @@ pub async fn prune_inactive_worktrees(db: &StateStore) -> Result Result<()> { + let tempdir = TestDir::new("manager-prune-worktree-retention")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.worktree_retention_secs = 3600; + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let session_id = create_session_in_dir( + &db, + &cfg, + "recently completed worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + stop_session_with_options(&db, &session_id, false).await?; + + let before = db + .get_session(&session_id)? + .context("retained session should exist")?; + let worktree_path = before + .worktree + .clone() + .context("retained session worktree missing")? + .path; + + let outcome = prune_inactive_worktrees(&db, &cfg).await?; + + assert!(outcome.cleaned_session_ids.is_empty()); + assert!(outcome.active_with_worktree_ids.is_empty()); + assert_eq!(outcome.retained_session_ids, vec![session_id.clone()]); + assert!(worktree_path.exists(), "retained worktree should remain"); + assert!( + db.get_session(&session_id)? + .context("retained session should still exist")? + .worktree + .is_some(), + "retained session should keep worktree metadata" + ); + + crate::worktree::remove( + &db.get_session(&session_id)? + .context("retained session should still exist")? + .worktree + .context("retained session should still have worktree")?, + )?; + db.clear_worktree_to_dir(&session_id, &repo_root)?; + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn merge_session_worktree_merges_branch_and_cleans_worktree() -> Result<()> { let tempdir = TestDir::new("manager-merge-worktree")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index eda90b22..10e9168c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2256,22 +2256,43 @@ impl Dashboard { } pub async fn prune_inactive_worktrees(&mut self) { - match manager::prune_inactive_worktrees(&self.db).await { + match manager::prune_inactive_worktrees(&self.db, &self.cfg).await { Ok(outcome) => { self.refresh(); - if outcome.cleaned_session_ids.is_empty() { + if outcome.cleaned_session_ids.is_empty() && outcome.retained_session_ids.is_empty() + { self.set_operator_note("no inactive worktrees to prune".to_string()); - } else if outcome.active_with_worktree_ids.is_empty() { + } else if outcome.cleaned_session_ids.is_empty() { self.set_operator_note(format!( - "pruned {} inactive worktree(s)", - outcome.cleaned_session_ids.len() + "deferred {} inactive worktree(s) within retention", + outcome.retained_session_ids.len() )); + } else if outcome.active_with_worktree_ids.is_empty() { + if outcome.retained_session_ids.is_empty() { + self.set_operator_note(format!( + "pruned {} inactive worktree(s)", + outcome.cleaned_session_ids.len() + )); + } else { + self.set_operator_note(format!( + "pruned {} inactive worktree(s); deferred {} within retention", + outcome.cleaned_session_ids.len(), + outcome.retained_session_ids.len() + )); + } } else { - self.set_operator_note(format!( + let mut note = format!( "pruned {} inactive worktree(s); skipped {} active session(s)", outcome.cleaned_session_ids.len(), outcome.active_with_worktree_ids.len() - )); + ); + if !outcome.retained_session_ids.is_empty() { + note.push_str(&format!( + "; deferred {} within retention", + outcome.retained_session_ids.len() + )); + } + self.set_operator_note(note); } } Err(error) => { @@ -8745,6 +8766,55 @@ diff --git a/src/next.rs b/src/next.rs Ok(()) } + #[tokio::test] + async fn prune_inactive_worktrees_reports_retained_sessions_within_retention() -> Result<()> { + let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let now = Utc::now(); + let retained_path = std::env::temp_dir().join(format!("ecc2-retained-{}", Uuid::new_v4())); + std::fs::create_dir_all(&retained_path)?; + + db.insert_session(&Session { + id: "stopped-1".to_string(), + task: "retain me".to_string(), + agent_type: "claude".to_string(), + working_dir: retained_path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(WorktreeInfo { + path: retained_path.clone(), + branch: "ecc/stopped-1".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut cfg = Config::default(); + cfg.db_path = db_path.clone(); + cfg.worktree_retention_secs = 3600; + + let dashboard_store = StateStore::open(&db_path)?; + let mut dashboard = Dashboard::new(dashboard_store, cfg); + dashboard.prune_inactive_worktrees().await; + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("deferred 1 inactive worktree(s) within retention") + ); + assert!(db + .get_session("stopped-1")? + .expect("stopped session should exist") + .worktree + .is_some()); + + let _ = std::fs::remove_dir_all(retained_path); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn merge_selected_worktree_sets_operator_note_when_ready() -> Result<()> { let tempdir = std::env::temp_dir().join(format!("dashboard-merge-{}", Uuid::new_v4())); @@ -9636,6 +9706,7 @@ diff --git a/src/next.rs b/src/next.rs worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 4, max_parallel_worktrees: 4, + worktree_retention_secs: 0, session_timeout_secs: 60, heartbeat_interval_secs: 5, auto_terminate_stale_sessions: false,