From adfe8a8311c0e224f73214b662e779736a3c5fa0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 16:08:29 -0700 Subject: [PATCH] feat: auto-prune inactive ecc2 worktrees --- ecc2/src/session/daemon.rs | 83 +++++++++++++++++++++++++++++++++++++ ecc2/src/session/manager.rs | 14 +++++++ ecc2/src/session/store.rs | 69 +++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 31 ++++++++++++++ 4 files changed, 195 insertions(+), 2 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 16f7c629..c2783322 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -37,6 +37,10 @@ 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 { + tracing::error!("Worktree auto-prune pass failed: {e}"); + } + time::sleep(heartbeat_interval).await; } } @@ -404,6 +408,46 @@ where Ok(merged) } +async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result { + maybe_auto_prune_inactive_worktrees_with_recorder( + || manager::prune_inactive_worktrees(db), + |pruned, active| db.record_daemon_auto_prune_pass(pruned, active), + ) + .await +} + +async fn maybe_auto_prune_inactive_worktrees_with(prune: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>, +{ + maybe_auto_prune_inactive_worktrees_with_recorder(prune, |_, _| Ok(())).await +} + +async fn maybe_auto_prune_inactive_worktrees_with_recorder( + prune: F, + mut record: R, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + R: FnMut(usize, usize) -> Result<()>, +{ + let outcome = prune().await?; + let pruned = outcome.cleaned_session_ids.len(); + let active = outcome.active_with_worktree_ids.len(); + record(pruned, active)?; + + if pruned > 0 { + tracing::info!("Auto-pruned {pruned} inactive worktree(s)"); + } + if active > 0 { + tracing::info!("Skipped {active} active worktree(s) during auto-prune"); + } + + Ok(pruned) +} + #[cfg(unix)] fn pid_is_alive(pid: u32) -> bool { if pid == 0 { @@ -769,6 +813,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let dispatch_order = order.clone(); @@ -832,6 +879,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); let recorded_clone = recorded.clone(); @@ -887,6 +937,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); @@ -943,6 +996,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); @@ -999,6 +1055,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let rebalance_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let rebalance_calls_clone = rebalance_calls.clone(); @@ -1199,4 +1258,28 @@ mod tests { assert_eq!(merged, 2); Ok(()) } + + #[tokio::test] + async fn maybe_auto_prune_inactive_worktrees_records_pruned_and_active_counts() -> Result<()> { + let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_clone = recorded.clone(); + + let pruned = maybe_auto_prune_inactive_worktrees_with_recorder( + || async move { + Ok(manager::WorktreePruneOutcome { + cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()], + active_with_worktree_ids: vec!["running-a".to_string()], + }) + }, + move |pruned, active| { + *recorded_clone.lock().unwrap() = Some((pruned, active)); + Ok(()) + }, + ) + .await?; + + assert_eq!(pruned, 2); + assert_eq!(*recorded.lock().unwrap(), Some((2, 1))); + Ok(()) + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index f288308c..e13cdd6e 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1585,6 +1585,16 @@ impl fmt::Display for CoordinationStatus { )?; } + if let Some(last_auto_prune_at) = self.daemon_activity.last_auto_prune_at.as_ref() { + writeln!( + f, + "Last daemon auto-prune: {} pruned / {} active @ {}", + self.daemon_activity.last_auto_prune_pruned, + self.daemon_activity.last_auto_prune_active_skipped, + last_auto_prune_at.to_rfc3339() + )?; + } + Ok(()) } } @@ -1693,6 +1703,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: Some(now), + last_auto_prune_pruned: 2, + last_auto_prune_active_skipped: 1, } } @@ -3171,6 +3184,7 @@ mod tests { assert!(rendered.contains( "Last daemon auto-merge: 1 merged / 1 active / 0 conflicted / 0 dirty / 0 failed" )); + assert!(rendered.contains("Last daemon auto-prune: 2 pruned / 1 active")); } #[test] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 8b493457..2cb906b8 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -33,6 +33,9 @@ pub struct DaemonActivity { pub last_auto_merge_conflicted_skipped: usize, pub last_auto_merge_dirty_skipped: usize, pub last_auto_merge_failed: usize, + pub last_auto_prune_at: Option>, + pub last_auto_prune_pruned: usize, + pub last_auto_prune_active_skipped: usize, } impl DaemonActivity { @@ -174,7 +177,10 @@ impl StateStore { last_auto_merge_active_skipped INTEGER NOT NULL DEFAULT 0, last_auto_merge_conflicted_skipped INTEGER NOT NULL DEFAULT 0, last_auto_merge_dirty_skipped INTEGER NOT NULL DEFAULT 0, - last_auto_merge_failed INTEGER NOT NULL DEFAULT 0 + last_auto_merge_failed INTEGER NOT NULL DEFAULT 0, + last_auto_prune_at TEXT, + last_auto_prune_pruned INTEGER NOT NULL DEFAULT 0, + last_auto_prune_active_skipped INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state); @@ -307,6 +313,33 @@ impl StateStore { .context("Failed to add last_auto_merge_failed column to daemon_activity table")?; } + if !self.has_column("daemon_activity", "last_auto_prune_at")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_at TEXT", + [], + ) + .context("Failed to add last_auto_prune_at column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_prune_pruned")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_pruned INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_prune_pruned column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_prune_active_skipped")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_active_skipped INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_prune_active_skipped column to daemon_activity table")?; + } + Ok(()) } @@ -712,7 +745,8 @@ impl StateStore { last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads, last_auto_merge_at, last_auto_merge_merged, last_auto_merge_active_skipped, last_auto_merge_conflicted_skipped, last_auto_merge_dirty_skipped, - last_auto_merge_failed + last_auto_merge_failed, last_auto_prune_at, last_auto_prune_pruned, + last_auto_prune_active_skipped FROM daemon_activity WHERE id = 1", [], @@ -752,6 +786,9 @@ impl StateStore { last_auto_merge_conflicted_skipped: row.get::<_, i64>(14)? as usize, last_auto_merge_dirty_skipped: row.get::<_, i64>(15)? as usize, last_auto_merge_failed: row.get::<_, i64>(16)? as usize, + last_auto_prune_at: parse_ts(row.get(17)?)?, + last_auto_prune_pruned: row.get::<_, i64>(18)? as usize, + last_auto_prune_active_skipped: row.get::<_, i64>(19)? as usize, }) }, ) @@ -847,6 +884,27 @@ impl StateStore { Ok(()) } + pub fn record_daemon_auto_prune_pass( + &self, + pruned: usize, + active_skipped: usize, + ) -> Result<()> { + self.conn.execute( + "UPDATE daemon_activity + SET last_auto_prune_at = ?1, + last_auto_prune_pruned = ?2, + last_auto_prune_active_skipped = ?3 + WHERE id = 1", + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + pruned as i64, + active_skipped as i64, + ], + )?; + + Ok(()) + } + pub fn delegated_children(&self, session_id: &str, limit: usize) -> Result> { let mut stmt = self.conn.prepare( "SELECT to_session @@ -1223,6 +1281,7 @@ mod tests { db.record_daemon_recovery_dispatch_pass(2, 1)?; db.record_daemon_rebalance_pass(3, 1)?; db.record_daemon_auto_merge_pass(2, 1, 1, 1, 0)?; + db.record_daemon_auto_prune_pass(3, 1)?; let activity = db.daemon_activity()?; assert_eq!(activity.last_dispatch_routed, 4); @@ -1238,10 +1297,13 @@ mod tests { assert_eq!(activity.last_auto_merge_conflicted_skipped, 1); assert_eq!(activity.last_auto_merge_dirty_skipped, 1); assert_eq!(activity.last_auto_merge_failed, 0); + assert_eq!(activity.last_auto_prune_pruned, 3); + assert_eq!(activity.last_auto_prune_active_skipped, 1); assert!(activity.last_dispatch_at.is_some()); assert!(activity.last_recovery_dispatch_at.is_some()); assert!(activity.last_rebalance_at.is_some()); assert!(activity.last_auto_merge_at.is_some()); + assert!(activity.last_auto_prune_at.is_some()); Ok(()) } @@ -1274,6 +1336,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; assert!(unresolved.prefers_rebalance_first()); assert!(unresolved.dispatch_cooloff_active()); diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index cc5f9912..4195a493 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2029,6 +2029,15 @@ impl Dashboard { )); } + if let Some(last_auto_prune_at) = self.daemon_activity.last_auto_prune_at.as_ref() { + lines.push(format!( + "Last daemon auto-prune {} pruned / {} active @ {}", + self.daemon_activity.last_auto_prune_pruned, + self.daemon_activity.last_auto_prune_active_skipped, + self.short_timestamp(&last_auto_prune_at.to_rfc3339()) + )); + } + if let Some(route_preview) = self.selected_route_preview.as_ref() { lines.push(format!("Next route {route_preview}")); } @@ -3041,6 +3050,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 1, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: Some(now + chrono::Duration::seconds(4)), + last_auto_prune_pruned: 3, + last_auto_prune_active_skipped: 1, }; let text = dashboard.selected_session_metrics_text(); @@ -3052,6 +3064,7 @@ diff --git a/src/next.rs b/src/next.rs assert!(text.contains( "Last daemon auto-merge 2 merged / 1 active / 1 conflicted / 0 dirty / 0 failed" )); + assert!(text.contains("Last daemon auto-prune 3 pruned / 1 active")); } #[test] @@ -3085,6 +3098,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3122,6 +3138,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3161,6 +3180,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3201,6 +3223,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3256,6 +3281,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3358,6 +3386,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text();