huskies: merge 1199 story Orphaned build-dir GC: reclaim dead worktree targets without touching warm caches
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
//! Side effects for the build-directory GC pass: directory-size walks,
|
||||
//! recursive removal via `spawn_blocking`, and the pass orchestration entry
|
||||
//! point.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::{GcCandidate, GcReport, select_merge_workspace, select_orphaned_worktree_targets};
|
||||
|
||||
/// Reclaim a single candidate directory: compute its size, then remove it.
|
||||
///
|
||||
/// Tolerates races — if the directory has already vanished (removed by a
|
||||
/// concurrent build, worktree cleanup, or merge), this returns `Ok(0)`
|
||||
/// rather than failing. Runs on `spawn_blocking` since both the size walk and
|
||||
/// the removal are blocking filesystem operations.
|
||||
async fn reclaim_candidate(candidate: &GcCandidate) -> Result<u64, String> {
|
||||
let path = candidate.path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let bytes = crate::service::disk_watch::io::dir_size_bytes(&path);
|
||||
match std::fs::remove_dir_all(&path) {
|
||||
Ok(()) => Ok(bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||
Err(e) => Err(format!("remove {}: {e}", path.display())),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking panicked: {e}"))?
|
||||
}
|
||||
|
||||
/// Run one GC pass: reclaim orphaned worktree `target/` dirs and a stale
|
||||
/// `merge_workspace`, tolerating races (skip on error) so a concurrent build
|
||||
/// or merge never fails the whole pass. Logs bytes reclaimed and duration.
|
||||
pub async fn run_gc_pass(project_root: &Path) -> GcReport {
|
||||
let start = Instant::now();
|
||||
|
||||
let entries = crate::worktree::list_worktrees(project_root).unwrap_or_default();
|
||||
let mut candidates = select_orphaned_worktree_targets(&entries, |id| {
|
||||
crate::pipeline_state::read_typed(id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|item| item.stage)
|
||||
});
|
||||
|
||||
let merge_lock_free = crate::agents::merge::merge_lock_is_free();
|
||||
if let Some(mw) = select_merge_workspace(project_root, merge_lock_free) {
|
||||
candidates.push(mw);
|
||||
}
|
||||
|
||||
let mut bytes_reclaimed = 0u64;
|
||||
let mut reclaimed = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
|
||||
for candidate in &candidates {
|
||||
match reclaim_candidate(candidate).await {
|
||||
Ok(bytes) => {
|
||||
if bytes > 0 {
|
||||
bytes_reclaimed += bytes;
|
||||
reclaimed.push(candidate.label.clone());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
crate::slog_warn!("[gc] Skipping '{}': {err}", candidate.label);
|
||||
skipped.push(candidate.label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start.elapsed();
|
||||
crate::slog!(
|
||||
"[gc] Pass complete: reclaimed {bytes_reclaimed} bytes across {} dir(s) in {:.2}s ({} skipped)",
|
||||
reclaimed.len(),
|
||||
duration.as_secs_f64(),
|
||||
skipped.len()
|
||||
);
|
||||
|
||||
GcReport {
|
||||
bytes_reclaimed,
|
||||
reclaimed,
|
||||
skipped,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::service::gc::GcCandidate;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_candidate_returns_zero_when_path_never_existed() {
|
||||
let candidate = GcCandidate {
|
||||
label: "999_never_existed".to_string(),
|
||||
path: PathBuf::from("/no/such/path/for/story-1199-test"),
|
||||
};
|
||||
let result = reclaim_candidate(&candidate).await;
|
||||
assert_eq!(result, Ok(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_candidate_returns_zero_when_path_vanishes_before_reclaim() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("target");
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
std::fs::write(path.join("a.bin"), vec![0u8; 128]).unwrap();
|
||||
|
||||
// Simulate a race: the directory is removed by another process
|
||||
// between selection and reclaim.
|
||||
std::fs::remove_dir_all(&path).unwrap();
|
||||
|
||||
let candidate = GcCandidate {
|
||||
label: "race".to_string(),
|
||||
path,
|
||||
};
|
||||
let result = reclaim_candidate(&candidate).await;
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(0),
|
||||
"a vanished directory must be tolerated, not treated as an error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_candidate_reports_bytes_for_a_real_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("target");
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
std::fs::write(path.join("a.bin"), vec![0u8; 256]).unwrap();
|
||||
|
||||
let candidate = GcCandidate {
|
||||
label: "real".to_string(),
|
||||
path: path.clone(),
|
||||
};
|
||||
let result = reclaim_candidate(&candidate).await;
|
||||
assert_eq!(result, Ok(256));
|
||||
assert!(!path.exists(), "reclaimed directory must be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_gc_pass_tolerates_one_candidate_vanishing_and_still_reclaims_the_rest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path().to_path_buf();
|
||||
|
||||
// No worktrees/merge_workspace on disk at all — the pass must still
|
||||
// complete cleanly (nothing to reclaim, nothing to error on).
|
||||
let report = run_gc_pass(&project_root).await;
|
||||
assert_eq!(report.bytes_reclaimed, 0);
|
||||
assert!(report.skipped.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Orphaned build-dir GC (story 1199) — reclaims disk space from dead
|
||||
//! worktree `target/` directories and a stale `.huskies/merge_workspace`,
|
||||
//! without ever touching the main project's or any live worktree's
|
||||
//! `target/` (the warm caches that keep rebuilds fast).
|
||||
//!
|
||||
//! Follows service-module conventions: this file holds pure orphan-selection
|
||||
//! logic (no I/O); `io.rs` is the only place performing side effects
|
||||
//! (directory-size walks, `fs::remove_dir_all`, `spawn_blocking`, the pass
|
||||
//! orchestration entry point).
|
||||
|
||||
/// Side effects for the GC pass: directory-size walks, recursive removal via
|
||||
/// `spawn_blocking`, and the pass orchestration entry point.
|
||||
pub mod io;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::pipeline_state::Stage;
|
||||
use crate::worktree::WorktreeListEntry;
|
||||
|
||||
/// A single directory identified as safe to reclaim by a GC pass.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GcCandidate {
|
||||
/// Human-readable label for reporting (story ID, or `"merge_workspace"`).
|
||||
pub label: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Summary of one GC pass: what was reclaimed, what was skipped (raced or
|
||||
/// errored), how many bytes were freed, and how long the pass took.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GcReport {
|
||||
pub bytes_reclaimed: u64,
|
||||
pub reclaimed: Vec<String>,
|
||||
pub skipped: Vec<String>,
|
||||
pub duration: std::time::Duration,
|
||||
}
|
||||
|
||||
/// Select `target/` directories under worktrees whose story is no longer
|
||||
/// active in the pool (terminal stage, or absent from the CRDT entirely).
|
||||
///
|
||||
/// Reuses [`crate::worktree::worktree_should_be_swept`] — the same predicate
|
||||
/// that decides whether a whole worktree should be swept — so a `target/`
|
||||
/// dir is only ever reclaimed here for a worktree that full cleanup would
|
||||
/// also remove. Live worktrees (Backlog, Coding, Qa, Merge) are never
|
||||
/// touched, preserving their warm build cache.
|
||||
pub fn select_orphaned_worktree_targets<F>(
|
||||
entries: &[WorktreeListEntry],
|
||||
lookup: F,
|
||||
) -> Vec<GcCandidate>
|
||||
where
|
||||
F: Fn(&str) -> Option<Stage>,
|
||||
{
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| crate::worktree::worktree_should_be_swept(lookup(&e.story_id).as_ref()))
|
||||
.map(|e| GcCandidate {
|
||||
label: e.story_id.clone(),
|
||||
path: e.path.join("target"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Select the `.huskies/merge_workspace` candidate, if it's safe to reclaim.
|
||||
///
|
||||
/// `merge_lock_free` should come from [`crate::agents::merge::merge_lock_is_free`]
|
||||
/// — a momentary snapshot of whether a squash-merge is currently running.
|
||||
/// When a merge is running, `merge_workspace` is live state, not an orphan.
|
||||
pub fn select_merge_workspace(
|
||||
project_root: &std::path::Path,
|
||||
merge_lock_free: bool,
|
||||
) -> Option<GcCandidate> {
|
||||
if !merge_lock_free {
|
||||
return None;
|
||||
}
|
||||
Some(GcCandidate {
|
||||
label: "merge_workspace".to_string(),
|
||||
path: project_root.join(".huskies").join("merge_workspace"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` if an automatic GC pass should run given the configured
|
||||
/// threshold and current free space.
|
||||
///
|
||||
/// `gc_min_free_gb == 0` disables automatic GC entirely — the on-demand `gc`
|
||||
/// MCP tool remains available regardless.
|
||||
pub fn should_auto_run(gc_min_free_gb: u64, free_bytes: u64) -> bool {
|
||||
gc_min_free_gb > 0 && free_bytes < gc_min_free_gb * 1_000_000_000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
fn done_stage() -> Stage {
|
||||
Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(story_id: &str) -> WorktreeListEntry {
|
||||
WorktreeListEntry {
|
||||
story_id: story_id.to_string(),
|
||||
path: PathBuf::from(format!("/project/.huskies/worktrees/{story_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── select_orphaned_worktree_targets ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn orphaned_target_is_selected_when_story_is_terminal() {
|
||||
let entries = vec![entry("100_done")];
|
||||
let candidates = select_orphaned_worktree_targets(&entries, |_| Some(done_stage()));
|
||||
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].label, "100_done");
|
||||
assert_eq!(
|
||||
candidates[0].path,
|
||||
PathBuf::from("/project/.huskies/worktrees/100_done/target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphaned_target_is_selected_when_story_absent_from_crdt() {
|
||||
let entries = vec![entry("101_purged")];
|
||||
let candidates = select_orphaned_worktree_targets(&entries, |_| None);
|
||||
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].label, "101_purged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_worktree_target_is_kept_not_selected() {
|
||||
let entries = vec![entry("102_coding")];
|
||||
let candidates = select_orphaned_worktree_targets(&entries, |_| {
|
||||
Some(Stage::Coding {
|
||||
claim: None,
|
||||
plan: Default::default(),
|
||||
retries: 0,
|
||||
})
|
||||
});
|
||||
|
||||
assert!(
|
||||
candidates.is_empty(),
|
||||
"a live (Coding) worktree's target/ must never be selected for GC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_pool_only_selects_the_orphan() {
|
||||
let entries = vec![entry("103_coding"), entry("104_done")];
|
||||
let candidates = select_orphaned_worktree_targets(&entries, |id| {
|
||||
if id == "104_done" {
|
||||
Some(done_stage())
|
||||
} else {
|
||||
Some(Stage::Coding {
|
||||
claim: None,
|
||||
plan: Default::default(),
|
||||
retries: 0,
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].label, "104_done");
|
||||
}
|
||||
|
||||
// ── select_merge_workspace ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn merge_workspace_selected_when_lock_free() {
|
||||
let root = PathBuf::from("/project");
|
||||
let candidate = select_merge_workspace(&root, true);
|
||||
assert_eq!(
|
||||
candidate,
|
||||
Some(GcCandidate {
|
||||
label: "merge_workspace".to_string(),
|
||||
path: PathBuf::from("/project/.huskies/merge_workspace"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_workspace_not_selected_when_merge_running() {
|
||||
let root = PathBuf::from("/project");
|
||||
assert_eq!(select_merge_workspace(&root, false), None);
|
||||
}
|
||||
|
||||
// ── should_auto_run (disabled config) ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn auto_run_disabled_when_threshold_is_zero() {
|
||||
assert!(!should_auto_run(0, 0));
|
||||
assert!(!should_auto_run(0, u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_run_triggers_below_threshold() {
|
||||
let gb = 1_000_000_000;
|
||||
assert!(should_auto_run(50, 10 * gb));
|
||||
assert!(!should_auto_run(50, 60 * gb));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ pub mod events;
|
||||
pub mod file_io;
|
||||
/// Gateway — multi-project proxy domain logic.
|
||||
pub mod gateway;
|
||||
/// Orphaned build-dir GC — reclaims dead worktree `target/` dirs and stale
|
||||
/// `merge_workspace` leftovers (story 1199).
|
||||
pub mod gc;
|
||||
/// Git operations — worktree-scoped git commands.
|
||||
pub mod git_ops;
|
||||
/// Merge — rebase agent work onto master and validate.
|
||||
|
||||
Reference in New Issue
Block a user