fix(desktop): eliminate agent startup beachball (#374)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-20 14:34:26 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent c6c27481a3
commit 2a131df718
5 changed files with 254 additions and 150 deletions
+3 -69
View File
@@ -20,9 +20,9 @@ use huddle::{
speak_agent_message, start_huddle, start_stt_pipeline,
};
use managed_agents::{
ensure_nest, find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents,
save_managed_agents, start_managed_agent_process, sync_managed_agent_processes, BackendKind,
ManagedAgentProcess,
ensure_nest, kill_stale_tracked_processes, load_managed_agents,
restore_managed_agents_on_launch, save_managed_agents, sync_managed_agent_processes,
BackendKind, ManagedAgentProcess,
};
use std::sync::{
atomic::{AtomicBool, Ordering},
@@ -31,72 +31,6 @@ use std::sync::{
use tauri::{http, Emitter, Manager, RunEvent};
use tauri_plugin_window_state::StateFlags;
fn restore_managed_agents_on_launch(
app: &tauri::AppHandle,
shutdown_started: &AtomicBool,
) -> Result<(), String> {
if shutdown_started.load(Ordering::SeqCst) {
return Ok(());
}
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
if shutdown_started.load(Ordering::SeqCst) {
return Ok(());
}
let mut records = load_managed_agents(app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes);
changed |= kill_stale_tracked_processes(&mut records, &runtimes);
// PID-file sweep: kill any orphaned agent processes we have receipts for
// that werent tracked in records (e.g. escaped process groups, double-forked).
let tracked_pids: Vec<u32> = records
.iter()
.filter_map(|r| r.runtime_pid)
.chain(runtimes.values().map(|rt| rt.child.id()))
.collect();
managed_agents::sweep_orphaned_agent_processes(app, &tracked_pids);
let pubkeys_to_restore = records
.iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
.map(|record| record.pubkey.clone())
.collect::<Vec<_>>();
for pubkey in pubkeys_to_restore {
if shutdown_started.load(Ordering::SeqCst) {
break;
}
let record = find_managed_agent_mut(&mut records, &pubkey)?;
match start_managed_agent_process(app, record, &mut runtimes) {
Ok(()) => {
changed = true;
}
Err(error) => {
record.updated_at = util::now_iso();
record.last_error = Some(error);
changed = true;
}
}
}
if changed {
save_managed_agents(app, &records)?;
}
Ok(())
}
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
let state = app.state::<AppState>();
let _store_guard = state
@@ -192,7 +192,36 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option<P
.find(|candidate| candidate.exists())
}
/// Resolve a command to an absolute path, caching results for the app lifetime.
/// The cache eliminates redundant login-shell spawns when multiple agents share
/// the same binaries (e.g. `npx`, `uvx`).
pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
// Fast path: return cached result without allocating a key.
if let Ok(guard) = cache.lock() {
if let Some(result) = guard.get(command) {
return result.clone();
}
}
// Slow path: resolve and cache.
let result = resolve_command_uncached(command, app);
if result.is_some() {
if let Ok(mut guard) = cache.lock() {
guard.insert(command.to_string(), result.clone());
}
}
result
}
fn resolve_command_uncached(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
if let Some(path) = resolve_workspace_command(command, app) {
return Some(path);
}
@@ -4,6 +4,7 @@ mod nest;
mod persona_avatars;
mod persona_card;
mod personas;
mod restore;
mod runtime;
mod storage;
mod teams;
@@ -14,6 +15,7 @@ pub use discovery::*;
pub use nest::*;
pub use persona_card::*;
pub use personas::*;
pub use restore::*;
pub use runtime::*;
pub use storage::*;
pub use teams::*;
@@ -0,0 +1,148 @@
use super::{
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents,
spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess,
};
use crate::app_state::AppState;
use crate::util;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Manager;
/// Restore managed agents that were running before the app was closed.
///
/// Split into three phases to minimise lock contention with the frontend:
/// A (under lock): sync process state, cleanup, collect agents to start
/// B (no locks): resolve commands and spawn processes in parallel
/// C (re-lock): write back PIDs and status to records on disk
pub fn restore_managed_agents_on_launch(
app: &tauri::AppHandle,
shutdown_started: &AtomicBool,
) -> Result<(), String> {
if shutdown_started.load(Ordering::SeqCst) {
return Ok(());
}
let state = app.state::<AppState>();
// ── Phase A (under lock): housekeeping + collect agents to restore ──
let agents_to_start: Vec<super::ManagedAgentRecord>;
{
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
if shutdown_started.load(Ordering::SeqCst) {
return Ok(());
}
let mut records = load_managed_agents(app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes);
changed |= kill_stale_tracked_processes(&mut records, &runtimes);
let tracked_pids: Vec<u32> = records
.iter()
.filter_map(|r| r.runtime_pid)
.chain(runtimes.values().map(|rt| rt.child.id()))
.collect();
super::sweep_orphaned_agent_processes(app, &tracked_pids);
let candidates: Vec<String> = records
.iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
.map(|record| record.pubkey.clone())
.collect();
let mut to_start = Vec::new();
for pubkey in &candidates {
if let Some(runtime) = runtimes.get_mut(pubkey) {
if runtime.child.try_wait().ok().flatten().is_none() {
continue;
}
}
if let Some(record) = records.iter().find(|r| r.pubkey == *pubkey) {
if let Some(pid) = record.runtime_pid {
if super::process_is_running(pid) {
continue;
}
}
to_start.push(record.clone());
}
}
agents_to_start = to_start;
if changed {
save_managed_agents(app, &records)?;
}
}
if agents_to_start.is_empty() {
return Ok(());
}
// ── Phase B (no locks): resolve commands and spawn processes in parallel ──
let spawn_results: Vec<(
String,
Result<(std::process::Child, std::path::PathBuf), String>,
)> = std::thread::scope(|scope| {
let handles: Vec<_> = agents_to_start
.iter()
.filter(|_| !shutdown_started.load(Ordering::SeqCst))
.map(|record| {
let pubkey = record.pubkey.clone();
let handle = scope.spawn(move || {
let result = spawn_agent_child(app, record);
(pubkey, result)
});
handle
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
if spawn_results.is_empty() {
return Ok(());
}
// ── Phase C (re-acquire lock): write back PIDs and status to records ──
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
for (pubkey, result) in spawn_results {
let record = match find_managed_agent_mut(&mut records, &pubkey) {
Ok(r) => r,
Err(_) => continue,
};
match result {
Ok((child, log_path)) => {
let now = util::now_iso();
record.updated_at = now.clone();
record.runtime_pid = Some(child.id());
record.last_started_at = Some(now);
record.last_stopped_at = None;
record.last_exit_code = None;
record.last_error = None;
runtimes.insert(pubkey, ManagedAgentProcess { child, log_path });
}
Err(error) => {
record.updated_at = util::now_iso();
record.last_error = Some(error);
}
}
}
save_managed_agents(app, &records)?;
Ok(())
}
+72 -81
View File
@@ -434,34 +434,13 @@ pub fn find_managed_agent_mut<'a>(
.ok_or_else(|| format!("agent {pubkey} not found"))
}
pub fn start_managed_agent_process(
/// Spawn an agent process without holding any locks on records or runtimes.
/// Returns the child process and log path on success. The caller is responsible
/// for updating `ManagedAgentRecord` fields and inserting into the runtimes map.
pub fn spawn_agent_child(
app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap<String, ManagedAgentProcess>,
) -> Result<(), String> {
if let Some(runtime) = runtimes.get_mut(&record.pubkey) {
if runtime
.child
.try_wait()
.map_err(|error| format!("failed to inspect running process: {error}"))?
.is_none()
{
return Ok(());
}
runtimes.remove(&record.pubkey);
}
if let Some(pid) = record.runtime_pid {
if process_is_running(pid) {
record.updated_at = now_iso();
record.last_error = None;
return Ok(());
}
record.runtime_pid = None;
}
record: &ManagedAgentRecord,
) -> Result<(std::process::Child, std::path::PathBuf), String> {
let log_path = managed_agent_log_path(app, &record.pubkey)?;
append_log_marker(
&log_path,
@@ -505,14 +484,8 @@ pub fn start_managed_agent_process(
command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command);
command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","));
command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command);
// Desktop-managed agents should favor the latest owner mention in a
// channel over stale in-flight work so follow-up pings don't appear to hang.
command.env("SPROUT_ACP_MULTIPLE_EVENT_HANDLING", "owner-interrupt");
// Timeout configuration: always set both IDLE_TIMEOUT and the deprecated TURN_TIMEOUT
// so older harness binaries (which only read TURN_TIMEOUT) still get a value.
if let Some(idle) = record.idle_timeout_seconds {
command.env("SPROUT_ACP_IDLE_TIMEOUT", idle.to_string());
// Mirror to deprecated var for older harness binaries.
command.env("SPROUT_ACP_TURN_TIMEOUT", idle.to_string());
} else {
command.env(
@@ -532,60 +505,47 @@ pub fn start_managed_agent_process(
"GOOSE_MODE",
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()),
);
// Pack-backed agents: pass the pack path AND the user's current edits.
// ACP's precedence model (CLI/env > persona > default) means env vars
// win over pack values. We read from PersonaRecord (which the user edits
// in the GUI) rather than ManagedAgentRecord (stale creation-time snapshot).
if let (Some(pack_path), Some(persona_name)) =
(&record.persona_pack_path, &record.persona_name_in_pack)
{
command.env("SPROUT_ACP_PERSONA_PACK", pack_path);
command.env("SPROUT_ACP_PERSONA_NAME", persona_name);
}
// Look up the current PersonaRecord for the user's latest edits.
let persona_prompt_and_model: Option<(String, Option<String>)> = record
.persona_id
.as_deref()
.and_then(|pid| {
super::load_personas(app)
.ok()?
.into_iter()
.find(|p| p.id == pid)
})
.map(|p| (p.system_prompt, p.model));
// Resolve system prompt and model: prefer the persona definition (if a
// persona pack is configured and the persona matched), otherwise fall back
// to the record-level overrides.
let has_persona_pack =
record.persona_pack_path.is_some() && record.persona_name_in_pack.is_some();
let persona_prompt_and_model: Option<(String, Option<String>)> = has_persona_pack
.then(|| {
record
.persona_id
.as_deref()
.and_then(|pid| {
super::load_personas(app)
.ok()?
.into_iter()
.find(|p| p.id == pid)
})
.map(|p| (p.system_prompt, p.model))
})
.flatten();
if let Some((prompt, model)) = persona_prompt_and_model {
command.env("SPROUT_ACP_SYSTEM_PROMPT", &prompt);
if let Some(m) = &model {
command.env("SPROUT_ACP_MODEL", m);
} else {
command.env_remove("SPROUT_ACP_MODEL");
}
} else {
// Fallback: persona not found (deleted?), use agent record values.
if let Some(system_prompt) = &record.system_prompt {
command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
} else {
command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
}
if let Some(model) = &record.model {
command.env("SPROUT_ACP_MODEL", model);
} else {
command.env_remove("SPROUT_ACP_MODEL");
}
}
let (effective_prompt, effective_model) = match persona_prompt_and_model {
Some((prompt, model)) => (Some(prompt), model),
None => (record.system_prompt.clone(), record.model.clone()),
};
if let Some(prompt) = &effective_prompt {
command.env("SPROUT_ACP_SYSTEM_PROMPT", prompt);
} else {
// Non-pack agents: use ManagedAgentRecord values directly.
if let Some(system_prompt) = &record.system_prompt {
command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
} else {
command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
}
if let Some(model) = &record.model {
command.env("SPROUT_ACP_MODEL", model);
} else {
command.env_remove("SPROUT_ACP_MODEL");
}
command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
}
if let Some(model) = &effective_model {
command.env("SPROUT_ACP_MODEL", model);
} else {
command.env_remove("SPROUT_ACP_MODEL");
}
if let Some(toolsets) = &record.mcp_toolsets {
command.env("SPROUT_TOOLSETS", toolsets);
@@ -617,10 +577,41 @@ pub fn start_managed_agent_process(
)
})?;
// Write a PID file so the orphan sweep can find this process even if the
// record is stale or the app crashes before updating records.
let _ = super::write_agent_pid_file(app, &record.pubkey, child.id());
Ok((child, log_path))
}
pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
runtimes: &mut HashMap<String, ManagedAgentProcess>,
) -> Result<(), String> {
if let Some(runtime) = runtimes.get_mut(&record.pubkey) {
if runtime
.child
.try_wait()
.map_err(|error| format!("failed to inspect running process: {error}"))?
.is_none()
{
return Ok(());
}
runtimes.remove(&record.pubkey);
}
if let Some(pid) = record.runtime_pid {
if process_is_running(pid) {
record.updated_at = now_iso();
record.last_error = None;
return Ok(());
}
record.runtime_pid = None;
}
let (child, log_path) = spawn_agent_child(app, record)?;
let now = now_iso();
record.updated_at = now.clone();
record.runtime_pid = Some(child.id());