//! Process management — kills orphaned PTY child processes on server shutdown. //! //! As of story 1090 (2026-05-15), all process termination in this module uses //! [`crate::process_kill::sigkill_pids_and_verify`] — SIGHUP-based killing via //! `portable_pty::ChildKiller` has been removed entirely from the server. //! //! ## History //! //! Prior to commit `fe9804b3`, the watchdog and all kill paths sent SIGHUP via //! `portable_pty::ChildKiller::kill()`. Claude Code ignores SIGHUP, so agents //! survived "kills" and ran concurrently with their replacements — the root cause //! of the 2026-05-15 duplicate-spawn incident. `fe9804b3` migrated the watchdog; //! story 1090 completes the migration by rewriting `kill_all_children` and //! `kill_child_for_key` (this file) to use `pids_matching` + `sigkill_pids_and_verify`. use crate::process_kill::{pids_matching, sigkill_pids_and_verify}; use crate::slog; use crate::slog_warn; use super::AgentPool; impl AgentPool { /// Kill all active PTY child processes. /// /// Called on server shutdown to prevent orphaned Claude Code processes from /// continuing to run after the server exits. Collects each agent's worktree /// path, then SIGKILLs every process running inside that path and verifies /// termination before returning. pub async fn kill_all_children(&self) { let worktree_paths: Vec<(String, std::path::PathBuf)> = { let agents = self.agents.lock().await; agents .iter() .filter_map(|(key, agent)| { agent .worktree_info .as_ref() .map(|wt| (key.clone(), wt.path.clone())) }) .collect() }; for (key, path) in worktree_paths { let pattern = path.display().to_string(); let pids = pids_matching(&pattern); if pids.is_empty() { slog!( "[agents] No processes found in worktree {} for '{key}' on shutdown", path.display() ); continue; } match sigkill_pids_and_verify(&pids) { Ok(n) => slog!( "[agents] SIGKILL'd {n} process(es) in worktree {} for '{key}' on shutdown", path.display() ), Err(survivors) => slog_warn!( "[agents] SIGKILL incomplete for '{key}' on shutdown: \ pids still alive: {survivors:?}" ), } } } /// Kill and deregister the child process for a specific agent key. /// /// Fallback used by `stop_agent` when no worktree path is recorded for the /// agent. Also the primary kill path for any caller that has only a composite /// key and not a worktree path directly. pub(super) async fn kill_child_for_key(&self, key: &str) { let worktree_path = { let agents = self.agents.lock().await; agents .get(key) .and_then(|a| a.worktree_info.as_ref().map(|wt| wt.path.clone())) }; let Some(path) = worktree_path else { slog_warn!( "[agents] No worktree path recorded for '{key}'; \ cannot SIGKILL via process_kill (no-op)" ); return; }; let pattern = path.display().to_string(); let pids = pids_matching(&pattern); if pids.is_empty() { slog!( "[agents] No processes found in worktree {} for '{key}' on stop", path.display() ); return; } match sigkill_pids_and_verify(&pids) { Ok(n) => slog!( "[agents] SIGKILL'd {n} process(es) in worktree {} for '{key}' on stop", path.display() ), Err(survivors) => slog_warn!( "[agents] SIGKILL incomplete for '{key}' on stop: \ pids still alive: {survivors:?}" ), } } } #[cfg(test)] mod tests { use super::super::AgentPool; use crate::agents::AgentStatus; use std::process::Command; /// Returns true if a process with the given PID is currently running. fn process_is_running(pid: u32) -> bool { Command::new("ps") .args(["-p", &pid.to_string()]) .output() .map(|o| o.status.success()) .unwrap_or(false) } #[tokio::test] async fn kill_all_children_is_safe_on_empty_pool() { let pool = AgentPool::new_test(3001); pool.kill_all_children().await; // must not panic } /// AC 4 — `kill_child_for_key` SIGKILLs the single agent's process and /// verifies it is gone within 2 s. The sleeper has the worktree path in /// its argv[0] so `pgrep -f` can locate it, mirroring how claude-code is /// launched with `--directory ` in production. #[tokio::test] async fn kill_child_for_key_kills_real_process() { use std::os::unix::process::CommandExt; let pool = AgentPool::new_test(3002); let tmp = tempfile::tempdir().unwrap(); let worktree = tmp.path(); // argv[0] = worktree path → pgrep -f finds this process. let mut child = Command::new("sleep") .arg0(worktree.to_string_lossy().as_ref()) .arg("100") .spawn() .expect("spawn sleeper"); let pid = child.id(); // Give pgrep a moment to see the new process. std::thread::sleep(std::time::Duration::from_millis(100)); pool.inject_test_agent_with_path( "story-1090-kill", "coder", AgentStatus::Running, worktree.to_path_buf(), ); assert!( process_is_running(pid), "sleeper pid {pid} should be running before kill_child_for_key" ); pool.kill_child_for_key("story-1090-kill:coder").await; let _ = child.wait(); // reap zombie so ps -p returns false assert!( !process_is_running(pid), "sleeper pid {pid} should be dead after kill_child_for_key" ); } /// AC 5 — `kill_all_children` SIGKILLs all agents' processes. Two agents /// with distinct worktree paths are injected; both must be gone after the call. #[tokio::test] async fn kill_all_children_kills_multiple_real_processes() { use std::os::unix::process::CommandExt; let pool = AgentPool::new_test(3003); let mut sleepers: Vec<(u32, std::process::Child, tempfile::TempDir)> = (0..2_u32) .map(|i| { let tmp = tempfile::tempdir().unwrap(); let worktree = tmp.path(); // argv[0] = worktree path for pgrep discoverability. let child = Command::new("sleep") .arg0(worktree.to_string_lossy().as_ref()) .arg("100") .spawn() .expect("spawn sleeper"); let pid = child.id(); pool.inject_test_agent_with_path( &format!("story-1090-all-{i}"), "coder", AgentStatus::Running, worktree.to_path_buf(), ); (pid, child, tmp) }) .collect(); // Give pgrep a moment to see the new processes. std::thread::sleep(std::time::Duration::from_millis(100)); for (pid, _, _) in &sleepers { assert!( process_is_running(*pid), "pid {pid} should be running before kill_all_children" ); } pool.kill_all_children().await; for (pid, child, _tmp) in &mut sleepers { let _ = child.wait(); // reap zombie assert!( !process_is_running(*pid), "pid {pid} should be dead after kill_all_children" ); } } }