mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 19:33:37 +08:00
feat: add worktree retention cleanup policy
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<usize> {
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
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| {
|
||||
|
||||
@@ -862,12 +862,19 @@ pub async fn merge_ready_worktrees(
|
||||
pub struct WorktreePruneOutcome {
|
||||
pub cleaned_session_ids: Vec<String>,
|
||||
pub active_with_worktree_ids: Vec<String>,
|
||||
pub retained_session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn prune_inactive_worktrees(db: &StateStore) -> Result<WorktreePruneOutcome> {
|
||||
pub async fn prune_inactive_worktrees(
|
||||
db: &StateStore,
|
||||
cfg: &Config,
|
||||
) -> Result<WorktreePruneOutcome> {
|
||||
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<WorktreePruneOu
|
||||
continue;
|
||||
}
|
||||
|
||||
if retention > 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<WorktreePruneOu
|
||||
Ok(WorktreePruneOutcome {
|
||||
cleaned_session_ids,
|
||||
active_with_worktree_ids,
|
||||
retained_session_ids,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1922,6 +1937,7 @@ mod tests {
|
||||
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,
|
||||
@@ -2607,10 +2623,11 @@ mod tests {
|
||||
.context("stopped session worktree missing")?
|
||||
.path;
|
||||
|
||||
let outcome = prune_inactive_worktrees(&db).await?;
|
||||
let outcome = prune_inactive_worktrees(&db, &cfg).await?;
|
||||
|
||||
assert_eq!(outcome.cleaned_session_ids, vec![stopped_id.clone()]);
|
||||
assert_eq!(outcome.active_with_worktree_ids, vec![active_id.clone()]);
|
||||
assert!(outcome.retained_session_ids.is_empty());
|
||||
assert!(active_path.exists(), "active worktree should remain");
|
||||
assert!(!stopped_path.exists(), "stopped worktree should be removed");
|
||||
|
||||
@@ -2633,6 +2650,64 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn prune_inactive_worktrees_defers_recent_sessions_within_retention() -> 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")?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user