Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", 1110], // ... + 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)
["src-tauri/src/managed_agents/runtime.rs", 1200], // ... + 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)
["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)
Expand All @@ -79,7 +79,7 @@ const overrides = new Map([
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 735], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch
["src-tauri/src/lib.rs", 770], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
]);

Expand Down
26 changes: 25 additions & 1 deletion desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
Expand Down
34 changes: 32 additions & 2 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
// All tracked PIDs have already been killed above, so pass an empty skip list.
managed_agents::sweep_orphaned_agent_processes(app, &[]);

// System-wide sweep: agent workers (goose, sprout-agent, etc.) are spawned
// 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(&[]);
Comment thread
wpfleger96 marked this conversation as resolved.

if changed {
save_managed_agents(app, &records)?;
}
Expand Down Expand Up @@ -645,11 +651,35 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application");

let shutdown_done = AtomicBool::new(false);
let shutdown_done = Arc::new(AtomicBool::new(false));

// Agent cleanup on SIGINT (Ctrl+C), SIGTERM, and SIGHUP (terminal close).
// The ctrlc crate with the "termination" feature covers all three signals
// and runs the handler on a dedicated thread (safe for mutex operations).
// `shutdown_done` prevents double-execution with the RunEvent handler.
// `process::exit(0)` intentionally skips Drop impls to avoid re-entrant
// locking in destructors during signal teardown.
#[cfg(unix)]
{
let signal_app = app.handle().clone();
let signal_shutdown_done = Arc::clone(&shutdown_done);
let signal_shutdown_started = Arc::clone(&shutdown_started);
if let Err(e) = ctrlc::set_handler(move || {
signal_shutdown_started.store(true, Ordering::SeqCst);
if !signal_shutdown_done.swap(true, Ordering::SeqCst) {
let _ = shutdown_managed_agents(&signal_app);
}
std::process::exit(0);
}) {
eprintln!("sprout-desktop: failed to register signal handler: {e}");
}
}

let run_shutdown_done = Arc::clone(&shutdown_done);
app.run(move |app_handle, event| match event {
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
shutdown_started.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) {
if !run_shutdown_done.swap(true, Ordering::SeqCst) {
prevent_sleep::release(&app_handle.state::<AppState>().prevent_sleep);
if let Err(error) = shutdown_managed_agents(app_handle) {
eprintln!("sprout-desktop: failed to stop managed agents: {error}");
Expand Down
6 changes: 6 additions & 0 deletions desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ pub fn restore_managed_agents_on_launch(
.collect();
super::sweep_orphaned_agent_processes(app, &tracked_pids);

// System-wide sweep: enumerate all user processes and kill any known
// 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);

let candidates: Vec<String> = records
.iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
Expand Down
156 changes: 152 additions & 4 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,16 +218,23 @@ fn sigterm_then_sigkill(pids: &[i32]) {
#[cfg(unix)]
pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) {
let entries = super::read_all_agent_pid_files(app);
let orphans: Vec<i32> = entries
// Collect live orphans AND dead-leader groups into a single kill batch.
// Dead leaders: PGID may have been recycled, but the window is narrow
// (PID files are from this session) and the cost of missing surviving
// group members outweighs the recycling risk.
let targets: Vec<i32> = entries
.iter()
.filter(|(_, pid)| {
!skip_pids.contains(pid) && process_is_running(*pid) && process_belongs_to_us(*pid)
if skip_pids.contains(pid) {
return false;
}
(process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid)
Comment thread
wpfleger96 marked this conversation as resolved.
})
.map(|(_, pid)| *pid as i32)
.collect();

if !orphans.is_empty() {
sigterm_then_sigkill(&orphans);
if !targets.is_empty() {
sigterm_then_sigkill(&targets);
}

// Clean up PID files for processes we just killed or that are already gone.
Expand All @@ -246,6 +253,147 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
let _ = app;
}

/// 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).
#[cfg(target_os = "macos")]
pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
extern "C" {
fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}

#[repr(C)]
struct BSDInfo {
_pad: [u8; 20],
pbi_uid: u32,
_rest: [u8; 112],
}
const _: () = assert!(std::mem::size_of::<BSDInfo>() == 136);
const PROC_PIDTBSDINFO: libc::c_int = 3;

let my_uid = unsafe { libc::getuid() };

let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return;
}

let buf_len = (count as usize) * 2;
let mut pids: Vec<libc::c_int> = 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);

let my_pid = std::process::id() as i32;
let mut orphans: Vec<i32> = Vec::new();

for &pid in &pids {
if pid <= 0 {
continue;
}
let upid = pid as u32;
if skip_pids.contains(&upid) || pid == my_pid {
continue;
}
// Check binary name first (cheap proc_name call) before UID lookup.
if !process_belongs_to_us(upid) {
continue;
}
// Verify UID to avoid killing another user's identically-named binary.
let mut info = std::mem::MaybeUninit::<BSDInfo>::zeroed();
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr() as *mut libc::c_void,
std::mem::size_of::<BSDInfo>() as libc::c_int,
)
};
if ret <= 0 {
continue;
}
let info = unsafe { info.assume_init() };
if info.pbi_uid != my_uid {
continue;
}
orphans.push(pid);
}

if !orphans.is_empty() {
eprintln!(
"sprout-desktop: system sweep found {} orphaned agent process(es), cleaning up",
orphans.len()
);
sigterm_then_sigkill(&orphans);
}
}

#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
let my_uid = unsafe { libc::getuid() };
let mut orphans: Vec<i32> = Vec::new();
let my_pid = std::process::id() as i32;

let Ok(entries) = std::fs::read_dir("/proc") else {
return;
};
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;
if skip_pids.contains(&upid) {
continue;
}
// Check ownership via /proc/<pid> metadata.
let Ok(meta) = entry.metadata() else {
continue;
};
use std::os::unix::fs::MetadataExt;
if meta.uid() != my_uid {
continue;
}
if process_belongs_to_us(upid) {
orphans.push(pid);
}
}

if !orphans.is_empty() {
eprintln!(
"sprout-desktop: system sweep found {} orphaned agent process(es), cleaning up",
orphans.len()
);
sigterm_then_sigkill(&orphans);
}
}

#[cfg(not(unix))]
pub(crate) fn sweep_system_agent_processes(_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
/// returns `true` if any records were modified.
Expand Down