feat: auto-prune inactive ecc2 worktrees

This commit is contained in:
Affaan Mustafa
2026-04-08 16:08:29 -07:00
parent b3f781a648
commit adfe8a8311
4 changed files with 195 additions and 2 deletions

View File

@@ -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<usize> {
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<F, Fut>(prune: F) -> Result<usize>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<manager::WorktreePruneOutcome>>,
{
maybe_auto_prune_inactive_worktrees_with_recorder(prune, |_, _| Ok(())).await
}
async fn maybe_auto_prune_inactive_worktrees_with_recorder<F, Fut, R>(
prune: F,
mut record: R,
) -> Result<usize>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<manager::WorktreePruneOutcome>>,
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(())
}
}

View File

@@ -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]

View File

@@ -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<chrono::DateTime<chrono::Utc>>,
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<Vec<String>> {
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());

View File

@@ -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();