feat(acp,desktop): identify and reap stale agent harness processes (#1582)

This commit is contained in:
Will Pfleger
2026-07-07 12:49:16 -04:00
committed by GitHub
parent b394e95ef0
commit 3c6c0d4478
6 changed files with 581 additions and 193 deletions

View File

@@ -2764,6 +2764,19 @@ fn handle_prompt_result(
PromptOutcome::Cancelled => "cancelled",
};
let agent_index = result.agent.index;
// Capture the spawn-time configured model and our PID before the agent is
// moved into match arms below. `desired_model` reflects the config/persona
// model at spawn time — it does NOT reflect `session/set_model` overrides,
// which live in buzz-agent's session state and are what `llm: (model) …`
// errors carry. The two can legitimately differ; `configured_model=` is
// still valuable for identifying a stale orphan running an old model.
let harness_configured_model = result
.agent
.desired_model
.as_deref()
.unwrap_or("<none>")
.to_string();
let harness_pid = std::process::id();
let channel_id = match &result.source {
PromptSource::Channel(ch) => Some(*ch),
@@ -2798,6 +2811,8 @@ fn handle_prompt_result(
tracing::warn!(
agent = agent_index,
outcome = outcome_label,
configured_model = %harness_configured_model,
pid = harness_pid,
"agent_returned — respawning"
);
let death_message = match outcome_label {
@@ -2842,6 +2857,8 @@ fn handle_prompt_result(
tracing::debug!(
agent = agent_index,
outcome = outcome_label,
configured_model = %harness_configured_model,
pid = harness_pid,
"agent_returned (cancelled)"
);
pool.return_agent(result.agent);
@@ -2858,6 +2875,8 @@ fn handle_prompt_result(
tracing::warn!(
agent = agent_index,
outcome = outcome_label,
configured_model = %harness_configured_model,
pid = harness_pid,
error = %e,
"transport/protocol error — respawning agent"
);
@@ -2882,6 +2901,8 @@ fn handle_prompt_result(
tracing::warn!(
agent = agent_index,
outcome = outcome_label,
configured_model = %harness_configured_model,
pid = harness_pid,
error = %e,
"agent_returned (application error — pipe intact)"
);

View File

@@ -72,7 +72,7 @@ impl Llm {
effective_model: &str,
) -> Result<LlmResponse, AgentError> {
let effort = cfg.thinking_effort;
match cfg.provider {
let result = match cfg.provider {
Provider::Anthropic => {
let v = self
.post_anthropic(
@@ -140,7 +140,17 @@ impl Llm {
})
.await
}
}
};
// Stamp the effective model into Llm errors so log lines carry
// `llm: (model-name) 404 Not Found: …` instead of the bare status.
// The `llm: ` prefix comes from `Display for AgentError::Llm`; the
// map_err here prepends `(model-name) ` to the inner string only.
// This is the single place all provider paths converge, so the mapping
// is centralized and never needs to be repeated in each provider arm.
result.map_err(|e| match e {
AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")),
other => other,
})
}
pub async fn summarize(

View File

@@ -132,6 +132,17 @@ pub async fn restore_managed_agents_on_launch(
// whose desktop process is no longer running and reap them.
super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids);
// Exact-path sweep: kill any buzz-acp process whose executable path
// matches this bundle's harness binary but is not in the tracked set.
// Complements the env-var sweep above — catches orphans that predate
// BUZZ_MANAGED_AGENT injection or lost their PID-file receipt.
//
// TODO: the three sweeps above each walk the PID table independently.
// A future consolidation should collect a single shared process snapshot
// at the top of this block and thread it through all sweep functions,
// replacing the three separate kernel enumerations.
super::sweep_untracked_bundle_harnesses(&tracked_pids);
let candidates: Vec<String> = records
.iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)

View File

@@ -16,6 +16,10 @@ use crate::{
mod path;
use path::build_augmented_path;
mod sweep;
pub(crate) use sweep::sweep_untracked_bundle_harnesses;
pub use sweep::{expected_harness_exe_path, select_untracked_bundle_harnesses, ProcessSnapshot};
type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
/// Binary name fragments for all known agent/harness processes that Buzz
@@ -169,40 +173,9 @@ fn buzz_marker_entry(instance_id: &str) -> Vec<u8> {
#[cfg(target_os = "macos")]
fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool {
let marker = buzz_marker_entry(instance_id);
let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
let mut buf_size: libc::size_t = 0;
// First call: get required buffer size.
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
let Some(buf) = sweep::procargs2_buffer(pid) else {
return false;
}
let mut buf: Vec<u8> = vec![0; buf_size];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
buf.as_mut_ptr() as *mut libc::c_void,
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return false;
}
buf.truncate(buf_size);
};
// Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...]
if buf.len() < std::mem::size_of::<libc::c_int>() {
@@ -463,11 +436,16 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
}
// ── macOS process-info FFI (shared by all sweep/reap functions) ──────────
//
// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`).
// All callers in this file reach it through `sweep::collect_all_pids()`.
// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that
// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without
// redefining the struct layout in two places.
#[cfg(target_os = "macos")]
extern "C" {
fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
fn proc_pidinfo(
pub(super) fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
@@ -480,11 +458,11 @@ extern "C" {
/// against the macOS SDK — total size 136 bytes.
#[cfg(target_os = "macos")]
#[repr(C)]
struct BSDInfo {
pub(super) struct BSDInfo {
_flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus
pbi_pid: u32, // offset 12
pbi_ppid: u32, // offset 16
pbi_uid: u32, // offset 20
pub(super) pbi_pid: u32, // offset 12
pub(super) pbi_ppid: u32, // offset 16
pub(super) pbi_uid: u32, // offset 20
_rest: [u8; 112],
}
@@ -492,7 +470,7 @@ struct BSDInfo {
const _: () = assert!(std::mem::size_of::<BSDInfo>() == 136);
#[cfg(target_os = "macos")]
const PROC_PIDTBSDINFO: libc::c_int = 3;
pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3;
/// Enumerate all processes on the system owned by the current user and kill any
/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker
@@ -503,33 +481,10 @@ const PROC_PIDTBSDINFO: libc::c_int = 3;
#[cfg(target_os = "macos")]
pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) {
let my_uid = unsafe { libc::getuid() };
// Loop until the buffer is large enough to hold all PIDs. Under a fork
// storm the process table can outgrow the initial estimate between the
// probe and the fill call.
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
let pids = sweep::collect_all_pids();
if pids.is_empty() {
return;
}
let my_pid = std::process::id() as i32;
let mut orphans: Vec<i32> = Vec::new();
@@ -729,27 +684,9 @@ pub(crate) fn collect_same_instance_orphans(
let my_pid = std::process::id() as i32;
let mut orphans = std::collections::HashSet::new();
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return orphans;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return orphans;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
let pids = sweep::collect_all_pids();
if pids.is_empty() {
return orphans;
}
for &pid in &pids {
@@ -904,39 +841,7 @@ fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool {
#[cfg(target_os = "macos")]
fn extract_buzz_marker_value(pid: u32) -> Option<String> {
let prefix = b"BUZZ_MANAGED_AGENT=";
let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
let mut buf_size: libc::size_t = 0;
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
let mut buf: Vec<u8> = vec![0; buf_size];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
buf.as_mut_ptr() as *mut libc::c_void,
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
buf.truncate(buf_size);
let buf = sweep::procargs2_buffer(pid)?;
if buf.len() < std::mem::size_of::<libc::c_int>() {
return None;
@@ -1009,27 +914,9 @@ fn desktop_is_alive_for_instance(instance_id: &str) -> bool {
let my_uid = unsafe { libc::getuid() };
let identifier_bytes = instance_id.as_bytes();
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return false;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return false;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
let pids = sweep::collect_all_pids();
if pids.is_empty() {
return false;
}
for &pid in &pids {
@@ -1072,36 +959,9 @@ fn desktop_is_alive_for_instance(instance_id: &str) -> bool {
}
// Check if this desktop process's args/env contain the identifier.
// The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings.
let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
let mut buf_size: libc::size_t = 0;
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else {
continue;
}
let mut args_buf: Vec<u8> = vec![0; buf_size];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
args_buf.as_mut_ptr() as *mut libc::c_void,
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
continue;
}
args_buf.truncate(buf_size);
};
// Boundary-anchored search: the identifier in the config JSON is
// followed by a non-identifier char (typically `"`). A raw substring
// match would let `...app` match inside `...app.dev`.
@@ -1168,27 +1028,9 @@ pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
let pids = sweep::collect_all_pids();
if pids.is_empty() {
return;
}
// Collect (pid, instance_id) for all foreign agent processes.
@@ -1311,6 +1153,8 @@ pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]
#[cfg(not(unix))]
pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {}
// Exact-path harness sweep lives in runtime/sweep.rs (re-exported above).
/// Kill stale agent processes from a previous session whose PID is still alive
/// but not tracked in the current `runtimes` map. Updates the record fields and
/// returns `true` if any records were modified.

View File

@@ -0,0 +1,500 @@
//! Boot-time sweep for untracked same-bundle harness processes.
//!
//! The env-var and PID-file sweeps cannot see a harness whose receipt is gone
//! or that predates `BUZZ_MANAGED_AGENT` injection. This sweep derives the
//! expected `buzz-acp` path from the running executable and kills any process
//! whose exe matches exactly, minus the tracked set.
use std::path::{Path, PathBuf};
// Re-declare the macOS process-info FFI so sweep.rs can call it independently.
// Multiple extern "C" declarations of the same symbol are legal in Rust; the
// linker sees one symbol regardless of how many translation units declare it.
#[cfg(target_os = "macos")]
extern "C" {
fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int;
}
// ── Shared low-level helpers ──────────────────────────────────────────────
/// Collect all PIDs currently on the system.
///
/// Loops until the buffer is large enough to hold all PIDs — under a fork
/// storm the count can grow between the probe and the fill call. Returns an
/// empty vec on any kernel error.
#[cfg(target_os = "macos")]
pub(super) fn collect_all_pids() -> Vec<libc::c_int> {
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return Vec::new();
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return Vec::new();
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
return pids;
}
}
}
/// Read the raw `KERN_PROCARGS2` buffer for the given PID.
///
/// Two-phase sysctl: first probe for the required buffer size, then fill.
/// A process's arguments are immutable after `execve`, so the size reported
/// by the probe is stable — no grow-and-retry loop is needed.
///
/// Returns `None` if either sysctl call fails (e.g. the process has already
/// exited or we lack permission).
#[cfg(target_os = "macos")]
pub(super) fn procargs2_buffer(pid: u32) -> Option<Vec<u8>> {
let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
let mut buf_size: libc::size_t = 0;
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
let mut buf: Vec<u8> = vec![0; buf_size];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
buf.as_mut_ptr() as *mut libc::c_void,
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
buf.truncate(buf_size);
Some(buf)
}
// ── ProcessSnapshot and pure decision function ────────────────────────────
/// A snapshot of one process for the pure kill-decision function. Holds only
/// the fields needed to decide whether a process is an untracked same-bundle
/// harness — no live process handles, no system calls.
#[derive(Debug, Clone)]
pub struct ProcessSnapshot {
/// PID of the process.
pub pid: u32,
/// Full executable path, as reported by the kernel.
pub exe_path: PathBuf,
}
/// Strip the kernel-appended `" (deleted)"` suffix from an executable path.
///
/// On Linux, `read_link("/proc/<pid>/exe")` returns `…/buzz-acp (deleted)`
/// when the on-disk binary has been replaced since the process launched.
/// That is exactly the class of stale-install orphan this sweep targets, so
/// we must strip the suffix before comparing against the expected path.
/// Falls back to the original path unchanged when the suffix is absent.
// Available on Linux (where it is called from collect_process_snapshots) and
// in test builds on all platforms (including macOS). Dead on macOS outside
// tests — suppress the warning there.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
pub(super) fn strip_deleted_suffix(path: PathBuf) -> PathBuf {
const SUFFIX: &str = " (deleted)";
match path.to_str() {
Some(s) if s.ends_with(SUFFIX) => PathBuf::from(&s[..s.len() - SUFFIX.len()]),
_ => path,
}
}
/// Pure kill-decision function: given a slice of process snapshots, the
/// expected harness executable path, and the set of tracked pids to spare,
/// returns the pids of processes that should be reaped.
///
/// Selection criteria:
/// - `exe_path` exactly matches `harness_exe` (same-bundle harness only).
/// - `pid` is not in `tracked_pids` (untracked — not owned by this session).
///
/// Children of tracked parents die when their parent's process group is
/// signalled — this function deliberately targets only harness-level processes
/// so we never directly kill a child of a live tracked parent.
pub fn select_untracked_bundle_harnesses(
snapshots: &[ProcessSnapshot],
harness_exe: &Path,
tracked_pids: &[u32],
) -> Vec<u32> {
snapshots
.iter()
.filter(|s| s.exe_path == harness_exe && !tracked_pids.contains(&s.pid))
.map(|s| s.pid)
.collect()
}
// ── Process-table enumeration ─────────────────────────────────────────────
/// Extract the executable path from a process's `KERN_PROCARGS2` buffer.
///
/// The buffer layout is: `[i32 argc][exec_path\0][null-pad][argv\0…][env\0…]`.
/// The exec path is therefore the first null-terminated string immediately
/// after the leading `i32` — no argv traversal is needed, unlike
/// `extract_buzz_marker_value` / `process_has_buzz_marker` which must skip
/// past both argv and the exec path to reach the environment entries.
///
/// Returns `None` if the buffer is unreadable or malformed.
#[cfg(target_os = "macos")]
fn proc_exe_path_from_procargs2(pid: u32) -> Option<PathBuf> {
let buf = procargs2_buffer(pid)?;
if buf.len() < std::mem::size_of::<libc::c_int>() {
return None;
}
// Skip the argc i32 at the start of the buffer.
let pos = std::mem::size_of::<libc::c_int>();
// The exec path immediately follows — scan to the first null byte.
let end = buf[pos..].iter().position(|&b| b == 0).map(|i| pos + i)?;
let path_bytes = &buf[pos..end];
if path_bytes.is_empty() {
return None;
}
// KERN_PROCARGS2 exec paths are always absolute UTF-8 on macOS.
let s = std::str::from_utf8(path_bytes).ok()?;
Some(PathBuf::from(s))
}
/// Collect process snapshots for all user-owned processes on macOS.
///
/// Applies a cheap `proc_name` pre-filter before the expensive
/// `KERN_PROCARGS2` sysctl: only PIDs whose binary name matches the harness
/// binary name (`buzz-acp`) proceed to the full exe-path fetch. This mirrors
/// the pattern `sweep_system_agent_processes` uses and cuts the expensive
/// two-sysctl call by ~99.9% (there are typically O(hundreds) of user
/// processes but at most a handful of `buzz-acp` instances).
#[cfg(target_os = "macos")]
fn collect_process_snapshots(harness_name: &str) -> Vec<ProcessSnapshot> {
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut snapshots = Vec::new();
let pids = collect_all_pids();
for &pid in &pids {
if pid <= 0 || pid == my_pid {
continue;
}
// Cheap name pre-filter: only proceed to the expensive KERN_PROCARGS2
// sysctl for PIDs whose binary name matches the harness binary name.
let mut name_buf = [0u8; 1024];
let len = unsafe {
proc_name(
pid,
name_buf.as_mut_ptr() as *mut libc::c_void,
name_buf.len() as u32,
)
};
if len <= 0 {
continue;
}
let name = String::from_utf8_lossy(&name_buf[..len as usize]);
if name != harness_name {
continue;
}
// Verify UID to avoid inspecting processes owned by other users.
let upid = pid as u32;
let mut info = std::mem::MaybeUninit::<super::BSDInfo>::zeroed();
let ret = unsafe {
super::proc_pidinfo(
pid,
super::PROC_PIDTBSDINFO,
0,
info.as_mut_ptr() as *mut libc::c_void,
std::mem::size_of::<super::BSDInfo>() as libc::c_int,
)
};
if ret <= 0 {
continue;
}
let info = unsafe { info.assume_init() };
if info.pbi_uid != my_uid {
continue;
}
if let Some(exe_path) = proc_exe_path_from_procargs2(upid) {
snapshots.push(ProcessSnapshot {
pid: upid,
exe_path,
});
}
}
snapshots
}
/// Collect process snapshots for all user-owned processes on Linux via /proc.
///
/// On Linux `/proc/<pid>/exe` is a symlink to the executable path. The kernel
/// appends `" (deleted)"` when the on-disk binary has been replaced since
/// launch — exactly the stale-install class this sweep targets. The suffix is
/// stripped so the comparison against `expected_harness_exe_path` succeeds.
#[cfg(all(unix, not(target_os = "macos")))]
fn collect_process_snapshots(harness_name: &str) -> Vec<ProcessSnapshot> {
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut snapshots = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return snapshots;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let Ok(pid) = name_str.parse::<i32>() else {
continue;
};
if pid <= 0 || pid == my_pid {
continue;
}
let upid = pid as u32;
// Cheap name pre-filter via /proc/<pid>/comm (15-char truncated, but
// "buzz-acp" is 8 chars so it's always preserved).
let Ok(comm) = std::fs::read_to_string(format!("/proc/{upid}/comm")) else {
continue;
};
if comm.trim() != harness_name {
continue;
}
// Check ownership.
let Ok(meta) = entry.metadata() else {
continue;
};
use std::os::unix::fs::MetadataExt;
if meta.uid() != my_uid {
continue;
}
// Resolve the executable path via the /proc symlink.
// Strip " (deleted)" so orphans whose binary was replaced still match.
if let Ok(exe_path) = std::fs::read_link(format!("/proc/{upid}/exe")) {
snapshots.push(ProcessSnapshot {
pid: upid,
exe_path: strip_deleted_suffix(exe_path),
});
}
}
snapshots
}
// ── expected_harness_exe_path ─────────────────────────────────────────────
/// Derive the expected path of the `buzz-acp` harness binary next to the
/// current executable. Returns `None` if `current_exe()` fails or has no
/// parent directory.
///
/// In a `.app` bundle: `.../Contents/MacOS/buzz-acp`.
/// In a dev checkout: `<target-dir>/debug/buzz-acp` or similar.
/// Never hardcoded — always derived from the running process.
///
/// Attempts `std::fs::canonicalize` to resolve symlinks so the path
/// comparison in `select_untracked_bundle_harnesses` is stable even when the
/// bundle is accessed through a symlink. Falls back to the raw (unresolved)
/// path on `canonicalize` failure — canonicalization can fail for paths that
/// exist only as kernel metadata (e.g. a process launched from a path that
/// has since been moved), so failure must never prevent the sweep from running;
/// it only narrows the comparison to raw paths, which is still correct for
/// the common case.
///
/// Residual false-negative: if the bundle itself has been translocated or
/// moved since launch, `current_exe()` reflects the new path but a running
/// harness may report the old path. In that case the exe-path comparison
/// fails harmlessly — the orphan is not killed, but neither is anything
/// incorrectly killed. Similarly, an orphan spawned by an older install of
/// the same app (different bundle path, e.g. a prior DMG) will not match
/// this path — that class is handled by `sweep_system_agent_processes`, which
/// scopes by `BUZZ_MANAGED_AGENT` instance ID rather than exe path.
pub fn expected_harness_exe_path() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let raw = dir.join("buzz-acp");
// Canonicalize if possible; fall back to the raw path on failure.
Some(std::fs::canonicalize(&raw).unwrap_or(raw))
}
/// The basename of the harness binary — used for the cheap name pre-filter in
/// `collect_process_snapshots` before the expensive exe-path lookup.
const HARNESS_BINARY_NAME: &str = "buzz-acp";
// ── sweep_untracked_bundle_harnesses ─────────────────────────────────────
/// Sweep and kill harness processes that share this bundle's exact `buzz-acp`
/// executable path but are not in `skip_pids`.
///
/// Complements the env-var-based `sweep_system_agent_processes`: this sweep
/// catches orphans that predate the `BUZZ_MANAGED_AGENT` env var injection
/// and any that lost their PID-file receipt.
///
/// **Boot-time only.** This function is called once, under the store lock,
/// before Phase B spawns any new agents — there is no window for a legitimate
/// harness to have started between the tracked-pid snapshot and this scan, so
/// no grace mechanism is needed. A future periodic caller would need the
/// `sweep_system_agent_processes_with_grace`-style two-tick grace to avoid
/// killing a harness that is legitimately starting up between the skip-list
/// snapshot and the scan.
///
/// Scoping guarantee: only processes whose exe path (after symlink resolution
/// where possible) equals `<this bundle>/buzz-acp` are candidates. Dev builds
/// at a different path, other installs, and children of tracked parents are
/// never directly targeted. Children die with their parent's process group
/// when `resolve_pgids_and_kill` signals the PGID.
#[cfg(unix)]
pub(crate) fn sweep_untracked_bundle_harnesses(skip_pids: &[u32]) {
let Some(harness_exe) = expected_harness_exe_path() else {
return;
};
let snapshots = collect_process_snapshots(HARNESS_BINARY_NAME);
let to_kill = select_untracked_bundle_harnesses(&snapshots, &harness_exe, skip_pids);
if to_kill.is_empty() {
return;
}
eprintln!(
"buzz-desktop: sweep_untracked_bundle_harnesses: reaping {} stale harness process(es) {:?} (exe: {})",
to_kill.len(),
to_kill,
harness_exe.display(),
);
// Small snapshot→kill PID-reuse window: a PID in `to_kill` could be
// recycled between the snapshot and the kill call. This matches the
// precedent set by the neighboring sweeps; `resolve_pgids_and_kill`'s
// PGID-recycling retain guard (skip a resolved PGID that is alive but
// not one of our orphan candidates) narrows the window further.
let to_kill_i32: Vec<i32> = to_kill.iter().map(|&p| p as i32).collect();
super::resolve_pgids_and_kill(&to_kill_i32);
}
#[cfg(not(unix))]
pub(crate) fn sweep_untracked_bundle_harnesses(_skip_pids: &[u32]) {}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── strip_deleted_suffix ─────────────────────────────────────────────
#[test]
fn strip_deleted_suffix_removes_kernel_suffix() {
// Linux appends " (deleted)" when the binary has been replaced since
// launch — this is exactly the stale orphan class we want to reap.
let p = PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp (deleted)");
assert_eq!(
strip_deleted_suffix(p),
PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp")
);
}
#[test]
fn strip_deleted_suffix_leaves_normal_path_unchanged() {
let p = PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp");
assert_eq!(strip_deleted_suffix(p.clone()), p,);
}
#[test]
fn strip_deleted_suffix_does_not_strip_partial_match() {
// "(deleted)" without the leading space must not be stripped.
let p = PathBuf::from("/some/path/buzz-acp(deleted)");
assert_eq!(strip_deleted_suffix(p.clone()), p,);
}
// ── select_untracked_bundle_harnesses ────────────────────────────────
const BUNDLE_HARNESS: &str = "/Applications/Buzz.app/Contents/MacOS/buzz-acp";
const DEV_HARNESS: &str = "/Users/dev/buzz/.worktrees/main/target/debug/buzz-acp";
fn snap(pid: u32, path: &str) -> ProcessSnapshot {
ProcessSnapshot {
pid,
exe_path: PathBuf::from(path),
}
}
#[test]
fn untracked_same_bundle_harness_is_killed() {
let snapshots = vec![snap(1001, BUNDLE_HARNESS)];
let result =
select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[]);
assert_eq!(result, vec![1001]);
}
#[test]
fn tracked_harness_is_spared() {
let snapshots = vec![snap(1002, BUNDLE_HARNESS)];
let result =
select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[1002]);
assert!(result.is_empty());
}
#[test]
fn different_bundle_path_is_spared() {
let snapshots = vec![snap(1003, DEV_HARNESS)];
let result =
select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[]);
assert!(result.is_empty());
}
#[test]
fn child_of_tracked_parent_not_directly_targeted() {
// A non-harness binary is never selected regardless of tracked state.
let snapshots = vec![snap(1004, "/Applications/Buzz.app/Contents/MacOS/goose")];
let result =
select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[]);
assert!(result.is_empty());
}
#[test]
fn empty_process_list_returns_empty() {
let result = select_untracked_bundle_harnesses(&[], &PathBuf::from(BUNDLE_HARNESS), &[]);
assert!(result.is_empty());
}
#[test]
fn mixed_snapshot_kills_only_untracked_same_bundle() {
let snapshots = vec![
snap(2001, BUNDLE_HARNESS), // tracked → spared
snap(2002, BUNDLE_HARNESS), // untracked → killed
snap(2003, DEV_HARNESS), // different path → spared
snap(2004, "/usr/bin/goose"), // unrelated → spared
];
let mut result =
select_untracked_bundle_harnesses(&snapshots, &PathBuf::from(BUNDLE_HARNESS), &[2001]);
result.sort();
assert_eq!(result, vec![2002]);
}
#[test]
fn deleted_suffix_stripped_path_matches_expected() {
// Snapshot with " (deleted)" suffix stripped → should match the clean expected path.
let raw = PathBuf::from("/Applications/Buzz.app/Contents/MacOS/buzz-acp (deleted)");
let snaps = vec![ProcessSnapshot {
pid: 3001,
exe_path: strip_deleted_suffix(raw),
}];
let result = select_untracked_bundle_harnesses(&snaps, &PathBuf::from(BUNDLE_HARNESS), &[]);
assert_eq!(result, vec![3001]);
}
}

View File

@@ -179,6 +179,8 @@ fn build_env_owner_only_sets_mode_and_removes_others() {
assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER"));
}
// select_untracked_bundle_harnesses tests live in runtime/sweep.rs (mod tests).
#[test]
fn build_env_allowlist_sets_both_envs_and_joins() {
let a = "a".repeat(64);