use std::path::PathBuf;
use super::{ReapOutcome, is_harness_agent_worktree, reap_worktree};
use crate::core::hook::HookEvent;
use crate::session_manager::decommission::WorktreeRemoval;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
use crate::session_manager::worktree_ownership::write_agent_sentinel;
const AGENT: &str = "a-fixture";
fn harness_worktree(fx: &GitWorktreeFixture, name: &str) -> PathBuf {
harness_worktree_for(fx, name, AGENT)
}
fn harness_worktree_for(fx: &GitWorktreeFixture, name: &str, agent_id: &str) -> PathBuf {
let base = fx.repo.join(".claude").join("worktrees");
let wt = fx.add_worktree_at(&base, name);
attribute(&wt, agent_id);
wt
}
fn attribute(path: &std::path::Path, agent_id: &str) {
attribute_to(path, agent_id, crate::core::session::SessionId::new());
}
fn attribute_to(path: &std::path::Path, agent_id: &str, parent: crate::core::session::SessionId) {
write_agent_sentinel(
path,
crate::session_manager::worktree_ownership::AgentWorktreeOwner {
agent_id: agent_id.to_string(),
delegation_id: crate::core::agent::DelegationId(uuid::Uuid::new_v4()),
parent_session_id: parent,
},
)
.expect("write agent sentinel");
}
fn harness_worktree_under(
fx: &GitWorktreeFixture,
name: &str,
agent_id: &str,
parent: crate::core::session::SessionId,
) -> PathBuf {
let base = fx.repo.join(".claude").join("worktrees");
let wt = fx.add_worktree_at(&base, name);
attribute_to(&wt, agent_id, parent);
wt
}
#[test]
fn reap_removes_a_clean_pushed_worktree() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree(&fx, "agent-alpha");
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let outcome = reap_worktree(&wt, AGENT, &[]);
assert_eq!(outcome, ReapOutcome::Removed, "clean+pushed must be reaped");
assert!(!wt.exists(), "the directory must be gone: {}", wt.display());
}
#[test]
fn reap_never_reports_removed_while_the_path_survives() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree(&fx, "agent-survivor");
let outcome = super::reap_outcome_of(&wt, WorktreeRemoval::Removed);
let reason = outcome
.refusal()
.expect("a surviving directory must not be reported as removed");
assert!(reason.contains("still on disk"), "{reason}");
assert!(wt.exists(), "the fixture directory must still be there");
std::fs::remove_dir_all(&wt).expect("remove the fixture directory");
assert_eq!(
super::reap_outcome_of(&wt, WorktreeRemoval::Removed),
ReapOutcome::Removed
);
}
#[test]
fn reap_refuses_a_dirty_worktree() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree(&fx, "agent-dirty");
std::fs::write(wt.join("README.md"), "edited, never committed\n").expect("write edit");
let outcome = reap_worktree(&wt, AGENT, &[]);
let reason = outcome.refusal().expect("a dirty worktree must be refused");
assert!(reason.contains("unsaved work"), "{reason}");
assert!(wt.exists(), "the directory must survive");
}
#[test]
fn reap_refuses_an_unpushed_commit() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree(&fx, "agent-unpushed");
GitWorktreeFixture::commit_unpushed(&wt);
let outcome = reap_worktree(&wt, AGENT, &[]);
let reason = outcome
.refusal()
.expect("an unpushed commit must be refused");
assert!(reason.contains("unsaved work"), "{reason}");
assert!(wt.exists(), "the directory must survive");
}
#[test]
fn reap_refuses_a_path_a_live_session_holds() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree(&fx, "agent-shared");
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let outcome = reap_worktree(&wt, AGENT, std::slice::from_ref(&wt));
let reason = outcome.refusal().expect("an in-use path must be refused");
assert!(reason.contains("still in use"), "{reason}");
assert!(wt.exists(), "the directory must survive");
}
#[test]
fn reap_refuses_a_worktree_outside_the_harness_base() {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree_at(&fx.repo.join("scratch"), "agent-escaped");
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let outcome = reap_worktree(&wt, AGENT, &[]);
let reason = outcome
.refusal()
.expect("a non-harness path must be refused");
assert!(reason.contains(".claude/worktrees"), "{reason}");
assert!(wt.exists(), "the directory must survive");
}
#[test]
fn reap_reports_already_gone_when_the_harness_reclaimed_it() {
let fx = GitWorktreeFixture::new();
let wt = fx.repo.join(".claude").join("worktrees").join("never-made");
assert_eq!(reap_worktree(&wt, AGENT, &[]), ReapOutcome::AlreadyGone);
}
#[test]
fn reap_refuses_a_directory_no_git_registry_claims() {
let tmp = tempfile::tempdir().expect("tempdir");
let wt = tmp.path().join(".claude").join("worktrees").join("orphan");
std::fs::create_dir_all(&wt).expect("create dirs");
attribute(&wt, AGENT);
let outcome = reap_worktree(&wt, AGENT, &[]);
let reason = outcome
.refusal()
.expect("a non-worktree directory must be refused");
assert!(reason.contains("no git repository claims"), "{reason}");
assert!(wt.exists(), "the directory must survive");
}
#[test]
fn reap_refuses_a_path_whose_sentinel_names_another_agent() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree_for(&fx, "agent-someone-else", "a-the-real-owner");
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let outcome = reap_worktree(&wt, "a-the-impostor", &[]);
let reason = outcome
.refusal()
.expect("a sentinel naming another agent must be refused");
assert!(reason.contains("a-the-impostor"), "{reason}");
assert!(wt.exists(), "another agent's worktree must survive");
}
#[test]
fn reap_refuses_a_path_with_no_sentinel() {
let fx = GitWorktreeFixture::new();
let base = fx.repo.join(".claude").join("worktrees");
let wt = fx.add_worktree_at(&base, "agent-unattributed");
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let outcome = reap_worktree(&wt, AGENT, &[]);
let reason = outcome
.refusal()
.expect("an unattributed path must be refused");
assert!(reason.contains("Unknown"), "{reason}");
assert!(wt.exists(), "the directory must survive");
}
#[test]
fn harness_shape_is_the_strict_leaf_form() {
assert!(is_harness_agent_worktree(std::path::Path::new(
"/r/.claude/worktrees/agent-x"
)));
assert!(!is_harness_agent_worktree(std::path::Path::new(
"/r/.claude/worktrees/agent-x/nested"
)));
assert!(!is_harness_agent_worktree(std::path::Path::new(
"/r/.claude/worktrees"
)));
assert!(!is_harness_agent_worktree(std::path::Path::new(
"/r/.worktrees/session-x"
)));
}
fn hermetic() -> (
std::sync::Arc<crate::daemon::state::DaemonState>,
tempfile::TempDir,
crate::core::session::SessionId,
) {
let dir = tempfile::tempdir().expect("temp dir");
let paths = crate::core::paths::FrameworkPaths::under(dir.path());
let state = std::sync::Arc::new(crate::daemon::state::DaemonState::with_paths(&paths));
(
state,
dir,
crate::core::session::SessionId(uuid::Uuid::new_v4()),
)
}
#[tokio::test]
async fn delegation_state_reports_live_ended_and_unknown() {
use crate::core::agent::{Delegation, DelegationStatus, ModelTier};
use crate::session_manager::worktree_ownership::AgentDelegationState;
let (state, _dir, session) = hermetic();
assert_eq!(
super::delegation_state_for_agent(&state, "never-seen"),
AgentDelegationState::Unknown,
"an empty registry knows nothing; it does not know the agent is gone"
);
let mut running = Delegation::new(session, None, "rust-engineer", ModelTier::Sonnet, "work");
running.agent_id = Some("agent-running".into());
running.status = DelegationStatus::Running;
state.upsert_delegation(running);
assert_eq!(
super::delegation_state_for_agent(&state, "agent-running"),
AgentDelegationState::Live
);
let mut done = Delegation::new(session, None, "rust-engineer", ModelTier::Sonnet, "work");
done.agent_id = Some("agent-done".into());
done.status = DelegationStatus::Completed;
state.upsert_delegation(done);
assert_eq!(
super::delegation_state_for_agent(&state, "agent-done"),
AgentDelegationState::Ended
);
let mut lost = Delegation::new(session, None, "rust-engineer", ModelTier::Sonnet, "work");
lost.agent_id = Some("agent-stale".into());
lost.status = DelegationStatus::Stale;
state.upsert_delegation(lost);
assert_eq!(
super::delegation_state_for_agent(&state, "agent-stale"),
AgentDelegationState::Live
);
}
#[tokio::test]
async fn subagent_stop_does_not_reap_a_resumable_worktree() {
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let wt = harness_worktree_under(&fx, "agent-resumable", "a5800", session);
std::fs::write(wt.join("work.txt"), "finished and pushed\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
for event in [HookEvent::SubagentStop, HookEvent::SubagentStopFailure] {
deliver(
&state,
session,
event,
serde_json::json!({ "agent_id": "a5800", "cwd": fx.repo.to_string_lossy() }),
);
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
assert!(
wt.exists(),
"a stop is a turn boundary — the agent stays resumable and its tree must survive"
);
deliver(
&state,
session,
HookEvent::SessionEnd,
serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
);
await_gone(&wt).await;
}
#[tokio::test]
async fn paths_in_use_protects_a_terminal_delegations_worktree() {
use crate::core::agent::{Delegation, DelegationStatus, ModelTier};
let (state, _dir, session) = hermetic();
let finished = PathBuf::from("/r/.claude/worktrees/agent-finished");
let mut done = Delegation::new(session, None, "rust-engineer", ModelTier::Sonnet, "work");
done.agent_id = Some("a-finished".into());
done.status = DelegationStatus::Completed;
done.worktree_path = Some(finished.clone());
state.upsert_delegation(done);
let in_use = super::paths_in_use(&state, "a-stopping-agent").await;
assert!(
in_use.contains(&finished),
"a finished delegation's tree can still be one the harness resumes into; got {in_use:?}"
);
}
#[test]
fn reap_refuses_a_worktree_a_live_process_is_standing_in() {
let fx = GitWorktreeFixture::new();
let wt = harness_worktree(&fx, "agent-occupied");
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let mut child = std::process::Command::new("sleep")
.arg("30")
.current_dir(&wt)
.spawn()
.expect("spawn a process inside the worktree");
let pid = child.id();
let outcome = reap_worktree(&wt, AGENT, &[]);
let _ = child.kill();
let _ = child.wait();
let reason = outcome
.refusal()
.expect("a worktree a live process stands in must be refused");
assert!(
reason.contains(&pid.to_string()),
"the refusal must name the process it found (pid {pid}): {reason}"
);
assert!(wt.exists(), "the directory must survive");
}
#[tokio::test]
async fn reap_spares_a_worktree_another_sessions_agent_still_holds() {
let (state, _dir, _session) = hermetic();
let other_session = crate::core::session::SessionId(uuid::Uuid::new_v4());
let held = PathBuf::from("/r/.claude/worktrees/agent-sibling");
let mut sibling = crate::core::agent::Delegation::observed(
other_session,
"rust-engineer",
"sibling work",
Some("toolu_sibling".to_string()),
);
sibling.agent_id = Some("a-sibling".to_string());
sibling.worktree_path = Some(held.clone());
state.upsert_delegation(sibling);
let in_use = super::paths_in_use(&state, "a-stopping-agent").await;
assert!(
in_use.contains(&held),
"a live agent in another session must still protect its tree; got {in_use:?}"
);
}
fn deliver(
state: &std::sync::Arc<crate::daemon::state::DaemonState>,
session: crate::core::session::SessionId,
event: HookEvent,
payload: serde_json::Value,
) {
crate::daemon::services::hook_service::HookService::new(std::sync::Arc::clone(state))
.process(session, event, payload);
}
#[tokio::test]
async fn session_end_reaps_a_worktree_whose_agent_never_stopped() {
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let wt = harness_worktree_under(&fx, "agent-abandoned", "a801", session);
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
deliver(
&state,
session,
HookEvent::SessionEnd,
serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
);
await_gone(&wt).await;
}
#[tokio::test]
async fn session_end_reaps_every_agent_of_the_ending_session() {
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let mut trees = Vec::new();
for (name, agent) in [("agent-one", "a901"), ("agent-two", "a902")] {
let wt = harness_worktree_under(&fx, name, agent, session);
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let mut d = crate::core::agent::Delegation::observed(
session,
"rust-engineer",
"work",
Some(format!("toolu_{agent}")),
);
d.agent_id = Some(agent.to_string());
d.worktree_path = Some(wt.clone());
state.upsert_delegation(d);
trees.push(wt);
}
deliver(
&state,
session,
HookEvent::SessionEnd,
serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
);
for wt in &trees {
await_gone(wt).await;
}
}
#[tokio::test]
async fn session_end_keeps_a_worktree_that_holds_unsaved_work() {
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let wt = harness_worktree_under(&fx, "agent-dirty-at-exit", "a803", session);
std::fs::write(wt.join("README.md"), "edited, never committed\n").expect("write edit");
let swept = settle(super::spawn_on_session_end(
&state,
session,
HookEvent::SessionEnd,
&serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
))
.await;
assert_eq!(
swept,
Some(super::SweepSummary {
removed: 0,
already_gone: 0,
kept: 1,
}),
"the dirt gate must keep this tree, and the summary must count it as kept"
);
assert!(wt.exists(), "an uncommitted edit must survive its session");
}
#[tokio::test]
async fn session_end_spares_another_sessions_agent() {
let (state, _dir, session) = hermetic();
let other = crate::core::session::SessionId(uuid::Uuid::new_v4());
let fx = GitWorktreeFixture::new();
let peer = harness_worktree_under(&fx, "agent-peer", "a804", other);
std::fs::write(peer.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&peer, "agent work");
let control = harness_worktree_under(&fx, "agent-mine", "a808", session);
std::fs::write(control.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&control, "agent work");
let swept = settle(super::spawn_on_session_end(
&state,
session,
HookEvent::SessionEnd,
&serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
))
.await;
assert_eq!(
swept,
Some(super::SweepSummary {
removed: 1,
already_gone: 0,
kept: 0,
}),
"only the ending session's own tree is a candidate"
);
assert!(
!control.exists(),
"the control must be gone, or this test proves nothing about the sweep having run"
);
assert!(
peer.exists(),
"a peer session's agent worktree must survive"
);
}
#[tokio::test]
async fn session_end_reaps_a_finished_agents_released_worktree() {
use crate::core::agent::{Delegation, DelegationStatus, ModelTier};
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let wt = harness_worktree_under(&fx, "agent-released", "a5801", session);
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let mut done = Delegation::new(session, None, "rust-engineer", ModelTier::Sonnet, "work");
done.agent_id = Some("a5801".into());
done.status = DelegationStatus::Completed;
done.worktree_path = Some(wt.clone());
state.upsert_delegation(done);
let swept = settle(super::spawn_on_session_end(
&state,
session,
HookEvent::SessionEnd,
&serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
))
.await;
assert_eq!(
swept,
Some(super::SweepSummary {
removed: 1,
already_gone: 0,
kept: 0,
}),
"known + terminal + released is the population this reap exists for"
);
assert!(!wt.exists(), "the directory must be gone: {}", wt.display());
}
#[tokio::test]
async fn session_end_keeps_a_worktree_whose_agent_names_two_directories() {
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let mut ambiguous = Vec::new();
for name in ["agent-moved-from", "agent-moved-to"] {
let wt = harness_worktree_under(&fx, name, "a5802", session);
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
ambiguous.push(wt);
}
let control = harness_worktree_under(&fx, "agent-unambiguous", "a5803", session);
std::fs::write(control.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&control, "agent work");
let swept = settle(super::spawn_on_session_end(
&state,
session,
HookEvent::SessionEnd,
&serde_json::json!({ "cwd": fx.repo.to_string_lossy() }),
))
.await;
assert_eq!(
swept,
Some(super::SweepSummary {
removed: 1,
already_gone: 0,
kept: 0,
}),
"only the unambiguous tree is a candidate; the ambiguous pair is not swept at all"
);
assert!(
!control.exists(),
"the control must be gone, or this test proves nothing about the sweep having run"
);
for wt in &ambiguous {
assert!(
wt.exists(),
"an undeterminable owner keeps every candidate: {}",
wt.display()
);
}
}
#[tokio::test]
async fn session_end_without_a_cwd_reaps_nothing() {
let (state, _dir, session) = hermetic();
let fx = GitWorktreeFixture::new();
let wt = harness_worktree_under(&fx, "agent-no-cwd", "a805", session);
std::fs::write(wt.join("work.txt"), "done\n").expect("write work");
GitWorktreeFixture::commit_all_and_push(&wt, "agent work");
let swept = settle(super::spawn_on_session_end(
&state,
session,
HookEvent::SessionEnd,
&serde_json::json!({}),
))
.await;
assert_eq!(swept, None, "an unresolvable store must start no sweep");
assert!(wt.exists(), "an unresolvable store must reclaim nothing");
}
#[test]
fn session_end_without_a_cwd_resolves_no_store() {
assert_eq!(
super::store_for(&serde_json::json!({})),
None,
"a payload with no cwd must resolve no store"
);
assert_eq!(
super::store_for(&serde_json::json!({ "cwd": 7 })),
None,
"a non-string cwd is not a path"
);
}
async fn settle(
handle: Option<tokio::task::JoinHandle<super::SweepSummary>>,
) -> Option<super::SweepSummary> {
match handle {
Some(h) => Some(h.await.expect("the sweep task must not panic")),
None => None,
}
}
async fn await_gone(path: &std::path::Path) {
for _ in 0..3000 {
if !path.exists() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let redirects: Vec<String> = crate::session_manager::worktree_safety::GIT_ENV_REDIRECTS
.iter()
.filter_map(|k| std::env::var(k).ok().map(|v| format!("{k}={v}")))
.collect();
panic!(
"{} was never removed; ambient git repository redirects: {}",
path.display(),
if redirects.is_empty() {
"none".to_string()
} else {
redirects.join(" ")
}
);
}