mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): scope agent sweep to the owning app instance (#808)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -52,7 +52,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
|
||||
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
|
||||
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1300], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux)
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1330], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents
|
||||
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
|
||||
["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
|
||||
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
|
||||
|
||||
@@ -152,7 +152,7 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
// in their own process groups by sprout-acp, so group-kills above only
|
||||
// reach the harness, not the workers. Scan all user processes and kill any
|
||||
// known agent binaries that are still running.
|
||||
managed_agents::sweep_system_agent_processes(&[]);
|
||||
managed_agents::sweep_system_agent_processes(&managed_agents::current_instance_id(app), &[]);
|
||||
|
||||
if changed {
|
||||
save_managed_agents(app, &records)?;
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn restore_managed_agents_on_launch(
|
||||
// agent binaries not tracked by this session. Catches orphans whose
|
||||
// PID files were already cleaned up (e.g. agent workers in their own
|
||||
// process group whose parent harness exited).
|
||||
super::sweep_system_agent_processes(&tracked_pids);
|
||||
super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids);
|
||||
|
||||
let candidates: Vec<String> = records
|
||||
.iter()
|
||||
|
||||
@@ -118,11 +118,32 @@ pub(crate) fn process_belongs_to_us(_pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a running process has `SPROUT_MANAGED_AGENT=1` in its environment,
|
||||
/// distinguishing Sprout-spawned agent trees from independently-launched ones.
|
||||
/// The value stamped into the `SPROUT_MANAGED_AGENT` env var of every agent we
|
||||
/// spawn, identifying *which* desktop instance owns it. We use the app's bundle
|
||||
/// identifier (`xyz.block.sprout.app` for release, `xyz.block.sprout.app.dev`
|
||||
/// for `just dev`) because it is stable across restarts — a relaunched dev
|
||||
/// instance still recognizes its own previously-spawned agents as reclaimable,
|
||||
/// while never matching another instance's (e.g. a dev build never reaps a DMG
|
||||
/// build's agents, and vice versa). This is what lets two Sprouts coexist on
|
||||
/// one machine without one's cleanup nuking the other's agents.
|
||||
pub(crate) fn current_instance_id(app: &AppHandle) -> String {
|
||||
app.config().identifier.clone()
|
||||
}
|
||||
|
||||
/// Build the full `SPROUT_MANAGED_AGENT=<instance-id>` env entry we match
|
||||
/// against when scanning processes. Kept here so the spawn stamp and the sweep
|
||||
/// matcher can never drift apart.
|
||||
fn sprout_marker_entry(instance_id: &str) -> Vec<u8> {
|
||||
format!("SPROUT_MANAGED_AGENT={instance_id}").into_bytes()
|
||||
}
|
||||
|
||||
/// Check if a running process is one of *our* managed agents: it must carry
|
||||
/// `SPROUT_MANAGED_AGENT=<instance_id>` in its environment, where `instance_id`
|
||||
/// is this desktop instance's id. A process stamped with a *different* instance
|
||||
/// id belongs to another live Sprout app and must never be reaped here.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn process_has_sprout_marker(pid: u32) -> bool {
|
||||
const MARKER: &[u8] = b"SPROUT_MANAGED_AGENT=1";
|
||||
fn process_has_sprout_marker(pid: u32, instance_id: &str) -> bool {
|
||||
let marker = sprout_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;
|
||||
@@ -192,20 +213,20 @@ fn process_has_sprout_marker(pid: u32) -> bool {
|
||||
args_remaining -= 1;
|
||||
}
|
||||
// Remaining bytes are null-delimited environment strings.
|
||||
buf[pos..].split(|&b| b == 0).any(|entry| entry == MARKER)
|
||||
buf[pos..].split(|&b| b == 0).any(|entry| entry == marker)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn process_has_sprout_marker(pid: u32) -> bool {
|
||||
fn process_has_sprout_marker(pid: u32, instance_id: &str) -> bool {
|
||||
let marker = sprout_marker_entry(instance_id);
|
||||
let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else {
|
||||
return false;
|
||||
};
|
||||
data.split(|&b| b == 0)
|
||||
.any(|entry| entry == b"SPROUT_MANAGED_AGENT=1")
|
||||
data.split(|&b| b == 0).any(|entry| entry == marker)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn process_has_sprout_marker(_pid: u32) -> bool {
|
||||
fn process_has_sprout_marker(_pid: u32, _instance_id: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -347,12 +368,13 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
|
||||
}
|
||||
|
||||
/// Enumerate all processes on the system owned by the current user and kill any
|
||||
/// that match `KNOWN_AGENT_BINARIES` but aren't in `skip_pids`. This catches
|
||||
/// orphans that escaped PID-file-based cleanup (e.g. agent workers spawned with
|
||||
/// their own process group whose parent harness already exited and had its PID
|
||||
/// file removed).
|
||||
/// agent binary stamped with *this* instance's `SPROUT_MANAGED_AGENT` marker
|
||||
/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped
|
||||
/// PID-file-based cleanup (e.g. agent workers spawned with their own process
|
||||
/// group whose parent harness already exited and had its PID file removed),
|
||||
/// while leaving another live Sprout instance's agents untouched.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
|
||||
pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) {
|
||||
extern "C" {
|
||||
fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
|
||||
fn proc_pidinfo(
|
||||
@@ -426,7 +448,7 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
|
||||
if info.pbi_uid != my_uid {
|
||||
continue;
|
||||
}
|
||||
if !process_has_sprout_marker(upid) {
|
||||
if !process_has_sprout_marker(upid, instance_id) {
|
||||
continue;
|
||||
}
|
||||
orphans.push(pid);
|
||||
@@ -442,7 +464,7 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
|
||||
pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) {
|
||||
let my_uid = unsafe { libc::getuid() };
|
||||
let mut orphans: Vec<i32> = Vec::new();
|
||||
let my_pid = std::process::id() as i32;
|
||||
@@ -473,7 +495,7 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
|
||||
if meta.uid() != my_uid {
|
||||
continue;
|
||||
}
|
||||
if process_belongs_to_us(upid) && process_has_sprout_marker(upid) {
|
||||
if process_belongs_to_us(upid) && process_has_sprout_marker(upid, instance_id) {
|
||||
orphans.push(pid);
|
||||
}
|
||||
}
|
||||
@@ -488,7 +510,7 @@ pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn sweep_system_agent_processes(_skip_pids: &[u32]) {}
|
||||
pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {}
|
||||
|
||||
/// 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
|
||||
@@ -982,11 +1004,13 @@ pub fn spawn_agent_child(
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
// Mark as Sprout-managed so the system-wide orphan sweep can
|
||||
// distinguish our processes from independently-launched agent binaries.
|
||||
// Propagates automatically through the full tree (sprout-acp → goose →
|
||||
// MCP servers) because neither sprout-acp nor goose calls env_clear().
|
||||
command.env("SPROUT_MANAGED_AGENT", "1");
|
||||
// Mark as Sprout-managed *and* which desktop instance owns us, so the
|
||||
// system-wide orphan sweep only reaps this instance's own agents and never
|
||||
// another live Sprout's (e.g. a `just dev` build won't kill a DMG build's
|
||||
// agents). Propagates automatically through the full tree (sprout-acp →
|
||||
// goose → MCP servers) because neither sprout-acp nor goose calls
|
||||
// env_clear().
|
||||
command.env("SPROUT_MANAGED_AGENT", current_instance_id(app));
|
||||
|
||||
// Spawn the harness in its own process group so we can kill the entire
|
||||
// tree (harness + MCP servers + agent subprocesses) on shutdown.
|
||||
@@ -1125,6 +1149,22 @@ pub fn stop_managed_agent_process(
|
||||
mod tests {
|
||||
use crate::managed_agents::known_acp_provider;
|
||||
|
||||
#[test]
|
||||
fn marker_entry_is_namespaced_by_instance_id() {
|
||||
// The spawn stamp and the sweep matcher must produce identical bytes;
|
||||
// both go through sprout_marker_entry, so this pins the on-the-wire
|
||||
// format and guards against a dev build (`...app.dev`) matching a
|
||||
// release build's (`...app`) agents.
|
||||
assert_eq!(
|
||||
super::sprout_marker_entry("xyz.block.sprout.app"),
|
||||
b"SPROUT_MANAGED_AGENT=xyz.block.sprout.app".to_vec()
|
||||
);
|
||||
assert_ne!(
|
||||
super::sprout_marker_entry("xyz.block.sprout.app"),
|
||||
super::sprout_marker_entry("xyz.block.sprout.app.dev")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprout_agent_has_mcp_hooks() {
|
||||
let p = known_acp_provider("sprout-agent").expect("should resolve");
|
||||
|
||||
Reference in New Issue
Block a user