From 5b340e7b201928e6240a30dbe148f39c4bf302d4 Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Fri, 17 Jul 2026 20:43:19 +0000 Subject: [PATCH] huskies: merge 1199 story Orphaned build-dir GC: reclaim dead worktree targets without touching warm caches --- server/src/agents/merge/mod.rs | 2 +- server/src/agents/merge/squash/mod.rs | 13 ++ server/src/agents/pool/worktree_lifecycle.rs | 30 +++ server/src/config/mod.rs | 11 + server/src/http/mcp/agent_tools/gc.rs | 37 ++++ server/src/http/mcp/agent_tools/mod.rs | 2 + server/src/http/mcp/dispatch.rs | 1 + server/src/http/mcp/tools_list/agent_tools.rs | 8 + server/src/http/mcp/tools_list/mod.rs | 3 +- server/src/service/gc/io.rs | 153 +++++++++++++ server/src/service/gc/mod.rs | 204 ++++++++++++++++++ server/src/service/mod.rs | 3 + server/src/startup/tick_loop.rs | 39 ++++ server/src/worktree/cleanup.rs | 1 + server/src/worktree/create.rs | 1 + server/src/worktree/mod.rs | 1 + server/src/worktree/remove.rs | 60 +++++- server/src/worktree/sweep.rs | 1 + 18 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 server/src/http/mcp/agent_tools/gc.rs create mode 100644 server/src/service/gc/io.rs create mode 100644 server/src/service/gc/mod.rs diff --git a/server/src/agents/merge/mod.rs b/server/src/agents/merge/mod.rs index ec869c37..28b3687b 100644 --- a/server/src/agents/merge/mod.rs +++ b/server/src/agents/merge/mod.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; 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. /// diff --git a/server/src/agents/merge/squash/mod.rs b/server/src/agents/merge/squash/mod.rs index be70e6ec..6b92710a 100644 --- a/server/src/agents/merge/squash/mod.rs +++ b/server/src/agents/merge/squash/mod.rs @@ -17,6 +17,19 @@ use crate::config::ProjectConfig; /// causing `git cherry-pick merge-queue/…` to fail with "bad revision". 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. fn resolve_base_branch(project_root: &Path) -> String { let configured = crate::config::ProjectConfig::load(project_root) diff --git a/server/src/agents/pool/worktree_lifecycle.rs b/server/src/agents/pool/worktree_lifecycle.rs index 016f784a..2fb3fa1e 100644 --- a/server/src/agents/pool/worktree_lifecycle.rs +++ b/server/src/agents/pool/worktree_lifecycle.rs @@ -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. +/// +/// 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) { let config = match crate::config::ProjectConfig::load(project_root) { 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. #[tokio::test] async fn terminal_transition_noop_when_no_worktree() { diff --git a/server/src/config/mod.rs b/server/src/config/mod.rs index 5f58b2e5..29f43cc8 100644 --- a/server/src/config/mod.rs +++ b/server/src/config/mod.rs @@ -148,6 +148,13 @@ pub struct ProjectConfig { /// merge or `FixupRequested`). Default: 3. Set to 0 to disable. #[serde(default = "default_merge_failure_block_threshold")] 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. @@ -464,6 +471,7 @@ impl Default for ProjectConfig { gateway_project: None, status_push_enabled: default_status_push_enabled(), merge_failure_block_threshold: default_merge_failure_block_threshold(), + gc_min_free_gb: 0, } } } @@ -555,6 +563,7 @@ impl ProjectConfig { gateway_project: None, status_push_enabled: default_status_push_enabled(), merge_failure_block_threshold: default_merge_failure_block_threshold(), + gc_min_free_gb: 0, }; validate_agents(&config.agent)?; return Ok(config); @@ -597,6 +606,7 @@ impl ProjectConfig { gateway_project: None, status_push_enabled: default_status_push_enabled(), merge_failure_block_threshold: default_merge_failure_block_threshold(), + gc_min_free_gb: 0, }; validate_agents(&config.agent)?; Ok(config) @@ -627,6 +637,7 @@ impl ProjectConfig { gateway_project: None, status_push_enabled: default_status_push_enabled(), merge_failure_block_threshold: default_merge_failure_block_threshold(), + gc_min_free_gb: 0, }) } } diff --git a/server/src/http/mcp/agent_tools/gc.rs b/server/src/http/mcp/agent_tools/gc.rs new file mode 100644 index 00000000..987b5196 --- /dev/null +++ b/server/src/http/mcp/agent_tools/gc.rs @@ -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 { + 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()); + } +} diff --git a/server/src/http/mcp/agent_tools/mod.rs b/server/src/http/mcp/agent_tools/mod.rs index 7b70f816..de85513e 100644 --- a/server/src/http/mcp/agent_tools/mod.rs +++ b/server/src/http/mcp/agent_tools/mod.rs @@ -1,9 +1,11 @@ //! MCP agent tools — start, stop, wait, list, and inspect agents via MCP. +mod gc; mod inspection; mod lifecycle; mod worktree; +pub(crate) use gc::tool_gc; pub(crate) use inspection::{ tool_get_agent_config, tool_get_agent_output, tool_get_agent_remaining_turns_and_budget, }; diff --git a/server/src/http/mcp/dispatch.rs b/server/src/http/mcp/dispatch.rs index 9615fb31..632b88d3 100644 --- a/server/src/http/mcp/dispatch.rs +++ b/server/src/http/mcp/dispatch.rs @@ -43,6 +43,7 @@ pub async fn dispatch_tool_call( "list_worktrees" => agent_tools::tool_list_worktrees(ctx), "remove_worktree" => agent_tools::tool_remove_worktree(&args, ctx).await, "cleanup_worktrees" => agent_tools::tool_cleanup_worktrees(&args, ctx).await, + "gc" => agent_tools::tool_gc(ctx).await, // Editor tools "get_editor_command" => agent_tools::tool_get_editor_command(&args, ctx), // Lifecycle tools diff --git a/server/src/http/mcp/tools_list/agent_tools.rs b/server/src/http/mcp/tools_list/agent_tools.rs index 5e2b4191..91043ce3 100644 --- a/server/src/http/mcp/tools_list/agent_tools.rs +++ b/server/src/http/mcp/tools_list/agent_tools.rs @@ -180,6 +180,14 @@ pub(super) fn agent_tools() -> Vec { } } }), + 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!({ "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.", diff --git a/server/src/http/mcp/tools_list/mod.rs b/server/src/http/mcp/tools_list/mod.rs index ab444a0e..c83b75e3 100644 --- a/server/src/http/mcp/tools_list/mod.rs +++ b/server/src/http/mcp/tools_list/mod.rs @@ -116,7 +116,8 @@ mod tests { assert!(names.contains(&"convert_item_type")); assert!(names.contains(&"edit")); assert!(names.contains(&"write")); - assert_eq!(tools.len(), 84); + assert!(names.contains(&"gc")); + assert_eq!(tools.len(), 85); } #[test] diff --git a/server/src/service/gc/io.rs b/server/src/service/gc/io.rs new file mode 100644 index 00000000..e2a0dcc3 --- /dev/null +++ b/server/src/service/gc/io.rs @@ -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 { + 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()); + } +} diff --git a/server/src/service/gc/mod.rs b/server/src/service/gc/mod.rs new file mode 100644 index 00000000..b3e228b2 --- /dev/null +++ b/server/src/service/gc/mod.rs @@ -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, + pub skipped: Vec, + 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( + entries: &[WorktreeListEntry], + lookup: F, +) -> Vec +where + F: Fn(&str) -> Option, +{ + 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 { + 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)); + } +} diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 60a139e2..538d994c 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -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. diff --git a/server/src/startup/tick_loop.rs b/server/src/startup/tick_loop.rs index f4ed28ac..277e37f2 100644 --- a/server/src/startup/tick_loop.rs +++ b/server/src/startup/tick_loop.rs @@ -241,6 +241,45 @@ pub(crate) fn spawn_tick_loop( &disk_watch_status, &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 diff --git a/server/src/worktree/cleanup.rs b/server/src/worktree/cleanup.rs index 8b743ea3..829612b5 100644 --- a/server/src/worktree/cleanup.rs +++ b/server/src/worktree/cleanup.rs @@ -216,6 +216,7 @@ mod tests { gateway_project: None, status_push_enabled: true, merge_failure_block_threshold: 3, + gc_min_free_gb: 0, } } diff --git a/server/src/worktree/create.rs b/server/src/worktree/create.rs index 4ccf4d94..2bab753b 100644 --- a/server/src/worktree/create.rs +++ b/server/src/worktree/create.rs @@ -255,6 +255,7 @@ mod tests { gateway_project: None, status_push_enabled: true, merge_failure_block_threshold: 3, + gc_min_free_gb: 0, } } diff --git a/server/src/worktree/mod.rs b/server/src/worktree/mod.rs index f2c4753b..611d05d5 100644 --- a/server/src/worktree/mod.rs +++ b/server/src/worktree/mod.rs @@ -13,6 +13,7 @@ pub use create::install_pre_commit_hook; pub use git::migrate_slug_paths; pub(crate) use git::resolve_base_branch; pub use remove::remove_worktree_by_story_id; +pub(crate) use sweep::worktree_should_be_swept; #[derive(Debug, Clone)] /// Details about a newly created worktree: path, branch, and base branch. diff --git a/server/src/worktree/remove.rs b/server/src/worktree/remove.rs index a5e053bf..bb02b261 100644 --- a/server/src/worktree/remove.rs +++ b/server/src/worktree/remove.rs @@ -7,6 +7,11 @@ use super::git::{branch_name, remove_worktree_sync, resolve_base_branch}; use super::{WorktreeInfo, worktree_path}; /// 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( project_root: &Path, info: &WorktreeInfo, @@ -14,13 +19,30 @@ pub async fn remove_worktree( ) -> Result<(), String> { 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 wt_path = info.path.clone(); let branch = info.branch.clone(); - tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch)) - .await - .map_err(|e| format!("spawn_blocking: {e}"))? + let result = + tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch)) + .await + .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. @@ -91,6 +113,7 @@ mod tests { gateway_project: None, status_push_enabled: true, merge_failure_block_threshold: 3, + gc_min_free_gb: 0, } } @@ -205,4 +228,35 @@ mod tests { .unwrap(); 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"); + } } diff --git a/server/src/worktree/sweep.rs b/server/src/worktree/sweep.rs index 08151dfa..dd648ade 100644 --- a/server/src/worktree/sweep.rs +++ b/server/src/worktree/sweep.rs @@ -137,6 +137,7 @@ mod tests { gateway_project: None, status_push_enabled: true, merge_failure_block_threshold: 3, + gc_min_free_gb: 0, } }