#![cfg(all(unix, feature = "test-helpers"))]
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tokio::time::sleep;
use terraphim_orchestrator::scope::{test_support::setup_git_repo, WORKTREE_REVIEW_PREFIX};
use terraphim_orchestrator::{CompoundReviewWorkflow, ReviewGroupDef, SwarmConfig};
use terraphim_types::FindingCategory;
const TEST_PROMPT: &str = "ignored-prompt";
fn write_sleep_wrapper(dir: &Path) -> PathBuf {
let script = dir.join("fake-agent.sh");
std::fs::write(
&script,
"#!/bin/sh\n# Ignore all args; sleep long enough for the test.\nexec /bin/sleep 999\n",
)
.expect("write wrapper script");
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).expect("chmod wrapper");
script
}
fn make_swarm_config(repo_path: PathBuf, worktree_root: PathBuf, cli_tool: &Path) -> SwarmConfig {
let group = ReviewGroupDef {
agent_name: "sleep-agent".to_string(),
category: FindingCategory::Quality,
llm_tier: "Quick".to_string(),
cli_tool: cli_tool.to_string_lossy().into_owned(),
model: None,
prompt_template: "test".to_string(),
prompt_content: TEST_PROMPT,
visual_only: false,
persona: None,
};
SwarmConfig {
groups: vec![group],
timeout: Duration::from_secs(600),
worktree_root,
repo_path,
base_branch: "HEAD".to_string(),
max_concurrent_agents: 1,
create_prs: false,
}
}
async fn poll_until<F>(deadline: Duration, mut check: F) -> bool
where
F: FnMut() -> bool,
{
let start = Instant::now();
while start.elapsed() < deadline {
if check() {
return true;
}
sleep(Duration::from_millis(50)).await;
}
check()
}
fn collect_pids_with_cwd_under(prefix: &Path) -> Vec<u32> {
let mut pids = Vec::new();
let proc = match std::fs::read_dir("/proc") {
Ok(p) => p,
Err(_) => return pids,
};
for entry in proc.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
let pid: u32 = match name_str.parse() {
Ok(n) => n,
Err(_) => continue,
};
let cwd_link = entry.path().join("cwd");
if let Ok(target) = std::fs::read_link(&cwd_link) {
if target.starts_with(prefix) {
pids.push(pid);
}
}
}
pids
}
fn pid_is_gone(pid: u32) -> bool {
!PathBuf::from(format!("/proc/{}", pid)).exists()
}
fn first_review_dir(base: &Path) -> Option<PathBuf> {
let entries = std::fs::read_dir(base).ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(s) = name.to_str() {
if s.starts_with(WORKTREE_REVIEW_PREFIX) {
return Some(entry.path());
}
}
}
None
}
fn any_review_dir_exists(base: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(base) else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(s) = name.to_str() {
if s.starts_with(WORKTREE_REVIEW_PREFIX) && entry.path().is_dir() {
return true;
}
}
}
false
}
fn any_review_admin_entry(repo: &Path) -> bool {
let admin_root = repo.join(".git").join("worktrees");
let Ok(entries) = std::fs::read_dir(&admin_root) else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(s) = name.to_str() {
if s.starts_with(WORKTREE_REVIEW_PREFIX) {
return true;
}
}
}
false
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_cancellation_leaves_no_worktree() {
let (_repo_tempdir, repo_path) = setup_git_repo();
let wt_tempdir = TempDir::new().expect("worktree tempdir");
let worktree_root = wt_tempdir.path().to_path_buf();
let script_tempdir = TempDir::new().expect("script tempdir");
let cli_tool = write_sleep_wrapper(script_tempdir.path());
let swarm = make_swarm_config(repo_path.clone(), worktree_root.clone(), &cli_tool);
let workflow = CompoundReviewWorkflow::new(swarm);
let handle = tokio::spawn(async move { workflow.run("HEAD", "HEAD").await });
let appeared = poll_until(Duration::from_secs(5), || {
first_review_dir(&worktree_root).is_some()
})
.await;
assert!(
appeared,
"worktree under {} never appeared within 5 s",
worktree_root.display()
);
let review_dir = first_review_dir(&worktree_root).unwrap();
println!("worktree created at {}", review_dir.display());
let mut subprocess_pids: Vec<u32> = Vec::new();
let subprocess_live = poll_until(Duration::from_secs(5), || {
subprocess_pids = collect_pids_with_cwd_under(&review_dir);
!subprocess_pids.is_empty()
})
.await;
assert!(
subprocess_live,
"no agent subprocess ever spawned with cwd under {}",
review_dir.display()
);
println!("captured agent PIDs before abort: {:?}", subprocess_pids);
handle.abort();
let _ = handle.await;
let pids_for_assert = subprocess_pids.clone();
let no_zombie = poll_until(Duration::from_secs(2), || {
pids_for_assert.iter().all(|p| pid_is_gone(*p))
})
.await;
let still_alive: Vec<u32> = pids_for_assert
.iter()
.copied()
.filter(|p| !pid_is_gone(*p))
.collect();
assert!(
no_zombie,
"agent subprocess(es) survived cancellation: {:?}",
still_alive
);
let dir_gone = poll_until(Duration::from_secs(2), || {
!any_review_dir_exists(&worktree_root)
})
.await;
assert!(
dir_gone,
"worktree directory under {} survived cancellation: dir_exists={}, list={:?}",
worktree_root.display(),
any_review_dir_exists(&worktree_root),
std::fs::read_dir(&worktree_root)
.map(|d| d
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>())
.unwrap_or_default()
);
let admin_gone = poll_until(Duration::from_secs(2), || {
!any_review_admin_entry(&repo_path)
})
.await;
assert!(
admin_gone,
"git admin entry under {}/.git/worktrees survived",
repo_path.display()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_storm_cancellation_leaves_no_worktree() {
let (_repo_tempdir, repo_path) = setup_git_repo();
let wt_tempdir = TempDir::new().expect("worktree tempdir");
let worktree_root = wt_tempdir.path().to_path_buf();
let script_tempdir = TempDir::new().expect("script tempdir");
let cli_tool = write_sleep_wrapper(script_tempdir.path());
let swarm_a = make_swarm_config(repo_path.clone(), worktree_root.clone(), &cli_tool);
let workflow_a = CompoundReviewWorkflow::new(swarm_a);
let handle_a = tokio::spawn(async move { workflow_a.run("HEAD", "HEAD").await });
let first_appeared = poll_until(Duration::from_secs(5), || {
first_review_dir(&worktree_root).is_some()
})
.await;
assert!(first_appeared, "first worktree did not appear");
let swarm_b = make_swarm_config(repo_path.clone(), worktree_root.clone(), &cli_tool);
let workflow_b = CompoundReviewWorkflow::new(swarm_b);
let handle_b = tokio::spawn(async move { workflow_b.run("HEAD", "HEAD").await });
sleep(Duration::from_millis(200)).await;
handle_a.abort();
handle_b.abort();
let _ = handle_a.await;
let _ = handle_b.await;
let dir_gone = poll_until(Duration::from_secs(5), || {
!any_review_dir_exists(&worktree_root)
})
.await;
assert!(
dir_gone,
"storm-cancelled runs left worktree(s) on disk under {}: {:?}",
worktree_root.display(),
std::fs::read_dir(&worktree_root)
.map(|d| d
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>())
.unwrap_or_default()
);
let admin_gone = poll_until(Duration::from_secs(5), || {
!any_review_admin_entry(&repo_path)
})
.await;
assert!(admin_gone, "git admin entries survived storm cancellation");
}