Files
huskies/server/src/agents/pool/worktree_lifecycle.rs
T

303 lines
11 KiB
Rust
Raw Normal View History

2026-05-13 21:37:07 +00:00
//! TransitionFired subscribers for worktree and feature-branch lifecycle.
//!
//! `spawn_worktree_create_subscriber` creates worktrees when stories enter
//! `Stage::Coding`. `spawn_worktree_cleanup_subscriber` removes worktrees
//! when stories reach terminal stages (Done, Archived, Abandoned, Superseded).
use std::path::{Path, PathBuf};
use crate::pipeline_state::Stage;
use crate::slog;
use crate::slog_warn;
/// Spawn a background task that creates a git worktree when a story enters `Stage::Coding`.
///
/// Subscribes to the pipeline transition broadcast channel. On each
/// `Stage::Coding` transition, creates the worktree and feature branch for the
/// story at `project_root` using `port` for the `.mcp.json` server URL.
/// The create is idempotent — if the worktree already exists it is reused.
pub(crate) fn spawn_worktree_create_subscriber(project_root: PathBuf, port: u16) {
let mut rx = crate::pipeline_state::subscribe_transitions();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(fired) => {
if matches!(fired.after, Stage::Coding) {
on_coding_transition(&project_root, port, &fired.story_id.0).await;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
slog_warn!(
"[worktree-create-sub] Subscriber lagged, skipped {n} event(s). \
Some worktrees may need manual creation."
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
/// Spawn a background task that removes git worktrees when stories reach terminal stages.
///
/// Subscribes to the pipeline transition broadcast channel. On transitions into
/// `Stage::Done`, `Stage::Archived`, `Stage::Abandoned`, or `Stage::Superseded`,
/// removes the worktree and feature branch for the story.
/// Non-fatal if the worktree does not exist.
pub(crate) fn spawn_worktree_cleanup_subscriber(project_root: PathBuf) {
let mut rx = crate::pipeline_state::subscribe_transitions();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(fired) => {
if matches!(
fired.after,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
) {
on_terminal_transition(&project_root, &fired.story_id.0).await;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
slog_warn!(
"[worktree-cleanup-sub] Subscriber lagged, skipped {n} event(s). \
Some worktrees may need manual removal."
);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
/// Create the worktree and feature branch for `story_id` when it enters `Stage::Coding`.
pub(crate) async fn on_coding_transition(project_root: &Path, port: u16, story_id: &str) {
let config = match crate::config::ProjectConfig::load(project_root) {
Ok(c) => c,
Err(e) => {
slog_warn!("[worktree-create-sub] Failed to load config for '{story_id}': {e}");
return;
}
};
slog!("[worktree-create-sub] Story '{story_id}' entered Coding; ensuring worktree exists.");
match crate::worktree::create_worktree(project_root, story_id, &config, port).await {
Ok(info) => {
slog!(
"[worktree-create-sub] Worktree ready for '{story_id}' at {}",
info.path.display()
);
if let Err(e) = crate::worktree::install_pre_commit_hook(&info.path) {
slog_warn!(
"[worktree-create-sub] Pre-commit hook install failed for '{story_id}': {e}"
);
}
}
Err(e) => {
slog_warn!("[worktree-create-sub] Failed to create worktree for '{story_id}': {e}");
}
}
}
/// Remove the worktree and feature branch for `story_id` after it reaches a terminal stage.
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,
Err(e) => {
slog_warn!("[worktree-cleanup-sub] Failed to load config for '{story_id}': {e}");
return;
}
};
slog!("[worktree-cleanup-sub] Story '{story_id}' reached terminal stage; removing worktree.");
match crate::worktree::remove_worktree_by_story_id(project_root, story_id, &config).await {
Ok(()) => slog!("[worktree-cleanup-sub] Worktree removed for '{story_id}'."),
Err(e) => {
// Non-fatal — worktree may not exist (story never had one, or already removed).
slog!("[worktree-cleanup-sub] Worktree removal for '{story_id}': {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_git_repo(dir: &Path) {
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.expect("git init");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.output()
.expect("git commit");
}
fn setup_project(tmp: &TempDir) -> PathBuf {
let root = tmp.path().join("project");
fs::create_dir_all(root.join(".huskies")).unwrap();
std::fs::write(root.join(".huskies").join("project.toml"), "").unwrap();
init_git_repo(&root);
root
}
/// AC1: on_coding_transition creates the worktree and feature branch.
#[tokio::test]
async fn coding_transition_creates_worktree() {
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
let story_id = "1006_test_create";
on_coding_transition(&root, 3001, story_id).await;
let wt_path = crate::worktree::worktree_path(&root, story_id);
assert!(
wt_path.exists(),
"worktree directory must exist after Coding transition"
);
assert!(
wt_path.join(".mcp.json").exists(),
".mcp.json must be written in the worktree"
);
// Verify the feature branch was created.
let branch_output = Command::new("git")
.args(["branch", "--list", "feature/story-1006_test_create"])
.current_dir(&root)
.output()
.expect("git branch --list");
assert!(
!String::from_utf8_lossy(&branch_output.stdout)
.trim()
.is_empty(),
"feature branch must exist after Coding transition"
);
}
/// AC1: calling on_coding_transition twice is idempotent.
#[tokio::test]
async fn coding_transition_is_idempotent() {
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
let story_id = "1006_test_idempotent";
on_coding_transition(&root, 3001, story_id).await;
on_coding_transition(&root, 3001, story_id).await;
let wt_path = crate::worktree::worktree_path(&root, story_id);
assert!(
wt_path.exists(),
"worktree must still exist after second Coding transition"
);
}
/// AC2: on_terminal_transition removes the worktree after it was created.
#[tokio::test]
async fn terminal_transition_removes_worktree() {
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
let story_id = "1006_test_remove";
on_coding_transition(&root, 3001, story_id).await;
let wt_path = crate::worktree::worktree_path(&root, story_id);
assert!(wt_path.exists(), "worktree must exist before cleanup");
on_terminal_transition(&root, story_id).await;
assert!(
!wt_path.exists(),
"worktree must be removed after terminal transition"
);
}
/// AC2: on_terminal_transition is a no-op (non-fatal) when no worktree exists.
#[tokio::test]
async fn terminal_transition_noop_when_no_worktree() {
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
// Should not panic or error — just log and return.
on_terminal_transition(&root, "1006_test_no_wt").await;
}
/// AC1+AC4: spawn_worktree_create_subscriber reacts to a real Coding transition.
#[tokio::test]
async fn create_subscriber_reacts_to_coding_transition() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
let story_id = "1006_test_sub_create";
crate::db::write_item_with_content(
story_id,
"1_backlog",
"---\nname: Test\n---\n",
crate::db::ItemMeta::named("Test"),
);
spawn_worktree_create_subscriber(root.clone(), 3001);
// Trigger the Coding transition.
crate::agents::lifecycle::move_story_to_current(story_id)
.expect("move to current must succeed");
// Give the subscriber task time to run and create the worktree.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let wt_path = crate::worktree::worktree_path(&root, story_id);
assert!(
wt_path.exists(),
"worktree must exist after Coding transition via subscriber"
);
}
/// AC2+AC4: spawn_worktree_cleanup_subscriber reacts to terminal transitions.
#[tokio::test]
async fn cleanup_subscriber_reacts_to_terminal_transition() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = TempDir::new().unwrap();
let root = setup_project(&tmp);
let story_id = "1006_test_sub_cleanup";
crate::db::write_item_with_content(
story_id,
"2_current",
"---\nname: Test\n---\n",
crate::db::ItemMeta::named("Test"),
);
// Create the worktree manually so the cleanup subscriber has something to remove.
let config = crate::config::ProjectConfig::load(&root).unwrap();
crate::worktree::create_worktree(&root, story_id, &config, 3001)
.await
.expect("create worktree must succeed");
let wt_path = crate::worktree::worktree_path(&root, story_id);
assert!(
wt_path.exists(),
"worktree must exist before subscriber test"
);
spawn_worktree_cleanup_subscriber(root.clone());
// Trigger a terminal transition (Abandoned).
crate::agents::lifecycle::abandon_story(story_id).expect("abandon must succeed");
// Give the subscriber time to process and remove the worktree.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert!(
!wt_path.exists(),
"worktree must be removed after terminal transition via subscriber"
);
}
}