mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): eliminate agent startup beachball (#374)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 weren’t 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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user