huskies: merge 1199 story Orphaned build-dir GC: reclaim dead worktree targets without touching warm caches
This commit is contained in:
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
mod squash;
|
mod squash;
|
||||||
|
|
||||||
pub(crate) use squash::run_squash_merge;
|
pub(crate) use squash::{merge_lock_is_free, run_squash_merge};
|
||||||
|
|
||||||
/// Typed outcome of a completed squash-merge operation.
|
/// Typed outcome of a completed squash-merge operation.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ use crate::config::ProjectConfig;
|
|||||||
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
|
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
|
||||||
static MERGE_LOCK: Mutex<()> = Mutex::new(());
|
static MERGE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Returns `true` when no squash-merge is currently running, i.e. the merge
|
||||||
|
/// lock is free.
|
||||||
|
///
|
||||||
|
/// Used by the build-directory GC pass (story 1199) to decide whether
|
||||||
|
/// `.huskies/merge_workspace` is safe to reclaim. This is a best-effort,
|
||||||
|
/// momentary check — the lock is not held across the reclaim itself, so a
|
||||||
|
/// merge that starts immediately afterward can still race with GC. That's
|
||||||
|
/// acceptable: the GC pass tolerates races and skips on error rather than
|
||||||
|
/// failing the whole pass.
|
||||||
|
pub(crate) fn merge_lock_is_free() -> bool {
|
||||||
|
MERGE_LOCK.try_lock().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the base branch for `project_root` from config, or auto-detect it.
|
/// Resolve the base branch for `project_root` from config, or auto-detect it.
|
||||||
fn resolve_base_branch(project_root: &Path) -> String {
|
fn resolve_base_branch(project_root: &Path) -> String {
|
||||||
let configured = crate::config::ProjectConfig::load(project_root)
|
let configured = crate::config::ProjectConfig::load(project_root)
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ pub(crate) async fn on_coding_transition(project_root: &Path, port: u16, story_i
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the worktree and feature branch for `story_id` after it reaches a terminal stage.
|
/// Remove the worktree and feature branch for `story_id` after it reaches a terminal stage.
|
||||||
|
///
|
||||||
|
/// Story 1199: bytes-reclaimed accounting for the worktree's `target/` dir
|
||||||
|
/// happens one layer down, in `remove_worktree` — the single choke point
|
||||||
|
/// shared by this subscriber, the `remove_worktree` MCP tool, and
|
||||||
|
/// `worktree::cleanup`/`sweep`. Logging it there (once) avoids a duplicate
|
||||||
|
/// log line here.
|
||||||
pub(crate) async fn on_terminal_transition(project_root: &Path, story_id: &str) {
|
pub(crate) async fn on_terminal_transition(project_root: &Path, story_id: &str) {
|
||||||
let config = match crate::config::ProjectConfig::load(project_root) {
|
let config = match crate::config::ProjectConfig::load(project_root) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -270,6 +276,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Story 1199 AC4: on_terminal_transition reclaims the worktree's target/
|
||||||
|
/// dir (and everything else in the worktree) when removing it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_transition_reclaims_target_dir() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let root = setup_project(&tmp);
|
||||||
|
let story_id = "1006_test_reclaim_target";
|
||||||
|
|
||||||
|
on_coding_transition(&root, 3001, story_id).await;
|
||||||
|
let wt_path = crate::worktree::worktree_path(&root, story_id);
|
||||||
|
let target_dir = wt_path.join("target");
|
||||||
|
fs::create_dir_all(&target_dir).unwrap();
|
||||||
|
fs::write(target_dir.join("build_artifact.bin"), vec![0u8; 512]).unwrap();
|
||||||
|
assert!(target_dir.exists(), "target/ must exist before cleanup");
|
||||||
|
|
||||||
|
on_terminal_transition(&root, story_id).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!target_dir.exists(),
|
||||||
|
"target/ dir must be reclaimed when the worktree is removed"
|
||||||
|
);
|
||||||
|
assert!(!wt_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
/// AC2: on_terminal_transition is a no-op (non-fatal) when no worktree exists.
|
/// AC2: on_terminal_transition is a no-op (non-fatal) when no worktree exists.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_transition_noop_when_no_worktree() {
|
async fn terminal_transition_noop_when_no_worktree() {
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ pub struct ProjectConfig {
|
|||||||
/// merge or `FixupRequested`). Default: 3. Set to 0 to disable.
|
/// merge or `FixupRequested`). Default: 3. Set to 0 to disable.
|
||||||
#[serde(default = "default_merge_failure_block_threshold")]
|
#[serde(default = "default_merge_failure_block_threshold")]
|
||||||
pub merge_failure_block_threshold: u32,
|
pub merge_failure_block_threshold: u32,
|
||||||
|
/// Free space (in GB) below which an automatic build-directory GC pass
|
||||||
|
/// (story 1199) is triggered to reclaim orphaned worktree `target/` dirs
|
||||||
|
/// and a stale `.huskies/merge_workspace`. Checked on the same tick as the
|
||||||
|
/// low-disk watchdog (story 1200), not a separate timer. Default: 0
|
||||||
|
/// (disabled) — the on-demand `gc` MCP tool remains available regardless.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gc_min_free_gb: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for the filesystem watcher's sweep behaviour.
|
/// Configuration for the filesystem watcher's sweep behaviour.
|
||||||
@@ -464,6 +471,7 @@ impl Default for ProjectConfig {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: default_status_push_enabled(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
|
gc_min_free_gb: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,6 +563,7 @@ impl ProjectConfig {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: default_status_push_enabled(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
|
gc_min_free_gb: 0,
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
return Ok(config);
|
return Ok(config);
|
||||||
@@ -597,6 +606,7 @@ impl ProjectConfig {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: default_status_push_enabled(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
|
gc_min_free_gb: 0,
|
||||||
};
|
};
|
||||||
validate_agents(&config.agent)?;
|
validate_agents(&config.agent)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@@ -627,6 +637,7 @@ impl ProjectConfig {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: default_status_push_enabled(),
|
status_push_enabled: default_status_push_enabled(),
|
||||||
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
merge_failure_block_threshold: default_merge_failure_block_threshold(),
|
||||||
|
gc_min_free_gb: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! MCP tool for the on-demand build-directory GC pass (story 1199).
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
|
||||||
|
/// MCP tool handler for `gc` — runs a build-directory GC pass on demand and
|
||||||
|
/// returns bytes reclaimed, duration, and which directories were reclaimed
|
||||||
|
/// or skipped (raced/errored).
|
||||||
|
pub(crate) async fn tool_gc(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
|
||||||
|
let report = crate::service::gc::io::run_gc_pass(&project_root).await;
|
||||||
|
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"bytes_reclaimed": report.bytes_reclaimed,
|
||||||
|
"reclaimed": report.reclaimed,
|
||||||
|
"skipped": report.skipped,
|
||||||
|
"duration_secs": report.duration.as_secs_f64(),
|
||||||
|
}))
|
||||||
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_gc_returns_zero_bytes_for_empty_project() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_gc(&ctx).await.expect("tool_gc must not fail");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(parsed["bytes_reclaimed"].as_u64(), Some(0));
|
||||||
|
assert!(parsed["reclaimed"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
//! MCP agent tools — start, stop, wait, list, and inspect agents via MCP.
|
//! MCP agent tools — start, stop, wait, list, and inspect agents via MCP.
|
||||||
|
|
||||||
|
mod gc;
|
||||||
mod inspection;
|
mod inspection;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod worktree;
|
mod worktree;
|
||||||
|
|
||||||
|
pub(crate) use gc::tool_gc;
|
||||||
pub(crate) use inspection::{
|
pub(crate) use inspection::{
|
||||||
tool_get_agent_config, tool_get_agent_output, tool_get_agent_remaining_turns_and_budget,
|
tool_get_agent_config, tool_get_agent_output, tool_get_agent_remaining_turns_and_budget,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub async fn dispatch_tool_call(
|
|||||||
"list_worktrees" => agent_tools::tool_list_worktrees(ctx),
|
"list_worktrees" => agent_tools::tool_list_worktrees(ctx),
|
||||||
"remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await,
|
"remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await,
|
||||||
"cleanup_worktrees" => agent_tools::tool_cleanup_worktrees(&args, ctx).await,
|
"cleanup_worktrees" => agent_tools::tool_cleanup_worktrees(&args, ctx).await,
|
||||||
|
"gc" => agent_tools::tool_gc(ctx).await,
|
||||||
// Editor tools
|
// Editor tools
|
||||||
"get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx),
|
"get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx),
|
||||||
// Lifecycle tools
|
// Lifecycle tools
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ pub(super) fn agent_tools() -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "gc",
|
||||||
|
"description": "Run a build-directory GC pass on demand (story 1199): reclaims orphaned worktree target/ dirs and a stale merge_workspace, never touching the main project's or any live worktree's target/. Returns bytes reclaimed, duration, and which directories were reclaimed or skipped.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "get_editor_command",
|
"name": "get_editor_command",
|
||||||
"description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.",
|
"description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.",
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"convert_item_type"));
|
assert!(names.contains(&"convert_item_type"));
|
||||||
assert!(names.contains(&"edit"));
|
assert!(names.contains(&"edit"));
|
||||||
assert!(names.contains(&"write"));
|
assert!(names.contains(&"write"));
|
||||||
assert_eq!(tools.len(), 84);
|
assert!(names.contains(&"gc"));
|
||||||
|
assert_eq!(tools.len(), 85);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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;
|
pub mod file_io;
|
||||||
/// Gateway — multi-project proxy domain logic.
|
/// Gateway — multi-project proxy domain logic.
|
||||||
pub mod gateway;
|
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.
|
/// Git operations — worktree-scoped git commands.
|
||||||
pub mod git_ops;
|
pub mod git_ops;
|
||||||
/// Merge — rebase agent work onto master and validate.
|
/// Merge — rebase agent work onto master and validate.
|
||||||
|
|||||||
@@ -241,6 +241,45 @@ pub(crate) fn spawn_tick_loop(
|
|||||||
&disk_watch_status,
|
&disk_watch_status,
|
||||||
&disk_watch_host_id,
|
&disk_watch_host_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Orphaned build-dir GC (story 1199): tied to the same
|
||||||
|
// low-disk signal as the watchdog above rather than its own
|
||||||
|
// timer. `gc_min_free_gb == 0` (default) disables this —
|
||||||
|
// the on-demand `gc` MCP tool remains available regardless.
|
||||||
|
let gc_min_free_gb = config::ProjectConfig::load(r)
|
||||||
|
.map(|c| c.gc_min_free_gb)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if let Ok(free_bytes) = service::disk_watch::io::free_space_bytes(r)
|
||||||
|
&& service::gc::should_auto_run(gc_min_free_gb, free_bytes)
|
||||||
|
{
|
||||||
|
let gc_root = r.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
service::gc::io::run_gc_pass(&gc_root).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphaned build-dir GC (story 1199): if free disk falls below
|
||||||
|
// `gc_min_free_gb`, run a GC pass to reclaim orphaned worktree
|
||||||
|
// `target/` dirs and a stale `merge_workspace`. Checked on the
|
||||||
|
// same disk-space tick as the low-disk watchdog above — not a
|
||||||
|
// separate timer — so GC only ever runs in response to an actual
|
||||||
|
// low-disk condition. `gc_min_free_gb` absent or 0 disables this
|
||||||
|
// (the on-demand `gc` MCP tool still works).
|
||||||
|
if tick_count.is_multiple_of(30)
|
||||||
|
&& let Some(ref r) = root
|
||||||
|
{
|
||||||
|
let gc_min_free_gb = config::ProjectConfig::load(r)
|
||||||
|
.map(|c| c.gc_min_free_gb)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if let Ok(free_bytes) = service::disk_watch::io::free_space_bytes(r)
|
||||||
|
&& service::gc::should_auto_run(gc_min_free_gb, free_bytes)
|
||||||
|
{
|
||||||
|
let root_for_gc = r.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
service::gc::io::run_gc_pass(&root_for_gc).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic reconciler: converge subscriber side effects so that
|
// Periodic reconciler: converge subscriber side effects so that
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ mod tests {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: true,
|
status_push_enabled: true,
|
||||||
merge_failure_block_threshold: 3,
|
merge_failure_block_threshold: 3,
|
||||||
|
gc_min_free_gb: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ mod tests {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: true,
|
status_push_enabled: true,
|
||||||
merge_failure_block_threshold: 3,
|
merge_failure_block_threshold: 3,
|
||||||
|
gc_min_free_gb: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub use create::install_pre_commit_hook;
|
|||||||
pub use git::migrate_slug_paths;
|
pub use git::migrate_slug_paths;
|
||||||
pub(crate) use git::resolve_base_branch;
|
pub(crate) use git::resolve_base_branch;
|
||||||
pub use remove::remove_worktree_by_story_id;
|
pub use remove::remove_worktree_by_story_id;
|
||||||
|
pub(crate) use sweep::worktree_should_be_swept;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
/// Details about a newly created worktree: path, branch, and base branch.
|
/// Details about a newly created worktree: path, branch, and base branch.
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ use super::git::{branch_name, remove_worktree_sync, resolve_base_branch};
|
|||||||
use super::{WorktreeInfo, worktree_path};
|
use super::{WorktreeInfo, worktree_path};
|
||||||
|
|
||||||
/// Remove a git worktree and its branch.
|
/// Remove a git worktree and its branch.
|
||||||
|
///
|
||||||
|
/// Story 1199: measures the worktree's `target/` size before removal (once
|
||||||
|
/// removed, there's nothing left to measure) and logs bytes reclaimed on
|
||||||
|
/// success, so terminal-story worktree cleanup reports its disk impact the
|
||||||
|
/// same way the on-demand `gc` pass does.
|
||||||
pub async fn remove_worktree(
|
pub async fn remove_worktree(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
info: &WorktreeInfo,
|
info: &WorktreeInfo,
|
||||||
@@ -14,13 +19,30 @@ pub async fn remove_worktree(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
run_teardown_commands(&info.path, config).await?;
|
run_teardown_commands(&info.path, config).await?;
|
||||||
|
|
||||||
|
let target_path = info.path.join("target");
|
||||||
|
let target_bytes = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::service::disk_watch::io::dir_size_bytes(&target_path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let root = project_root.to_path_buf();
|
let root = project_root.to_path_buf();
|
||||||
let wt_path = info.path.clone();
|
let wt_path = info.path.clone();
|
||||||
let branch = info.branch.clone();
|
let branch = info.branch.clone();
|
||||||
|
|
||||||
|
let result =
|
||||||
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
|
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("spawn_blocking: {e}"))?
|
.map_err(|e| format!("spawn_blocking: {e}"))?;
|
||||||
|
|
||||||
|
if result.is_ok() && target_bytes > 0 {
|
||||||
|
crate::slog!(
|
||||||
|
"[worktree-remove] Reclaimed {target_bytes} bytes from '{}' target/ on removal",
|
||||||
|
info.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a git worktree by story ID, deriving the path and branch deterministically.
|
/// Remove a git worktree by story ID, deriving the path and branch deterministically.
|
||||||
@@ -91,6 +113,7 @@ mod tests {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: true,
|
status_push_enabled: true,
|
||||||
merge_failure_block_threshold: 3,
|
merge_failure_block_threshold: 3,
|
||||||
|
gc_min_free_gb: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,4 +228,35 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!path.exists());
|
assert!(!path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Story 1199 AC4: removal reclaims a populated `target/` dir along with
|
||||||
|
/// the rest of the worktree.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_worktree_reclaims_populated_target_dir() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let project_root = tmp.path().join("my-project");
|
||||||
|
fs::create_dir_all(&project_root).unwrap();
|
||||||
|
init_git_repo(&project_root);
|
||||||
|
|
||||||
|
let info = super::super::create::create_worktree(
|
||||||
|
&project_root,
|
||||||
|
"90_reclaim_target",
|
||||||
|
&empty_config(),
|
||||||
|
3001,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let target_dir = info.path.join("target");
|
||||||
|
fs::create_dir_all(&target_dir).unwrap();
|
||||||
|
fs::write(target_dir.join("build_artifact.bin"), vec![0u8; 1024]).unwrap();
|
||||||
|
assert!(target_dir.exists());
|
||||||
|
|
||||||
|
remove_worktree(&project_root, &info, &empty_config())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!info.path.exists(), "whole worktree must be gone");
|
||||||
|
assert!(!target_dir.exists(), "target/ must be reclaimed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ mod tests {
|
|||||||
gateway_project: None,
|
gateway_project: None,
|
||||||
status_push_enabled: true,
|
status_push_enabled: true,
|
||||||
merge_failure_block_threshold: 3,
|
merge_failure_block_threshold: 3,
|
||||||
|
gc_min_free_gb: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user