From 187bb5155e4fd59dc7b3b4483dcb82a3344ecb0b Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 10:55:10 -0400 Subject: [PATCH 01/28] Re-key managed runtimes by relay pair Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/relay.rs | 121 ++++++++++ desktop/src-tauri/src/app_state.rs | 30 +-- .../src-tauri/src/commands/agent_config.rs | 18 +- .../src-tauri/src/commands/agent_discovery.rs | 10 +- .../src-tauri/src/commands/agent_models.rs | 4 +- .../src-tauri/src/commands/agent_settings.rs | 4 +- desktop/src-tauri/src/commands/agents.rs | 16 +- .../src-tauri/src/commands/personas/mod.rs | 4 +- desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/restore.rs | 42 +++- .../src-tauri/src/managed_agents/runtime.rs | 210 +++++++++--------- .../src/managed_agents/runtime_types.rs | 108 +++++++++ .../src-tauri/src/managed_agents/storage.rs | 50 ++++- desktop/src-tauri/src/shutdown.rs | 15 +- 15 files changed, 484 insertions(+), 152 deletions(-) create mode 100644 crates/buzz-core/src/relay.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime_types.rs diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 6139ee3aed..dd57b46937 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -30,6 +30,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// Canonical relay runtime identities. +pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs new file mode 100644 index 0000000000..77c74a069a --- /dev/null +++ b/crates/buzz-core/src/relay.rs @@ -0,0 +1,121 @@ +//! Canonical relay identities shared by runtime components. + +use thiserror::Error; +use url::{Host, Url}; + +/// Errors returned while canonicalizing a relay URL for runtime identity. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NormalizeRelayUrlError { + /// The input is not a valid URL. + #[error("invalid relay URL: {0}")] + InvalidUrl(String), + /// Relay sockets must use WebSocket schemes. + #[error("relay URL scheme must be ws or wss")] + InvalidScheme, + /// Relay identity never includes user credentials. + #[error("relay URL must not contain credentials")] + Credentials, + /// Relay identity never includes a fragment. + #[error("relay URL must not contain a fragment")] + Fragment, + /// A relay URL requires a host. + #[error("relay URL must contain a host")] + MissingHost, +} + +/// Canonicalize a WebSocket relay URL for use as a runtime identity key. +/// +/// This is the sole normalizer for `(agent, relay)` process identity. It keeps +/// the WebSocket scheme, lowercases DNS hosts, folds all loopback spellings to +/// `127.0.0.1`, removes default ports and a root slash, and preserves non-root +/// paths and queries. It deliberately is **not** the NIP-42 AUTH comparison +/// helper in `buzz-auth`: AUTH validation is a security boundary with narrower +/// equivalence rules and must not be widened by runtime-key canonicalization. +/// +/// Connection code may retain the configured URL; this canonical form is for +/// identity, receipts, status and deduplication. +pub fn normalize_relay_url(raw: &str) -> Result { + let mut url = Url::parse(raw.trim()) + .map_err(|error| NormalizeRelayUrlError::InvalidUrl(error.to_string()))?; + if !matches!(url.scheme(), "ws" | "wss") { + return Err(NormalizeRelayUrlError::InvalidScheme); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(NormalizeRelayUrlError::Credentials); + } + if url.fragment().is_some() { + return Err(NormalizeRelayUrlError::Fragment); + } + + let host = url.host().ok_or(NormalizeRelayUrlError::MissingHost)?; + let loopback = match host { + Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), + Host::Ipv4(address) => address.is_loopback(), + Host::Ipv6(address) => address.is_loopback(), + }; + if loopback { + url.set_host(Some("127.0.0.1")) + .map_err(|_| NormalizeRelayUrlError::MissingHost)?; + } else if let Host::Domain(domain) = host { + let lowercase = domain.to_ascii_lowercase(); + url.set_host(Some(&lowercase)) + .map_err(|_| NormalizeRelayUrlError::MissingHost)?; + } + + let default_port = match url.scheme() { + "ws" => Some(80), + "wss" => Some(443), + _ => None, + }; + if url.port() == default_port { + url.set_port(None) + .map_err(|_| NormalizeRelayUrlError::InvalidScheme)?; + } + if url.path() == "/" { + url.set_path(""); + } + Ok(url.to_string().trim_end_matches('/').to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_spellings_have_one_identity() { + let ipv6 = normalize_relay_url("wss://[::1]/").unwrap(); + let ipv4 = normalize_relay_url("wss://127.0.0.1/").unwrap(); + let localhost = normalize_relay_url("wss://localhost/").unwrap(); + assert_eq!(ipv6, ipv4); + assert_eq!(ipv4, localhost); + assert_eq!(localhost, "wss://127.0.0.1"); + } + + #[test] + fn canonicalizes_only_identity_equivalences() { + assert_eq!( + normalize_relay_url(" WSS://Relay.Example:443/ ").unwrap(), + "wss://relay.example" + ); + assert_eq!( + normalize_relay_url("ws://relay.example:8080/community/?x=1").unwrap(), + "ws://relay.example:8080/community/?x=1" + ); + } + + #[test] + fn rejects_non_relay_and_ambiguous_urls() { + assert_eq!( + normalize_relay_url("https://relay.example").unwrap_err(), + NormalizeRelayUrlError::InvalidScheme + ); + assert_eq!( + normalize_relay_url("wss://user@relay.example").unwrap_err(), + NormalizeRelayUrlError::Credentials + ); + assert_eq!( + normalize_relay_url("wss://relay.example/#x").unwrap_err(), + NormalizeRelayUrlError::Fragment + ); + } +} diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 3d910c3133..cc7d7144b1 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -14,7 +14,7 @@ use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; use crate::managed_agents::config_bridge::SessionConfigCache; -use crate::managed_agents::ManagedAgentProcess; +use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, @@ -40,12 +40,13 @@ pub struct AppState { pub managed_agent_profile_reconcile_enabled: AtomicBool, /// Shared shutdown signal checked by launch-time agent restoration. pub shutdown_started: AtomicBool, - /// Serializes the restore spawn/register transition with shutdown cleanup, - /// preventing an agent from spawning after shutdown has swept processes. - pub managed_agent_restore_transition: Mutex<()>, + /// Serializes every managed-runtime transition that changes the protected + /// PID set: spawn/register, adoption, stop, shutdown, and sweep snapshots. + /// Never perform network I/O while holding this lock. + pub managed_agent_runtime_transition: Mutex<()>, pub managed_agents_store_lock: Mutex<()>, pub channel_templates_store_lock: Mutex<()>, - pub managed_agent_processes: Mutex>, + pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded @@ -98,9 +99,10 @@ pub struct AppState { /// Ordering: written once in `setup()` with `Ordering::Release`; read in /// `get_identity` with `Ordering::Acquire`. pub reset_failed: AtomicBool, - /// Cached ACP session config from running agents, keyed by agent pubkey. + /// Cached ACP session config from running agents, keyed by canonical + /// `(agent pubkey, relay URL)` runtime identity. /// Populated when the harness emits `session_config_captured` observer events. - pub session_config_cache: Mutex>, + pub session_config_cache: Mutex>, /// IOKit power assertion state — prevents idle sleep while agents run. pub prevent_sleep: Arc>, /// In-process mesh-llm node started by Buzz Desktop. @@ -202,7 +204,7 @@ pub fn build_app_state() -> AppState { managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), shutdown_started: AtomicBool::new(false), - managed_agent_restore_transition: Mutex::new(()), + managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), @@ -236,19 +238,19 @@ impl AppState { self.huddle_state.lock().map_err(|e| e.to_string()) } - pub fn get_session_cache(&self, pubkey: &str) -> Option { - self.session_config_cache.lock().ok()?.get(pubkey).cloned() + pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { + self.session_config_cache.lock().ok()?.get(key).cloned() } - pub fn put_session_cache(&self, pubkey: &str, cache: SessionConfigCache) { + pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { if let Ok(mut map) = self.session_config_cache.lock() { - map.insert(pubkey.to_string(), cache); + map.insert(key, cache); } } - pub fn clear_session_cache(&self, pubkey: &str) { + pub fn clear_agent_session_caches(&self, pubkey: &str) { if let Ok(mut map) = self.session_config_cache.lock() { - map.remove(pubkey); + map.retain(|key, _| key.pubkey != pubkey); } } diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index f6141a53eb..9e70a311f8 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -15,6 +15,7 @@ use crate::{ current_instance_id, known_acp_runtime, load_managed_agents, load_personas, resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, + ManagedAgentRuntimeKey, }, }; @@ -357,7 +358,7 @@ pub async fn get_agent_config_surface( save_managed_agents(&app, &records)?; } for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); + state.clear_agent_session_caches(pubkey); } records .into_iter() @@ -368,7 +369,14 @@ pub async fn get_agent_config_surface( let personas = load_personas(&app).unwrap_or_default(); let effective_cmd = crate::managed_agents::record_agent_command(&record, &personas); let runtime_meta = known_acp_runtime(&effective_cmd); - let session_cache = state.get_session_cache(&pubkey); + let runtime_key = ManagedAgentRuntimeKey::new( + pubkey.clone(), + &crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ), + )?; + let session_cache = state.get_session_cache(&runtime_key); let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); Ok(resolve_config_surface( @@ -387,6 +395,7 @@ pub async fn get_agent_config_surface( #[tauri::command] pub fn put_agent_session_config( pubkey: String, + relay_url: String, payload: serde_json::Value, app: AppHandle, state: State<'_, AppState>, @@ -420,7 +429,10 @@ pub fn put_agent_session_config( captured_at: crate::util::now_iso(), }; - state.put_session_cache(&pubkey, cache); + let Ok(runtime_key) = ManagedAgentRuntimeKey::new(pubkey, &relay_url) else { + return; + }; + state.put_session_cache(runtime_key, cache); } fn parse_config_options(raw: Option<&serde_json::Value>) -> Vec { diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 99e83cd4a5..058d51cb5d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -331,8 +331,9 @@ async fn restart_setup_mode_agents_after_install( let runtime_matches = known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); let setup_mode = runtimes - .get(&record.pubkey) - .map(|p| p.setup_mode) + .iter() + .find(|(key, _)| key.pubkey == record.pubkey) + .map(|(_, p)| p.setup_mode) .unwrap_or(false); let effective = resolve_effective_agent_env( record, @@ -458,8 +459,9 @@ async fn restart_single_agent_after_install( } let setup_mode = runtimes - .get(&pubkey_owned) - .map(|p| p.setup_mode) + .iter() + .find(|(key, _)| key.pubkey == pubkey_owned) + .map(|(_, p)| p.setup_mode) .unwrap_or(false); if !setup_mode { return Err(format!( diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 6bb7f06ad5..425eadb5f6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -47,7 +47,7 @@ pub async fn get_agent_models( save_managed_agents(&app, &records)?; } for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); + state.clear_agent_session_caches(pubkey); } let record = records @@ -792,7 +792,7 @@ pub async fn update_managed_agent( let (_, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); + state.clear_agent_session_caches(pubkey); } let record = find_managed_agent_mut(&mut records, &input.pubkey)?; diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index b654158401..4f043d2403 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -42,7 +42,7 @@ pub async fn set_managed_agent_start_on_app_launch( save_managed_agents(&app, &records)?; } for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); + state.clear_agent_session_caches(pubkey); } { @@ -87,7 +87,7 @@ pub async fn set_managed_agent_auto_restart( save_managed_agents(&app, &records)?; } for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); + state.clear_agent_session_caches(pubkey); } { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index ebae5d4514..73aa1aa468 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -466,7 +466,7 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Result<(), String> { save_managed_agents(&app, &agents)?; } for pk in &exited_pubkeys { - state.clear_session_cache(pk); + state.clear_agent_session_caches(pk); } // runtimes drops here (process lock released before Phase 2). } @@ -491,7 +491,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // Side effects — strictly after records leave disk. for pk in &cascade { - state.clear_session_cache(pk); + state.clear_agent_session_caches(pk); // Remove nsec from keyring after the record is gone. delete_agent_key(pk); super::agents::tombstone_managed_agent_pending(&app, &state, pk); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 3a9c66b1ab..7fb3885c42 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -25,6 +25,7 @@ mod repos; mod restore; pub mod retention; mod runtime; +mod runtime_types; pub(crate) mod spawn_hash; pub(crate) mod storage; pub(crate) mod team_events; @@ -66,6 +67,7 @@ pub use repos::{ }; pub use restore::*; pub use runtime::*; +pub use runtime_types::*; pub use storage::*; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e4fc18b39f..e212dfd3ab 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -10,7 +10,7 @@ use crate::util; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; -type SpawnResult = Result; +type SpawnResult = Result<(super::ManagedAgentRuntimeKey, ManagedAgentProcess), String>; type AgentSpawnResult = (String, SpawnResult); /// Backfill the pinned persona snapshot for pre-existing agents created before @@ -150,7 +150,11 @@ pub async fn restore_managed_agents_on_launch( let mut to_start = Vec::new(); for pubkey in &candidates { - if let Some(runtime) = runtimes.get_mut(pubkey) { + if let Some(runtime) = runtimes + .iter_mut() + .find(|(key, _)| key.pubkey == *pubkey) + .map(|(_, runtime)| runtime) + { if runtime.child.try_wait().ok().flatten().is_none() { continue; } @@ -241,7 +245,7 @@ pub async fn restore_managed_agents_on_launch( // prevents this transition or waits until every child is tracked and can // be terminated. let restore_transition = state - .managed_agent_restore_transition + .managed_agent_runtime_transition .lock() .map_err(|error| error.to_string())?; if shutdown_started.load(Ordering::SeqCst) { @@ -257,7 +261,18 @@ pub async fn restore_managed_agents_on_launch( .map(|record| { let pubkey = record.pubkey.clone(); let handle = scope.spawn(move || { - let result = spawn_agent_child(app, record, owner_hex_ref); + let workspace_relay = + crate::relay::relay_ws_url_with_override(&app.state::()); + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &workspace_relay, + ); + let result = + super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) + .and_then(|key| { + spawn_agent_child(app, record, &key.relay_url, false, owner_hex_ref) + .map(|process| (key, process)) + }); (pubkey, result) }); handle @@ -290,15 +305,28 @@ pub async fn restore_managed_agents_on_launch( Err(_) => continue, }; match result { - Ok(process) => { + Ok((key, mut process)) => { let now = util::now_iso(); + let receipt = super::ManagedAgentRuntimeReceipt { + key: key.clone(), + pid: process.child.id(), + desktop_instance_id: super::current_instance_id(app), + started_at: now.clone(), + }; + if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { + let _ = super::terminate_process(process.child.id()); + let _ = process.child.wait(); + record.updated_at = now; + record.last_error = Some(error); + continue; + } record.updated_at = now.clone(); - record.runtime_pid = Some(process.child.id()); + record.runtime_pid = None; record.last_started_at = Some(now); record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; - runtimes.insert(pubkey.clone(), process); + runtimes.insert(key, super::ManagedAgentPairRuntime::starting(process)); successfully_spawned.push(pubkey); } Err(error) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 77b3a241e0..b541d4a476 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,8 +8,8 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentProcess, ManagedAgentRecord, - ManagedAgentSummary, + spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, + ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -1115,7 +1115,7 @@ pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u3 /// returns `true` if any records were modified. pub fn kill_stale_tracked_processes( records: &mut [ManagedAgentRecord], - runtimes: &HashMap, + runtimes: &HashMap, instance_id: &str, ) -> bool { use crate::managed_agents::BackendKind; @@ -1128,7 +1128,7 @@ pub fn kill_stale_tracked_processes( let Some(pid) = record.runtime_pid else { continue; }; - if !runtimes.contains_key(&record.pubkey) { + if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { if process_belongs_to_us(pid) && process_has_buzz_marker(pid, instance_id) { let _ = terminate_process(pid); } @@ -1143,23 +1143,26 @@ pub fn kill_stale_tracked_processes( pub fn sync_managed_agent_processes( records: &mut [ManagedAgentRecord], - runtimes: &mut HashMap, - instance_id: &str, + runtimes: &mut HashMap, + _instance_id: &str, ) -> (bool, Vec) { let mut changed = false; let mut exited = Vec::new(); - for (pubkey, runtime) in runtimes.iter_mut() { + for (key, runtime) in runtimes.iter_mut() { let status = match runtime.child.try_wait() { Ok(status) => status, Err(error) => { - if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { record.updated_at = now_iso(); record.last_error = Some(format!("failed to inspect process state: {error}")); record.last_error_code = None; } changed = true; - exited.push(pubkey.clone()); + exited.push(key.clone()); continue; } }; @@ -1168,9 +1171,11 @@ pub fn sync_managed_agent_processes( continue; }; - if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { record.updated_at = now_iso(); - record.runtime_pid = None; record.last_stopped_at = Some(now_iso()); record.last_exit_code = status.code(); let log_err = if status.success() { @@ -1190,37 +1195,21 @@ pub fn sync_managed_agent_processes( } changed = true; - exited.push(pubkey.clone()); + exited.push(key.clone()); } - let mut exited_pubkeys: Vec = exited.clone(); - for pubkey in exited { - runtimes.remove(&pubkey); + let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); + for key in exited { + runtimes.remove(&key); } + // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the + // authoritative lifecycle source; migration cleanup is handled separately. for record in records.iter_mut() { - if runtimes.contains_key(&record.pubkey) { - continue; - } - - let Some(pid) = record.runtime_pid else { - continue; - }; - - if process_is_running(pid) - && process_belongs_to_us(pid) - && process_has_buzz_marker(pid, instance_id) - { - continue; - } - - record.runtime_pid = None; - record.updated_at = now_iso(); - if record.last_stopped_at.is_none() { - record.last_stopped_at = Some(now_iso()); + if record.runtime_pid.take().is_some() { + record.updated_at = now_iso(); + changed = true; } - changed = true; - exited_pubkeys.push(record.pubkey.clone()); } (changed, exited_pubkeys) @@ -1258,7 +1247,7 @@ fn persona_drift_state( pub fn build_managed_agent_summary( app: &AppHandle, record: &ManagedAgentRecord, - runtimes: &HashMap, + runtimes: &HashMap, personas: &[crate::managed_agents::types::AgentDefinition], ) -> Result { use crate::managed_agents::BackendKind; @@ -1287,7 +1276,11 @@ pub fn build_managed_agent_summary( (status, None, String::new()) } else { let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); - if let Some(runtime) = runtimes.get(&record.pubkey) { + if let Some(runtime) = runtimes + .iter() + .find(|(key, _)| key.pubkey == record.pubkey) + .map(|(_, runtime)| runtime) + { ( "running".to_string(), Some(runtime.child.id()), @@ -1328,26 +1321,30 @@ pub fn build_managed_agent_summary( // stamped at spawn. This catches out-of-band adapter changes (manual // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The // cache is read-only here — no subprocess is spawned. - let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| { - use tauri::Manager; - let state = app.state::(); - let global_for_hash = - crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); - let hash_drift = runtime.spawn_config_hash - != crate::managed_agents::spawn_hash::spawn_config_hash( - record, - personas, - &teams_for_hash, - &crate::relay::relay_ws_url_with_override(&state), - &global_for_hash, + let needs_restart = runtimes + .iter() + .find(|(key, _)| key.pubkey == record.pubkey) + .map(|(_, runtime)| runtime) + .is_some_and(|runtime| { + use tauri::Manager; + let state = app.state::(); + let global_for_hash = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); + let hash_drift = runtime.spawn_config_hash + != crate::managed_agents::spawn_hash::spawn_config_hash( + record, + personas, + &teams_for_hash, + &crate::relay::relay_ws_url_with_override(&state), + &global_for_hash, + ); + let availability_drift = super::availability_drift( + runtime.adapter_availability.as_ref(), + super::adapter_availability_cached(), ); - let availability_drift = super::availability_drift( - runtime.adapter_availability.as_ref(), - super::adapter_availability_cached(), - ); - hash_drift || availability_drift - }); + hash_drift || availability_drift + }); // Resolve the effective harness the same way, then derive args/mcp from it, // so the UI reflects the persona's current harness (or an explicit pin). @@ -1491,12 +1488,15 @@ pub(crate) fn configure_runtime_cli( pub fn spawn_agent_child( app: &AppHandle, record: &ManagedAgentRecord, + relay_url: &str, + lazy: bool, owner_hex: Option<&str>, ) -> Result { if let Some(error) = spawn_key_refusal(record) { return Err(error); } - let log_path = managed_agent_log_path(app, &record.pubkey)?; + let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, &format!( @@ -1546,17 +1546,9 @@ pub fn spawn_agent_child( .map(|p| p.display().to_string()) .unwrap_or_else(|| effective_command.clone()); - // The agent's effective relay drives both the child's relay connection - // (BUZZ_RELAY_URL) and git credential-helper URL: an explicit per-agent - // relay wins; an empty one falls back to the active workspace relay. - let effective_relay_url = { - use tauri::Manager; - let state = app.state::(); - crate::relay::effective_agent_relay_url( - &record.relay_url, - &crate::relay::relay_ws_url_with_override(&state), - ) - }; + // The caller supplies the explicit canonical pair relay. This is the only + // relay this child may connect to, regardless of the record/workspace default. + let effective_relay_url = runtime_key.relay_url.clone(); // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink @@ -1588,6 +1580,7 @@ pub fn spawn_agent_child( command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); + command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -1932,7 +1925,7 @@ pub fn spawn_agent_child( None }; - let _ = super::write_agent_pid_file(app, &record.pubkey, child.id()); + // Receipt persistence belongs to the caller's atomic register transition. // Windows: assign the harness to a Job Object so its whole tree dies with // the handle. The Unix process-group equivalent is set above. @@ -1966,10 +1959,19 @@ fn child_rust_log_filter() -> String { pub fn start_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, - runtimes: &mut HashMap, + runtimes: &mut HashMap, owner_hex: Option<&str>, ) -> Result<(), String> { - if let Some(runtime) = runtimes.get_mut(&record.pubkey) { + let relay_url = { + use tauri::Manager; + let state = app.state::(); + crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ) + }; + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url)?; + if let Some(runtime) = runtimes.get_mut(&key) { if runtime .child .try_wait() @@ -1979,60 +1981,64 @@ pub fn start_managed_agent_process( return Ok(()); } - runtimes.remove(&record.pubkey); + runtimes.remove(&key); + super::remove_agent_runtime_receipt(app, &key); } - if let Some(pid) = record.runtime_pid { - if process_is_running(pid) - && process_belongs_to_us(pid) - && process_has_buzz_marker(pid, ¤t_instance_id(app)) - { - record.updated_at = now_iso(); - record.last_error = None; - record.last_error_code = None; - return Ok(()); - } + // Scalar PIDs are migration-only and never establish pair liveness. + record.runtime_pid = None; - record.runtime_pid = None; + let mut process = spawn_agent_child(app, record, &key.relay_url, false, owner_hex)?; + let now = now_iso(); + let receipt = super::ManagedAgentRuntimeReceipt { + key: key.clone(), + pid: process.child.id(), + desktop_instance_id: current_instance_id(app), + started_at: now.clone(), + }; + if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { + let _ = terminate_process(process.child.id()); + let _ = process.child.wait(); + return Err(error); } - let process = spawn_agent_child(app, record, owner_hex)?; - - let now = now_iso(); record.updated_at = now.clone(); - record.runtime_pid = Some(process.child.id()); record.last_started_at = Some(now); record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; record.last_error_code = None; - runtimes.insert(record.pubkey.clone(), process); + runtimes.insert(key, ManagedAgentPairRuntime::starting(process)); Ok(()) } pub fn stop_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, - runtimes: &mut HashMap, + runtimes: &mut HashMap, ) -> Result<(), String> { - let Some(mut runtime) = runtimes.remove(&record.pubkey) else { - if let Some(pid) = record.runtime_pid { - if process_is_running(pid) { + let key = runtimes + .keys() + .find(|key| key.pubkey == record.pubkey) + .cloned(); + let Some(key) = key else { + // Legacy PID cleanup only; pair receipts are restored separately. + if let Some(pid) = record.runtime_pid.take() { + if process_is_running(pid) + && process_belongs_to_us(pid) + && process_has_buzz_marker(pid, ¤t_instance_id(app)) + { terminate_process(pid)?; } - - let now = now_iso(); - record.runtime_pid = None; - record.updated_at = now.clone(); - record.last_stopped_at = Some(now); - record.last_exit_code = None; - record.last_error = None; - record.last_error_code = None; + record.updated_at = now_iso(); } super::remove_agent_pid_file(app, &record.pubkey); return Ok(()); }; + let Some(mut runtime) = runtimes.remove(&key) else { + return Ok(()); + }; // On Unix, kill the entire process group via terminate_process. // On Windows, drop the Job Object handle (KILL_ON_JOB_CLOSE) so the whole @@ -2065,7 +2071,7 @@ pub fn stop_managed_agent_process( record.last_error = None; record.last_error_code = None; - super::remove_agent_pid_file(app, &record.pubkey); + super::remove_agent_runtime_receipt(app, &key); append_log_marker( &runtime.log_path, diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs new file mode 100644 index 0000000000..155e02bcdf --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use super::ManagedAgentProcess; + +/// Canonical identity of one managed-agent harness on one relay. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct ManagedAgentRuntimeKey { + pub pubkey: String, + pub relay_url: String, +} + +impl ManagedAgentRuntimeKey { + pub fn new(pubkey: impl Into, relay_url: &str) -> Result { + Ok(Self { + pubkey: pubkey.into(), + relay_url: buzz_core_pkg::relay::normalize_relay_url(relay_url) + .map_err(|error| error.to_string())?, + }) + } + + /// Stable opaque identifier/path suffix derived only from canonical fields. + pub fn runtime_id(&self) -> String { + let relay_hash = hex::encode(Sha256::digest(self.relay_url.as_bytes())); + format!("{}__{relay_hash}", self.pubkey) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManagedAgentRuntimeLifecycle { + Starting, + Listening, + Waking, + Ready, + Failed, + Stopped, +} + +#[derive(Debug)] +pub struct ManagedAgentPairRuntime { + pub process: ManagedAgentProcess, + pub lifecycle: ManagedAgentRuntimeLifecycle, + pub error: Option, +} + +impl std::ops::Deref for ManagedAgentPairRuntime { + type Target = ManagedAgentProcess; + + fn deref(&self) -> &Self::Target { + &self.process + } +} + +impl std::ops::DerefMut for ManagedAgentPairRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.process + } +} + +impl ManagedAgentPairRuntime { + pub fn starting(process: ManagedAgentProcess) -> Self { + Self { + process, + lifecycle: ManagedAgentRuntimeLifecycle::Starting, + error: None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedAgentRuntimeStatus { + pub pubkey: String, + pub relay_url: String, + /// Exact descriptor URL echoed only by reconcile result rows so callers can + /// correlate a canonical response without normalizing on the frontend. + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_relay_url: Option, + pub local_setup: bool, + pub lifecycle: ManagedAgentRuntimeLifecycle, + pub pid: Option, + pub error: Option, + pub log_path: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedAgentRuntimeTarget { + pub pubkey: String, + pub relay_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedAgentCommunityTarget { + pub relay_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ManagedAgentRuntimeReceipt { + pub key: ManagedAgentRuntimeKey, + pub pid: u32, + pub desktop_instance_id: String, + pub started_at: String, +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 7eb07b9741..d5ceae765c 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -8,7 +8,9 @@ use std::{ use tauri::{AppHandle, Manager}; use crate::app_state::keyring_service; -use crate::managed_agents::ManagedAgentRecord; +use crate::managed_agents::{ + ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt, +}; use crate::secret_store::{KeyringProbe, SecretStore}; /// Keyring key name for an agent's nsec, namespaced from the human identity @@ -54,6 +56,15 @@ pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result Result { + Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) +} + /// The keyring operations the migration chokepoint needs. Abstracted so the /// migrate-and-strip decision logic ([`migrate_inline_key`]) can be unit-tested /// against a fake without touching the live OS keyring. @@ -598,8 +609,41 @@ fn agent_pids_dir(app: &AppHandle) -> Result { Ok(dir) } -/// Write a PID file for a spawned agent. The PID equals the PGID since we -/// spawn with `process_group(0)`. +/// Persist a pair-scoped runtime receipt atomically. Callers must register the +/// process in memory in the same runtime transition; on write failure they must +/// terminate the child before releasing that transition. +pub fn write_agent_runtime_receipt( + app: &AppHandle, + receipt: &ManagedAgentRuntimeReceipt, +) -> Result<(), String> { + let path = agent_pids_dir(app)?.join(format!("{}.json", receipt.key.runtime_id())); + let payload = serde_json::to_vec(receipt) + .map_err(|error| format!("failed to serialize runtime receipt: {error}"))?; + atomic_write_json_restricted(&path, &payload) +} + +pub fn remove_agent_runtime_receipt(app: &AppHandle, key: &ManagedAgentRuntimeKey) { + if let Ok(dir) = agent_pids_dir(app) { + let _ = fs::remove_file(dir.join(format!("{}.json", key.runtime_id()))); + } +} + +pub fn read_all_agent_runtime_receipts(app: &AppHandle) -> Vec { + let Ok(dir) = agent_pids_dir(app) else { + return Vec::new(); + }; + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) + .filter_map(|entry| fs::read(entry.path()).ok()) + .filter_map(|bytes| serde_json::from_slice(&bytes).ok()) + .collect() +} + +/// Write a legacy PID file for a spawned agent. Retained only for migration. pub fn write_agent_pid_file(app: &AppHandle, pubkey: &str, pid: u32) -> Result<(), String> { let path = agent_pids_dir(app)?.join(format!("{pubkey}.pid")); fs::write(&path, pid.to_string()) diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index 03fc16d1eb..3fae30cabf 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -3,7 +3,7 @@ use tauri::Manager; use crate::app_state::AppState; use crate::managed_agents::{ self, kill_stale_tracked_processes, load_managed_agents, save_managed_agents, - sync_managed_agent_processes, BackendKind, ManagedAgentProcess, + sync_managed_agent_processes, BackendKind, }; use crate::{prevent_sleep, util}; @@ -121,7 +121,7 @@ pub(crate) fn shutdown_mesh_runtime(app: &tauri::AppHandle) { pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let _restore_transition = state - .managed_agent_restore_transition + .managed_agent_runtime_transition .lock() .map_err(|error| error.to_string())?; let _store_guard = state @@ -149,7 +149,7 @@ pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), Stri struct AgentToStop { idx: usize, pid: u32, - runtime: Option, + runtime: Option, } let mut to_stop: Vec = Vec::new(); @@ -157,10 +157,15 @@ pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), Stri if record.backend != BackendKind::Local { continue; } - if record.runtime_pid.is_none() && !runtimes.contains_key(&record.pubkey) { + if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { continue; } - let runtime = runtimes.remove(&record.pubkey); + let key = runtimes + .keys() + .find(|key| key.pubkey == record.pubkey) + .cloned() + .expect("checked above"); + let runtime = runtimes.remove(&key); let Some(pid) = runtime .as_ref() .map(|rt| rt.child.id()) From 5be38c6fb3136c9f7f045f601e95c58c9e3c361c Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 10:58:43 -0400 Subject: [PATCH 02/28] Add pair runtime lifecycle commands Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src-tauri/src/app_state.rs | 6 + desktop/src-tauri/src/lib.rs | 11 +- desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src-tauri/src/managed_agents/restore.rs | 23 +- .../src-tauri/src/managed_agents/runtime.rs | 44 +++- .../src/managed_agents/runtime_commands.rs | 201 ++++++++++++++++++ .../src-tauri/src/managed_agents/storage.rs | 17 +- 7 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime_commands.rs diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index cc7d7144b1..778bd5fb3f 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -248,6 +248,12 @@ impl AppState { } } + pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.remove(key); + } + } + pub fn clear_agent_session_caches(&self, pubkey: &str) { if let Ok(mut map) = self.session_config_cache.lock() { map.retain(|key, _| key.pubkey != pubkey); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c22de0205f..d83d64126c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -46,7 +46,11 @@ use huddle::{ join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, }; -use managed_agents::{backfill_persona_snapshots, ensure_nest, try_regenerate_nest}; +use managed_agents::{ + backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, + reconcile_managed_agent_runtimes, restart_managed_agent_runtime, start_managed_agent_runtime, + stop_managed_agent_runtime, try_regenerate_nest, +}; #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; #[cfg(all(feature = "mesh-llm", target_os = "macos"))] @@ -766,6 +770,11 @@ pub fn run() { resolve_oa_owner, list_relay_agents, list_managed_agents, + list_managed_agent_runtimes, + start_managed_agent_runtime, + stop_managed_agent_runtime, + restart_managed_agent_runtime, + reconcile_managed_agent_runtimes, create_managed_agent, start_managed_agent, stop_managed_agent, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 7fb3885c42..e5c138ba97 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -26,6 +26,7 @@ mod restore; pub mod retention; mod runtime; mod runtime_types; +mod runtime_commands; pub(crate) mod spawn_hash; pub(crate) mod storage; pub(crate) mod team_events; @@ -68,6 +69,7 @@ pub use repos::{ pub use restore::*; pub use runtime::*; pub use runtime_types::*; +pub use runtime_commands::*; pub use storage::*; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e212dfd3ab..3b093e444b 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -114,10 +114,25 @@ pub async fn restore_managed_agents_on_launch( changed |= kill_stale_tracked_processes(&mut records, &runtimes, &super::current_instance_id(app)); - let tracked_pids: Vec = records - .iter() - .filter_map(|r| r.runtime_pid) - .chain(runtimes.values().map(|rt| rt.child.id())) + let tracked_pids: Vec = runtimes + .values() + .map(|runtime| runtime.child.id()) + .chain( + super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(_, receipt)| { + let canonical = super::ManagedAgentRuntimeKey::new( + receipt.key.pubkey.clone(), + &receipt.key.relay_url, + ) + .ok()?; + (canonical == receipt.key + && receipt.desktop_instance_id == super::current_instance_id(app) + && super::process_is_running(receipt.pid) + && super::process_belongs_to_us(receipt.pid)) + .then_some(receipt.pid) + }), + ) .collect(); super::sweep_orphaned_agent_processes(app, &tracked_pids); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b541d4a476..1fd7eb343a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -409,20 +409,46 @@ fn resolve_pgids_and_kill(candidate_pids: &[i32]) { /// `skip_pids` are PIDs already handled by the tracked-agent path. #[cfg(unix)] pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { - let entries = super::read_all_agent_pid_files(app); + let legacy_entries = super::read_all_agent_pid_files(app); + let instance_id = current_instance_id(app); + let receipt_entries: Vec<_> = super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(path, receipt)| { + let canonical = ManagedAgentRuntimeKey::new( + receipt.key.pubkey.clone(), + &receipt.key.relay_url, + ) + .ok(); + let valid = canonical.as_ref() == Some(&receipt.key) + && path.file_name().and_then(|name| name.to_str()) + == Some(&format!("{}.json", receipt.key.runtime_id())) + && receipt.desktop_instance_id == instance_id + && process_is_running(receipt.pid) + && process_belongs_to_us(receipt.pid) + && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id); + if valid { + Some((path, receipt)) + } else { + super::remove_agent_runtime_receipt_path(&path); + None + } + }) + .collect(); // 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 = entries + let targets: Vec = legacy_entries .iter() - .filter(|(_, pid)| { + .map(|(_, pid)| *pid) + .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) + .filter(|pid| { if skip_pids.contains(pid) { return false; } (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid) }) - .map(|(_, pid)| *pid as i32) + .map(|pid| pid as i32) .collect(); if !targets.is_empty() { @@ -430,7 +456,7 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) } // Clean up PID files for processes we just killed or that are already gone. - for (pubkey, pid) in &entries { + for (pubkey, pid) in &legacy_entries { if skip_pids.contains(pid) { continue; } @@ -438,6 +464,14 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) super::remove_agent_pid_file(app, pubkey); } } + for (_, receipt) in &receipt_entries { + if skip_pids.contains(&receipt.pid) { + continue; + } + if !process_is_running(receipt.pid) || !process_belongs_to_us(receipt.pid) { + super::remove_agent_runtime_receipt(app, &receipt.key); + } + } } #[cfg(not(unix))] diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs new file mode 100644 index 0000000000..8e0322ace3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -0,0 +1,201 @@ +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, Emitter, Manager}; + +use super::{ + agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut, + load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path, + process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents, + spawn_agent_child, terminate_process, write_agent_runtime_receipt, AgentReadiness, BackendKind, + ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, + ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, +}; +use crate::app_state::AppState; + +const STATUS_EVENT: &str = "managed-agent-runtime-status"; + +fn status_for( + app: &AppHandle, + record: &super::ManagedAgentRecord, + key: &ManagedAgentRuntimeKey, + runtime: Option<&ManagedAgentPairRuntime>, + requested_relay_url: Option, +) -> ManagedAgentRuntimeStatus { + let personas = load_personas(app).unwrap_or_default(); + let global = load_global_agent_config(app).unwrap_or_default(); + let command = record_agent_command(record, &personas); + let metadata = super::known_acp_runtime(&command); + let effective = resolve_effective_agent_env(record, &personas, metadata, &global); + let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready); + ManagedAgentRuntimeStatus { + pubkey: key.pubkey.clone(), + relay_url: key.relay_url.clone(), + requested_relay_url, + local_setup, + lifecycle: runtime + .map(|runtime| runtime.lifecycle.clone()) + .unwrap_or(ManagedAgentRuntimeLifecycle::Stopped), + pid: runtime.map(|runtime| runtime.child.id()), + error: runtime.and_then(|runtime| runtime.error.clone()), + log_path: managed_agent_runtime_log_path(app, key) + .ok() + .map(|path| path.display().to_string()), + } +} + +fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { + let _ = app.emit(STATUS_EVENT, status); +} + +#[tauri::command] +pub fn list_managed_agent_runtimes(app: AppHandle) -> Result, String> { + let state = app.state::(); + let records = load_managed_agents(&app)?; + let runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + Ok(runtimes + .iter() + .filter_map(|(key, runtime)| { + let record = records.iter().find(|record| record.pubkey == key.pubkey)?; + Some(status_for(&app, record, key, Some(runtime), None)) + }) + .collect()) +} + +#[tauri::command] +pub fn start_managed_agent_runtime( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + start_pair(pubkey, relay_url, true, app) +} + +fn start_pair( + pubkey: String, + relay_url: String, + lazy: bool, + app: AppHandle, +) -> Result { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if state.shutdown_started.load(Ordering::Acquire) { + return Err("desktop shutdown has started".into()); + } + let _store = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = find_managed_agent_mut(&mut records, &pubkey)?; + if record.backend != BackendKind::Local { + return Err("managed runtime pairs require a local agent".into()); + } + let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + let mut runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + if runtimes.get_mut(&key).is_some_and(|runtime| { + runtime.child.try_wait().ok().flatten().is_none() + }) { + let status = status_for(&app, record, &key, runtimes.get(&key), None); + return Ok(status); + } + runtimes.remove(&key); + + let owner = state.keys.lock().ok().map(|keys| keys.public_key().to_hex()); + let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?; + let now = crate::util::now_iso(); + let receipt = ManagedAgentRuntimeReceipt { + key: key.clone(), + pid: process.child.id(), + desktop_instance_id: current_instance_id(&app), + started_at: now.clone(), + }; + if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { + let _ = terminate_process(process.child.id()); + let _ = process.child.wait(); + return Err(error); + } + record.runtime_pid = None; + record.updated_at = now.clone(); + record.last_started_at = Some(now); + record.last_stopped_at = None; + record.last_error = None; + runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + let status = status_for(&app, record, &key, runtimes.get(&key), None); + drop(runtimes); + save_managed_agents(&app, &records)?; + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub fn stop_managed_agent_runtime( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let _store = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = find_managed_agent_mut(&mut records, &pubkey)?; + let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + let mut runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + if let Some(mut runtime) = runtimes.remove(&key) { + if process_is_running(runtime.child.id()) { + terminate_process(runtime.child.id())?; + } + let status = runtime.child.wait().map_err(|e| e.to_string())?; + record.last_exit_code = status.code(); + let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); + } + super::remove_agent_runtime_receipt(&app, &key); + state.clear_agent_session_cache(&key); + record.runtime_pid = None; + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + let status = status_for(&app, record, &key, None, None); + drop(runtimes); + save_managed_agents(&app, &records)?; + emit_status(&app, &status); + Ok(status) +} + +#[tauri::command] +pub fn restart_managed_agent_runtime( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; + start_pair(pubkey, relay_url, true, app) +} + +#[tauri::command] +pub async fn reconcile_managed_agent_runtimes( + communities: Vec, + app: AppHandle, +) -> Result, String> { + // Discovery is Rust-owned. Until membership has been authenticated, never + // infer an empty membership set as success: return explicit failed rows. + let records = load_managed_agents(&app)?; + let mut rows = Vec::new(); + for community in communities { + for record in records.iter().filter(|record| record.backend == BackendKind::Local) { + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &community.relay_url)?; + let mut row = status_for( + &app, + record, + &key, + None, + Some(community.relay_url.clone()), + ); + row.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + row.error = Some("authenticated membership discovery is not yet available".into()); + rows.push(row); + } + } + Ok(rows) +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index d5ceae765c..d6005ea6bd 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -628,7 +628,13 @@ pub fn remove_agent_runtime_receipt(app: &AppHandle, key: &ManagedAgentRuntimeKe } } -pub fn read_all_agent_runtime_receipts(app: &AppHandle) -> Vec { +pub fn remove_agent_runtime_receipt_path(path: &Path) { + let _ = fs::remove_file(path); +} + +pub fn read_all_agent_runtime_receipts( + app: &AppHandle, +) -> Vec<(PathBuf, ManagedAgentRuntimeReceipt)> { let Ok(dir) = agent_pids_dir(app) else { return Vec::new(); }; @@ -638,8 +644,13 @@ pub fn read_all_agent_runtime_receipts(app: &AppHandle) -> Vec Date: Sun, 19 Jul 2026 11:00:19 -0400 Subject: [PATCH 03/28] Discover agent relay memberships with bounded auth Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../src/managed_agents/runtime_commands.rs | 100 +++++++++++++++--- desktop/src-tauri/src/relay.rs | 30 ++++++ 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 8e0322ace3..78d805832d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -173,28 +173,98 @@ pub fn restart_managed_agent_runtime( start_pair(pubkey, relay_url, true, app) } +async fn discover_agent_membership( + state: &AppState, + record: super::ManagedAgentRecord, + requested_relay_url: String, +) -> Result, String> { + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?; + let keys = nostr::Keys::parse(record.private_key_nsec.trim()) + .map_err(|error| format!("invalid managed-agent key: {error}"))?; + let api_base = crate::relay::relay_http_base_url(&key.relay_url); + let events = tokio::time::timeout( + std::time::Duration::from_secs(10), + crate::relay::query_relay_at_with_keys( + state, + &api_base, + &[serde_json::json!({"kinds": [39002], "#p": [record.pubkey]})], + &keys, + record.auth_tag.as_deref(), + ), + ) + .await + .map_err(|_| "membership discovery timed out".to_string())??; + Ok((!events.is_empty()).then_some((record, key, requested_relay_url))) +} + #[tauri::command] pub async fn reconcile_managed_agent_runtimes( communities: Vec, app: AppHandle, ) -> Result, String> { - // Discovery is Rust-owned. Until membership has been authenticated, never - // infer an empty membership set as success: return explicit failed rows. + use futures_util::{stream, StreamExt}; + let records = load_managed_agents(&app)?; - let mut rows = Vec::new(); + let mut jobs = Vec::new(); for community in communities { - for record in records.iter().filter(|record| record.backend == BackendKind::Local) { - let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &community.relay_url)?; - let mut row = status_for( - &app, - record, - &key, - None, - Some(community.relay_url.clone()), - ); - row.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - row.error = Some("authenticated membership discovery is not yet available".into()); - rows.push(row); + for record in records + .iter() + .filter(|record| record.backend == BackendKind::Local) + { + jobs.push((record.clone(), community.relay_url.clone())); + } + } + let discoveries: Vec<_> = stream::iter(jobs) + .map(|(record, requested)| { + let state = app.state::(); + async move { + let fallback_record = record.clone(); + let fallback_requested = requested.clone(); + discover_agent_membership(&state, record, requested) + .await + .map_err(|error| (fallback_record, fallback_requested, error)) + } + }) + .buffer_unordered(6) + .collect() + .await; + + let mut rows = Vec::new(); + for discovery in discoveries { + match discovery { + Ok(Some((record, key, requested))) => { + match start_pair( + record.pubkey.clone(), + key.relay_url.clone(), + true, + app.clone(), + ) { + Ok(mut status) => { + status.requested_relay_url = Some(requested); + rows.push(status); + } + Err(error) => { + let mut status = status_for( + &app, + &record, + &key, + None, + Some(requested), + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + rows.push(status); + } + } + } + Ok(None) => {} + Err((record, requested, error)) => { + let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested)?; + let mut status = status_for(&app, &record, &key, None, Some(requested)); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + rows.push(status); + } } } Ok(rows) diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index d7d600d66b..7ae5426e39 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -341,6 +341,36 @@ pub async fn query_relay_at( parse_json_response(response).await } +pub async fn query_relay_at_with_keys( + state: &AppState, + api_base_url: &str, + filters: &[serde_json::Value], + keys: &Keys, + auth_tag: Option<&str>, +) -> Result, String> { + let url = format!("{}/query", api_base_url); + let body_bytes = + serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; + let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; + let mut request = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .body(body_bytes) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + parse_json_response(response).await +} + // ── Command response parsing ──────────────────────────────────────────────── /// Parse a command-event OK message of the form `"response:"`. From 065fdf7e4606a9d4c45158bbb531c8d1aebdba81 Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 11:01:39 -0400 Subject: [PATCH 04/28] Require runtime marker before receipt protection Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src-tauri/src/managed_agents/restore.rs | 6 +++++- desktop/src-tauri/src/managed_agents/runtime.rs | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 3b093e444b..9a99ff8032 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -129,7 +129,11 @@ pub async fn restore_managed_agents_on_launch( (canonical == receipt.key && receipt.desktop_instance_id == super::current_instance_id(app) && super::process_is_running(receipt.pid) - && super::process_belongs_to_us(receipt.pid)) + && super::process_belongs_to_us(receipt.pid) + && super::process_has_buzz_marker( + receipt.pid, + &receipt.desktop_instance_id, + )) .then_some(receipt.pid) }), ) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1fd7eb343a..789da47d09 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -169,7 +169,7 @@ fn buzz_marker_entry(instance_id: &str) -> Vec { /// is this desktop instance's id. A process stamped with a *different* instance /// id belongs to another live Buzz app and must never be reaped here. #[cfg(target_os = "macos")] -fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { let marker = buzz_marker_entry(instance_id); let Some(buf) = sweep::procargs2_buffer(pid) else { return false; @@ -213,7 +213,7 @@ fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { } #[cfg(all(unix, not(target_os = "macos")))] -fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { let marker = buzz_marker_entry(instance_id); let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { return false; @@ -222,7 +222,7 @@ fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { } #[cfg(not(unix))] -fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { +pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { false } From e18707b03bdbf1dc27e5080510504290ca4555c6 Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 11:03:36 -0400 Subject: [PATCH 05/28] Ingest pair-scoped runtime lifecycle frames Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src-tauri/src/lib.rs | 3 +- .../src/managed_agents/runtime_commands.rs | 43 +++++++++++++++++++ .../src/managed_agents/runtime_types.rs | 9 ++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d83d64126c..5e3fee76bb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -49,7 +49,7 @@ use huddle::{ use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, reconcile_managed_agent_runtimes, restart_managed_agent_runtime, start_managed_agent_runtime, - stop_managed_agent_runtime, try_regenerate_nest, + stop_managed_agent_runtime, put_managed_agent_runtime_lifecycle, try_regenerate_nest, }; #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; @@ -775,6 +775,7 @@ pub fn run() { stop_managed_agent_runtime, restart_managed_agent_runtime, reconcile_managed_agent_runtimes, + put_managed_agent_runtime_lifecycle, create_managed_agent, start_managed_agent, stop_managed_agent, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 78d805832d..390ec7a934 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -47,6 +47,49 @@ fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { let _ = app.emit(STATUS_EVENT, status); } +#[tauri::command] +pub fn put_managed_agent_runtime_lifecycle( + outer_pubkey: String, + payload: super::ManagedAgentRuntimeLifecycleObserverPayload, + app: AppHandle, +) -> Result { + if outer_pubkey.to_ascii_lowercase() != payload.pubkey.to_ascii_lowercase() { + return Err("observer signer does not match lifecycle payload pubkey".into()); + } + if matches!( + payload.lifecycle, + ManagedAgentRuntimeLifecycle::Starting | ManagedAgentRuntimeLifecycle::Stopped + ) { + return Err("observer cannot author starting or stopped lifecycle".into()); + } + if payload.lifecycle == ManagedAgentRuntimeLifecycle::Failed && payload.error.is_none() { + return Err("failed lifecycle requires an error".into()); + } + if payload.lifecycle != ManagedAgentRuntimeLifecycle::Failed && payload.error.is_some() { + return Err("lifecycle error is only valid for failed".into()); + } + + let state = app.state::(); + let key = ManagedAgentRuntimeKey::new(payload.pubkey, &payload.relay_url)?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) + .ok_or_else(|| format!("agent {} not found", key.pubkey))?; + let mut runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + let runtime = runtimes + .get_mut(&key) + .ok_or_else(|| "lifecycle frame does not match a tracked runtime pair".to_string())?; + if runtime.child.try_wait().map_err(|e| e.to_string())?.is_some() { + return Err("lifecycle frame arrived after process exit".into()); + } + runtime.lifecycle = payload.lifecycle; + runtime.error = payload.error; + let status = status_for(&app, record, &key, Some(runtime), None); + emit_status(&app, &status); + Ok(status) +} + #[tauri::command] pub fn list_managed_agent_runtimes(app: AppHandle) -> Result, String> { let state = app.state::(); diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 155e02bcdf..1392560320 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -85,6 +85,15 @@ pub struct ManagedAgentRuntimeStatus { pub log_path: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagedAgentRuntimeLifecycleObserverPayload { + pub pubkey: String, + pub relay_url: String, + pub lifecycle: ManagedAgentRuntimeLifecycle, + pub error: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentRuntimeTarget { From ea5f79631d239765d1a37a83eef9dbf880ce0421 Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 11:08:31 -0400 Subject: [PATCH 06/28] Harden pair receipt and lifecycle validation Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src-tauri/src/lib.rs | 5 +- desktop/src-tauri/src/managed_agents/mod.rs | 4 +- .../src-tauri/src/managed_agents/restore.rs | 18 +-- .../src-tauri/src/managed_agents/runtime.rs | 33 ++-- .../src/managed_agents/runtime/tests.rs | 41 +++++ .../src/managed_agents/runtime_commands.rs | 153 +++++++++++++++--- 6 files changed, 198 insertions(+), 56 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5e3fee76bb..9017356ab5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -48,8 +48,9 @@ use huddle::{ }; use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, - reconcile_managed_agent_runtimes, restart_managed_agent_runtime, start_managed_agent_runtime, - stop_managed_agent_runtime, put_managed_agent_runtime_lifecycle, try_regenerate_nest, + put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, + restart_managed_agent_runtime, start_managed_agent_runtime, stop_managed_agent_runtime, + try_regenerate_nest, }; #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index e5c138ba97..66f3d882b9 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -25,8 +25,8 @@ mod repos; mod restore; pub mod retention; mod runtime; -mod runtime_types; mod runtime_commands; +mod runtime_types; pub(crate) mod spawn_hash; pub(crate) mod storage; pub(crate) mod team_events; @@ -68,8 +68,8 @@ pub use repos::{ }; pub use restore::*; pub use runtime::*; -pub use runtime_types::*; pub use runtime_commands::*; +pub use runtime_types::*; pub use storage::*; pub use teams::*; pub use types::*; diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 9a99ff8032..9f9b861f5f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -120,20 +120,12 @@ pub async fn restore_managed_agents_on_launch( .chain( super::read_all_agent_runtime_receipts(app) .into_iter() - .filter_map(|(_, receipt)| { - let canonical = super::ManagedAgentRuntimeKey::new( - receipt.key.pubkey.clone(), - &receipt.key.relay_url, + .filter_map(|(path, receipt)| { + super::valid_agent_runtime_receipt( + &path, + &receipt, + &super::current_instance_id(app), ) - .ok()?; - (canonical == receipt.key - && receipt.desktop_instance_id == super::current_instance_id(app) - && super::process_is_running(receipt.pid) - && super::process_belongs_to_us(receipt.pid) - && super::process_has_buzz_marker( - receipt.pid, - &receipt.desktop_instance_id, - )) .then_some(receipt.pid) }), ) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 789da47d09..2bc7a79f4f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -401,6 +401,25 @@ fn resolve_pgids_and_kill(candidate_pids: &[i32]) { sigterm_then_sigkill(&unique); } +pub(crate) fn valid_agent_runtime_receipt( + path: &std::path::Path, + receipt: &super::ManagedAgentRuntimeReceipt, + instance_id: &str, +) -> bool { + let Ok(canonical) = + ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) + else { + return false; + }; + canonical == receipt.key + && path.file_name().and_then(|name| name.to_str()) + == Some(&format!("{}.json", receipt.key.runtime_id())) + && receipt.desktop_instance_id == instance_id + && process_is_running(receipt.pid) + && process_belongs_to_us(receipt.pid) + && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) +} + /// Kill orphaned agent processes using PID file receipts. Reads all files from /// `agent-pids/`, verifies each PID still belongs to a known agent binary, /// then resolves each candidate's actual PGID and signals the process group. @@ -414,19 +433,7 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) let receipt_entries: Vec<_> = super::read_all_agent_runtime_receipts(app) .into_iter() .filter_map(|(path, receipt)| { - let canonical = ManagedAgentRuntimeKey::new( - receipt.key.pubkey.clone(), - &receipt.key.relay_url, - ) - .ok(); - let valid = canonical.as_ref() == Some(&receipt.key) - && path.file_name().and_then(|name| name.to_str()) - == Some(&format!("{}.json", receipt.key.runtime_id())) - && receipt.desktop_instance_id == instance_id - && process_is_running(receipt.pid) - && process_belongs_to_us(receipt.pid) - && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id); - if valid { + if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { Some((path, receipt)) } else { super::remove_agent_runtime_receipt_path(&path); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 6a75867506..6e1c1dcc27 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -799,3 +799,44 @@ fn own_group_grandchild_detected_by_ancestor_walk() { unsafe { libc::kill(-(intermediate_pid as i32), libc::SIGKILL) }; let _ = intermediate.wait(); } + +// ── pair receipt validation tests ─────────────────────────────────────── + +fn receipt_fixture( + key: crate::managed_agents::ManagedAgentRuntimeKey, +) -> crate::managed_agents::ManagedAgentRuntimeReceipt { + crate::managed_agents::ManagedAgentRuntimeReceipt { + key, + pid: std::process::id(), + desktop_instance_id: "test-instance".into(), + started_at: "now".into(), + } +} + +#[test] +fn receipt_validation_rejects_noncanonical_identity() { + let mut receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + receipt.key.relay_url = "WSS://RELAY.EXAMPLE/".into(); + let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id())); + assert!(!super::valid_agent_runtime_receipt( + &path, + &receipt, + "test-instance" + )); +} + +#[test] +fn receipt_validation_rejects_wrong_pair_filename() { + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + assert!(!super::valid_agent_runtime_receipt( + std::path::Path::new("corrupted.json"), + &receipt, + "test-instance" + )); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 390ec7a934..3d44d18620 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -47,12 +47,10 @@ fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { let _ = app.emit(STATUS_EVENT, status); } -#[tauri::command] -pub fn put_managed_agent_runtime_lifecycle( - outer_pubkey: String, - payload: super::ManagedAgentRuntimeLifecycleObserverPayload, - app: AppHandle, -) -> Result { +fn observer_lifecycle_key( + outer_pubkey: &str, + payload: &super::ManagedAgentRuntimeLifecycleObserverPayload, +) -> Result { if outer_pubkey.to_ascii_lowercase() != payload.pubkey.to_ascii_lowercase() { return Err("observer signer does not match lifecycle payload pubkey".into()); } @@ -68,19 +66,35 @@ pub fn put_managed_agent_runtime_lifecycle( if payload.lifecycle != ManagedAgentRuntimeLifecycle::Failed && payload.error.is_some() { return Err("lifecycle error is only valid for failed".into()); } + ManagedAgentRuntimeKey::new(payload.pubkey.clone(), &payload.relay_url) +} +#[tauri::command] +pub fn put_managed_agent_runtime_lifecycle( + outer_pubkey: String, + payload: super::ManagedAgentRuntimeLifecycleObserverPayload, + app: AppHandle, +) -> Result { + let key = observer_lifecycle_key(&outer_pubkey, &payload)?; let state = app.state::(); - let key = ManagedAgentRuntimeKey::new(payload.pubkey, &payload.relay_url)?; let records = load_managed_agents(&app)?; let record = records .iter() .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) .ok_or_else(|| format!("agent {} not found", key.pubkey))?; - let mut runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; let runtime = runtimes .get_mut(&key) .ok_or_else(|| "lifecycle frame does not match a tracked runtime pair".to_string())?; - if runtime.child.try_wait().map_err(|e| e.to_string())?.is_some() { + if runtime + .child + .try_wait() + .map_err(|e| e.to_string())? + .is_some() + { return Err("lifecycle frame arrived after process exit".into()); } runtime.lifecycle = payload.lifecycle; @@ -91,10 +105,15 @@ pub fn put_managed_agent_runtime_lifecycle( } #[tauri::command] -pub fn list_managed_agent_runtimes(app: AppHandle) -> Result, String> { +pub fn list_managed_agent_runtimes( + app: AppHandle, +) -> Result, String> { let state = app.state::(); let records = load_managed_agents(&app)?; - let runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; Ok(runtimes .iter() .filter_map(|(key, runtime)| { @@ -127,23 +146,34 @@ fn start_pair( if state.shutdown_started.load(Ordering::Acquire) { return Err("desktop shutdown has started".into()); } - let _store = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; let mut records = load_managed_agents(&app)?; let record = find_managed_agent_mut(&mut records, &pubkey)?; if record.backend != BackendKind::Local { return Err("managed runtime pairs require a local agent".into()); } let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; - let mut runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; - if runtimes.get_mut(&key).is_some_and(|runtime| { - runtime.child.try_wait().ok().flatten().is_none() - }) { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + if runtimes + .get_mut(&key) + .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) + { let status = status_for(&app, record, &key, runtimes.get(&key), None); return Ok(status); } runtimes.remove(&key); - let owner = state.keys.lock().ok().map(|keys| keys.public_key().to_hex()); + let owner = state + .keys + .lock() + .ok() + .map(|keys| keys.public_key().to_hex()); let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { @@ -181,11 +211,17 @@ pub fn stop_managed_agent_runtime( .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; - let _store = state.managed_agents_store_lock.lock().map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; let mut records = load_managed_agents(&app)?; let record = find_managed_agent_mut(&mut records, &pubkey)?; let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; - let mut runtimes = state.managed_agent_processes.lock().map_err(|e| e.to_string())?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; if let Some(mut runtime) = runtimes.remove(&key) { if process_is_running(runtime.child.id()) { terminate_process(runtime.child.id())?; @@ -287,13 +323,7 @@ pub async fn reconcile_managed_agent_runtimes( rows.push(status); } Err(error) => { - let mut status = status_for( - &app, - &record, - &key, - None, - Some(requested), - ); + let mut status = status_for(&app, &record, &key, None, Some(requested)); status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; status.error = Some(error); rows.push(status); @@ -312,3 +342,74 @@ pub async fn reconcile_managed_agent_runtimes( } Ok(rows) } + +#[cfg(test)] +mod tests { + use super::*; + + fn payload( + relay_url: &str, + lifecycle: ManagedAgentRuntimeLifecycle, + error: Option<&str>, + ) -> super::super::ManagedAgentRuntimeLifecycleObserverPayload { + super::super::ManagedAgentRuntimeLifecycleObserverPayload { + pubkey: "aa".repeat(32), + relay_url: relay_url.into(), + lifecycle, + error: error.map(str::to_owned), + } + } + + #[test] + fn observer_lifecycle_key_preserves_exact_canonical_pair() { + let first = payload( + "WSS://Relay.Example:443/", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + let key = observer_lifecycle_key(&first.pubkey, &first).unwrap(); + assert_eq!(key.pubkey, first.pubkey); + assert_eq!(key.relay_url, "wss://relay.example"); + + let other = payload( + "wss://other.example", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + assert_ne!(key, observer_lifecycle_key(&other.pubkey, &other).unwrap()); + } + + #[test] + fn observer_lifecycle_rejects_cross_agent_and_desktop_states() { + let ready = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + assert!(observer_lifecycle_key(&"bb".repeat(32), &ready).is_err()); + + let stopped = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Stopped, + None, + ); + assert!(observer_lifecycle_key(&stopped.pubkey, &stopped).is_err()); + } + + #[test] + fn observer_lifecycle_enforces_failed_error_contract() { + let failed = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Failed, + None, + ); + assert!(observer_lifecycle_key(&failed.pubkey, &failed).is_err()); + + let ready_with_error = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Ready, + Some("unexpected"), + ); + assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); + } +} From 279d31281f95f289fc7a7fb98e986c5027c8458d Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 11:24:36 -0400 Subject: [PATCH 07/28] Replace restored pair runtime before respawn Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../src-tauri/src/managed_agents/restore.rs | 3 +- .../src-tauri/src/managed_agents/runtime.rs | 48 ++++++++++++++++ .../src/managed_agents/runtime/tests.rs | 56 +++++++++++++++++++ .../src/managed_agents/runtime_commands.rs | 8 ++- 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 9f9b861f5f..a5ce64d5b4 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -263,7 +263,7 @@ pub async fn restore_managed_agents_on_launch( return Ok(()); } - // ── Phase B (no locks): resolve commands and spawn processes in parallel ── + // ── Phase B (transition lock held): resolve commands and spawn in parallel ── let spawn_results: Vec = std::thread::scope(|scope| { let owner_hex_ref = owner_hex.as_deref(); let handles: Vec<_> = agents_to_start @@ -281,6 +281,7 @@ pub async fn restore_managed_agents_on_launch( let result = super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) .and_then(|key| { + super::terminate_untracked_pair_runtime(app, &key)?; spawn_agent_child(app, record, &key.relay_url, false, owner_hex_ref) .map(|process| (key, process)) }); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 2bc7a79f4f..35e87b97b1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -420,6 +420,54 @@ pub(crate) fn valid_agent_runtime_receipt( && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) } +fn terminate_runtime_receipt_with( + path: &std::path::Path, + receipt: &super::ManagedAgentRuntimeReceipt, + terminate: impl FnOnce(u32) -> Result<(), String>, + mut is_running: impl FnMut(u32) -> bool, + remove: impl FnOnce(&std::path::Path), +) -> Result<(), String> { + terminate(receipt.pid)?; + for _ in 0..20 { + if !is_running(receipt.pid) { + remove(path); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(format!( + "prior runtime {} for pair {} on {} did not exit", + receipt.pid, receipt.key.pubkey, receipt.key.relay_url + )) +} + +/// Replace a valid prior-session process before registering a new child for +/// the same pair. The caller must hold the runtime transition lock so receipt +/// inspection, termination, spawn, and registration cannot race shutdown or +/// another start. +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, +) -> Result<(), String> { + let instance_id = current_instance_id(app); + let Some((path, receipt)) = super::read_all_agent_runtime_receipts(app) + .into_iter() + .find(|(path, receipt)| { + receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) + }) + else { + return Ok(()); + }; + + terminate_runtime_receipt_with( + &path, + &receipt, + terminate_process, + process_is_running, + super::remove_agent_runtime_receipt_path, + ) +} + /// Kill orphaned agent processes using PID file receipts. Reads all files from /// `agent-pids/`, verifies each PID still belongs to a known agent binary, /// then resolves each candidate's actual PGID and signals the process group. diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 6e1c1dcc27..67beb8c15e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -840,3 +840,59 @@ fn receipt_validation_rejects_wrong_pair_filename() { "test-instance" )); } + +#[test] +fn replacement_removes_receipt_only_after_confirmed_exit() { + use std::cell::{Cell, RefCell}; + + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + let path = std::path::Path::new("pair.json"); + let terminated = Cell::new(None); + let polls = Cell::new(0); + let removed = RefCell::new(None); + + super::terminate_runtime_receipt_with( + path, + &receipt, + |pid| { + terminated.set(Some(pid)); + Ok(()) + }, + |_| { + let poll = polls.get() + 1; + polls.set(poll); + poll < 2 + }, + |path| *removed.borrow_mut() = Some(path.to_path_buf()), + ) + .unwrap(); + + assert_eq!(terminated.get(), Some(receipt.pid)); + assert_eq!(polls.get(), 2); + assert_eq!(removed.into_inner().as_deref(), Some(path)); +} + +#[test] +fn replacement_failure_keeps_receipt() { + use std::cell::Cell; + + let receipt = receipt_fixture( + crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example") + .unwrap(), + ); + let removed = Cell::new(false); + let error = super::terminate_runtime_receipt_with( + std::path::Path::new("pair.json"), + &receipt, + |_| Err("signal failed".into()), + |_| false, + |_| removed.set(true), + ) + .unwrap_err(); + + assert_eq!(error, "signal failed"); + assert!(!removed.get()); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 3d44d18620..251f9ccbae 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -6,9 +6,10 @@ use super::{ agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut, load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents, - spawn_agent_child, terminate_process, write_agent_runtime_receipt, AgentReadiness, BackendKind, - ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, - ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, + spawn_agent_child, terminate_process, terminate_untracked_pair_runtime, + write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime, + ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, + ManagedAgentRuntimeStatus, }; use crate::app_state::AppState; @@ -168,6 +169,7 @@ fn start_pair( return Ok(status); } runtimes.remove(&key); + terminate_untracked_pair_runtime(&app, &key)?; let owner = state .keys From 4608f72ee58b83abf0157c8c650b43b6ef7469a0 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 10:45:59 -0400 Subject: [PATCH 08/28] feat(acp): model lazy pool lifecycle Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- TESTING.md | 8 + crates/buzz-acp/README.md | 1 + crates/buzz-acp/src/config.rs | 20 + crates/buzz-acp/src/lib.rs | 413 +++++++++++++----- crates/buzz-acp/src/pool_lifecycle.rs | 312 +++++++++++++ crates/buzz-acp/tests/pool_lifecycle_state.rs | 4 + 6 files changed, 658 insertions(+), 100 deletions(-) create mode 100644 crates/buzz-acp/src/pool_lifecycle.rs create mode 100644 crates/buzz-acp/tests/pool_lifecycle_state.rs diff --git a/TESTING.md b/TESTING.md index 242dd44f25..51a5eb44c1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -235,6 +235,14 @@ The justfile also ships `just goose key="$AGENT_NSEC"` (foreground) and same env. See `crates/buzz-acp/README.md` for parallel agents, heartbeats, respond-to gates, and forum subscriptions. +To exercise deferred ACP startup, add `BUZZ_ACP_LAZY_POOL=true` before launching +`buzz-acp`. The harness should connect, authenticate, subscribe, and publish +online presence without starting the configured ACP child. The first accepted, +flushable mention should start exactly one child and then dispatch the queued +message. Automated coverage in `pool_lifecycle_state` pins single-wake, +retry/backoff, and stale-result behavior; it does not replace this real +relay/process smoke test. + Send the agent a task — switch your shell back to the **sender** identity from step 4 and @mention the agent: diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index b9c8406242..d9cd362cb8 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -115,6 +115,7 @@ All configuration is via environment variables (or CLI flags — every env var h | Flag | Env Var | Default | Description | |------|---------|---------|-------------| | `--agents` | `BUZZ_ACP_AGENTS` | `1` | Number of agent subprocesses (1–32). | +| `--lazy-pool` | `BUZZ_ACP_LAZY_POOL` | `false` | Connect, subscribe, and queue accepted work before starting ACP/LLM subprocesses. The first accepted event wakes one pool initialization task; failures retry with bounded exponential backoff while work remains. | | `--heartbeat-interval` | `BUZZ_ACP_HEARTBEAT_INTERVAL` | `0` | Seconds between heartbeat prompts. `0` = disabled. Must be `0` or ≥10 when enabled. | | `--heartbeat-prompt` | `BUZZ_ACP_HEARTBEAT_PROMPT` | (built-in) | Custom heartbeat prompt text. Conflicts with `--heartbeat-prompt-file`. | | `--heartbeat-prompt-file` | `BUZZ_ACP_HEARTBEAT_PROMPT_FILE` | — | Read heartbeat prompt from a file. Conflicts with `--heartbeat-prompt`. | diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d78c4fa319..c2a5dd2485 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -467,6 +467,10 @@ pub struct CliArgs { /// Publish encrypted ACP observer frames over the relay. #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + + /// Connect and subscribe before starting the ACP/LLM subprocess pool. + #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] + pub lazy_pool: bool, } /// Merged NIP-01 subscription filter for a single channel. @@ -537,6 +541,8 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. + pub lazy_pool: bool, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -993,6 +999,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + lazy_pool: args.lazy_pool, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1361,6 +1368,7 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + lazy_pool: false, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -2026,6 +2034,18 @@ channels = "ALL" assert!(err.to_string().contains("turn liveness interval must be 0")); } + #[test] + fn lazy_pool_defaults_off() { + assert!(!CliArgs::parse_from(["buzz-acp"]).lazy_pool); + } + + #[test] + fn lazy_pool_cli_flag_enables_deferred_startup() { + let args = CliArgs::try_parse_from(["buzz-acp", "--lazy-pool=true"]); + assert!(args.is_err(), "bool flags do not take an explicit value"); + assert!(CliArgs::parse_from(["buzz-acp", "--lazy-pool"]).lazy_pool); + } + #[test] fn test_summary_includes_agents_and_heartbeat() { let config = test_config(SubscribeMode::Mentions); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 923e132167..fb05885d60 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6,6 +6,7 @@ mod engram_fetch; mod filter; mod observer; mod pool; +mod pool_lifecycle; mod queue; mod relay; mod setup_mode; @@ -39,6 +40,7 @@ use pool::{ AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState, TimeoutKind, }; +use pool_lifecycle::PoolLifecycle; use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; use tokio::sync::{mpsc, watch}; @@ -88,6 +90,28 @@ async fn publish_presence( Ok(()) } +fn emit_runtime_lifecycle( + observer: Option<&observer::ObserverHandle>, + pubkey: &str, + relay_url: &str, + lifecycle: &str, + error: Option<&str>, +) { + if let Some(observer) = observer { + observer.emit( + "managed_agent_runtime_lifecycle", + None, + &observer::ObserverContext::default(), + serde_json::json!({ + "pubkey": pubkey, + "relayUrl": relay_url, + "lifecycle": lifecycle, + "error": error, + }), + ); + } +} + /// Resolve the agent's owner pubkey at startup. /// /// Priority: @@ -1240,95 +1264,13 @@ async fn tokio_main() -> Result<()> { ); } - // One agent failing to start must not kill the whole pool. We attempt each - // spawn under a 60-second timeout; failures are logged and skipped. If ALL - // agents fail we return an error. A partial pool is valid — the harness - // continues with reduced capacity and logs a warning. - let mut agent_slots: Vec> = Vec::with_capacity(config.agents as usize); - for i in 0..config.agents as usize { - // Spawn OUTSIDE the timeout so we always own the child for cleanup. - // This matches the run_models pattern and prevents zombie leaks on - // init timeout (the cancelled future would drop the AcpClient via - // Drop which is best-effort only). - let spawn_result = AcpClient::spawn( - &config.agent_command, - &config.agent_args, - &config.persona_env_vars, - config.has_generated_codex_config, - ) - .await; - match spawn_result { - Ok(mut acp) => { - acp.set_observer(observer.clone(), i); - match tokio::time::timeout(Duration::from_secs(60), acp.initialize()).await { - Ok(Ok(init_result)) => { - tracing::info!(agent = i, "agent initialized: {init_result}"); - let protocol_version = - init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; - tracing::info!( - agent = i, - name = init_result - .get("agentInfo") - .or_else(|| init_result.get("serverInfo")) - .and_then(|info| info.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"), - "agent initialized — non-cancelling steer enabled (try-and-tolerate)" - ); - acp.observe( - "agent_initialized", - serde_json::json!({ - "agentIndex": i, - "initializeResult": init_result, - }), - ); - let agent_name = normalized_agent_name(&init_result); - agent_slots.push(Some(OwnedAgent { - index: i, - acp, - state: SessionState::default(), - model_capabilities: None, - desired_model: config.model.clone(), - model_overridden: false, - agent_name, - goose_system_prompt_supported: None, - protocol_version, - })); - } - Ok(Err(e)) => { - tracing::error!(agent = i, "agent initialize failed: {e}"); - acp.shutdown().await; - agent_slots.push(None); - } - Err(_) => { - tracing::error!(agent = i, "agent timed out during init (60s)"); - acp.shutdown().await; - agent_slots.push(None); - } - } - } - Err(e) => { - tracing::error!(agent = i, "agent failed to spawn: {e}"); - agent_slots.push(None); - } - } - } - let live_count = agent_slots.iter().filter(|s| s.is_some()).count(); - if live_count == 0 { - return Err(anyhow::anyhow!( - "all {} agents failed to start — cannot continue", - config.agents - )); - } - if live_count < config.agents as usize { - tracing::warn!( - "started {}/{} agents — continuing with reduced pool", - live_count, - config.agents - ); - } - tracing::info!("agent_pool_ready agents={}", live_count); - let mut pool = AgentPool::from_slots(agent_slots); + let mut pool = if config.lazy_pool { + AgentPool::from_slots((0..config.agents).map(|_| None).collect()) + } else { + initialize_agent_pool(&PoolStartup::from_config(&config, observer.clone()), None).await? + }; + let mut pool_ready = !config.lazy_pool; + let mut pool_lifecycle: PoolLifecycle = PoolLifecycle::listening(); // Capture a startup watermark BEFORE connecting to the relay. This timestamp // is used for membership notification replay (via startup_watermark) and as @@ -1508,6 +1450,10 @@ async fn tokio_main() -> Result<()> { )); } + let dedup_mode = config.dedup_mode; + let mut queue = + EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs); + // Online means the harness can receive work, not merely that its socket is // connected. Publishing after channel subscriptions gives desktop callers // a durable readiness boundary before they send a startup mention. @@ -1518,9 +1464,15 @@ async fn tokio_main() -> Result<()> { } } - let dedup_mode = config.dedup_mode; - let mut queue = - EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs); + if config.lazy_pool { + emit_runtime_lifecycle( + observer.as_ref(), + &pubkey_hex, + &config.relay_url, + "listening", + None, + ); + } let base_prompt_content = config.base_prompt_content.take(); let ctx = Arc::new(PromptContext { @@ -1608,6 +1560,8 @@ async fn tokio_main() -> Result<()> { let (respawn_tx, mut respawn_rx) = mpsc::channel::(config.agents as usize); // JoinSet for respawn tasks so shutdown can abort them. let mut respawn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + let (wake_tx, mut wake_rx) = mpsc::channel::<(u32, Result)>(1); + let mut wake_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); // Channel for non-cancelling steer ack watchers to forward outcomes back // to the main loop. Each `pool.send_steer(...) == Ok(())` spawns a @@ -1694,10 +1648,39 @@ async fn tokio_main() -> Result<()> { Result(Box), Panic(tokio::task::JoinError), SteerAck(SteerAckEvent), + Wake(u32, Result), } loop { - if last_maintenance.elapsed() >= maintenance_interval { + if config.lazy_pool && !pool_ready { + if let Some(attempt) = pool_lifecycle + .start_wake_if_due(queue.has_flushable_work(), tokio::time::Instant::now()) + { + emit_runtime_lifecycle( + observer.as_ref(), + &pubkey_hex, + &config.relay_url, + "waking", + None, + ); + let startup = PoolStartup::from_config(&config, observer.clone()); + let wake_tx = wake_tx.clone(); + let wake_shutdown = shutdown_rx.clone(); + wake_tasks.spawn(async move { + let result = initialize_agent_pool(&startup, Some(wake_shutdown)) + .await + .map_err(|error| error.to_string()); + if let Err(error) = wake_tx.send((attempt, result)).await { + let (_attempt, result) = error.0; + if let Ok(mut abandoned_pool) = result { + shutdown_agent_pool(&mut abandoned_pool).await; + } + } + }); + } + } + + if pool_ready && last_maintenance.elapsed() >= maintenance_interval { last_maintenance = std::time::Instant::now(); queue.compact_expired_state(); @@ -1779,7 +1762,7 @@ async fn tokio_main() -> Result<()> { biased; // recv() returning None means all senders dropped (pool was torn down). // Break cleanly instead of panicking. - r = result_rx.recv() => match r { + r = result_rx.recv(), if pool_ready => match r { Some(result) => Some(PoolEvent::Result(Box::new(result))), None => { tracing::info!("result channel closed — exiting main loop"); @@ -1800,6 +1783,34 @@ async fn tokio_main() -> Result<()> { Some(ack_event) = steer_ack_rx.recv() => { Some(PoolEvent::SteerAck(ack_event)) } + Some((attempt, result)) = wake_rx.recv(), if config.lazy_pool && !pool_ready => { + Some(PoolEvent::Wake(attempt, result)) + } + _ = async { + match pool_lifecycle.retry_at() { + Some(retry_at) if !pool_ready => tokio::time::sleep_until(retry_at).await, + _ => std::future::pending().await, + } + } => None, + Some(Err(error)) = wake_tasks.join_next(), if !wake_tasks.is_empty() => { + if let Some(attempt) = pool_lifecycle.waking_attempt() { + let message = format!("pool wake task failed: {error}"); + if pool_lifecycle.cancel_wake( + attempt, + message.clone(), + tokio::time::Instant::now(), + ) { + emit_runtime_lifecycle( + observer.as_ref(), + &pubkey_hex, + &config.relay_url, + "failed", + Some(&message), + ); + } + } + None + } control_event = async { match relay_observer_control_rx.as_mut() { Some(rx) => rx.recv().await, @@ -1909,7 +1920,11 @@ async fn tokio_main() -> Result<()> { // complete normally (the relay may reject actions if // the agent lost access). let drained_ids = queue.drain_channel(ch); - let invalidated = pool.invalidate_channel_sessions(ch); + let invalidated = if pool_ready { + pool.invalidate_channel_sessions(ch) + } else { + 0 + }; // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); @@ -2167,10 +2182,12 @@ async fn tokio_main() -> Result<()> { } } } - for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) - { - typing_channels.insert(channel_id, thread_tags); + if pool_ready { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx) + { + typing_channels.insert(channel_id, thread_tags); + } } } None => { @@ -2191,7 +2208,9 @@ async fn tokio_main() -> Result<()> { } } => { let _ = result_rx; - if queue.has_flushable_work() { + if !pool_ready { + tracing::debug!("heartbeat_skipped_pool_not_ready"); + } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) @@ -2439,10 +2458,56 @@ async fn tokio_main() -> Result<()> { typing_channels.insert(channel_id, thread_tags); } } + Some(PoolEvent::Wake(attempt, result)) => { + let completion = result.as_ref().map(|_| ()).map_err(|error| error.clone()); + if let Err(error) = + pool_lifecycle.complete_wake(attempt, result, tokio::time::Instant::now()) + { + tracing::warn!(attempt, error, "discarding stale pool wake result"); + continue; + } + match completion { + Ok(()) => { + pool = pool_lifecycle + .take_ready() + .expect("successful wake stores a ready pool"); + pool_ready = true; + emit_runtime_lifecycle( + observer.as_ref(), + &pubkey_hex, + &config.relay_url, + "ready", + None, + ); + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx) + { + typing_channels.insert(channel_id, thread_tags); + } + } + Err(error) => { + debug_assert_eq!(pool_lifecycle.failed_error(), Some(error.as_str())); + emit_runtime_lifecycle( + observer.as_ref(), + &pubkey_hex, + &config.relay_url, + "failed", + Some(&error), + ); + } + } + } None => {} // relay/heartbeat/shutdown branches handled inline above } } + wake_tasks.shutdown().await; + while let Ok((_attempt, result)) = wake_rx.try_recv() { + if let Ok(mut awakened_pool) = result { + shutdown_agent_pool(&mut awakened_pool).await; + } + } + tracing::info!("shutdown: waiting for in-flight prompts"); // 30 s is generous for in-flight prompts to be cancelled; using // max_turn_duration here would cause Ctrl+C to hang for up to an hour. @@ -3491,6 +3556,152 @@ fn normalized_agent_name(init_result: &serde_json::Value) -> String { .to_ascii_lowercase() } +async fn shutdown_agent_slots(slots: &mut [Option]) { + for slot in slots { + if let Some(mut agent) = slot.take() { + agent.acp.shutdown().await; + } + } +} + +async fn shutdown_agent_pool(pool: &mut AgentPool) { + pool.join_set.shutdown().await; + while let Ok(mut result) = pool.result_rx_try_recv() { + result.agent.acp.shutdown().await; + } + for slot in pool.agents_mut() { + if let Some(mut agent) = slot.take() { + agent.acp.shutdown().await; + } + } +} + +struct PoolStartup { + agents: u32, + command: String, + args: Vec, + extra_env: Vec<(String, String)>, + has_generated_codex_config: bool, + model: Option, + observer: Option, +} + +impl PoolStartup { + fn from_config(config: &Config, observer: Option) -> Self { + Self { + agents: config.agents, + command: config.agent_command.clone(), + args: config.agent_args.clone(), + extra_env: config.persona_env_vars.clone(), + has_generated_codex_config: config.has_generated_codex_config, + model: config.model.clone(), + observer, + } + } +} + +async fn initialize_agent_pool( + startup: &PoolStartup, + mut shutdown: Option>, +) -> Result { + // One agent failing to start must not kill the whole pool. + // Attempt each spawn under a 60-second timeout; a partial pool is valid. + let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); + for i in 0..startup.agents as usize { + let spawn_result = AcpClient::spawn( + &startup.command, + &startup.args, + &startup.extra_env, + startup.has_generated_codex_config, + ) + .await; + match spawn_result { + Ok(mut acp) => { + acp.set_observer(startup.observer.clone(), i); + let initialize = tokio::time::timeout(Duration::from_secs(60), acp.initialize()); + let initialize_result = match shutdown.as_mut() { + Some(shutdown) => tokio::select! { + biased; + _ = shutdown.changed() => { + acp.shutdown().await; + shutdown_agent_slots(&mut agent_slots).await; + return Err(anyhow::anyhow!("pool initialization cancelled by shutdown")); + } + result = initialize => result, + }, + None => initialize.await, + }; + match initialize_result { + Ok(Ok(init_result)) => { + tracing::info!(agent = i, "agent initialized: {init_result}"); + let protocol_version = + init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; + tracing::info!( + agent = i, + name = init_result + .get("agentInfo") + .or_else(|| init_result.get("serverInfo")) + .and_then(|info| info.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"), + "agent initialized — non-cancelling steer enabled (try-and-tolerate)" + ); + acp.observe( + "agent_initialized", + serde_json::json!({ + "agentIndex": i, + "initializeResult": init_result, + }), + ); + let agent_name = normalized_agent_name(&init_result); + agent_slots.push(Some(OwnedAgent { + index: i, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: startup.model.clone(), + model_overridden: false, + agent_name, + goose_system_prompt_supported: None, + protocol_version, + })); + } + Ok(Err(e)) => { + tracing::error!(agent = i, "agent initialize failed: {e}"); + acp.shutdown().await; + agent_slots.push(None); + } + Err(_) => { + tracing::error!(agent = i, "agent timed out during init (60s)"); + acp.shutdown().await; + agent_slots.push(None); + } + } + } + Err(e) => { + tracing::error!(agent = i, "agent failed to spawn: {e}"); + agent_slots.push(None); + } + } + } + let live_count = agent_slots.iter().filter(|slot| slot.is_some()).count(); + if live_count == 0 { + return Err(anyhow::anyhow!( + "all {} agents failed to start — cannot continue", + startup.agents + )); + } + if live_count < startup.agents as usize { + tracing::warn!( + "started {}/{} agents — continuing with reduced pool", + live_count, + startup.agents + ); + } + tracing::info!("agent_pool_ready agents={}", live_count); + Ok(AgentPool::from_slots(agent_slots)) +} + // ── spawn_and_init ──────────────────────────────────────────────────────────── /// Spawn an agent subprocess and run the MCP `initialize` handshake. /// @@ -4395,6 +4606,7 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + lazy_pool: false, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -4560,6 +4772,7 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + lazy_pool: false, agent_owner: None, no_base_prompt: false, base_prompt_content: None, diff --git a/crates/buzz-acp/src/pool_lifecycle.rs b/crates/buzz-acp/src/pool_lifecycle.rs new file mode 100644 index 0000000000..a1099a2dc5 --- /dev/null +++ b/crates/buzz-acp/src/pool_lifecycle.rs @@ -0,0 +1,312 @@ +//! Lazy agent-pool lifecycle state. +//! +//! Relay connection, subscription, and event buffering live outside this +//! module. This state machine owns only whether a deferred pool has not started, +//! is waking, is ready, or is waiting to retry after a failed wake. + +use std::time::Duration; +use tokio::time::Instant; + +const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(5); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(300); + +#[derive(Debug)] +pub(crate) enum PoolLifecycle

{ + Listening, + Waking { + attempt: u32, + }, + Ready(P), + Failed { + attempt: u32, + retry_at: Instant, + error: String, + }, +} + +impl

PoolLifecycle

{ + pub(crate) fn listening() -> Self { + Self::Listening + } + + /// Start the first wake, or a due retry, when buffered work exists. + /// + /// Returns the attempt token exactly once per transition into `Waking`; + /// callers attach it to the single pool-initialization task and return it + /// with the result. + pub(crate) fn start_wake_if_due( + &mut self, + has_pending_work: bool, + now: Instant, + ) -> Option { + if !has_pending_work { + return None; + } + + let next_attempt = match self { + Self::Listening => Some(1), + Self::Failed { + attempt, retry_at, .. + } if now >= *retry_at => Some(attempt.saturating_add(1)), + Self::Waking { .. } | Self::Ready(_) | Self::Failed { .. } => None, + }; + + if let Some(attempt) = next_attempt { + *self = Self::Waking { attempt }; + } + next_attempt + } + + pub(crate) fn take_ready(&mut self) -> Option

{ + match std::mem::replace(self, Self::Listening) { + Self::Ready(pool) => Some(pool), + other => { + *self = other; + None + } + } + } + + pub(crate) fn waking_attempt(&self) -> Option { + match self { + Self::Waking { attempt } => Some(*attempt), + _ => None, + } + } + + pub(crate) fn retry_at(&self) -> Option { + match self { + Self::Failed { retry_at, .. } => Some(*retry_at), + _ => None, + } + } + + pub(crate) fn failed_error(&self) -> Option<&str> { + match self { + Self::Failed { error, .. } => Some(error), + _ => None, + } + } + + pub(crate) fn cancel_wake(&mut self, attempt: u32, error: String, now: Instant) -> bool { + self.complete_wake(attempt, Err(error), now).is_ok() + } + + /// Complete the matching in-flight wake attempt. + /// + /// A failure remains retryable. A result returned outside `Waking`, or from + /// an older attempt, is rejected: accepting it could replace a newer pool. + pub(crate) fn complete_wake( + &mut self, + completed_attempt: u32, + result: Result, + now: Instant, + ) -> Result<(), &'static str> { + let attempt = match self { + Self::Waking { attempt } if *attempt == completed_attempt => *attempt, + Self::Waking { .. } => return Err("wake result attempt did not match Waking attempt"), + _ => return Err("wake completed while lifecycle was not Waking"), + }; + + *self = match result { + Ok(pool) => Self::Ready(pool), + Err(error) => Self::Failed { + attempt, + retry_at: now + retry_delay(attempt), + error, + }, + }; + Ok(()) + } +} + +fn retry_delay(attempt: u32) -> Duration { + let exponent = attempt.saturating_sub(1).min(63); + let multiplier = 1_u64.checked_shl(exponent).unwrap_or(u64::MAX); + Duration::from_secs( + INITIAL_RETRY_DELAY + .as_secs() + .saturating_mul(multiplier) + .min(MAX_RETRY_DELAY.as_secs()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn first_pending_event_starts_exactly_one_wake() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + + assert_eq!(lifecycle.start_wake_if_due(false, now), None); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + assert_eq!(lifecycle.start_wake_if_due(true, now), None); + assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 1 })); + } + + #[tokio::test(start_paused = true)] + async fn failure_retries_only_when_work_exists_and_deadline_is_due() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + lifecycle + .complete_wake(1, Err("provider unavailable".into()), now) + .unwrap(); + + assert_eq!( + lifecycle.start_wake_if_due(true, now + Duration::from_secs(4)), + None + ); + assert_eq!( + lifecycle.start_wake_if_due(false, now + Duration::from_secs(5)), + None + ); + assert_eq!( + lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)), + Some(2) + ); + assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 })); + } + + #[tokio::test(start_paused = true)] + async fn retry_backoff_doubles_and_caps_at_five_minutes() { + let mut now = Instant::now(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + + for attempt in 1..=9 { + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(attempt)); + assert!(matches!( + lifecycle, + PoolLifecycle::Waking { attempt: actual } if actual == attempt + )); + lifecycle + .complete_wake(attempt, Err("no brain".into()), now) + .unwrap(); + + let expected = retry_delay(attempt); + let retry_at = match &lifecycle { + PoolLifecycle::Failed { retry_at, .. } => *retry_at, + _ => panic!("failure must enter Failed"), + }; + assert_eq!(retry_at, now + expected); + assert!(expected <= MAX_RETRY_DELAY); + now = retry_at; + } + + assert_eq!(retry_delay(7), MAX_RETRY_DELAY); + assert_eq!(retry_delay(u32::MAX), MAX_RETRY_DELAY); + } + + #[tokio::test(start_paused = true)] + async fn successful_retry_consumes_pool_and_stops_future_wakes() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::listening(); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + lifecycle + .complete_wake(1, Err("first attempt failed".into()), now) + .unwrap(); + + let retry_at = match &lifecycle { + PoolLifecycle::Failed { retry_at, .. } => *retry_at, + _ => panic!("expected Failed"), + }; + assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2)); + lifecycle.complete_wake(2, Ok("pool"), retry_at).unwrap(); + + assert!(matches!(lifecycle, PoolLifecycle::Ready("pool"))); + assert_eq!( + lifecycle.start_wake_if_due(true, retry_at + Duration::from_secs(600)), + None + ); + } + + #[tokio::test(start_paused = true)] + async fn stale_or_duplicate_wake_result_is_rejected() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + assert_eq!( + lifecycle.complete_wake(1, Ok(()), now), + Err("wake completed while lifecycle was not Waking") + ); + + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + lifecycle.complete_wake(1, Ok(()), now).unwrap(); + assert_eq!( + lifecycle.complete_wake(1, Ok(()), now), + Err("wake completed while lifecycle was not Waking") + ); + assert!(matches!(lifecycle, PoolLifecycle::Ready(()))); + } + + #[tokio::test(start_paused = true)] + async fn stale_attempt_result_cannot_replace_current_wake() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::<&str>::listening(); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + lifecycle + .complete_wake(1, Err("attempt one failed".into()), now) + .unwrap(); + + let retry_at = match &lifecycle { + PoolLifecycle::Failed { retry_at, .. } => *retry_at, + _ => panic!("expected Failed"), + }; + assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2)); + assert_eq!( + lifecycle.complete_wake(1, Ok("stale pool"), retry_at), + Err("wake result attempt did not match Waking attempt") + ); + assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 })); + lifecycle + .complete_wake(2, Ok("current pool"), retry_at) + .unwrap(); + assert!(matches!(lifecycle, PoolLifecycle::Ready("current pool"))); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_wake_enters_failed_and_can_retry() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + assert_eq!(lifecycle.waking_attempt(), Some(1)); + assert!(lifecycle.cancel_wake(1, "task panicked".into(), now)); + assert_eq!(lifecycle.failed_error(), Some("task panicked")); + assert_eq!( + lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)), + Some(2) + ); + } + + #[test] + fn take_ready_transfers_pool_exactly_once() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::listening(); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + lifecycle.complete_wake(1, Ok("pool"), now).unwrap(); + assert_eq!(lifecycle.take_ready(), Some("pool")); + assert_eq!(lifecycle.take_ready(), None); + } + + #[test] + fn failed_state_preserves_attempt_deadline_and_error() { + let now = Instant::now(); + let mut lifecycle = PoolLifecycle::<()>::listening(); + assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1)); + lifecycle.complete_wake(1, Err("boom".into()), now).unwrap(); + + match lifecycle { + PoolLifecycle::Failed { + attempt, + retry_at, + error, + } => { + assert_eq!(attempt, 1); + assert_eq!(retry_at, now + Duration::from_secs(5)); + assert_eq!(error, "boom"); + } + _ => panic!("expected Failed"), + } + } +} diff --git a/crates/buzz-acp/tests/pool_lifecycle_state.rs b/crates/buzz-acp/tests/pool_lifecycle_state.rs new file mode 100644 index 0000000000..8bbc5b8e2c --- /dev/null +++ b/crates/buzz-acp/tests/pool_lifecycle_state.rs @@ -0,0 +1,4 @@ +// Compile and run the lifecycle state-machine contract as an integration target. +#[allow(dead_code)] +#[path = "../src/pool_lifecycle.rs"] +mod pool_lifecycle; From 772d9cece421af69a37cd2a200aa6a944e449d7d Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:49:19 -0400 Subject: [PATCH 09/28] feat(desktop): add pair-scoped agent runtime UI Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src/app/AppShell.tsx | 4 +- .../agents/lib/useAgentsDataRefresh.ts | 8 ++ .../agents/managedAgentRuntimeHooks.ts | 54 ++++++++ .../agents/managedAgentRuntimeStatus.test.mjs | 70 ++++++++++ .../agents/managedAgentRuntimeStatus.ts | 49 +++++++ .../useManagedAgentRuntimeReconciliation.ts | 23 ++++ .../features/channels/ui/MembersSidebar.tsx | 16 +++ .../channels/ui/MembersSidebarMemberCard.tsx | 36 ++--- .../ui/ActiveAgentCommunitiesSettingsCard.tsx | 129 ++++++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 2 + desktop/src/shared/api/tauriManagedAgents.ts | 40 +++++- desktop/src/shared/api/types.ts | 23 ++++ 12 files changed, 433 insertions(+), 21 deletions(-) create mode 100644 desktop/src/features/agents/managedAgentRuntimeHooks.ts create mode 100644 desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs create mode 100644 desktop/src/features/agents/managedAgentRuntimeStatus.ts create mode 100644 desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts create mode 100644 desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 369241bac2..c4ab1d126a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; - import { deriveShellRoute } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; @@ -37,6 +36,7 @@ import { import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; +import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation"; import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion"; @@ -89,7 +89,6 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; - const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -115,6 +114,7 @@ export function AppShell() { const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); + useManagedAgentRuntimeReconciliation(communitiesHook.communities); const { goAgents, goChannel, diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 83aa995477..9f655a77fa 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -8,6 +8,7 @@ import { relayAgentsQueryKey, teamsQueryKey, } from "@/features/agents/hooks"; +import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; // Trailing-coalesce window: a backfill burst (up to 500 inbound events fed // one-by-one through reconcile) fires one `agents-data-changed` per event. @@ -27,6 +28,12 @@ export function useAgentsDataRefresh(): void { useEffect(() => { let timer: ReturnType | undefined; + const unlistenRuntime = listen("managed-agent-runtime-status", () => { + void queryClient.invalidateQueries({ + queryKey: managedAgentRuntimesQueryKey, + }); + }); + const unlisten = listen("agents-data-changed", () => { if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { @@ -40,6 +47,7 @@ export function useAgentsDataRefresh(): void { return () => { if (timer !== undefined) clearTimeout(timer); void unlisten.then((fn) => fn()); + void unlistenRuntime.then((fn) => fn()); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts new file mode 100644 index 0000000000..2b0b789433 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + listManagedAgentRuntimes, + restartManagedAgentRuntime, + startManagedAgentRuntime, + stopManagedAgentRuntime, +} from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; + +export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const; + +export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: managedAgentRuntimesQueryKey, + queryFn: listManagedAgentRuntimes, + }); +} + +export function useManagedAgentRuntimeAction() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + action, + pubkey, + relayUrl, + }: { + action: "start" | "stop" | "restart"; + pubkey: string; + relayUrl: string; + }) => { + if (action === "stop") return stopManagedAgentRuntime(pubkey, relayUrl); + if (action === "restart") { + return restartManagedAgentRuntime(pubkey, relayUrl); + } + return startManagedAgentRuntime(pubkey, relayUrl); + }, + onSuccess: (runtime) => { + queryClient.setQueryData( + managedAgentRuntimesQueryKey, + (current = []) => { + const index = current.findIndex( + (candidate) => candidate.runtimeId === runtime.runtimeId, + ); + if (index === -1) return [...current, runtime]; + return current.map((candidate, candidateIndex) => + candidateIndex === index ? runtime : candidate, + ); + }, + ); + }, + }); +} diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs new file mode 100644 index 0000000000..4652e88ff5 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentCommunityAvailability, + agentCommunityStatusDetail, + findManagedAgentRuntime, +} from "./managedAgentRuntimeStatus.ts"; + +const runtime = (overrides = {}) => ({ + runtimeId: "runtime-a", + pubkey: "aa", + relayUrl: "wss://relay.example", + localSetup: true, + lifecycle: "ready", + pid: 1, + error: null, + logPath: null, + ...overrides, +}); + +test("projects every backend lifecycle to the four product labels", () => { + assert.equal(agentCommunityAvailability(runtime()), "Here"); + for (const lifecycle of ["starting", "listening", "waking"]) { + assert.equal(agentCommunityAvailability(runtime({ lifecycle })), "Waking"); + } + for (const lifecycle of ["failed", "stopped"]) { + assert.equal( + agentCommunityAvailability(runtime({ lifecycle })), + "Unavailable", + ); + } +}); + +test("backend-authoritative local setup takes precedence", () => { + assert.equal( + agentCommunityAvailability( + runtime({ localSetup: false, lifecycle: "ready" }), + ), + "Needs setup on this device", + ); +}); + +test("unavailable detail distinguishes stopped and failed", () => { + assert.equal( + agentCommunityStatusDetail(runtime({ lifecycle: "stopped" })), + "Stopped by you", + ); + assert.equal( + agentCommunityStatusDetail( + runtime({ lifecycle: "failed", error: "Relay timed out" }), + ), + "Relay timed out", + ); +}); + +test("selects one relay without collapsing same-pubkey pairs", () => { + const runtimes = [ + runtime({ relayUrl: "wss://a.example", lifecycle: "ready" }), + runtime({ relayUrl: "wss://b.example", lifecycle: "failed" }), + ]; + assert.equal( + findManagedAgentRuntime(runtimes, "AA", "wss://b.example")?.lifecycle, + "failed", + ); + assert.equal( + findManagedAgentRuntime(runtimes, "aa", "wss://c.example"), + undefined, + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts new file mode 100644 index 0000000000..a428264c73 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -0,0 +1,49 @@ +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; + +export type AgentCommunityAvailability = + | "Here" + | "Waking" + | "Needs setup on this device" + | "Unavailable"; + +export function agentCommunityAvailability( + runtime: ManagedAgentRuntimeStatus, +): AgentCommunityAvailability { + if (!runtime.localSetup) return "Needs setup on this device"; + + switch (runtime.lifecycle) { + case "starting": + case "listening": + case "waking": + return "Waking"; + case "ready": + return "Here"; + case "failed": + case "stopped": + return "Unavailable"; + } +} + +export function agentCommunityStatusDetail( + runtime: ManagedAgentRuntimeStatus, +): string | null { + if (!runtime.localSetup) + return "Set up this agent on this device to start it."; + if (runtime.lifecycle === "stopped") return "Stopped by you"; + if (runtime.lifecycle === "failed") + return runtime.error ?? "Could not connect"; + return null; +} + +export function findManagedAgentRuntime( + runtimes: readonly ManagedAgentRuntimeStatus[], + pubkey: string, + relayUrl: string, +): ManagedAgentRuntimeStatus | undefined { + const normalizedPubkey = pubkey.toLowerCase(); + return runtimes.find( + (runtime) => + runtime.pubkey.toLowerCase() === normalizedPubkey && + (runtime.relayUrl === relayUrl || runtime.requestedRelayUrl === relayUrl), + ); +} diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts new file mode 100644 index 0000000000..c1abecbde7 --- /dev/null +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -0,0 +1,23 @@ +import { useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; +import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents"; + +export function useManagedAgentRuntimeReconciliation( + communities: readonly { relayUrl: string }[], +): void { + const queryClient = useQueryClient(); + const reconciled = React.useRef(false); + + React.useEffect(() => { + if (reconciled.current) return; + reconciled.current = true; + + void reconcileManagedAgentRuntimes(communities) + .then((runtimes) => { + queryClient.setQueryData(managedAgentRuntimesQueryKey, runtimes); + }) + .catch(() => undefined); + }, [communities, queryClient]); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 4fa3a575aa..251edd619a 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -48,6 +48,8 @@ import { MODAL_SEARCH_SHELL_CLASS, } from "@/shared/ui/modalSearchStyles"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; +import { useManagedAgentRuntimesQuery } from "@/features/agents/managedAgentRuntimeHooks"; +import { findManagedAgentRuntime } from "@/features/agents/managedAgentRuntimeStatus"; import { EditRespondToDialog } from "./EditRespondToDialog"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; @@ -122,6 +124,7 @@ type MembersSidebarProps = { open: boolean; onOpenChange: (open: boolean) => void; onViewActivity?: (pubkey: string) => void; + relayUrl?: string; }; export function MembersSidebar({ @@ -130,8 +133,12 @@ export function MembersSidebar({ open, onOpenChange, onViewActivity, + relayUrl, }: MembersSidebarProps) { const channelId = channel?.id ?? null; + const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({ + enabled: open, + }); const queryClient = useQueryClient(); const searchInputRef = React.useRef(null); const [searchQuery, setSearchQuery] = React.useState(""); @@ -570,6 +577,15 @@ export function MembersSidebar({ ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) : undefined } + managedAgentRuntime={ + memberIsBot && relayUrl + ? findManagedAgentRuntime( + managedAgentRuntimesQuery.data ?? [], + member.pubkey, + relayUrl, + ) + : undefined + } member={member} memberIsBot={memberIsBot} memberAvatarLabel={ diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 8234517432..b594a4ee2b 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -20,13 +20,16 @@ import { } from "@/features/agents/lib/managedAgentControlActions"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { agentCommunityAvailability } from "@/features/agents/managedAgentRuntimeStatus"; import { truncatePubkey } from "@/shared/lib/pubkey"; import type { ChannelMember, ManagedAgent, + ManagedAgentRuntimeStatus, PresenceStatus, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -45,6 +48,7 @@ type MembersSidebarMemberCardProps = { isActionPending: boolean; isArchived: boolean; managedAgent?: ManagedAgent; + managedAgentRuntime?: ManagedAgentRuntimeStatus; member: ChannelMember; memberAvatarLabel: string; memberIsBot: boolean; @@ -104,19 +108,6 @@ function formatRespondToLabel(agent: ManagedAgent) { } } -function formatManagedAgentStatus(agent: ManagedAgent) { - switch (agent.status) { - case "running": - return "Running"; - case "stopped": - return "Stopped"; - case "deployed": - return "Deployed"; - case "not_deployed": - return "Not deployed"; - } -} - export function MembersSidebarMemberCard({ canChangeRole, canModerate, @@ -124,6 +115,7 @@ export function MembersSidebarMemberCard({ isActionPending, isArchived, managedAgent, + managedAgentRuntime, member, memberAvatarLabel, memberIsBot, @@ -205,13 +197,21 @@ export function MembersSidebarMemberCard({ ) : null} )} - {managedAgent ? ( - - {formatManagedAgentStatus(managedAgent)} - + {managedAgentRuntime + ? agentCommunityAvailability(managedAgentRuntime) + : "Unavailable"} + ) : null} {managedAgent ? ( ( + null, + ); + + const agentNames = React.useMemo( + () => + new Map( + (agentsQuery.data ?? []).map((agent) => [ + agent.pubkey.toLowerCase(), + agent.name, + ]), + ), + [agentsQuery.data], + ); + const runtimes = runtimesQuery.data ?? []; + + async function runAction(runtime: ManagedAgentRuntimeStatus) { + setPendingRuntimeId(runtime.runtimeId); + try { + await action.mutateAsync({ + action: + runtime.lifecycle === "starting" || + runtime.lifecycle === "listening" || + runtime.lifecycle === "waking" || + runtime.lifecycle === "ready" + ? "stop" + : runtime.lifecycle === "stopped" + ? "start" + : "restart", + pubkey: runtime.pubkey, + relayUrl: runtime.relayUrl, + }); + } finally { + setPendingRuntimeId(null); + } + } + + return ( +

+ +
+ {runtimesQuery.isPending ? ( +

Loading…

+ ) : runtimes.length === 0 ? ( +

+ No agent community runtimes found. +

+ ) : ( + runtimes.map((runtime) => { + const status = agentCommunityAvailability(runtime); + const detail = agentCommunityStatusDetail(runtime); + const pending = pendingRuntimeId === runtime.runtimeId; + return ( +
+
+
+

+ {agentNames.get(runtime.pubkey.toLowerCase()) ?? + truncatePubkey(runtime.pubkey)} +

+ + {status} + +
+

+ {runtime.relayUrl} +

+ {detail ? ( +

{detail}

+ ) : null} +
+ {runtime.localSetup ? ( + + ) : null} +
+ ); + }) + )} +
+ {action.error instanceof Error ? ( +

{action.error.message}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 96b5da1d34..a203269239 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -77,6 +77,7 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; +import { ActiveAgentCommunitiesSettingsCard } from "./ActiveAgentCommunitiesSettingsCard"; import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; @@ -814,6 +815,7 @@ export function renderSettingsSection(
+
); diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index f9e7625788..55713c1eac 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -3,7 +3,10 @@ import { invokeTauri, type RawManagedAgent, } from "@/shared/api/tauri"; -import type { ManagedAgent } from "@/shared/api/types"; +import type { + ManagedAgent, + ManagedAgentRuntimeStatus, +} from "@/shared/api/types"; export async function startManagedAgent(pubkey: string): Promise { const response = await invokeTauri("start_managed_agent", { @@ -46,3 +49,38 @@ export async function setManagedAgentAutoRestart( ); return fromRawManagedAgent(response); } + +export async function listManagedAgentRuntimes(): Promise< + ManagedAgentRuntimeStatus[] +> { + return invokeTauri( + "list_managed_agent_runtimes", + ); +} + +export async function startManagedAgentRuntime( + pubkey: string, + relayUrl: string, +): Promise { + return invokeTauri("start_managed_agent_runtime", { pubkey, relayUrl }); +} + +export async function stopManagedAgentRuntime( + pubkey: string, + relayUrl: string, +): Promise { + return invokeTauri("stop_managed_agent_runtime", { pubkey, relayUrl }); +} + +export async function restartManagedAgentRuntime( + pubkey: string, + relayUrl: string, +): Promise { + return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl }); +} + +export async function reconcileManagedAgentRuntimes( + communities: readonly { relayUrl: string }[], +): Promise { + return invokeTauri("reconcile_managed_agent_runtimes", { communities }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 17d31f95ac..4b2d96f2be 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -314,6 +314,29 @@ export type RelayAgent = { respondToAllowlist: string[]; }; +export type ManagedAgentRuntimeLifecycle = + | "starting" + | "listening" + | "waking" + | "ready" + | "failed" + | "stopped"; + +export type ManagedAgentRuntimeStatus = { + /** Opaque backend-owned identity for this canonical agent + relay pair. */ + runtimeId: string; + pubkey: string; + /** Original community descriptor, present on startup reconciliation results. */ + requestedRelayUrl?: string; + /** Canonical, backend-owned pair identity component. Do not normalize in TS. */ + relayUrl: string; + localSetup: boolean; + lifecycle: ManagedAgentRuntimeLifecycle; + pid: number | null; + error: string | null; + logPath: string | null; +}; + export type ManagedAgentBackend = | { type: "local" } | { type: "provider"; id: string; config: Record }; From 093e0d66fbc1a1d56342fe8b56a0a0c5cc73f4ae Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:53:35 -0400 Subject: [PATCH 10/28] fix(desktop): align managed runtime identity contract Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../agents/managedAgentRuntimeHooks.ts | 4 +++- .../agents/managedAgentRuntimeStatus.test.mjs | 9 ++++++++- .../agents/managedAgentRuntimeStatus.ts | 6 ++++++ .../useManagedAgentRuntimeReconciliation.ts | 7 ++++++- .../ui/ActiveAgentCommunitiesSettingsCard.tsx | 18 ++++++++++-------- desktop/src/shared/api/types.ts | 4 +--- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 2b0b789433..673247aeff 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -41,7 +41,9 @@ export function useManagedAgentRuntimeAction() { managedAgentRuntimesQueryKey, (current = []) => { const index = current.findIndex( - (candidate) => candidate.runtimeId === runtime.runtimeId, + (candidate) => + candidate.pubkey === runtime.pubkey && + candidate.relayUrl === runtime.relayUrl, ); if (index === -1) return [...current, runtime]; return current.map((candidate, candidateIndex) => diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs index 4652e88ff5..57c0863f4c 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -5,10 +5,10 @@ import { agentCommunityAvailability, agentCommunityStatusDetail, findManagedAgentRuntime, + managedAgentRuntimeKey, } from "./managedAgentRuntimeStatus.ts"; const runtime = (overrides = {}) => ({ - runtimeId: "runtime-a", pubkey: "aa", relayUrl: "wss://relay.example", localSetup: true, @@ -54,6 +54,13 @@ test("unavailable detail distinguishes stopped and failed", () => { ); }); +test("pair key cannot collide at component boundaries", () => { + assert.notEqual( + managedAgentRuntimeKey(runtime({ pubkey: "ab", relayUrl: "c" })), + managedAgentRuntimeKey(runtime({ pubkey: "a", relayUrl: "bc" })), + ); +}); + test("selects one relay without collapsing same-pubkey pairs", () => { const runtimes = [ runtime({ relayUrl: "wss://a.example", lifecycle: "ready" }), diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index a428264c73..ea5094c7ef 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -35,6 +35,12 @@ export function agentCommunityStatusDetail( return null; } +export function managedAgentRuntimeKey( + runtime: Pick, +): string { + return JSON.stringify([runtime.pubkey, runtime.relayUrl]); +} + export function findManagedAgentRuntime( runtimes: readonly ManagedAgentRuntimeStatus[], pubkey: string, diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts index c1abecbde7..8456e881ef 100644 --- a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -18,6 +18,11 @@ export function useManagedAgentRuntimeReconciliation( .then((runtimes) => { queryClient.setQueryData(managedAgentRuntimesQueryKey, runtimes); }) - .catch(() => undefined); + .catch((error) => { + console.warn( + "[managed-agent-runtimes] startup reconcile failed:", + error, + ); + }); }, [communities, queryClient]); } diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx index 81f608df25..0db7985754 100644 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx @@ -8,6 +8,7 @@ import { import { agentCommunityAvailability, agentCommunityStatusDetail, + managedAgentRuntimeKey, } from "@/features/agents/managedAgentRuntimeStatus"; import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -19,9 +20,9 @@ export function ActiveAgentCommunitiesSettingsCard() { const agentsQuery = useManagedAgentsQuery(); const runtimesQuery = useManagedAgentRuntimesQuery(); const action = useManagedAgentRuntimeAction(); - const [pendingRuntimeId, setPendingRuntimeId] = React.useState( - null, - ); + const [pendingRuntimeKey, setPendingRuntimeKey] = React.useState< + string | null + >(null); const agentNames = React.useMemo( () => @@ -36,7 +37,7 @@ export function ActiveAgentCommunitiesSettingsCard() { const runtimes = runtimesQuery.data ?? []; async function runAction(runtime: ManagedAgentRuntimeStatus) { - setPendingRuntimeId(runtime.runtimeId); + setPendingRuntimeKey(managedAgentRuntimeKey(runtime)); try { await action.mutateAsync({ action: @@ -52,7 +53,7 @@ export function ActiveAgentCommunitiesSettingsCard() { relayUrl: runtime.relayUrl, }); } finally { - setPendingRuntimeId(null); + setPendingRuntimeKey(null); } } @@ -73,12 +74,13 @@ export function ActiveAgentCommunitiesSettingsCard() { runtimes.map((runtime) => { const status = agentCommunityAvailability(runtime); const detail = agentCommunityStatusDetail(runtime); - const pending = pendingRuntimeId === runtime.runtimeId; + const runtimeKey = managedAgentRuntimeKey(runtime); + const pending = pendingRuntimeKey === runtimeKey; return (
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 4b2d96f2be..d28f2d0cf1 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -323,10 +323,8 @@ export type ManagedAgentRuntimeLifecycle = | "stopped"; export type ManagedAgentRuntimeStatus = { - /** Opaque backend-owned identity for this canonical agent + relay pair. */ - runtimeId: string; pubkey: string; - /** Original community descriptor, present on startup reconciliation results. */ + /** Exact submitted descriptor, present only on startup reconcile results. */ requestedRelayUrl?: string; /** Canonical, backend-owned pair identity component. Do not normalize in TS. */ relayUrl: string; From d95b6c412f67a04e774b4b63d12aefa2c479efe8 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 10:57:01 -0400 Subject: [PATCH 11/28] test(desktop): expose managed runtime pair selectors Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src/app/AppShell.tsx | 2 +- .../features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c4ab1d126a..f10f7febaf 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -114,7 +114,7 @@ export function AppShell() { const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); - useManagedAgentRuntimeReconciliation(communitiesHook.communities); + useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot const { goAgents, goChannel, diff --git a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx index 0db7985754..268bd863f2 100644 --- a/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ActiveAgentCommunitiesSettingsCard.tsx @@ -79,6 +79,8 @@ export function ActiveAgentCommunitiesSettingsCard() { return (
From e757959a816d2cdb2f484506f6b6a487ea62a0ec Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 11:05:34 -0400 Subject: [PATCH 12/28] feat(desktop): route managed runtime lifecycle frames Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src/features/agents/observerRelayStore.ts | 3 +++ desktop/src/shared/api/tauriManagedAgents.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 919cbbc20c..035f19131e 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -4,6 +4,7 @@ import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay"; import type { RelayEvent, ManagedAgent } from "@/shared/api/types"; import type { ControlResultFrame } from "@/shared/api/types"; import { putAgentSessionConfig } from "@/shared/api/tauri"; +import { putManagedAgentRuntimeLifecycle } from "@/shared/api/tauriManagedAgents"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; import { @@ -402,6 +403,8 @@ async function handleRelayObserverEvent( onSessionConfigCaptured?.(agentPubkey); } else if (parsed.kind === "control_result") { dispatchControlResult(agentPubkey, parsed.payload); + } else if (parsed.kind === "managed_agent_runtime_lifecycle") { + void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload); } } catch (error) { if (activeGeneration !== generation) { diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 55713c1eac..c74b099f88 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -79,6 +79,16 @@ export async function restartManagedAgentRuntime( return invokeTauri("restart_managed_agent_runtime", { pubkey, relayUrl }); } +export async function putManagedAgentRuntimeLifecycle( + outerPubkey: string, + payload: unknown, +): Promise { + return invokeTauri("put_managed_agent_runtime_lifecycle", { + outerPubkey, + payload, + }); +} + export async function reconcileManagedAgentRuntimes( communities: readonly { relayUrl: string }[], ): Promise { From 0d38c619771d3a3012944aa7ee9344ea777a68a8 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 19 Jul 2026 11:08:01 -0400 Subject: [PATCH 13/28] fix(desktop): handle dropped lifecycle frames Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src/features/agents/observerRelayStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 035f19131e..7c2f0d9ced 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -404,7 +404,11 @@ async function handleRelayObserverEvent( } else if (parsed.kind === "control_result") { dispatchControlResult(agentPubkey, parsed.payload); } else if (parsed.kind === "managed_agent_runtime_lifecycle") { - void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload); + void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch( + (error) => { + console.debug("Late/untracked lifecycle frame dropped:", error); + }, + ); } } catch (error) { if (activeGeneration !== generation) { From 6cd7b5b89dc068e71f11dea44f755a5362110396 Mon Sep 17 00:00:00 2001 From: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 10:43:37 -0400 Subject: [PATCH 14/28] test: add agents-everywhere live harness fixtures Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/tests/e2e/fixtures/fake-acp-agent.mjs | 84 +++++++++ desktop/tests/e2e/helpers/twoRelayHarness.ts | 159 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100755 desktop/tests/e2e/fixtures/fake-acp-agent.mjs create mode 100644 desktop/tests/e2e/helpers/twoRelayHarness.ts diff --git a/desktop/tests/e2e/fixtures/fake-acp-agent.mjs b/desktop/tests/e2e/fixtures/fake-acp-agent.mjs new file mode 100755 index 0000000000..c06999b190 --- /dev/null +++ b/desktop/tests/e2e/fixtures/fake-acp-agent.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** Deterministic ACP fixture for agents-everywhere live tests. */ +import { createInterface } from "node:readline"; + +const wakeDelayMs = Number.parseInt( + process.env.BUZZ_E2E_FAKE_ACP_WAKE_MS ?? "0", + 10, +); +if (!Number.isFinite(wakeDelayMs) || wakeDelayMs < 0) { + throw new Error("BUZZ_E2E_FAKE_ACP_WAKE_MS must be a non-negative integer"); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +const textFromPrompt = (params) => + (params?.prompt ?? []) + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n"); + +let sessionCounter = 0; +const input = createInterface({ + input: process.stdin, + crlfDelay: Number.POSITIVE_INFINITY, +}); +for await (const line of input) { + if (!line.trim()) continue; + const request = JSON.parse(line); + if (request.id === undefined || typeof request.method !== "string") continue; + + switch (request.method) { + case "initialize": + if (wakeDelayMs > 0) await sleep(wakeDelayMs); + write({ + jsonrpc: "2.0", + id: request.id, + result: { protocolVersion: 2, agentCapabilities: {} }, + }); + break; + case "session/new": + sessionCounter += 1; + write({ + jsonrpc: "2.0", + id: request.id, + result: { sessionId: `fake-session-${sessionCounter}` }, + }); + break; + case "session/prompt": { + const prompt = textFromPrompt(request.params); + const ids = [...prompt.matchAll(/\bAE-ID:([A-Za-z0-9._:-]+)\b/g)].map( + (match) => match[1], + ); + write({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: request.params?.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `AE-ACK:${ids.join(",")}` }, + }, + }, + }); + write({ + jsonrpc: "2.0", + id: request.id, + result: { stopReason: "end_turn" }, + }); + break; + } + case "session/cancel": + write({ jsonrpc: "2.0", id: request.id, result: {} }); + break; + default: + write({ + jsonrpc: "2.0", + id: request.id, + error: { + code: -32601, + message: `Unsupported fixture method: ${request.method}`, + }, + }); + } +} diff --git a/desktop/tests/e2e/helpers/twoRelayHarness.ts b/desktop/tests/e2e/helpers/twoRelayHarness.ts new file mode 100644 index 0000000000..335d202476 --- /dev/null +++ b/desktop/tests/e2e/helpers/twoRelayHarness.ts @@ -0,0 +1,159 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +export type RelayPorts = { main: number; health: number; metrics: number }; +export type RelaySpec = { + name: string; + ports: RelayPorts; + databaseUrl: string; + redisUrl: string; +}; + +type OwnedProcess = { name: string; child: ChildProcess; logPath: string }; + +export class TwoRelayHarness { + readonly root: string; + readonly relays: readonly [RelaySpec, RelaySpec]; + private readonly processes: OwnedProcess[] = []; + + private constructor(root: string, relays: readonly [RelaySpec, RelaySpec]) { + this.root = root; + this.relays = relays; + } + + static async create(relays: readonly [RelaySpec, RelaySpec]) { + return new TwoRelayHarness( + await mkdtemp(join(tmpdir(), "buzz-ae-e2e-")), + relays, + ); + } + + async startRelays(binary = process.env.BUZZ_E2E_RELAY_BIN) { + if (!binary) + throw new Error("BUZZ_E2E_RELAY_BIN is required for the live gate"); + await Promise.all( + this.relays.map((relay) => this.startRelay(binary, relay)), + ); + } + + async startAcp( + name: string, + relayWsUrl: string, + privateKey: string, + extraEnv: NodeJS.ProcessEnv = {}, + ) { + const binary = process.env.BUZZ_E2E_ACP_BIN; + if (!binary) + throw new Error("BUZZ_E2E_ACP_BIN is required for the live gate"); + return this.spawnOwned(name, binary, [], { + BUZZ_RELAY_URL: relayWsUrl, + BUZZ_PRIVATE_KEY: privateKey, + BUZZ_ACP_LAZY_POOL: "1", + BUZZ_ACP_AGENT_COMMAND: process.execPath, + BUZZ_ACP_AGENT_ARGS: resolve("tests/e2e/fixtures/fake-acp-agent.mjs"), + ...extraEnv, + }); + } + + async logs(): Promise { + const chunks = await Promise.all( + this.processes.map(async ({ name, logPath }) => { + const body = await readFile(logPath, "utf8").catch(() => ""); + return `===== ${name} =====\n${body}`; + }), + ); + return chunks.join("\n"); + } + + private signal(child: ChildProcess, signal: NodeJS.Signals) { + if (child.exitCode !== null || child.signalCode !== null || !child.pid) + return; + if (process.platform === "win32") { + child.kill(signal); + return; + } + try { + process.kill(-child.pid, signal); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH") throw error; + } + } + + private async stopChild(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolveExit) => + child.once("exit", () => resolveExit()), + ); + this.signal(child, "SIGTERM"); + if (await Promise.race([exited.then(() => true), delay(5_000, false)])) + return; + this.signal(child, "SIGKILL"); + if (!(await Promise.race([exited.then(() => true), delay(2_000, false)]))) { + throw new Error(`child process ${child.pid ?? "unknown"} did not exit`); + } + } + + async stop() { + await Promise.all( + [...this.processes].reverse().map(({ child }) => this.stopChild(child)), + ); + await rm(this.root, { recursive: true, force: true }); + } + + private async startRelay(binary: string, relay: RelaySpec) { + const child = this.spawnOwned(relay.name, binary, [], { + DATABASE_URL: relay.databaseUrl, + REDIS_URL: relay.redisUrl, + RELAY_URL: `ws://127.0.0.1:${relay.ports.main}`, + BUZZ_BIND_ADDR: `127.0.0.1:${relay.ports.main}`, + BUZZ_HEALTH_PORT: String(relay.ports.health), + BUZZ_METRICS_PORT: String(relay.ports.metrics), + BUZZ_REQUIRE_AUTH_TOKEN: "false", + BUZZ_RECONCILE_CHANNELS: "true", + }); + await this.waitForHealth(relay, child); + } + + private spawnOwned( + name: string, + command: string, + args: string[], + env: NodeJS.ProcessEnv, + ) { + const logPath = join(this.root, `${name}.log`); + const child = spawn(command, args, { + cwd: resolve(".."), + env: { ...process.env, ...env, RUST_LOG: process.env.RUST_LOG ?? "info" }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + const log = createWriteStream(logPath, { flags: "a" }); + child.stdout?.pipe(log, { end: false }); + child.stderr?.pipe(log, { end: false }); + child.on("exit", () => log.end()); + this.processes.push({ name, child, logPath }); + return child; + } + + private async waitForHealth(relay: RelaySpec, child: ChildProcess) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`${relay.name} exited before readiness`); + } + try { + const response = await fetch( + `http://127.0.0.1:${relay.ports.health}/_readiness`, + ); + if (response.ok) return; + } catch {} + await delay(100); + } + throw new Error(`${relay.name} was not ready within 30s`); + } +} From 22dbdc5b5d3d2e20b35b34f831c7dbfd2df5a83f Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 12:48:46 -0400 Subject: [PATCH 15/28] Fix restore eager-spawn + startup race (F1/F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore_managed_agents_on_launch spawned every auto-start pair with lazy=false, booting an eager LLM child on every ordinary launch and silently reintroducing N idle brains — the exact idle cost the pair runtime is meant to avoid. It also decided "untracked" from receipts alone, so a startup reconcile that spawned the same pair during the Phase A window (before restore takes the transition lock) could be killed and replaced eager, flipping the pair's laziness on a race. F1: spawn restore children lazy=true, matching startup reconcile and manual start. Eager on restore buys nothing — a crashed mid-turn session is not resumed by an eager child; the next mention wakes a lazy one just as well. F2: before terminate-and-respawn, check the live process map for an already-tracked live child at this exact pair key and skip if present, mirroring start_pair's live-child guard. Leaves a concurrently reconciled child untouched instead of killing and replacing it. Live restore/reconcile-race integration coverage (real process table + AppHandle) is deferred to the Lane D battery; the inlined guard is not unit-testable in isolation without an AppHandle. Co-authored-by: Dawn Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../src-tauri/src/managed_agents/restore.rs | 93 +++++++++++++++---- 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index a5ce64d5b4..fab481e12b 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -10,8 +10,21 @@ use crate::util; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; -type SpawnResult = Result<(super::ManagedAgentRuntimeKey, ManagedAgentProcess), String>; -type AgentSpawnResult = (String, SpawnResult); +/// Outcome of a Phase B spawn attempt for one restore candidate. +/// +/// `Skipped` covers the case where a concurrently-running startup reconcile +/// already spawned and tracked this exact pair during the Phase A window (the +/// transition lock is only held from Phase B onward). Restore must then leave +/// that live child alone rather than terminate-and-respawn it — mirroring the +/// live-child guard in `start_pair` (`runtime_commands.rs`). Without this, +/// restore would kill reconcile's lazy child by its receipt and replace it with +/// an eager one, flipping the pair's laziness on a startup race. +enum SpawnOutcome { + Spawned(super::ManagedAgentRuntimeKey, ManagedAgentProcess), + Skipped, + Failed(String), +} +type AgentSpawnResult = (String, SpawnOutcome); /// Backfill the pinned persona snapshot for pre-existing agents created before /// the record became the spawn source of truth. Runs once at launch, before @@ -270,7 +283,6 @@ pub async fn restore_managed_agents_on_launch( .iter() .filter(|_| !shutdown_started.load(Ordering::SeqCst)) .map(|record| { - let pubkey = record.pubkey.clone(); let handle = scope.spawn(move || { let workspace_relay = crate::relay::relay_ws_url_with_override(&app.state::()); @@ -278,14 +290,52 @@ pub async fn restore_managed_agents_on_launch( &record.relay_url, &workspace_relay, ); - let result = - super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) - .and_then(|key| { - super::terminate_untracked_pair_runtime(app, &key)?; - spawn_agent_child(app, record, &key.relay_url, false, owner_hex_ref) - .map(|process| (key, process)) - }); - (pubkey, result) + let outcome = + match super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) + { + Ok(key) => { + // F2: if a concurrent startup reconcile already + // tracked a live child for this exact pair during + // the Phase A window, leave it alone. Mirrors the + // live-child guard in `start_pair`. + let already_live = app + .state::() + .managed_agent_processes + .lock() + .ok() + .and_then(|mut runtimes| { + runtimes.get_mut(&key).map(|runtime| { + runtime.child.try_wait().ok().flatten().is_none() + }) + }) + .unwrap_or(false); + if already_live { + SpawnOutcome::Skipped + } else { + match super::terminate_untracked_pair_runtime(app, &key) + .and_then(|()| { + // F1: restore spawns lazy, matching + // reconcile and manual start. Eager on + // restore buys nothing — a crashed + // mid-turn session is not resumed by an + // eager child — and silently reintroduces + // N idle brains on every launch. + spawn_agent_child( + app, + record, + &key.relay_url, + true, + owner_hex_ref, + ) + }) { + Ok(process) => SpawnOutcome::Spawned(key, process), + Err(error) => SpawnOutcome::Failed(error), + } + } + } + Err(error) => SpawnOutcome::Failed(error), + }; + (record.pubkey.clone(), outcome) }); handle }) @@ -311,13 +361,15 @@ pub async fn restore_managed_agents_on_launch( let mut successfully_spawned: Vec = Vec::new(); - for (pubkey, result) in spawn_results { - let record = match find_managed_agent_mut(&mut records, &pubkey) { - Ok(r) => r, - Err(_) => continue, - }; - match result { - Ok((key, mut process)) => { + for (pubkey, outcome) in spawn_results { + match outcome { + // Skipped means a concurrent reconcile already owns a live child for + // this pair; leave its runtime and record state untouched. + SpawnOutcome::Skipped => continue, + SpawnOutcome::Spawned(key, mut process) => { + let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { + continue; + }; let now = util::now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { key: key.clone(), @@ -341,7 +393,10 @@ pub async fn restore_managed_agents_on_launch( runtimes.insert(key, super::ManagedAgentPairRuntime::starting(process)); successfully_spawned.push(pubkey); } - Err(error) => { + SpawnOutcome::Failed(error) => { + let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { + continue; + }; record.updated_at = util::now_iso(); record.last_error = Some(error); } From 14b2271b1af20ec7b2deb587f7288b98fa16834d Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 13:12:46 -0400 Subject: [PATCH 16/28] fix(desktop): keep pair runtime lifecycle authoritative Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- crates/buzz-acp/src/lib.rs | 8 ++ .../src/managed_agents/process_lifecycle.rs | 2 + .../src-tauri/src/managed_agents/runtime.rs | 14 ++-- .../src/managed_agents/runtime_commands.rs | 78 ++++++++++++++++--- .../src/managed_agents/runtime_types.rs | 19 +++-- .../src-tauri/src/managed_agents/storage.rs | 7 -- desktop/src-tauri/src/managed_agents/types.rs | 2 + 7 files changed, 96 insertions(+), 34 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index fb05885d60..3c4d7ab501 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -92,6 +92,7 @@ async fn publish_presence( fn emit_runtime_lifecycle( observer: Option<&observer::ObserverHandle>, + start_nonce: &str, pubkey: &str, relay_url: &str, lifecycle: &str, @@ -105,6 +106,7 @@ fn emit_runtime_lifecycle( serde_json::json!({ "pubkey": pubkey, "relayUrl": relay_url, + "startNonce": start_nonce, "lifecycle": lifecycle, "error": error, }), @@ -1450,6 +1452,7 @@ async fn tokio_main() -> Result<()> { )); } + let runtime_start_nonce = std::env::var("BUZZ_MANAGED_AGENT_START_NONCE").unwrap_or_default(); let dedup_mode = config.dedup_mode; let mut queue = EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs); @@ -1467,6 +1470,7 @@ async fn tokio_main() -> Result<()> { if config.lazy_pool { emit_runtime_lifecycle( observer.as_ref(), + &runtime_start_nonce, &pubkey_hex, &config.relay_url, "listening", @@ -1658,6 +1662,7 @@ async fn tokio_main() -> Result<()> { { emit_runtime_lifecycle( observer.as_ref(), + &runtime_start_nonce, &pubkey_hex, &config.relay_url, "waking", @@ -1802,6 +1807,7 @@ async fn tokio_main() -> Result<()> { ) { emit_runtime_lifecycle( observer.as_ref(), + &runtime_start_nonce, &pubkey_hex, &config.relay_url, "failed", @@ -2474,6 +2480,7 @@ async fn tokio_main() -> Result<()> { pool_ready = true; emit_runtime_lifecycle( observer.as_ref(), + &runtime_start_nonce, &pubkey_hex, &config.relay_url, "ready", @@ -2489,6 +2496,7 @@ async fn tokio_main() -> Result<()> { debug_assert_eq!(pool_lifecycle.failed_error(), Some(error.as_str())); emit_runtime_lifecycle( observer.as_ref(), + &runtime_start_nonce, &pubkey_hex, &config.relay_url, "failed", diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 1ef3eae010..8dddf9f715 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -136,6 +136,7 @@ pub fn finish_spawn( spawn_config_hash: u64, setup_mode: bool, adapter_availability: Option, + start_nonce: String, agent_name: &str, ) -> super::ManagedAgentProcess { let job = create_job_for_child(child.id()); @@ -151,6 +152,7 @@ pub fn finish_spawn( spawn_config_hash, setup_mode, adapter_availability, + start_nonce, job, } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 35e87b97b1..cc1c13a205 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1955,13 +1955,11 @@ pub fn spawn_agent_child( } } - // Mark as Buzz-managed *and* which desktop instance owns us, so the - // system-wide orphan sweep only reaps this instance's own agents and never - // another live Buzz's (e.g. a `just dev` build won't kill a DMG build's - // agents). Propagates automatically through the full tree (buzz-acp → - // goose → MCP servers) because neither buzz-acp nor goose calls - // env_clear(). - command.env("BUZZ_MANAGED_AGENT", current_instance_id(app)); + // Stamp desktop ownership and an unpredictable harness-generation identity. + let start_nonce = uuid::Uuid::new_v4().simple().to_string(); + command + .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) + .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); // Spawn the harness in its own process group so we can kill the entire // tree (harness + MCP servers + agent subprocesses) on shutdown. @@ -2025,6 +2023,7 @@ pub fn spawn_agent_child( spawn_config_hash, spawned_setup_mode, spawned_adapter_availability, + start_nonce, &record.name, )); #[cfg(not(windows))] @@ -2034,6 +2033,7 @@ pub fn spawn_agent_child( spawn_config_hash, setup_mode: spawned_setup_mode, adapter_availability: spawned_adapter_availability, + start_nonce, }) } diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 251f9ccbae..7e870bb1b4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -52,7 +52,7 @@ fn observer_lifecycle_key( outer_pubkey: &str, payload: &super::ManagedAgentRuntimeLifecycleObserverPayload, ) -> Result { - if outer_pubkey.to_ascii_lowercase() != payload.pubkey.to_ascii_lowercase() { + if !outer_pubkey.eq_ignore_ascii_case(&payload.pubkey) { return Err("observer signer does not match lifecycle payload pubkey".into()); } if matches!( @@ -90,6 +90,9 @@ pub fn put_managed_agent_runtime_lifecycle( let runtime = runtimes .get_mut(&key) .ok_or_else(|| "lifecycle frame does not match a tracked runtime pair".to_string())?; + if runtime.start_nonce != payload.start_nonce { + return Err("lifecycle frame does not match the current harness generation".into()); + } if runtime .child .try_wait() @@ -110,18 +113,51 @@ pub fn list_managed_agent_runtimes( app: AppHandle, ) -> Result, String> { let state = app.state::(); - let records = load_managed_agents(&app)?; - let runtimes = state + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - Ok(runtimes - .iter() - .filter_map(|(key, runtime)| { - let record = records.iter().find(|record| record.pubkey == key.pubkey)?; - Some(status_for(&app, record, key, Some(runtime), None)) + let exited_keys: Vec<_> = runtimes + .iter_mut() + .filter_map(|(key, runtime)| match runtime.child.try_wait() { + Ok(Some(_)) | Err(_) => Some(key.clone()), + Ok(None) => None, }) - .collect()) + .collect(); + let mut statuses = Vec::new(); + for key in exited_keys { + runtimes.remove(&key); + super::remove_agent_runtime_receipt(&app, &key); + state.clear_agent_session_cache(&key); + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) + { + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + let status = status_for(&app, record, &key, None, None); + emit_status(&app, &status); + statuses.push(status); + } + } + statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; + Some(status_for(&app, record, key, Some(runtime), None)) + })); + drop(runtimes); + save_managed_agents(&app, &records)?; + Ok(statuses) } #[tauri::command] @@ -130,13 +166,14 @@ pub fn start_managed_agent_runtime( relay_url: String, app: AppHandle, ) -> Result { - start_pair(pubkey, relay_url, true, app) + start_pair(pubkey, relay_url, true, None, app) } fn start_pair( pubkey: String, relay_url: String, lazy: bool, + expected_updated_at: Option<&str>, app: AppHandle, ) -> Result { let state = app.state::(); @@ -156,6 +193,9 @@ fn start_pair( if record.backend != BackendKind::Local { return Err("managed runtime pairs require a local agent".into()); } + if expected_updated_at.is_some_and(|expected| record.updated_at != expected) { + return Err("managed agent changed while runtime reconciliation was in flight".into()); + } let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; let mut runtimes = state .managed_agent_processes @@ -251,7 +291,7 @@ pub fn restart_managed_agent_runtime( app: AppHandle, ) -> Result { stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, app) + start_pair(pubkey, relay_url, true, None, app) } async fn discover_agent_membership( @@ -290,7 +330,7 @@ pub async fn reconcile_managed_agent_runtimes( for community in communities { for record in records .iter() - .filter(|record| record.backend == BackendKind::Local) + .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) { jobs.push((record.clone(), community.relay_url.clone())); } @@ -318,6 +358,7 @@ pub async fn reconcile_managed_agent_runtimes( record.pubkey.clone(), key.relay_url.clone(), true, + Some(&record.updated_at), app.clone(), ) { Ok(mut status) => { @@ -357,11 +398,24 @@ mod tests { super::super::ManagedAgentRuntimeLifecycleObserverPayload { pubkey: "aa".repeat(32), relay_url: relay_url.into(), + start_nonce: "test-generation".into(), lifecycle, error: error.map(str::to_owned), } } + #[test] + fn runtime_key_rejects_non_hex_pubkeys() { + assert!(ManagedAgentRuntimeKey::new("../not-a-key", "wss://relay.example").is_err()); + assert!(ManagedAgentRuntimeKey::new("gg".repeat(32), "wss://relay.example").is_err()); + } + + #[test] + fn runtime_key_canonicalizes_hex_pubkeys() { + let key = ManagedAgentRuntimeKey::new("AA".repeat(32), "wss://relay.example").unwrap(); + assert_eq!(key.pubkey, "aa".repeat(32)); + } + #[test] fn observer_lifecycle_key_preserves_exact_canonical_pair() { let first = payload( diff --git a/desktop/src-tauri/src/managed_agents/runtime_types.rs b/desktop/src-tauri/src/managed_agents/runtime_types.rs index 1392560320..4862cedbae 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_types.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_types.rs @@ -13,8 +13,12 @@ pub struct ManagedAgentRuntimeKey { impl ManagedAgentRuntimeKey { pub fn new(pubkey: impl Into, relay_url: &str) -> Result { + let pubkey = pubkey.into(); + if pubkey.len() != 64 || !pubkey.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("managed-agent pubkey must be 64 hexadecimal characters".into()); + } Ok(Self { - pubkey: pubkey.into(), + pubkey: pubkey.to_ascii_lowercase(), relay_url: buzz_core_pkg::relay::normalize_relay_url(relay_url) .map_err(|error| error.to_string())?, }) @@ -43,6 +47,9 @@ pub struct ManagedAgentPairRuntime { pub process: ManagedAgentProcess, pub lifecycle: ManagedAgentRuntimeLifecycle, pub error: Option, + /// Unpredictable identity for this exact harness generation. Lifecycle + /// frames from prior processes are rejected even when the pair is live. + pub start_nonce: String, } impl std::ops::Deref for ManagedAgentPairRuntime { @@ -61,10 +68,12 @@ impl std::ops::DerefMut for ManagedAgentPairRuntime { impl ManagedAgentPairRuntime { pub fn starting(process: ManagedAgentProcess) -> Self { + let start_nonce = process.start_nonce.clone(); Self { process, lifecycle: ManagedAgentRuntimeLifecycle::Starting, error: None, + start_nonce, } } } @@ -90,17 +99,11 @@ pub struct ManagedAgentRuntimeStatus { pub struct ManagedAgentRuntimeLifecycleObserverPayload { pub pubkey: String, pub relay_url: String, + pub start_nonce: String, pub lifecycle: ManagedAgentRuntimeLifecycle, pub error: Option, } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ManagedAgentRuntimeTarget { - pub pubkey: String, - pub relay_url: String, -} - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedAgentCommunityTarget { diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index d6005ea6bd..b9c6c7e6cd 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -654,13 +654,6 @@ pub fn read_all_agent_runtime_receipts( .collect() } -/// Write a legacy PID file for a spawned agent. Retained only for migration. -pub fn write_agent_pid_file(app: &AppHandle, pubkey: &str, pid: u32) -> Result<(), String> { - let path = agent_pids_dir(app)?.join(format!("{pubkey}.pid")); - fs::write(&path, pid.to_string()) - .map_err(|error| format!("failed to write PID file {}: {error}", path.display())) -} - /// Remove the PID file for an agent (e.g. on normal stop). pub fn remove_agent_pid_file(app: &AppHandle, pubkey: &str) { if let Ok(dir) = agent_pids_dir(app) { diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index ba851f8a7d..90da759af0 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -441,6 +441,8 @@ pub struct ManagedAgentProcess { /// cached availability and sets `needs_restart` on drift, catching out-of- /// band adapter changes that Phase-1 auto-restart doesn't cover. pub adapter_availability: Option, + /// Unpredictable identity shared only with this harness generation. + pub start_nonce: String, /// Win32 Job Object owning the harness + its entire process tree. Closing /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole /// tree — the Windows mirror of the Unix process-group teardown. `None` From 873ef6e07802db20d823aadfdf10e3e44fd9f8c8 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 13:12:46 -0400 Subject: [PATCH 17/28] fix(desktop): keep pair runtime presence honest Co-authored-by: Max Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../agents/lib/useAgentsDataRefresh.ts | 3 ++ .../agents/managedAgentRuntimeHooks.ts | 53 ++++++++++++++++++- ...managedAgentRuntimeReconciliation.test.mjs | 49 +++++++++++++++++ .../src/features/agents/observerRelayStore.ts | 18 +++---- .../observerRelaySubscriptionGate.test.mjs | 15 ++++++ .../useManagedAgentRuntimeReconciliation.ts | 11 +++- .../features/channels/ui/ChannelScreen.tsx | 5 +- .../channels/ui/MembersSidebarMemberCard.tsx | 15 ++++-- 8 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs create mode 100644 desktop/src/features/agents/observerRelaySubscriptionGate.test.mjs diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 9f655a77fa..174fb9c92c 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -32,6 +32,9 @@ export function useAgentsDataRefresh(): void { void queryClient.invalidateQueries({ queryKey: managedAgentRuntimesQueryKey, }); + // Pair startup also changes the legacy managed-agent scalar status. + // Keep that cache synchronized for consumers outside pair-runtime UI. + void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }); const unlisten = listen("agents-data-changed", () => { diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 673247aeff..46ad1bd034 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { listManagedAgentRuntimes, @@ -10,6 +15,52 @@ import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const; +export function mergeManagedAgentRuntimeStatuses( + baseline: readonly ManagedAgentRuntimeStatus[] | undefined, + current: readonly ManagedAgentRuntimeStatus[] | undefined, + reconciled: readonly ManagedAgentRuntimeStatus[], +): ManagedAgentRuntimeStatus[] { + const baselineByPair = new Map( + (baseline ?? []).map((runtime) => [runtimePairKey(runtime), runtime]), + ); + const currentByPair = new Map( + (current ?? []).map((runtime) => [runtimePairKey(runtime), runtime]), + ); + const reconciledPairs = new Set(); + const merged = reconciled.map((runtime) => { + const key = runtimePairKey(runtime); + reconciledPairs.add(key); + const currentRuntime = currentByPair.get(key); + const baselineRuntime = baselineByPair.get(key); + // A status event or user action may update this pair while startup + // reconciliation is discovering others. Only preserve cache rows that + // changed after the reconcile began; otherwise its result is newer. + return currentRuntime && currentRuntime !== baselineRuntime + ? { ...runtime, ...currentRuntime } + : runtime; + }); + + for (const runtime of current ?? []) { + if (!reconciledPairs.has(runtimePairKey(runtime))) merged.push(runtime); + } + return merged; +} + +function runtimePairKey(runtime: ManagedAgentRuntimeStatus): string { + return JSON.stringify([runtime.pubkey, runtime.relayUrl]); +} + +export function cacheReconciledManagedAgentRuntimes( + queryClient: QueryClient, + baseline: readonly ManagedAgentRuntimeStatus[] | undefined, + runtimes: readonly ManagedAgentRuntimeStatus[], +): void { + queryClient.setQueryData( + managedAgentRuntimesQueryKey, + (current) => mergeManagedAgentRuntimeStatuses(baseline, current, runtimes), + ); +} + export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) { return useQuery({ enabled: options?.enabled ?? true, diff --git a/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs b/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs new file mode 100644 index 0000000000..4ec07c9170 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRuntimeReconciliation.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeManagedAgentRuntimeStatuses } from "./managedAgentRuntimeHooks.ts"; + +const runtime = (overrides = {}) => ({ + pubkey: "aa", + relayUrl: "wss://relay.example", + localSetup: true, + lifecycle: "starting", + pid: 1, + error: null, + logPath: null, + ...overrides, +}); + +test("startup reconcile does not clobber a lifecycle update received in flight", () => { + const starting = runtime(); + const ready = runtime({ lifecycle: "ready" }); + const reconciled = runtime({ requestedRelayUrl: "WSS://RELAY.EXAMPLE/" }); + + assert.deepEqual( + mergeManagedAgentRuntimeStatuses([starting], [ready], [reconciled]), + [{ ...reconciled, ...ready }], + ); +}); + +test("startup reconcile replaces an unchanged baseline row with its newer result", () => { + const stopped = runtime({ lifecycle: "stopped", pid: null }); + const starting = runtime({ lifecycle: "starting", pid: 2 }); + + assert.deepEqual( + mergeManagedAgentRuntimeStatuses([stopped], [stopped], [starting]), + [starting], + ); +}); + +test("startup reconcile preserves unrelated runtime rows", () => { + const existing = runtime({ + relayUrl: "wss://other.example", + lifecycle: "ready", + }); + const discovered = runtime(); + + assert.deepEqual( + mergeManagedAgentRuntimeStatuses([], [existing], [discovered]), + [discovered, existing], + ); +}); diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 7c2f0d9ced..56c69f915a 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -590,17 +590,17 @@ export function getAgentTranscript( return state?.items ?? EMPTY_TRANSCRIPT; } +export function shouldObserveManagedAgents( + agents: readonly Pick[], +): boolean { + return agents.length > 0; +} + export function useManagedAgentObserverBridge( agents: readonly Pick[], ) { const subscriptionId = React.useId(); - const hasActiveAgent = React.useMemo( - () => - agents.some( - (agent) => agent.status === "running" || agent.status === "deployed", - ), - [agents], - ); + const hasManagedAgent = shouldObserveManagedAgents(agents); const agentPubkeys = React.useMemo( () => agents.map((agent) => agent.pubkey), @@ -618,11 +618,11 @@ export function useManagedAgentObserverBridge( }, [subscriptionId, agentPubkeys]); React.useEffect(() => { - if (!hasActiveAgent) { + if (!hasManagedAgent) { return; } void ensureRelayObserverSubscription(); - }, [hasActiveAgent]); + }, [hasManagedAgent]); // Wire up config-surface query invalidation when session_config_captured fires. const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/observerRelaySubscriptionGate.test.mjs b/desktop/src/features/agents/observerRelaySubscriptionGate.test.mjs new file mode 100644 index 0000000000..e776eeeb92 --- /dev/null +++ b/desktop/src/features/agents/observerRelaySubscriptionGate.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldObserveManagedAgents } from "./observerRelayStore.ts"; + +test("observer ingestion opens for a cold stopped managed agent", () => { + assert.equal( + shouldObserveManagedAgents([{ pubkey: "aa", status: "stopped" }]), + true, + ); +}); + +test("observer ingestion stays closed when there are no owned agents", () => { + assert.equal(shouldObserveManagedAgents([]), false); +}); diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts index 8456e881ef..698acd20dd 100644 --- a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -1,7 +1,11 @@ import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; -import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; +import { + cacheReconciledManagedAgentRuntimes, + managedAgentRuntimesQueryKey, +} from "@/features/agents/managedAgentRuntimeHooks"; +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents"; export function useManagedAgentRuntimeReconciliation( @@ -14,9 +18,12 @@ export function useManagedAgentRuntimeReconciliation( if (reconciled.current) return; reconciled.current = true; + const baseline = queryClient.getQueryData( + managedAgentRuntimesQueryKey, + ); void reconcileManagedAgentRuntimes(communities) .then((runtimes) => { - queryClient.setQueryData(managedAgentRuntimesQueryKey, runtimes); + cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes); }) .catch((error) => { console.warn( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 840d0b5d2b..52b3ae7fb7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -32,6 +32,7 @@ import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; import { useWelcomeKickoffEntrance } from "@/features/onboarding/useWelcomeKickoffEntrance"; import { useWelcomeKickoffStagePresence } from "@/features/onboarding/useWelcomeKickoffStagePresence"; import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate"; +import { useCommunities } from "@/features/communities/useCommunities"; import { mergeMessages, useChannelMessagesQuery, @@ -95,6 +96,7 @@ export function ChannelScreen({ targetMessageId, }: ChannelScreenProps) { const { goHome } = useAppNavigation(); + const { activeCommunity } = useCommunities(); const { markChannelRead, markChannelUnread, @@ -787,7 +789,6 @@ export function ChannelScreen({ isSinglePanelView, ], ); - return ( @@ -963,13 +964,13 @@ export function ChannelScreen({ )}
- diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b594a4ee2b..503362ae54 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -202,15 +202,20 @@ export function MembersSidebarMemberCard({ className="mt-1 normal-case tracking-normal" data-testid={`sidebar-managed-agent-status-${member.pubkey}`} variant={ - managedAgentRuntime && - agentCommunityAvailability(managedAgentRuntime) === "Here" - ? "default" - : "secondary" + managedAgentRuntime + ? agentCommunityAvailability(managedAgentRuntime) === "Here" + ? "default" + : "secondary" + : managedAgent && isManagedAgentActive(managedAgent) + ? "default" + : "secondary" } > {managedAgentRuntime ? agentCommunityAvailability(managedAgentRuntime) - : "Unavailable"} + : managedAgent && isManagedAgentActive(managedAgent) + ? "Running" + : "Stopped"} ) : null} {managedAgent ? ( From 2a0ae2ba973f198afb5ba9cb6a2d5d610008aa7d Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 13:12:46 -0400 Subject: [PATCH 18/28] test: exercise agents everywhere across two relays Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/playwright.config.ts | 1 + desktop/playwright.live.config.ts | 9 + .../tests/e2e/agents-everywhere.live.spec.ts | 261 ++++++++++++++++++ desktop/tests/e2e/fixtures/fake-acp-agent.mjs | 22 +- desktop/tests/e2e/helpers/twoRelayHarness.ts | 10 +- 5 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 desktop/playwright.live.config.ts create mode 100644 desktop/tests/e2e/agents-everywhere.live.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 9c35447e84..2f81de105b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -135,6 +135,7 @@ export default defineConfig({ "**/persona-env-vars.spec.ts", "**/persona-sync.spec.ts", "**/team-snapshot.spec.ts", + "**/agents-everywhere.live.spec.ts", "**/parity-ancestor-island.spec.ts", ], use: { diff --git a/desktop/playwright.live.config.ts b/desktop/playwright.live.config.ts new file mode 100644 index 0000000000..e1e5a4079b --- /dev/null +++ b/desktop/playwright.live.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + testMatch: "**/agents-everywhere.live.spec.ts", + timeout: 90_000, + workers: 1, + reporter: "list", +}); diff --git a/desktop/tests/e2e/agents-everywhere.live.spec.ts b/desktop/tests/e2e/agents-everywhere.live.spec.ts new file mode 100644 index 0000000000..e1d82525b4 --- /dev/null +++ b/desktop/tests/e2e/agents-everywhere.live.spec.ts @@ -0,0 +1,261 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { expect, test } from "@playwright/test"; + +import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; + +const exec = promisify(execFile); +const enabled = process.env.BUZZ_E2E_AGENTS_EVERYWHERE === "1"; +const relayBin = process.env.BUZZ_E2E_RELAY_BIN; +const cliBin = process.env.BUZZ_E2E_CLI_BIN; +const adminBin = process.env.BUZZ_E2E_ADMIN_BIN; + +function required(name: string, value: string | undefined): string { + if (!value) throw new Error(`${name} is required for the live gate`); + return value; +} + +async function run( + binary: string, + args: string[], + env: NodeJS.ProcessEnv = {}, +): Promise { + const { stdout } = await exec(binary, args, { + cwd: "..", + env: { ...process.env, BUZZ_AUTH_TAG: "", ...env }, + }); + return stdout; +} + +function keyField(output: string, label: string): string { + const value = output.match(new RegExp(`^${label}:\\s+(\\S+)$`, "m"))?.[1]; + if (!value) throw new Error(`missing ${label} in key output`); + return value; +} + +async function processTree( + rootPid: number, +): Promise> { + const { stdout } = await exec("ps", ["-axo", "pid=,ppid=,rss=,command="]); + const rows = stdout + .trim() + .split("\n") + .map((line) => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/); + if (!match) return undefined; + return { + pid: Number(match[1]), + ppid: Number(match[2]), + rssKb: Number(match[3]), + command: match[4], + }; + }) + .filter((row): row is NonNullable => row !== undefined); + const pids = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + if (pids.has(row.ppid) && !pids.has(row.pid)) { + pids.add(row.pid); + changed = true; + } + } + } + return rows + .filter((row) => pids.has(row.pid)) + .map(({ pid, rssKb, command }) => ({ pid, rssKb, command })); +} + +async function eventually(fn: () => Promise): Promise { + const deadline = Date.now() + 30_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const value = await fn(); + if (value !== undefined) return value; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw lastError ?? new Error("live assertion timed out"); +} + +test.describe("agents everywhere live two-relay gate", () => { + test.skip(!enabled, "set BUZZ_E2E_AGENTS_EVERYWHERE=1 to run live gate"); + + test("same agent listens and wakes independently in two communities", async () => { + test.setTimeout(90_000); + const portBase = 20_000 + (process.pid % 5_000) * 2; + const specs: [RelaySpec, RelaySpec] = [ + { + name: "relay-a", + ports: { + main: portBase, + health: portBase + 10_000, + metrics: portBase + 20_000, + }, + databaseUrl: required( + "BUZZ_E2E_DATABASE_URL", + process.env.BUZZ_E2E_DATABASE_URL, + ), + redisUrl: process.env.BUZZ_E2E_REDIS_A ?? "redis://127.0.0.1:6379/11", + }, + { + name: "relay-b", + ports: { + main: portBase + 1, + health: portBase + 10_001, + metrics: portBase + 20_001, + }, + databaseUrl: required( + "BUZZ_E2E_DATABASE_URL", + process.env.BUZZ_E2E_DATABASE_URL, + ), + redisUrl: process.env.BUZZ_E2E_REDIS_B ?? "redis://127.0.0.1:6379/12", + }, + ]; + const harness = await TwoRelayHarness.create(specs); + try { + await harness.startRelays(required("BUZZ_E2E_RELAY_BIN", relayBin)); + const senderOutput = await run(required("BUZZ_E2E_ADMIN_BIN", adminBin), [ + "generate-key", + ]); + const agentOutput = await run(required("BUZZ_E2E_ADMIN_BIN", adminBin), [ + "generate-key", + ]); + const senderKey = keyField(senderOutput, "Secret key"); + const agentKey = keyField(agentOutput, "Secret key"); + const agentPubkey = keyField(agentOutput, "Public key"); + const channels: Array<{ relay: RelaySpec; id: string }> = []; + + for (const relay of specs) { + const relayHttp = `http://127.0.0.1:${relay.ports.main}`; + const senderEnv = { + BUZZ_RELAY_URL: relayHttp, + BUZZ_PRIVATE_KEY: senderKey, + }; + const created = JSON.parse( + await run( + required("BUZZ_E2E_CLI_BIN", cliBin), + [ + "channels", + "create", + "--name", + `ae-live-${relay.name}-${process.pid}`, + "--type", + "stream", + "--visibility", + "open", + ], + senderEnv, + ), + ); + await run( + required("BUZZ_E2E_CLI_BIN", cliBin), + [ + "channels", + "add-member", + "--channel", + created.channel_id, + "--pubkey", + agentPubkey, + "--role", + "member", + ], + senderEnv, + ); + await run( + required("BUZZ_E2E_CLI_BIN", cliBin), + ["users", "set-profile", "--name", "AgentsEverywhereProbe"], + { BUZZ_RELAY_URL: relayHttp, BUZZ_PRIVATE_KEY: agentKey }, + ); + channels.push({ relay, id: created.channel_id }); + } + + const acpChildren = []; + for (const { relay } of channels) { + acpChildren.push( + await harness.startAcp( + `acp-${relay.name}`, + `ws://127.0.0.1:${relay.ports.main}`, + agentKey, + { BUZZ_ACP_RESPOND_TO: "anyone", BUZZ_ACP_NO_MEMORY: "true" }, + ), + ); + } + await new Promise((resolve) => setTimeout(resolve, 2_000)); + if (!acpChildren.every((child) => child.exitCode === null)) { + throw new Error(`ACP listener exited early:\n${await harness.logs()}`); + } + const idleTrees = await Promise.all( + acpChildren.map((child) => { + if (!child.pid) throw new Error("ACP listener has no pid"); + return processTree(child.pid); + }), + ); + expect(idleTrees.every((tree) => tree.length === 1)).toBe(true); + test.info().annotations.push({ + type: "idle-processes", + description: idleTrees + .map( + (tree) => + `${tree.length} process / ${tree.reduce((sum, row) => sum + row.rssKb, 0)} KiB RSS`, + ) + .join("; "), + }); + + for (const { relay, id } of channels) { + const relayHttp = `http://127.0.0.1:${relay.ports.main}`; + await run( + required("BUZZ_E2E_CLI_BIN", cliBin), + [ + "messages", + "send", + "--channel", + id, + "--content", + `@AgentsEverywhereProbe AE-ID:${relay.name}`, + ], + { BUZZ_RELAY_URL: relayHttp, BUZZ_PRIVATE_KEY: senderKey }, + ); + const messages = await eventually(async () => { + const output = await run( + required("BUZZ_E2E_CLI_BIN", cliBin), + ["messages", "get", "--channel", id, "--limit", "20"], + { BUZZ_RELAY_URL: relayHttp, BUZZ_PRIVATE_KEY: senderKey }, + ); + const rows = JSON.parse(output) as Array<{ content?: string }>; + return rows.some((row) => row.content === `AE-ACK:${relay.name}`) + ? rows + : undefined; + }); + expect( + messages.filter((row) => row.content === `AE-ACK:${relay.name}`), + ).toHaveLength(1); + } + } catch (error) { + console.error(await harness.logs()); + throw error; + } finally { + await harness.stop(); + await eventually(async () => { + const survivors = ( + await Promise.all( + harness.ownedPids.map(async (pid) => { + try { + process.kill(pid, 0); + return pid; + } catch { + return undefined; + } + }), + ) + ).filter((pid) => pid !== undefined); + return survivors.length === 0 ? true : undefined; + }); + } + }); +}); diff --git a/desktop/tests/e2e/fixtures/fake-acp-agent.mjs b/desktop/tests/e2e/fixtures/fake-acp-agent.mjs index c06999b190..41702a3bc7 100755 --- a/desktop/tests/e2e/fixtures/fake-acp-agent.mjs +++ b/desktop/tests/e2e/fixtures/fake-acp-agent.mjs @@ -1,7 +1,11 @@ #!/usr/bin/env node /** Deterministic ACP fixture for agents-everywhere live tests. */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { createInterface } from "node:readline"; +const exec = promisify(execFile); + const wakeDelayMs = Number.parseInt( process.env.BUZZ_E2E_FAKE_ACP_WAKE_MS ?? "0", 10, @@ -50,6 +54,7 @@ for await (const line of input) { const ids = [...prompt.matchAll(/\bAE-ID:([A-Za-z0-9._:-]+)\b/g)].map( (match) => match[1], ); + const ack = `AE-ACK:${ids.join(",")}`; write({ jsonrpc: "2.0", method: "session/update", @@ -57,10 +62,25 @@ for await (const line of input) { sessionId: request.params?.sessionId, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: `AE-ACK:${ids.join(",")}` }, + content: { type: "text", text: ack }, }, }, }); + const channel = prompt.match(/^Channel: .+ \(#([^)]+)\)$/m)?.[1]; + const replyTo = prompt.match(/--reply-to ([0-9a-f]{64})/)?.[1]; + const cli = process.env.BUZZ_E2E_CLI_BIN; + if (cli && channel) { + const args = [ + "messages", + "send", + "--channel", + channel, + "--content", + ack, + ]; + if (replyTo) args.push("--reply-to", replyTo); + await exec(cli, args, { env: process.env }); + } write({ jsonrpc: "2.0", id: request.id, diff --git a/desktop/tests/e2e/helpers/twoRelayHarness.ts b/desktop/tests/e2e/helpers/twoRelayHarness.ts index 335d202476..2eded2d9bc 100644 --- a/desktop/tests/e2e/helpers/twoRelayHarness.ts +++ b/desktop/tests/e2e/helpers/twoRelayHarness.ts @@ -20,6 +20,12 @@ export class TwoRelayHarness { readonly relays: readonly [RelaySpec, RelaySpec]; private readonly processes: OwnedProcess[] = []; + get ownedPids(): number[] { + return this.processes.flatMap(({ child }) => + child.pid ? [child.pid] : [], + ); + } + private constructor(root: string, relays: readonly [RelaySpec, RelaySpec]) { this.root = root; this.relays = relays; @@ -52,9 +58,11 @@ export class TwoRelayHarness { return this.spawnOwned(name, binary, [], { BUZZ_RELAY_URL: relayWsUrl, BUZZ_PRIVATE_KEY: privateKey, - BUZZ_ACP_LAZY_POOL: "1", + BUZZ_AUTH_TAG: "", + BUZZ_ACP_LAZY_POOL: "true", BUZZ_ACP_AGENT_COMMAND: process.execPath, BUZZ_ACP_AGENT_ARGS: resolve("tests/e2e/fixtures/fake-acp-agent.mjs"), + BUZZ_E2E_CLI_BIN: process.env.BUZZ_E2E_CLI_BIN, ...extraEnv, }); } From 635511e06357356648f201ce24e18d786710305e Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 13:46:43 -0400 Subject: [PATCH 19/28] fix(desktop): stop every runtime pair for an agent Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../src-tauri/src/managed_agents/runtime.rs | 76 +---------- .../src/managed_agents/runtime/stop.rs | 122 ++++++++++++++++++ 2 files changed, 125 insertions(+), 73 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime/stop.rs diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index cc1c13a205..13b14505f7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -17,6 +17,9 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; +mod stop; +pub use stop::stop_managed_agent_process; + mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; @@ -2102,79 +2105,6 @@ pub fn start_managed_agent_process( Ok(()) } -pub fn stop_managed_agent_process( - app: &AppHandle, - record: &mut ManagedAgentRecord, - runtimes: &mut HashMap, -) -> Result<(), String> { - let key = runtimes - .keys() - .find(|key| key.pubkey == record.pubkey) - .cloned(); - let Some(key) = key else { - // Legacy PID cleanup only; pair receipts are restored separately. - if let Some(pid) = record.runtime_pid.take() { - if process_is_running(pid) - && process_belongs_to_us(pid) - && process_has_buzz_marker(pid, ¤t_instance_id(app)) - { - terminate_process(pid)?; - } - record.updated_at = now_iso(); - } - super::remove_agent_pid_file(app, &record.pubkey); - return Ok(()); - }; - let Some(mut runtime) = runtimes.remove(&key) else { - return Ok(()); - }; - - // On Unix, kill the entire process group via terminate_process. - // On Windows, drop the Job Object handle (KILL_ON_JOB_CLOSE) so the whole - // harness tree dies — Child::kill() would orphan the agent workers + MCP - // servers. If job assignment failed at spawn, fall back to Child::kill(). - #[cfg(unix)] - terminate_process(runtime.child.id())?; - #[cfg(windows)] - match runtime.job.take() { - Some(job) => drop(job), - None => runtime - .child - .kill() - .map_err(|error| format!("failed to kill agent process: {error}"))?, - } - #[cfg(not(any(unix, windows)))] - runtime - .child - .kill() - .map_err(|error| format!("failed to kill agent process: {error}"))?; - let status = runtime - .child - .wait() - .map_err(|error| format!("failed to wait for agent shutdown: {error}"))?; - let now = now_iso(); - record.runtime_pid = None; - record.updated_at = now.clone(); - record.last_stopped_at = Some(now); - record.last_exit_code = status.code(); - record.last_error = None; - record.last_error_code = None; - - super::remove_agent_runtime_receipt(app, &key); - - append_log_marker( - &runtime.log_path, - &format!( - "=== stopped {} ({}) at {} ===", - record.name, - record.pubkey, - now_iso() - ), - )?; - - Ok(()) -} - /// Returns the (key, value) env var pairs that should be forwarded to the /// agent process for model and provider selection. /// diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs new file mode 100644 index 0000000000..3520b9bdc1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; + +use tauri::AppHandle; + +use super::{ + append_log_marker, current_instance_id, now_iso, process_belongs_to_us, + process_has_buzz_marker, process_is_running, terminate_process, ManagedAgentPairRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, +}; + +fn managed_agent_runtime_keys( + runtimes: &HashMap, + pubkey: &str, +) -> Vec { + runtimes + .keys() + .filter(|key| key.pubkey.eq_ignore_ascii_case(pubkey)) + .cloned() + .collect() +} + +pub fn stop_managed_agent_process( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, +) -> Result<(), String> { + let keys = managed_agent_runtime_keys(runtimes, &record.pubkey); + if keys.is_empty() { + // Legacy PID cleanup only; pair receipts are restored separately. + if let Some(pid) = record.runtime_pid.take() { + if process_is_running(pid) + && process_belongs_to_us(pid) + && process_has_buzz_marker(pid, ¤t_instance_id(app)) + { + terminate_process(pid)?; + } + record.updated_at = now_iso(); + } + super::super::remove_agent_pid_file(app, &record.pubkey); + return Ok(()); + } + + let mut errors = Vec::new(); + for key in keys { + let Some(mut runtime) = runtimes.remove(&key) else { + continue; + }; + let result = (|| -> Result<(), String> { + #[cfg(unix)] + terminate_process(runtime.child.id())?; + #[cfg(windows)] + match runtime.job.take() { + Some(job) => drop(job), + None => runtime + .child + .kill() + .map_err(|error| format!("failed to kill agent process: {error}"))?, + } + #[cfg(not(any(unix, windows)))] + runtime + .child + .kill() + .map_err(|error| format!("failed to kill agent process: {error}"))?; + let status = runtime + .child + .wait() + .map_err(|error| format!("failed to wait for agent shutdown: {error}"))?; + record.last_exit_code = status.code(); + super::super::remove_agent_runtime_receipt(app, &key); + append_log_marker( + &runtime.log_path, + &format!( + "=== stopped {} ({}) at {} ===", + record.name, + record.pubkey, + now_iso() + ), + ) + })(); + if let Err(error) = result { + errors.push(format!("{}: {error}", key.relay_url)); + // Keep failed teardown visible/manageable instead of orphaning it. + runtimes.insert(key, runtime); + } + } + + let now = now_iso(); + record.runtime_pid = None; + record.updated_at = now.clone(); + record.last_stopped_at = Some(now); + record.last_error = None; + record.last_error_code = None; + super::super::remove_agent_pid_file(app, &record.pubkey); + + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "failed to stop one or more managed-agent runtimes: {}", + errors.join("; ") + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_wide_selection_drains_every_pair_only_for_that_agent() { + let agent = "aa".repeat(32); + let other = "bb".repeat(32); + let first = ManagedAgentRuntimeKey::new(&agent, "wss://one.example").unwrap(); + let second = ManagedAgentRuntimeKey::new(&agent, "wss://two.example").unwrap(); + let unrelated = ManagedAgentRuntimeKey::new(other, "wss://one.example").unwrap(); + let runtimes = HashMap::from([(first.clone(), ()), (second.clone(), ()), (unrelated, ())]); + + let mut selected = managed_agent_runtime_keys(&runtimes, &agent); + selected.sort_by(|left, right| left.relay_url.cmp(&right.relay_url)); + assert_eq!(selected, vec![first, second]); + } +} From 8d6fa16c11fa2918c7d410ee0a7cd5b59992c006 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 13:46:43 -0400 Subject: [PATCH 20/28] fix(desktop): preserve runtime pairs across bounces Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../src-tauri/src/commands/agent_discovery.rs | 89 ++++++-------- desktop/src-tauri/src/commands/agents.rs | 72 +++++++++++ .../src/commands/global_agent_config.rs | 114 +++++++----------- .../src-tauri/src/managed_agents/runtime.rs | 1 + .../src/managed_agents/runtime/stop.rs | 43 ++++++- .../src/managed_agents/runtime_commands.rs | 10 +- 6 files changed, 200 insertions(+), 129 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 058d51cb5d..acc8f30ad3 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -297,17 +297,6 @@ async fn restart_setup_mode_agents_after_install( use tauri::Manager; // ── Pre-scan: collect candidate pubkeys without holding locks ──────────── - let state = app.state::(); - let owner_hex = match super::agents::workspace_owner_hex(&state) { - Ok(h) => h, - Err(e) => { - eprintln!( - "buzz-desktop: install_acp_runtime: failed to compute owner_hex for restart: {e}" - ); - return (0, 0); - } - }; - let app_for_scan = app.clone(); let runtime_id_owned = runtime_id.to_string(); let candidates = tokio::task::spawn_blocking(move || { @@ -326,7 +315,6 @@ async fn restart_setup_mode_agents_after_install( .iter() .filter(|record| { let is_local = record.backend == BackendKind::Local; - let pid = record.runtime_pid; let effective_cmd = record_agent_command(record, &personas); let runtime_matches = known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); @@ -342,7 +330,10 @@ async fn restart_setup_mode_agents_after_install( &global, ); let now_ready = matches!(agent_readiness(&effective), AgentReadiness::Ready); - let pid_alive = pid.is_some_and(crate::managed_agents::process_is_running); + let pid_alive = runtimes.iter().any(|(key, runtime)| { + key.pubkey.eq_ignore_ascii_case(&record.pubkey) + && crate::managed_agents::process_is_running(runtime.child.id()) + }); should_restart_after_install( is_local, pid_alive, @@ -365,7 +356,7 @@ async fn restart_setup_mode_agents_after_install( let mut failed_restart_count: u32 = 0; for pubkey in &candidates { - let outcome = restart_single_agent_after_install(app, pubkey, &owner_hex, runtime_id).await; + let outcome = restart_single_agent_after_install(app, pubkey, runtime_id).await; match outcome { InstallRestartOutcome::Restarted => restarted_count += 1, InstallRestartOutcome::FailedAfterStop => failed_restart_count += 1, @@ -384,16 +375,15 @@ async fn restart_setup_mode_agents_after_install( async fn restart_single_agent_after_install( app: &tauri::AppHandle, pubkey: &str, - owner_hex: &str, runtime_id: &str, ) -> InstallRestartOutcome { use crate::{ app_state::AppState, managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, - load_global_agent_config, load_managed_agents, load_personas, process_is_running, - record_agent_command, resolve_effective_agent_env, save_managed_agents, - stop_managed_agent_process, sync_managed_agent_processes, AgentReadiness, BackendKind, + load_global_agent_config, load_managed_agents, load_personas, record_agent_command, + resolve_effective_agent_env, save_managed_agents, stop_managed_agent_process, + sync_managed_agent_processes, AgentReadiness, BackendKind, }, }; use tauri::Manager; @@ -435,14 +425,11 @@ async fn restart_single_agent_after_install( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey_owned} is no longer a local agent")); } - let Some(pid) = record.runtime_pid else { - return Err(format!( - "agent {pubkey_owned} no longer has a recorded PID after sync" - )); - }; - if !process_is_running(pid) { + let runtime_keys = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); + if runtime_keys.is_empty() { return Err(format!( - "agent {pubkey_owned} process {pid} is no longer running" + "agent {pubkey_owned} no longer has a live pair runtime after sync" )); } @@ -482,53 +469,45 @@ async fn restart_single_agent_after_install( stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; save_managed_agents(&app_for_stop, &records)?; - Ok(()) + Ok(runtime_keys) }) .await; - let stopped = match stop_result { - Ok(Ok(())) => true, + let runtime_keys = match stop_result { + Ok(Ok(runtime_keys)) => runtime_keys, Ok(Err(e)) => { eprintln!("buzz-desktop: install_acp_runtime: skipping restart of {pubkey}: {e}"); - false + return InstallRestartOutcome::Skipped; } Err(e) => { eprintln!( "buzz-desktop: install_acp_runtime: spawn_blocking failed for stop of {pubkey}: {e}" ); - false + return InstallRestartOutcome::Skipped; } }; - if !stopped { - return InstallRestartOutcome::Skipped; - } - - // Start via the normal preflight path — same as config-change restart. + let relay_urls: Vec<_> = runtime_keys.into_iter().map(|key| key.relay_url).collect(); + let state = app.state::(); + match super::agents::start_local_agent_pairs_with_preflight(app, &state, pubkey, &relay_urls) + .await { - use tauri::Manager; - let state = app.state::(); - match super::agents::start_local_agent_with_preflight(app, &state, pubkey, owner_hex, false) - .await - { - Ok(_) => { - eprintln!( - "buzz-desktop: install_acp_runtime: restarted setup-mode agent {pubkey} after install" - ); - InstallRestartOutcome::Restarted - } - Err(e) => { + Ok(_) => { + eprintln!( + "buzz-desktop: install_acp_runtime: restarted setup-mode agent {pubkey} after install" + ); + InstallRestartOutcome::Restarted + } + Err(e) => { + eprintln!( + "buzz-desktop: install_acp_runtime: failed to start {pubkey} after install: {e}" + ); + if let Err(save_err) = persist_last_error_on_install(app, pubkey, &e) { eprintln!( - "buzz-desktop: install_acp_runtime: failed to start {pubkey} after install: {e}" + "buzz-desktop: install_acp_runtime: failed to persist last_error for {pubkey}: {save_err}" ); - // Persist last_error so the UI surfaces a diagnosable stopped state. - if let Err(save_err) = persist_last_error_on_install(app, pubkey, &e) { - eprintln!( - "buzz-desktop: install_acp_runtime: failed to persist last_error for {pubkey}: {save_err}" - ); - } - InstallRestartOutcome::FailedAfterStop } + InstallRestartOutcome::FailedAfterStop } } } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 73aa1aa468..09217b6b54 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -307,6 +307,78 @@ async fn ensure_relay_mesh_for_record( Ok(()) } +pub(super) async fn start_local_agent_pairs_with_preflight( + app: &AppHandle, + state: &AppState, + pubkey: &str, + relay_urls: &[String], +) -> Result { + let record_snapshot = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(app)? + .into_iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))? + }; + if record_snapshot.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + ensure_relay_mesh_for_record(app, &record_snapshot, false).await?; + + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + let personas = load_personas(app).unwrap_or_default(); + if let Some(persona_id) = record.persona_id.clone() { + if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) { + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + } + save_managed_agents(app, &records)?; + } + + let mut errors = Vec::new(); + for relay_url in relay_urls { + if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy( + pubkey.to_string(), + relay_url.clone(), + app.clone(), + ) { + errors.push(format!("{relay_url}: {error}")); + } + } + if !errors.is_empty() { + return Err(format!( + "failed to restart one or more managed-agent runtime pairs: {}", + errors.join("; ") + )); + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let personas = load_personas(app).unwrap_or_default(); + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + build_managed_agent_summary(app, record, &runtimes, &personas) +} + pub(super) async fn start_local_agent_with_preflight( app: &AppHandle, state: &AppState, diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 37a1be5226..91219bafb9 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -17,10 +17,10 @@ use crate::{ app_state::AppState, managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, - load_global_agent_config, load_managed_agents, load_personas, process_is_running, - record_agent_command, resolve_effective_agent_env, save_global_agent_config, - save_managed_agents, stop_managed_agent_process, sync_managed_agent_processes, - validate_global_config, AgentReadiness, BackendKind, GlobalAgentConfig, + load_global_agent_config, load_managed_agents, load_personas, record_agent_command, + resolve_effective_agent_env, save_global_agent_config, save_managed_agents, + stop_managed_agent_process, sync_managed_agent_processes, validate_global_config, + AgentReadiness, BackendKind, GlobalAgentConfig, }, }; @@ -64,8 +64,6 @@ pub async fn set_global_agent_config( config: GlobalAgentConfig, app: AppHandle, ) -> Result { - use tauri::Manager; - // ── Phase 1: disk write (sync, spawn_blocking) ──────────────────────── // // Validate, snapshot old config, write new config, collect pre-filter @@ -108,26 +106,10 @@ pub async fn set_global_agent_config( let mut restarted_count: u32 = 0; let mut failed_restart_count: u32 = 0; if !candidates.is_empty() { - let state = app.state::(); - let owner_hex = match super::agents::workspace_owner_hex(&state) { - Ok(h) => h, - Err(e) => { - eprintln!( - "buzz-desktop: set_global_agent_config: failed to compute owner_hex for restart: {e}" - ); - return Ok(GlobalAgentConfigSaveResult { - config: new_global, - restarted_count: 0, - failed_restart_count: 0, - }); - } - }; - for pubkey in &candidates { let outcome = restart_local_agent_on_config_change( &app, pubkey, - &owner_hex, &old_global, &new_global, &personas_snapshot, @@ -196,6 +178,12 @@ fn collect_restart_candidates( return (Vec::new(), Vec::new()); } }; + use tauri::Manager; + let state = app.state::(); + let mut runtimes = state + .managed_agent_processes + .lock() + .unwrap_or_else(|error| error.into_inner()); let candidates = records .iter() @@ -203,10 +191,13 @@ fn collect_restart_candidates( if record.backend != BackendKind::Local { return false; } - // Quick pre-check: must have a recorded PID (may still be alive). - let Some(pid) = record.runtime_pid else { + let has_live_runtime = runtimes.iter_mut().any(|(key, runtime)| { + key.pubkey.eq_ignore_ascii_case(&record.pubkey) + && runtime.child.try_wait().ok().flatten().is_none() + }); + if !has_live_runtime { return false; - }; + } let effective_cmd = record_agent_command(record, &all_personas); let runtime_meta = known_acp_runtime(&effective_cmd); let old_effective = @@ -220,8 +211,7 @@ fn collect_restart_candidates( // restart for a process that already exited between the pre-filter // scan and Phase 2. NotReady→Ready bypasses the alive check // because Phase 2 will stop-then-start unconditionally. - let env_changed = - old_ready && process_is_running(pid) && old_effective.env != new_effective.env; + let env_changed = old_ready && old_effective.env != new_effective.env; should_restart_on_config_change(old_ready, new_ready, env_changed) }) @@ -257,7 +247,6 @@ fn collect_restart_candidates( async fn restart_local_agent_on_config_change( app: &AppHandle, pubkey: &str, - owner_hex: &str, old_global: &GlobalAgentConfig, new_global: &GlobalAgentConfig, personas_snapshot: &[crate::managed_agents::AgentDefinition], @@ -303,14 +292,11 @@ async fn restart_local_agent_on_config_change( if record.backend != BackendKind::Local { return Err(format!("agent {pubkey_owned} is no longer a local agent")); } - let Some(pid) = record.runtime_pid else { + let runtime_keys = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); + if runtime_keys.is_empty() { return Err(format!( - "agent {pubkey_owned} no longer has a live process after sync" - )); - }; - if !process_is_running(pid) { - return Err(format!( - "agent {pubkey_owned} process {pid} is no longer running" + "agent {pubkey_owned} no longer has a live pair runtime after sync" )); } @@ -341,58 +327,46 @@ async fn restart_local_agent_on_config_change( stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; save_managed_agents(&app_for_stop, &records)?; - Ok(()) + Ok(runtime_keys) }) .await; - let stopped = match stop_result { - Ok(Ok(())) => true, + let runtime_keys = match stop_result { + Ok(Ok(runtime_keys)) => runtime_keys, Ok(Err(e)) => { eprintln!("buzz-desktop: set_global_agent_config: skipping restart of {pubkey}: {e}"); - false + return RestartOutcome::Skipped; } Err(e) => { eprintln!( "buzz-desktop: set_global_agent_config: spawn_blocking failed for stop of {pubkey}: {e}" ); - false + return RestartOutcome::Skipped; } }; - if !stopped { - return RestartOutcome::Skipped; - } - - // ── Step 2: start via the normal preflight path ──────────────────────── - // - // start_local_agent_with_preflight handles: re-acquiring the store lock, - // persona re-snapshot (agent starts with current persona config), passing - // owner_hex (NIP-OA auth_tag fallback for legacy records), saving the - // updated record, and retaining the event for relay sync. + let relay_urls: Vec<_> = runtime_keys.into_iter().map(|key| key.relay_url).collect(); + use tauri::Manager; + let state = app.state::(); + match super::agents::start_local_agent_pairs_with_preflight(app, &state, pubkey, &relay_urls) + .await { - use tauri::Manager; - let state = app.state::(); - match super::agents::start_local_agent_with_preflight(app, &state, pubkey, owner_hex, false) - .await - { - Ok(_) => { - eprintln!( - "buzz-desktop: set_global_agent_config: restarted agent {pubkey} with updated config" - ); - RestartOutcome::Restarted - } - Err(e) => { + Ok(_) => { + eprintln!( + "buzz-desktop: set_global_agent_config: restarted agent {pubkey} with updated config" + ); + RestartOutcome::Restarted + } + Err(e) => { + eprintln!( + "buzz-desktop: set_global_agent_config: failed to start {pubkey} after restart: {e}" + ); + if let Err(save_err) = persist_last_error(app, pubkey, &e) { eprintln!( - "buzz-desktop: set_global_agent_config: failed to start {pubkey} after restart: {e}" + "buzz-desktop: set_global_agent_config: failed to persist last_error for {pubkey}: {save_err}" ); - // Persist last_error so the UI surfaces a diagnosable stopped state. - if let Err(save_err) = persist_last_error(app, pubkey, &e) { - eprintln!( - "buzz-desktop: set_global_agent_config: failed to persist last_error for {pubkey}: {save_err}" - ); - } - RestartOutcome::FailedAfterStop } + RestartOutcome::FailedAfterStop } } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 13b14505f7..1b506329bf 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -18,6 +18,7 @@ mod path; pub(in crate::managed_agents) use path::build_augmented_path; mod stop; +pub(crate) use stop::managed_agent_runtime_keys; pub use stop::stop_managed_agent_process; mod sweep; diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 3520b9bdc1..8bbfd26413 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -8,7 +8,7 @@ use super::{ ManagedAgentRecord, ManagedAgentRuntimeKey, }; -fn managed_agent_runtime_keys( +pub(crate) fn managed_agent_runtime_keys( runtimes: &HashMap, pubkey: &str, ) -> Vec { @@ -19,6 +19,17 @@ fn managed_agent_runtime_keys( .collect() } +#[cfg(test)] +pub(crate) fn managed_agent_runtime_relay_urls( + runtimes: &HashMap, + pubkey: &str, +) -> Vec { + managed_agent_runtime_keys(runtimes, pubkey) + .into_iter() + .map(|key| key.relay_url) + .collect() +} + pub fn stop_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, @@ -67,7 +78,7 @@ pub fn stop_managed_agent_process( .map_err(|error| format!("failed to wait for agent shutdown: {error}"))?; record.last_exit_code = status.code(); super::super::remove_agent_runtime_receipt(app, &key); - append_log_marker( + if let Err(error) = append_log_marker( &runtime.log_path, &format!( "=== stopped {} ({}) at {} ===", @@ -75,7 +86,13 @@ pub fn stop_managed_agent_process( record.pubkey, now_iso() ), - ) + ) { + eprintln!( + "buzz-desktop: failed to append stop marker for {} on {}: {error}", + record.pubkey, key.relay_url + ); + } + Ok(()) })(); if let Err(error) = result { errors.push(format!("{}: {error}", key.relay_url)); @@ -106,6 +123,26 @@ pub fn stop_managed_agent_process( mod tests { use super::*; + #[test] + fn pair_preserving_restart_targets_exact_original_relays() { + let agent = "aa".repeat(32); + let other = "bb".repeat(32); + let first = ManagedAgentRuntimeKey::new(&agent, "wss://one.example").unwrap(); + let second = ManagedAgentRuntimeKey::new(&agent, "wss://two.example").unwrap(); + let unrelated = ManagedAgentRuntimeKey::new(other, "wss://fallback.example").unwrap(); + let runtimes = HashMap::from([(first, ()), (second, ()), (unrelated, ())]); + + let mut relays = managed_agent_runtime_relay_urls(&runtimes, &agent); + relays.sort(); + assert_eq!( + relays, + vec![ + "wss://one.example".to_string(), + "wss://two.example".to_string() + ] + ); + } + #[test] fn agent_wide_selection_drains_every_pair_only_for_that_agent() { let agent = "aa".repeat(32); diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 7e870bb1b4..36c1a39c0b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -160,13 +160,21 @@ pub fn list_managed_agent_runtimes( Ok(statuses) } +pub(crate) fn start_managed_agent_runtime_pair_lazy( + pubkey: String, + relay_url: String, + app: AppHandle, +) -> Result { + start_pair(pubkey, relay_url, true, None, app) +} + #[tauri::command] pub fn start_managed_agent_runtime( pubkey: String, relay_url: String, app: AppHandle, ) -> Result { - start_pair(pubkey, relay_url, true, None, app) + start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app) } fn start_pair( From 72073943ddf9f5f84081bff8bfbeb74ea8c52ef1 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sun, 19 Jul 2026 13:46:43 -0400 Subject: [PATCH 21/28] fix(desktop): retain pair restart snapshots Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- desktop/src-tauri/src/commands/agents.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 09217b6b54..e049a4a862 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -343,6 +343,9 @@ pub(super) async fn start_local_agent_pairs_with_preflight( } } save_managed_agents(app, &records)?; + if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { + retain_managed_agent_pending(app, state, saved_record); + } } let mut errors = Vec::new(); From d702701a4bb6e70a847258abd1d0161354abe830 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Mon, 20 Jul 2026 12:57:26 -0400 Subject: [PATCH 22/28] fix(desktop): keep pair runtime tracked when stop fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair-scoped stop_managed_agent_runtime removed the runtime from the map before fallible terminate/wait and bailed with ?, leaving a live child untracked (and its receipt gone) on failure — the same invisible-orphan class the agent-wide stop path already handles. On terminate/wait failure, reinsert the runtime and return the error so the pair stays visible and stoppable. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell Signed-off-by: Matt Toohey --- .../src/managed_agents/runtime_commands.rs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 36c1a39c0b..e77ba544e3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -273,12 +273,25 @@ pub fn stop_managed_agent_runtime( .lock() .map_err(|e| e.to_string())?; if let Some(mut runtime) = runtimes.remove(&key) { - if process_is_running(runtime.child.id()) { - terminate_process(runtime.child.id())?; + let stop_result = if process_is_running(runtime.child.id()) { + terminate_process(runtime.child.id()) + } else { + Ok(()) + } + .and_then(|()| runtime.child.wait().map_err(|e| e.to_string())); + match stop_result { + Ok(status) => { + record.last_exit_code = status.code(); + let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); + } + Err(error) => { + // Keep failed teardown visible/manageable instead of + // orphaning it: the child stays tracked and the receipt + // stays on disk until a stop actually succeeds. + runtimes.insert(key, runtime); + return Err(error); + } } - let status = runtime.child.wait().map_err(|e| e.to_string())?; - record.last_exit_code = status.code(); - let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); } super::remove_agent_runtime_receipt(&app, &key); state.clear_agent_session_cache(&key); From 22c647d085b7eb1323319f54544df580506c5bbd Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Tue, 21 Jul 2026 09:11:38 -0700 Subject: [PATCH 23/28] fix(desktop): scope sidebar agent controls to community Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Matt Toohey --- .../agents/managedAgentRuntimeStatus.ts | 21 +++ .../features/channels/ui/MembersSidebar.tsx | 43 +++--- .../channels/ui/MembersSidebarMemberCard.tsx | 27 +++- .../channels/ui/useMembersSidebarActions.ts | 42 +++++- .../src/features/home/ui/InboxDetailPane.tsx | 3 + desktop/src/testing/e2eBridge.ts | 122 ++++++++++++++++++ desktop/tests/e2e/channels.spec.ts | 114 +++++++++++++--- desktop/tests/helpers/bridge.ts | 12 ++ 8 files changed, 346 insertions(+), 38 deletions(-) diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index ea5094c7ef..5a6d23a9ba 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -41,6 +41,27 @@ export function managedAgentRuntimeKey( return JSON.stringify([runtime.pubkey, runtime.relayUrl]); } +export type ManagedAgentPairAction = "start" | "stop" | "restart"; + +/** Menu action for one agent+community pair. A missing runtime row means the + * pair is not running here, so the only sensible action is to start it. */ +export function managedAgentPairAction( + runtime: ManagedAgentRuntimeStatus | undefined, +): ManagedAgentPairAction { + if (!runtime || runtime.lifecycle === "stopped") return "start"; + if (runtime.lifecycle === "failed") return "restart"; + return "stop"; +} + +export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< + ManagedAgentPairAction, + string +> = { + start: "Start", + stop: "Stop", + restart: "Restart", +}; + export function findManagedAgentRuntime( runtimes: readonly ManagedAgentRuntimeStatus[], pubkey: string, diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 251edd619a..280bd31be3 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -49,7 +49,10 @@ import { } from "@/shared/ui/modalSearchStyles"; import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard"; import { useManagedAgentRuntimesQuery } from "@/features/agents/managedAgentRuntimeHooks"; -import { findManagedAgentRuntime } from "@/features/agents/managedAgentRuntimeStatus"; +import { + findManagedAgentRuntime, + managedAgentPairAction, +} from "@/features/agents/managedAgentRuntimeStatus"; import { EditRespondToDialog } from "./EditRespondToDialog"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; @@ -496,6 +499,7 @@ export function MembersSidebar({ removableManagedBots, currentPubkey, onOpenChange, + relayUrl, }); useFeedbackToasts(actionNoticeMessage, actionErrorMessage); @@ -560,6 +564,24 @@ export function MembersSidebar({ currentPubkey && memberProfile.ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(), ); + const managedAgent = memberIsBot + ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) + : undefined; + const managedAgentRuntime = + memberIsBot && relayUrl + ? findManagedAgentRuntime( + managedAgentRuntimesQuery.data ?? [], + member.pubkey, + relayUrl, + ) + : undefined; + // Mirrors the dispatch condition in useMembersSidebarActions: local + // agents in a community context act on the pair; provider agents keep + // the agent-wide deploy/!shutdown action. + const pairAction = + managedAgent?.backend.type === "local" && relayUrl + ? managedAgentPairAction(managedAgentRuntime) + : undefined; return (
{ - void handleAgentLifecycleAction(agent); + void handleAgentLifecycleAction(agent, managedAgentRuntime); }} onOpenProfile={handleOpenProfile} onRemoveMember={handleRemoveMember} @@ -616,6 +626,7 @@ export function MembersSidebar({ } : undefined } + pairAction={pairAction} presenceStatus={ memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null } diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 503362ae54..a332cb8500 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -20,7 +20,11 @@ import { } from "@/features/agents/lib/managedAgentControlActions"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; -import { agentCommunityAvailability } from "@/features/agents/managedAgentRuntimeStatus"; +import { + agentCommunityAvailability, + MANAGED_AGENT_PAIR_ACTION_LABELS, + type ManagedAgentPairAction, +} from "@/features/agents/managedAgentRuntimeStatus"; import { truncatePubkey } from "@/shared/lib/pubkey"; import type { ChannelMember, @@ -49,6 +53,9 @@ type MembersSidebarMemberCardProps = { isArchived: boolean; managedAgent?: ManagedAgent; managedAgentRuntime?: ManagedAgentRuntimeStatus; + /** When set, the lifecycle menu item controls this agent+community pair + * (local agents in a community context) instead of the whole agent. */ + pairAction?: ManagedAgentPairAction; member: ChannelMember; memberAvatarLabel: string; memberIsBot: boolean; @@ -116,6 +123,7 @@ export function MembersSidebarMemberCard({ isArchived, managedAgent, managedAgentRuntime, + pairAction, member, memberAvatarLabel, memberIsBot, @@ -270,6 +278,7 @@ export function MembersSidebarMemberCard({ onUnban={onUnban} onUntimeout={onUntimeout} onViewActivity={onViewActivity} + pairAction={pairAction} /> ) : null}
@@ -297,6 +306,7 @@ function MemberActionsMenu({ onUnban, onUntimeout, onViewActivity, + pairAction, }: { canChangeRole: boolean; canModerateMember: boolean; @@ -316,6 +326,7 @@ function MemberActionsMenu({ onUnban: (member: ChannelMember) => void; onUntimeout: (member: ChannelMember) => void; onViewActivity?: (pubkey: string) => void; + pairAction?: ManagedAgentPairAction; }) { const showChangeRole = canChangeRole && !memberIsBot && member.role !== "owner"; @@ -354,8 +365,12 @@ function MemberActionsMenu({ disabled={disabled} onClick={() => onManagedAgentAction(managedAgent)} > - {getManagedAgentActionIcon(managedAgent)} - {getManagedAgentPrimaryActionLabel(managedAgent)} + {pairAction + ? getPairActionIcon(pairAction) + : getManagedAgentActionIcon(managedAgent)} + {pairAction + ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] + : getManagedAgentPrimaryActionLabel(managedAgent)} {onEditRespondTo ? ( ; + if (action === "restart") return ; + return ; +} + function getManagedAgentActionIcon(agent: ManagedAgent) { if (isManagedAgentActive(agent)) { return ; diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index fcd8620ef7..2586f79218 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -11,12 +11,18 @@ import { startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; +import { useManagedAgentRuntimeAction } from "@/features/agents/managedAgentRuntimeHooks"; +import { managedAgentPairAction } from "@/features/agents/managedAgentRuntimeStatus"; import { channelsQueryKey, useRemoveChannelMemberMutation, } from "@/features/channels/hooks"; import { removeChannelMember } from "@/shared/api/tauri"; -import type { ChannelMember, ManagedAgent } from "@/shared/api/types"; +import type { + ChannelMember, + ManagedAgent, + ManagedAgentRuntimeStatus, +} from "@/shared/api/types"; type UseMembersSidebarActionsOptions = { channelId: string | null; @@ -24,6 +30,9 @@ type UseMembersSidebarActionsOptions = { removableManagedBots: readonly ManagedAgent[]; currentPubkey?: string; onOpenChange: (open: boolean) => void; + /** Active community relay. When set, local-agent lifecycle actions are + * scoped to this agent+community pair instead of the whole agent. */ + relayUrl?: string; }; type BulkAgentActionResult = { @@ -41,11 +50,13 @@ export function useMembersSidebarActions({ removableManagedBots, currentPubkey, onOpenChange, + relayUrl, }: UseMembersSidebarActionsOptions) { const queryClient = useQueryClient(); const removeMemberMutation = useRemoveChannelMemberMutation(channelId); const startManagedAgentMutation = useStartManagedAgentMutation(); const stopManagedAgentMutation = useStopManagedAgentMutation(); + const runtimeActionMutation = useManagedAgentRuntimeAction(); const [actionNoticeMessage, setActionNoticeMessage] = React.useState< string | null >(null); @@ -66,7 +77,8 @@ export function useMembersSidebarActions({ activeActionKey !== null || removeMemberMutation.isPending || startManagedAgentMutation.isPending || - stopManagedAgentMutation.isPending; + stopManagedAgentMutation.isPending || + runtimeActionMutation.isPending; const clearActionFeedback = React.useCallback(() => { setActionNoticeMessage(null); @@ -126,11 +138,35 @@ export function useMembersSidebarActions({ } } - async function handleLifecycleAction(agent: ManagedAgent) { + async function handleLifecycleAction( + agent: ManagedAgent, + runtime?: ManagedAgentRuntimeStatus, + ) { clearActionFeedback(); setActiveActionKey(`agent:${agent.pubkey}`); try { + // Local agents run one harness per agent+community pair. Scope the + // action to the active community so stopping the agent here never + // touches its runtimes in other communities. Provider agents keep the + // agent-wide deploy/!shutdown flow below. + if (agent.backend.type === "local" && relayUrl) { + const action = managedAgentPairAction(runtime); + await runtimeActionMutation.mutateAsync({ + action, + pubkey: agent.pubkey, + relayUrl, + }); + setActionNoticeMessage( + action === "stop" + ? `Stopped ${agent.name} in this community.` + : action === "restart" + ? `Restarted ${agent.name} in this community.` + : `Started ${agent.name} in this community.`, + ); + return; + } + if (isManagedAgentActive(agent)) { await stopManagedAgentWithRules({ agent, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 611adbf559..23a1748225 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -7,6 +7,7 @@ import type { InboxReply, } from "@/features/home/lib/inbox"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; +import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; import { type InboxDisplayMessage, @@ -128,6 +129,7 @@ export function InboxDetailPane({ onToggleReaction, }: InboxDetailPaneProps) { const detailPaneRef = React.useRef(null); + const { activeCommunity } = useCommunities(); // Refs for the shared anchored-scroll hook's container and content roots. const scrollContainerRef = React.useRef(null); const contentRef = React.useRef(null); @@ -583,6 +585,7 @@ export function InboxDetailPane({ currentPubkey={currentPubkey} onOpenChange={setIsMembersSidebarOpen} open={isMembersSidebarOpen} + relayUrl={activeCommunity?.relayUrl} /> ) : null} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1ea8998313..997ec2d37a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -81,6 +81,12 @@ type MockManagedAgentSeed = { respondToAllowlist?: string[]; }; +type MockManagedAgentRuntimeSeed = { + pubkey: string; + relayUrl: string; + lifecycle?: MockManagedAgentRuntimeRow["lifecycle"]; +}; + type MockRelayAgentSeed = { pubkey: string; name: string; @@ -183,6 +189,9 @@ type E2eConfig = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + /** Per agent+relay runtime rows for the pair-scoped lifecycle commands + * (`list/start/stop/restart_managed_agent_runtime`). */ + managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; personas?: MockPersonaSeed[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; @@ -764,6 +773,24 @@ type MockManagedAgent = RawManagedAgent & { log_lines: string[]; }; +// Mirrors the Rust `ManagedAgentRuntimeStatus` camelCase wire shape for the +// pair-scoped lifecycle commands. +type MockManagedAgentRuntimeRow = { + pubkey: string; + relayUrl: string; + localSetup: boolean; + lifecycle: + | "starting" + | "listening" + | "waking" + | "ready" + | "failed" + | "stopped"; + pid: number | null; + error: string | null; + logPath: string | null; +}; + type WsHandler = (message: unknown) => void; const GLOBAL_MOCK_SUBSCRIPTION = "*"; @@ -1967,6 +1994,17 @@ function resetMockRelayAgents(config?: E2eConfig) { function resetMockManagedAgents(config?: E2eConfig) { mockManagedAgents = []; + mockManagedAgentRuntimes = (config?.mock?.managedAgentRuntimes ?? []).map( + (seed) => ({ + pubkey: seed.pubkey, + relayUrl: seed.relayUrl, + localSetup: true, + lifecycle: seed.lifecycle ?? "ready", + pid: seed.lifecycle === "stopped" ? null : 43000, + error: null, + logPath: null, + }), + ); for (const seed of config?.mock?.managedAgents ?? []) { mockManagedAgents.push(buildSeededManagedAgent(seed)); @@ -2663,6 +2701,7 @@ let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; +let mockManagedAgentRuntimes: MockManagedAgentRuntimeRow[] = []; // Mutable `save_subscriptions` table mirror — TEST-ONLY. // @@ -7519,6 +7558,11 @@ async function handleCreateManagedAgent( }; mockManagedAgents.unshift(managedAgent); + if (args.input.spawnAfterCreate && managedAgent.backend.type === "local") { + // The real create command spawns a pair runtime on the agent's effective + // relay (`start_local_agent_with_preflight`), so mirror that row here. + upsertMockManagedAgentRuntime(pubkey, managedAgent.relay_url, "ready"); + } applyMockDisplayName(pubkey, name); mockAgentPubkeys.add(pubkey); mockProfiles.set(pubkey, { @@ -7552,6 +7596,55 @@ function getMockManagedAgent(pubkey: string): MockManagedAgent { return agent; } +function upsertMockManagedAgentRuntime( + pubkey: string, + relayUrl: string, + lifecycle: MockManagedAgentRuntimeRow["lifecycle"], +): MockManagedAgentRuntimeRow { + let row = mockManagedAgentRuntimes.find( + (candidate) => + candidate.pubkey === pubkey && candidate.relayUrl === relayUrl, + ); + if (!row) { + row = { + pubkey, + relayUrl, + localSetup: true, + lifecycle, + pid: null, + error: null, + logPath: null, + }; + mockManagedAgentRuntimes.push(row); + } + row.lifecycle = lifecycle; + row.pid = + lifecycle === "stopped" + ? null + : (row.pid ?? 43000 + mockManagedAgentRuntimes.length); + row.error = null; + return row; +} + +// Pair-scoped lifecycle, mirroring `runtime_commands.rs`: the command touches +// exactly one agent+relay runtime and rejects non-local agents. +function handleManagedAgentRuntimeAction( + action: "start" | "stop" | "restart", + args: { pubkey: string; relayUrl: string }, +): MockManagedAgentRuntimeRow { + const agent = getMockManagedAgent(args.pubkey); + if (agent.backend.type !== "local") { + throw new Error("managed runtime pairs require a local agent"); + } + return { + ...upsertMockManagedAgentRuntime( + args.pubkey, + args.relayUrl, + action === "stop" ? "stopped" : "ready", + ), + }; +} + function isRelayMeshManagedAgent(agent: MockManagedAgent): boolean { return agent.backend.type === "local" && agent.provider === "relay-mesh"; } @@ -7593,6 +7686,9 @@ async function handleStartManagedAgent( } else { agent.status = "running"; agent.pid = agent.pid ?? 42000 + mockManagedAgents.indexOf(agent); + // The real command spawns a pair runtime keyed by the agent's effective + // relay (`start_managed_agent_process`), so mirror that row here. + upsertMockManagedAgentRuntime(agent.pubkey, agent.relay_url, "ready"); } agent.updated_at = now; agent.last_started_at = now; @@ -7616,6 +7712,15 @@ async function handleStopManagedAgent(args: { agent.pid = null; agent.updated_at = now; agent.last_stopped_at = now; + // Legacy agent-wide stop deliberately drains EVERY pair runtime for the + // agent (`runtime/stop.rs`) — mirror that so specs can prove the sidebar + // no longer routes through it. + for (const row of mockManagedAgentRuntimes) { + if (row.pubkey === args.pubkey) { + row.lifecycle = "stopped"; + row.pid = null; + } + } setMockPresenceStatus(agent.pubkey, "offline"); agent.log_lines.push(`stopped mock harness at ${now}`); syncMockRelayAgentsFromManagedAgents(); @@ -9975,6 +10080,23 @@ export function maybeInstallE2eTauriMocks() { return handleStopManagedAgent( payload as Parameters[0], ); + case "list_managed_agent_runtimes": + return mockManagedAgentRuntimes.map((row) => ({ ...row })); + case "start_managed_agent_runtime": + return handleManagedAgentRuntimeAction( + "start", + payload as { pubkey: string; relayUrl: string }, + ); + case "stop_managed_agent_runtime": + return handleManagedAgentRuntimeAction( + "stop", + payload as { pubkey: string; relayUrl: string }, + ); + case "restart_managed_agent_runtime": + return handleManagedAgentRuntimeAction( + "restart", + payload as { pubkey: string; relayUrl: string }, + ); case "set_agent_managed_profiles": return undefined; case "set_managed_agent_auto_restart": diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index dce8dd2fa7..7da20334f3 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2925,18 +2925,22 @@ test("removing a channel-scoped agent preserves the managed agent record", async await expect(page.getByTestId(`managed-agent-${agentPubkey}`)).toHaveCount(1); }); -test("members sidebar can respawn a stopped managed bot", async ({ page }) => { +test("members sidebar can stop and start a managed bot in this community", async ({ + page, +}) => { const agentName = `sidebar-agent-${Date.now()}`; await page.goto("/"); const agentPubkey = await addGenericAgent(page, "general", agentName); const baselineCommands = await readCommandLog(page); - const baselineStartCount = baselineCommands.filter( - (command) => command === "start_managed_agent", - ).length; - const baselineStopCount = baselineCommands.filter( - (command) => command === "stop_managed_agent", - ).length; + const baselineLegacyStartCount = commandCount( + baselineCommands, + "start_managed_agent", + ); + const baselineLegacyStopCount = commandCount( + baselineCommands, + "stop_managed_agent", + ); await openMembersSidebar(page, "general"); @@ -2945,30 +2949,108 @@ test("members sidebar can respawn a stopped managed bot", async ({ page }) => { ); const agentAction = page.getByTestId(`sidebar-agent-action-${agentPubkey}`); - await expect(agentStatus).toContainText("Running"); + // The badge is pair-scoped: it reports the agent+active-community runtime. + await expect(agentStatus).toContainText("Here"); await openMemberMenu(page, agentPubkey); await expect(agentAction).toContainText("Stop"); await agentAction.click(); - await expect(agentStatus).toContainText("Stopped"); + await expect(agentStatus).toContainText("Unavailable"); await openMemberMenu(page, agentPubkey); - await expect(agentAction).toContainText("Respawn"); + await expect(agentAction).toContainText("Start"); await agentAction.click(); - await expect(agentStatus).toContainText("Running"); + await expect(agentStatus).toContainText("Here"); await expect( page .locator("[data-sonner-toast]") - .filter({ hasText: `Respawned ${agentName}.` }), + .filter({ hasText: `Started ${agentName} in this community.` }), ).toBeVisible(); + // The lifecycle menu item must dispatch the pair-scoped runtime commands, + // never the legacy agent-wide start/stop that drains every community. const commands = await readCommandLog(page); + expect(commandCount(commands, "stop_managed_agent_runtime")).toBe(1); + expect(commandCount(commands, "start_managed_agent_runtime")).toBe(1); + expect(commandCount(commands, "start_managed_agent")).toBe( + baselineLegacyStartCount, + ); + expect(commandCount(commands, "stop_managed_agent")).toBe( + baselineLegacyStopCount, + ); +}); + +test("stopping a managed bot in one community leaves its other communities running", async ({ + page, +}) => { + const agentPubkey = TEST_IDENTITIES.charlie.pubkey; + // Must match the relay of the community seeded by the mock bridge + // (`seedDefaultCommunity` follows BUZZ_E2E_RELAY_URL); the agent also has a + // live runtime in a second community on another relay. + const activeRelayUrl = ( + process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000" + ).replace(/^http/, "ws"); + const otherRelayUrl = "ws://other-community.example"; + + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agentPubkey, + name: "charlie", + status: "running", + channelNames: ["general"], + }, + ], + managedAgentRuntimes: [ + { pubkey: agentPubkey, relayUrl: activeRelayUrl }, + { pubkey: agentPubkey, relayUrl: otherRelayUrl }, + ], + }); + await page.goto("/"); + await openMembersSidebar(page, "general"); + + const agentStatus = page.getByTestId( + `sidebar-managed-agent-status-${agentPubkey}`, + ); + const agentAction = page.getByTestId(`sidebar-agent-action-${agentPubkey}`); + + await expect(agentStatus).toContainText("Here"); + await openMemberMenu(page, agentPubkey); + await expect(agentAction).toContainText("Stop"); + await agentAction.click(); + + await expect(agentStatus).toContainText("Unavailable"); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Stopped charlie in this community." }), + ).toBeVisible(); + + // Stop(A) must leave the community-B runtime untouched. + const runtimes = await page.evaluate(async () => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + ) => Promise>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) { + throw new Error("Mock bridge is not installed."); + } + return invoke("list_managed_agent_runtimes"); + }); expect( - commands.filter((command) => command === "start_managed_agent").length, - ).toBe(baselineStartCount + 1); + runtimes.find((runtime) => runtime.relayUrl === activeRelayUrl)?.lifecycle, + ).toBe("stopped"); expect( - commands.filter((command) => command === "stop_managed_agent").length, - ).toBe(baselineStopCount + 1); + runtimes.find((runtime) => runtime.relayUrl === otherRelayUrl)?.lifecycle, + ).toBe("ready"); + + // And it must never route through the legacy agent-wide stop. + const commands = await readCommandLog(page); + expect(commandCount(commands, "stop_managed_agent_runtime")).toBe(1); + expect(commandCount(commands, "stop_managed_agent")).toBe(0); }); test("members sidebar omits bulk controls for managed bots", async ({ diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8273e697a4..15606e82c9 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -197,6 +197,18 @@ type MockBridgeOptions = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + /** Per agent+relay runtime rows for pair-scoped lifecycle commands. */ + managedAgentRuntimes?: Array<{ + pubkey: string; + relayUrl: string; + lifecycle?: + | "starting" + | "listening" + | "waking" + | "ready" + | "failed" + | "stopped"; + }>; personas?: MockPersonaSeed[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; From 6c5725896256f1e76c639fb2f06e3aa721c7d4b9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 21 Jul 2026 11:46:42 -0700 Subject: [PATCH 24/28] fix(desktop): make agent status, stop, and bootstrap pair-scoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the pair-runtime fix plan so each community's UI tells the truth about its own (agent, community) pair: - Summary scoping: build_managed_agent_summary resolves the viewed workspace's pair key (relay pin, else active workspace relay) and looks up status/pid/log and needs_restart by exact pair key — an agent running only in another community now reads as stopped here, and restart drift is hashed against the pair's own relay instead of the currently viewed workspace's, so community switches no longer flag spurious restarts. - Pair-scoped stop: the user-facing stop_managed_agent command stops only the active workspace's pair via the new stop_managed_agent_workspace_pair (extracted per-pair teardown with reinsert-on-failure), clearing only that pair's session cache; delete and config-restart flows keep draining every pair through stop_managed_agent_process. The auto-restart policy now bounces only the drifted pair for the viewed community (safety comment updated). - Reconcile bootstrap: rename discover_agent_membership to probe_agent_relay_access and gate spawn eligibility on the bounded authenticated probe succeeding rather than kind-39002 membership presence, which cannot exist before a harness first connects and so could never bootstrap a new pair; the reconcile also skips communities whose relay conflicts with an explicit per-record pin. - Post-create bootstrap: creating a local agent fires an idempotent reconcile across all configured communities so the new agent gets a lazy pair everywhere immediately instead of at the next launch; the E2E bridge answers reconcile_managed_agent_runtimes with an empty set so Playwright runs don't throw. Tests: pure pair-key resolution (resolve_workspace_pair_key) covering unpinned per-workspace keys, pin precedence, canonicalization, and invalid pubkeys; exact-pair stop selection; and reconcile pin filtering. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 25 ++- desktop/src-tauri/src/commands/agents.rs | 14 +- .../src-tauri/src/managed_agents/runtime.rs | 68 ++++-- .../src/managed_agents/runtime/stop.rs | 203 +++++++++++++----- .../src/managed_agents/runtime/tests.rs | 48 +++++ .../src/managed_agents/runtime_commands.rs | 86 +++++++- desktop/src/features/agents/hooks.ts | 7 + .../features/agents/lib/autoRestartPolicy.ts | 14 +- .../agents/managedAgentRuntimeHooks.ts | 32 +++ desktop/src/testing/e2eBridge.ts | 3 + 10 files changed, 401 insertions(+), 99 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3aeb04996c..7b1f5040e8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -114,7 +114,11 @@ const overrides = new Map([ // keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores // and load_readonly support; file grew past 1000 default. Queued to split. // +7 for try_delete_agent_key result-returning seam (snapshot-import rollback). - ["src-tauri/src/managed_agents/storage.rs", 1335], + // +48 (1335 -> 1383): agents-everywhere pair re-key — pair-scoped runtime + // receipts (write_agent_runtime_receipt atomic JSON + remove/read_all + // helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped + // runtime log path. Load-bearing crash-recovery surface; queued to split. + ["src-tauri/src/managed_agents/storage.rs", 1383], // harness-persona-sync: persona-runtime resolution threaded into the spawn // path here. Load-bearing feature growth; queued to split in the resolver // unify refactor followup. +26 for resolve_effective_prompt_model_provider @@ -321,7 +325,11 @@ const overrides = new Map([ // the relay_admission freshness-verification test. The loopback mock was // hardened (std::net + request-read-before-write) adding ~10 lines. // Queued to split test helpers to relay/tests.rs. - ["src-tauri/src/relay.rs", 1047], + // +30 (1047 -> 1077): agents-everywhere pair re-key — query_relay_at_with_keys + // (NIP-98 signed /query with explicit agent keys + optional x-auth-tag) for + // bounded-auth agent relay-membership discovery. Load-bearing; queued to + // split alongside the test-helper split. + ["src-tauri/src/relay.rs", 1077], // degraded-network resilience: visibleChannelId field + getter/setter, NOTICE // handler for relay back-pressure, and rate-limit gate imports add ~74 lines // of load-bearing degraded-network recovery code. Queued to split. @@ -392,7 +400,12 @@ const overrides = new Map([ // +5 (1068 -> 1073): merge with main, which independently added the // managed_agent_profile_reconcile_enabled flag (field + doc + init) under // its own 1042-line override. Union of two separately approved additions. - ["src-tauri/src/app_state.rs", 1073], + // +8 (1073 -> 1081): agents-everywhere pair re-key — managed_agent_processes + // and session_config_cache re-keyed by ManagedAgentRuntimeKey, the runtime + // transition lock doc broadened to cover all protected-PID transitions, and + // clear_agent_session_caches (per-pubkey retain) added alongside the + // per-key clear. Load-bearing identity-contract change; queued to split. + ["src-tauri/src/app_state.rs", 1081], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, @@ -462,7 +475,11 @@ const overrides = new Map([ // is_safe_to_reveal allowlist + baked_env_thinking_effort_is_unmasked test. // +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub. // +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub. - ["src-tauri/src/commands/agent_config.rs", 1021], + // +11 (1021 -> 1032): agents-everywhere pair re-key — session-cache reads in + // get_agent_config_surface derive the ManagedAgentRuntimeKey (relay-URL + // fallback resolution) and put_agent_session_config gains a relay_url param. + // Load-bearing identity plumbing; queued to split. + ["src-tauri/src/commands/agent_config.rs", 1032], // codex-install-auto-restart review-fixes: should_restart_after_install // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy // cache tests replaced with 6 pure availability_drift predicate tests; diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index e049a4a862..c65900b5de 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -8,10 +8,11 @@ use crate::{ ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, - validate_provider_config, BackendKind, CreateManagedAgentRequest, - CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + stop_managed_agent_process, stop_managed_agent_workspace_pair, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -1215,9 +1216,10 @@ pub async fn stop_managed_agent( "remote agents are stopped via !shutdown message, not this command".to_string(), ); } - stop_managed_agent_process(&app, record, &mut runtimes)?; + // Pair-scoped: stops only the active workspace's pair; delete and + // the config-restart flows still drain every pair. + stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; } - state.clear_agent_session_caches(&pubkey); save_managed_agents(&app, &records)?; let record = records .iter() diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1b506329bf..a80a19f483 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -19,7 +19,7 @@ pub(in crate::managed_agents) use path::build_augmented_path; mod stop; pub(crate) use stop::managed_agent_runtime_keys; -pub use stop::stop_managed_agent_process; +pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; @@ -1337,6 +1337,36 @@ fn persona_drift_state( (out_of_date, false) } +/// Resolve the runtime-pair key this record maps to for the active +/// workspace: an explicit per-record relay pin wins, otherwise the active +/// workspace relay. Returns `None` for records that cannot form a valid +/// pair key yet (e.g. key-less agents that mint keys on first start). +pub(crate) fn workspace_pair_key( + app: &AppHandle, + record: &ManagedAgentRecord, +) -> Option { + use tauri::Manager; + let state = app.state::(); + resolve_workspace_pair_key( + &record.pubkey, + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ) +} + +/// Pure core of [`workspace_pair_key`]: pin-else-workspace relay precedence +/// plus canonical key construction, kept `AppHandle`-free so summary/stop +/// scoping semantics are unit-testable. +pub(crate) fn resolve_workspace_pair_key( + pubkey: &str, + record_relay_url: &str, + workspace_relay_url: &str, +) -> Option { + let effective_relay = + crate::relay::effective_agent_relay_url(record_relay_url, workspace_relay_url); + ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() +} + pub fn build_managed_agent_summary( app: &AppHandle, record: &ManagedAgentRecord, @@ -1345,6 +1375,14 @@ pub fn build_managed_agent_summary( ) -> Result { use crate::managed_agents::BackendKind; + // Community-scoped truth: this summary describes the pair for the relay + // the record resolves to right now (pin, else active workspace). An agent + // running only in another community must read as stopped here — matching + // by pubkey alone would show every community a green light as long as any + // pair anywhere is alive. + let pair_key = workspace_pair_key(app, record); + let pair_runtime = pair_key.as_ref().and_then(|key| runtimes.get(key)); + let (status, pid, log_path) = if record.backend != BackendKind::Local { // Two-axis status model for remote agents: // @@ -1369,11 +1407,7 @@ pub fn build_managed_agent_summary( (status, None, String::new()) } else { let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); - if let Some(runtime) = runtimes - .iter() - .find(|(key, _)| key.pubkey == record.pubkey) - .map(|(_, runtime)| runtime) - { + if let Some(runtime) = pair_runtime { ( "running".to_string(), Some(runtime.child.id()), @@ -1405,22 +1439,22 @@ pub fn build_managed_agent_summary( let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); // Restart badge: the running process stamped its effective spawn config - // at launch; recompute from current disk state and flag drift. Only a - // tracked live process can drift — stopped agents spawn fresh, and - // adopted (runtime_pid-only) processes have no stamped hash to compare. + // at launch; recompute from current disk state and flag drift. Only the + // tracked live pair for THIS workspace can drift — stopped agents spawn + // fresh, adopted (runtime_pid-only) processes have no stamped hash to + // compare, and pairs running for other communities are judged in their + // own community (hashing them against this workspace's relay would flag + // a spurious restart on every community switch). // // Additionally, for runtimes with an adapter version gate (codex only), // check whether the cached adapter availability has drifted from the value // stamped at spawn. This catches out-of-band adapter changes (manual // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The // cache is read-only here — no subprocess is spawned. - let needs_restart = runtimes - .iter() - .find(|(key, _)| key.pubkey == record.pubkey) - .map(|(_, runtime)| runtime) - .is_some_and(|runtime| { - use tauri::Manager; - let state = app.state::(); + let needs_restart = pair_key + .as_ref() + .and_then(|key| runtimes.get(key).map(|runtime| (key, runtime))) + .is_some_and(|(key, runtime)| { let global_for_hash = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); @@ -1429,7 +1463,7 @@ pub fn build_managed_agent_summary( record, personas, &teams_for_hash, - &crate::relay::relay_ws_url_with_override(&state), + &key.relay_url, &global_for_hash, ); let availability_drift = super::availability_drift( diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 8bbfd26413..08bca15feb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -30,6 +30,126 @@ pub(crate) fn managed_agent_runtime_relay_urls( .collect() } +/// Stop the single tracked runtime pair at `key`, if present. +/// +/// Terminates the child, records the exit code, removes the pair receipt, +/// and appends a stop marker to the pair log. On teardown failure the +/// runtime is reinserted so the pair stays visible and stoppable instead of +/// becoming an invisible orphan. Touches no other pair for the agent and +/// does no record-level stop bookkeeping — callers own that. +fn stop_managed_agent_pair( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, + key: &ManagedAgentRuntimeKey, +) -> Result<(), String> { + let Some(mut runtime) = runtimes.remove(key) else { + return Ok(()); + }; + let result = (|| -> Result<(), String> { + #[cfg(unix)] + terminate_process(runtime.child.id())?; + #[cfg(windows)] + match runtime.job.take() { + Some(job) => drop(job), + None => runtime + .child + .kill() + .map_err(|error| format!("failed to kill agent process: {error}"))?, + } + #[cfg(not(any(unix, windows)))] + runtime + .child + .kill() + .map_err(|error| format!("failed to kill agent process: {error}"))?; + let status = runtime + .child + .wait() + .map_err(|error| format!("failed to wait for agent shutdown: {error}"))?; + record.last_exit_code = status.code(); + super::super::remove_agent_runtime_receipt(app, key); + if let Err(error) = append_log_marker( + &runtime.log_path, + &format!( + "=== stopped {} ({}) at {} ===", + record.name, + record.pubkey, + now_iso() + ), + ) { + eprintln!( + "buzz-desktop: failed to append stop marker for {} on {}: {error}", + record.pubkey, key.relay_url + ); + } + Ok(()) + })(); + if let Err(error) = result { + // Keep failed teardown visible/manageable instead of orphaning it. + runtimes.insert(key.clone(), runtime); + return Err(error); + } + Ok(()) +} + +/// Terminate a legacy scalar-PID child (pre-pair records) and remove the +/// agent-scoped pid file. Pair receipts are restored separately. +fn stop_legacy_scalar_pid(app: &AppHandle, record: &mut ManagedAgentRecord) -> Result<(), String> { + if let Some(pid) = record.runtime_pid.take() { + if process_is_running(pid) + && process_belongs_to_us(pid) + && process_has_buzz_marker(pid, ¤t_instance_id(app)) + { + terminate_process(pid)?; + } + record.updated_at = now_iso(); + } + super::super::remove_agent_pid_file(app, &record.pubkey); + Ok(()) +} + +/// Stop the runtime pair this record resolves to for the active workspace +/// (explicit relay pin, else the active workspace relay) — the pair-scoped +/// counterpart of [`stop_managed_agent_process`], which drains every pair. +/// +/// Community-scoped surfaces (profile panel, Agents tab, auto-restart) stop +/// through here so stopping an agent in one community never tears down its +/// pairs in other communities. Clears the matching agent session cache +/// (pair-scoped when a pair key resolves). When no pair is tracked for this +/// workspace, only legacy scalar-PID cleanup runs. +pub fn stop_managed_agent_workspace_pair( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, +) -> Result<(), String> { + use tauri::Manager; + let state = app.state::(); + match super::workspace_pair_key(app, record) { + Some(pair_key) if runtimes.contains_key(&pair_key) => { + stop_managed_agent_pair(app, record, runtimes, &pair_key)?; + state.clear_agent_session_cache(&pair_key); + super::super::remove_agent_pid_file(app, &record.pubkey); + let now = now_iso(); + record.runtime_pid = None; + record.updated_at = now.clone(); + record.last_stopped_at = Some(now); + record.last_error = None; + record.last_error_code = None; + } + Some(pair_key) => { + // No tracked pair here — a pubkey-wide cache clear would disturb + // live pairs in other communities, so stay pair-scoped. + stop_legacy_scalar_pid(app, record)?; + state.clear_agent_session_cache(&pair_key); + } + None => { + stop_legacy_scalar_pid(app, record)?; + state.clear_agent_session_caches(&record.pubkey); + } + } + Ok(()) +} + pub fn stop_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, @@ -37,67 +157,13 @@ pub fn stop_managed_agent_process( ) -> Result<(), String> { let keys = managed_agent_runtime_keys(runtimes, &record.pubkey); if keys.is_empty() { - // Legacy PID cleanup only; pair receipts are restored separately. - if let Some(pid) = record.runtime_pid.take() { - if process_is_running(pid) - && process_belongs_to_us(pid) - && process_has_buzz_marker(pid, ¤t_instance_id(app)) - { - terminate_process(pid)?; - } - record.updated_at = now_iso(); - } - super::super::remove_agent_pid_file(app, &record.pubkey); - return Ok(()); + return stop_legacy_scalar_pid(app, record); } let mut errors = Vec::new(); for key in keys { - let Some(mut runtime) = runtimes.remove(&key) else { - continue; - }; - let result = (|| -> Result<(), String> { - #[cfg(unix)] - terminate_process(runtime.child.id())?; - #[cfg(windows)] - match runtime.job.take() { - Some(job) => drop(job), - None => runtime - .child - .kill() - .map_err(|error| format!("failed to kill agent process: {error}"))?, - } - #[cfg(not(any(unix, windows)))] - runtime - .child - .kill() - .map_err(|error| format!("failed to kill agent process: {error}"))?; - let status = runtime - .child - .wait() - .map_err(|error| format!("failed to wait for agent shutdown: {error}"))?; - record.last_exit_code = status.code(); - super::super::remove_agent_runtime_receipt(app, &key); - if let Err(error) = append_log_marker( - &runtime.log_path, - &format!( - "=== stopped {} ({}) at {} ===", - record.name, - record.pubkey, - now_iso() - ), - ) { - eprintln!( - "buzz-desktop: failed to append stop marker for {} on {}: {error}", - record.pubkey, key.relay_url - ); - } - Ok(()) - })(); - if let Err(error) = result { + if let Err(error) = stop_managed_agent_pair(app, record, runtimes, &key) { errors.push(format!("{}: {error}", key.relay_url)); - // Keep failed teardown visible/manageable instead of orphaning it. - runtimes.insert(key, runtime); } } @@ -143,6 +209,31 @@ mod tests { ); } + #[test] + fn pair_scoped_selection_targets_only_the_exact_pair() { + // stop_managed_agent_workspace_pair resolves one key and removes only + // that map entry: the same agent's pair on another relay and other + // agents' pairs must survive a pair-scoped stop. + let agent = "aa".repeat(32); + let other = "bb".repeat(32); + let viewed = ManagedAgentRuntimeKey::new(&agent, "wss://one.example").unwrap(); + let elsewhere = ManagedAgentRuntimeKey::new(&agent, "wss://two.example").unwrap(); + let unrelated = ManagedAgentRuntimeKey::new(other, "wss://one.example").unwrap(); + let mut runtimes = HashMap::from([ + (viewed.clone(), ()), + (elsewhere.clone(), ()), + (unrelated.clone(), ()), + ]); + + // Non-canonical spelling of the viewed workspace relay resolves to + // the same canonical key that spawn stamped. + let resolved = ManagedAgentRuntimeKey::new(&agent, "WSS://One.Example:443/").unwrap(); + assert_eq!(resolved, viewed); + assert!(runtimes.remove(&resolved).is_some()); + assert!(runtimes.contains_key(&elsewhere)); + assert!(runtimes.contains_key(&unrelated)); + } + #[test] fn agent_wide_selection_drains_every_pair_only_for_that_agent() { let agent = "aa".repeat(32); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 67beb8c15e..45b6b98a4e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -896,3 +896,51 @@ fn replacement_failure_keeps_receipt() { assert_eq!(error, "signal failed"); assert!(!removed.get()); } + +// ── workspace pair-key resolution (summary/stop scoping) ──────────────── + +#[test] +fn unpinned_record_resolves_pair_key_per_workspace() { + // Community-scoped truth: an unpinned agent running only on relay A must + // read as running in workspace A and stopped in workspace B — the pair + // key the summary looks up differs per workspace. + let pubkey = "aa".repeat(32); + let key_a = super::resolve_workspace_pair_key(&pubkey, "", "wss://one.example").unwrap(); + let key_b = super::resolve_workspace_pair_key(&pubkey, "", "wss://two.example").unwrap(); + + let runtimes = std::collections::HashMap::from([(key_a.clone(), ())]); + assert!(runtimes.contains_key(&key_a)); + assert!(!runtimes.contains_key(&key_b)); +} + +#[test] +fn pinned_record_resolves_to_pin_in_every_workspace() { + // A pin means "this agent lives on that relay": the same pair is + // resolved (and judged for drift) no matter which community is viewed. + let pubkey = "aa".repeat(32); + let from_a = + super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://one.example") + .unwrap(); + let from_b = + super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://two.example") + .unwrap(); + assert_eq!(from_a, from_b); + assert_eq!(from_a.relay_url, "wss://pinned.example"); +} + +#[test] +fn workspace_pair_key_is_canonical() { + // Spawn stamps the canonical key; lookup must hit the same entry even + // when the workspace relay is written in a non-canonical form. + let pubkey = "aa".repeat(32); + let stamped = super::resolve_workspace_pair_key(&pubkey, "", "wss://one.example").unwrap(); + let viewed = super::resolve_workspace_pair_key(&pubkey, "", "WSS://One.Example:443/").unwrap(); + assert_eq!(stamped, viewed); +} + +#[test] +fn invalid_pubkey_resolves_no_pair_key() { + // Key-less records (keys minted on first start) cannot form a pair key; + // the summary must fall back to the stopped/legacy-pid path, not panic. + assert!(super::resolve_workspace_pair_key("not-a-key", "", "wss://one.example").is_none()); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index e77ba544e3..64a722e1aa 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -315,16 +315,25 @@ pub fn restart_managed_agent_runtime( start_pair(pubkey, relay_url, true, None, app) } -async fn discover_agent_membership( +/// Probe whether this agent can operate on `requested_relay_url`. +/// +/// Runs a bounded authenticated query with the agent's own keys (NIP-42 + +/// NIP-OA auth tag). Auth success is the spawn-eligibility signal: NIP-29 +/// membership (kind 39002) cannot exist before the agent's harness first +/// connects to a relay, so gating on membership *presence* could never +/// bootstrap a pair on a newly configured community — it only rediscovered +/// pairs that had already run. A rejected or timed-out probe surfaces as a +/// Failed status row instead of a silent skip. +async fn probe_agent_relay_access( state: &AppState, record: super::ManagedAgentRecord, requested_relay_url: String, -) -> Result, String> { +) -> Result<(super::ManagedAgentRecord, ManagedAgentRuntimeKey, String), String> { let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested_relay_url)?; let keys = nostr::Keys::parse(record.private_key_nsec.trim()) .map_err(|error| format!("invalid managed-agent key: {error}"))?; let api_base = crate::relay::relay_http_base_url(&key.relay_url); - let events = tokio::time::timeout( + tokio::time::timeout( std::time::Duration::from_secs(10), crate::relay::query_relay_at_with_keys( state, @@ -335,8 +344,25 @@ async fn discover_agent_membership( ), ) .await - .map_err(|_| "membership discovery timed out".to_string())??; - Ok((!events.is_empty()).then_some((record, key, requested_relay_url))) + .map_err(|_| "relay access probe timed out".to_string())??; + Ok((record, key, requested_relay_url)) +} + +/// True when `record` may run a pair on `relay_url`: unpinned records run on +/// every configured community; a record with an explicit `relay_url` pin runs +/// only on that relay (compared canonically). +fn record_allows_relay(record: &super::ManagedAgentRecord, relay_url: &str) -> bool { + let pinned = record.relay_url.trim(); + if pinned.is_empty() { + return true; + } + match ( + buzz_core_pkg::relay::normalize_relay_url(pinned), + buzz_core_pkg::relay::normalize_relay_url(relay_url), + ) { + (Ok(pinned), Ok(requested)) => pinned == requested, + _ => false, + } } #[tauri::command] @@ -352,17 +378,20 @@ pub async fn reconcile_managed_agent_runtimes( for record in records .iter() .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) + // Respect explicit relay pins: a pinned agent runs only on its + // pinned relay — never spawn it onto other communities. + .filter(|record| record_allows_relay(record, &community.relay_url)) { jobs.push((record.clone(), community.relay_url.clone())); } } - let discoveries: Vec<_> = stream::iter(jobs) + let probes: Vec<_> = stream::iter(jobs) .map(|(record, requested)| { let state = app.state::(); async move { let fallback_record = record.clone(); let fallback_requested = requested.clone(); - discover_agent_membership(&state, record, requested) + probe_agent_relay_access(&state, record, requested) .await .map_err(|error| (fallback_record, fallback_requested, error)) } @@ -372,9 +401,9 @@ pub async fn reconcile_managed_agent_runtimes( .await; let mut rows = Vec::new(); - for discovery in discoveries { - match discovery { - Ok(Some((record, key, requested))) => { + for probe in probes { + match probe { + Ok((record, key, requested)) => { match start_pair( record.pubkey.clone(), key.relay_url.clone(), @@ -394,7 +423,6 @@ pub async fn reconcile_managed_agent_runtimes( } } } - Ok(None) => {} Err((record, requested, error)) => { let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested)?; let mut status = status_for(&app, &record, &key, None, Some(requested)); @@ -425,6 +453,42 @@ mod tests { } } + fn record_with_relay(relay_url: &str) -> super::super::ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{}", + "name": "pin-test", + "relay_url": "{relay_url}", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"#, + "aa".repeat(32) + )) + .unwrap() + } + + #[test] + fn unpinned_record_allows_every_relay() { + let record = record_with_relay(""); + assert!(record_allows_relay(&record, "wss://one.example")); + assert!(record_allows_relay(&record, "wss://two.example")); + } + + #[test] + fn pinned_record_allows_only_its_canonical_relay() { + let record = record_with_relay("wss://one.example"); + assert!(record_allows_relay(&record, "wss://one.example")); + // Canonical comparison: scheme case, default port, trailing slash. + assert!(record_allows_relay(&record, "WSS://One.Example:443/")); + assert!(!record_allows_relay(&record, "wss://two.example")); + } + #[test] fn runtime_key_rejects_non_hex_pubkeys() { assert!(ManagedAgentRuntimeKey::new("../not-a-key", "wss://relay.example").is_err()); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index ff09cf0f41..12ff35a5bd 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -42,6 +42,7 @@ import { startManagedAgent, stopManagedAgent, } from "@/shared/api/tauriManagedAgents"; +import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; import { createPersona, deletePersona, @@ -347,6 +348,12 @@ export function useCreateManagedAgentMutation() { ]; }, ); + + // The create command spawns only the active community's pair — kick a + // reconcile so the new agent gets a lazy pair in every other community. + if (created.agent.backend.type === "local") { + bootstrapManagedAgentRuntimePairs(queryClient); + } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.ts b/desktop/src/features/agents/lib/autoRestartPolicy.ts index ad7a18a253..2d06a42e39 100644 --- a/desktop/src/features/agents/lib/autoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/autoRestartPolicy.ts @@ -3,11 +3,15 @@ import type { AgentWorkingSource } from "../agentWorkingSignal"; /** * Chunk F auto-restart policy — the pure decision core. * - * SAFETY-CRITICAL: `stop_managed_agent_process` is SIGTERM → ≤1s → SIGKILL - * with no in-process drain, so this predicate is the ONLY thing standing - * between the policy loop and killing a mid-turn agent. Every never-fire - * condition below is load-bearing; the test matrix enumerates them - * exhaustively. + * SAFETY-CRITICAL: the stop command is SIGTERM → ≤1s → SIGKILL with no + * in-process drain, so this predicate is the ONLY thing standing between + * the policy loop and killing a mid-turn agent. Every never-fire condition + * below is load-bearing; the test matrix enumerates them exhaustively. + * + * Scope: the stop/start commands the loop fires are pair-scoped to the + * active community, and `needsRestart` reports drift for that same pair — + * a fire bounces only the community being viewed, never an agent's pairs + * in other communities. * * Decisions: * - "fire": restart now (all gates green, continuity window satisfied). diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 46ad1bd034..aef9c8c1e8 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -5,8 +5,10 @@ import { type QueryClient, } from "@tanstack/react-query"; +import { loadCommunities } from "@/features/communities/communityStorage"; import { listManagedAgentRuntimes, + reconcileManagedAgentRuntimes, restartManagedAgentRuntime, startManagedAgentRuntime, stopManagedAgentRuntime, @@ -61,6 +63,36 @@ export function cacheReconciledManagedAgentRuntimes( ); } +/** + * Bootstrap runtime pairs in every configured community (fire-and-forget). + * + * Called after an agent create: the create command spawns only the active + * community's pair, and the startup reconcile won't run again until the next + * launch or community switch, so without this kick a brand-new agent stays + * deaf in every other community. Idempotent — live pairs are skipped and + * missing ones spawn lazily (warm socket, no LLM until first mention). + */ +export function bootstrapManagedAgentRuntimePairs( + queryClient: QueryClient, +): void { + const baseline = queryClient.getQueryData( + managedAgentRuntimesQueryKey, + ); + const communities = loadCommunities().map((community) => ({ + relayUrl: community.relayUrl, + })); + void reconcileManagedAgentRuntimes(communities) + .then((runtimes) => { + cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes); + }) + .catch((error) => { + console.warn( + "[managed-agent-runtimes] post-create reconcile failed:", + error, + ); + }); +} + export function useManagedAgentRuntimesQuery(options?: { enabled?: boolean }) { return useQuery({ enabled: options?.enabled ?? true, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 997ec2d37a..c07f508e42 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10097,6 +10097,9 @@ export function maybeInstallE2eTauriMocks() { "restart", payload as { pubkey: string; relayUrl: string }, ); + case "reconcile_managed_agent_runtimes": + // Post-create bootstrap reconcile: no new pairs in the mock world. + return []; case "set_agent_managed_profiles": return undefined; case "set_managed_agent_auto_restart": From 6e748dcf658595eb85ef15144f807b1a09cf87de Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 21 Jul 2026 14:06:28 -0700 Subject: [PATCH 25/28] fix(desktop+acp): close remaining agents-everywhere review gaps Address the cross-agent review of the pair-runtime rework (b469dc1) and finish the plan's Phase 2/3 gaps so every community's UI tells the truth about its own (agent, community) pair and bootstraps reliably. Silent functional breaks: - Session-config bridge: put_agent_session_config no longer requires a relay_url arg the frontend never passed (which silently stopped live-captured model/config from reaching the cache). It reads the pair relay from the harness-attached payload relayUrl (buzz-acp now rides it in session_config_captured, like the lifecycle frames), falling back to the record's effective relay for older harnesses. - Reconcile degradation: a community whose relay URL cannot form a pair key now yields a Failed row (keyed by the raw requested URL) instead of ?-aborting the whole batch and dropping every other community's row. - Pair-status matching: findManagedAgentRuntime canonicalizes the stored community URL (localhost->127.0.0.1, default ports, trailing slash) to match backend-canonical rows, so the pair menu/badge no longer show a running ws://localhost:3000 pair as stopped after a list refetch. buzz-acp robustness: - Gate the lazy Failed-state retry-deadline sleep on pending work so a drained queue after a failed wake no longer busy-spins the select loop. - Drain wake tasks gracefully on shutdown (fire the watch, 30s timeout, abort only as a backstop) so a mid-initialize pool reaps its own partially-spawned agents instead of leaking them via best-effort Drop. Desktop lifecycle + perf: - Shutdown drains every tracked pair per record (not just the first), so a multi-community agent gets the graceful SIGTERM->2s->SIGKILL fan-out. - list_managed_agent_runtimes loads personas/global config once outside the locks and skips the store rewrite when no runtime exited. - reconcile runs its blocking start_pair loop on spawn_blocking. Feedback's substantive gaps (Phase 2): - Members-sidebar search-add routes a local managed agent through attachManagedAgentToChannel (membership + ensure the active community's pair) instead of a bare membership add that leaves it deaf. - Runtime reconciliation is now incremental and retrying: keyed by canonical relay URL, it reconciles a newly configured relay as soon as it appears (no longer coupled to the add flow also switching communities) and retries a failed relay with a capped backoff (5s/30s/2m) rather than giving up until the next switch or relaunch. Docs + tests (Phase 3): - Document that auto-start gating on reconcile is proactive fan-out policy, not a correctness prerequisite; refresh the stale attach comment to note the status check and start are pair-scoped to the active community. - Unit tests: pure reconciliation planning (pending/classify/backoff) and the malformed-URL Failed-row degradation. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- crates/buzz-acp/src/config.rs | 8 +- crates/buzz-acp/src/lib.rs | 37 ++- crates/buzz-acp/src/pool.rs | 8 + desktop/scripts/check-file-sizes.mjs | 6 +- .../src-tauri/src/commands/agent_config.rs | 26 +- .../src/managed_agents/runtime_commands.rs | 233 +++++++++++++++--- desktop/src-tauri/src/shutdown.rs | 33 ++- desktop/src/features/agents/channelAgents.ts | 8 +- .../managedAgentReconciliationPlan.test.mjs | 108 ++++++++ .../agents/managedAgentReconciliationPlan.ts | 95 +++++++ .../agents/managedAgentRuntimeStatus.test.mjs | 36 +++ .../agents/managedAgentRuntimeStatus.ts | 39 ++- .../useManagedAgentRuntimeReconciliation.ts | 134 ++++++++-- desktop/src/features/channels/hooks.ts | 2 +- .../features/channels/ui/MembersSidebar.tsx | 31 +++ 15 files changed, 721 insertions(+), 83 deletions(-) create mode 100644 desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs create mode 100644 desktop/src/features/agents/managedAgentReconciliationPlan.ts diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index c2a5dd2485..a38d6faa14 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -2036,14 +2036,16 @@ channels = "ALL" #[test] fn lazy_pool_defaults_off() { - assert!(!CliArgs::parse_from(["buzz-acp"]).lazy_pool); + let key = "0".repeat(64); + assert!(!CliArgs::parse_from(["buzz-acp", "--private-key", &key]).lazy_pool); } #[test] fn lazy_pool_cli_flag_enables_deferred_startup() { - let args = CliArgs::try_parse_from(["buzz-acp", "--lazy-pool=true"]); + let key = "0".repeat(64); + let args = CliArgs::try_parse_from(["buzz-acp", "--private-key", &key, "--lazy-pool=true"]); assert!(args.is_err(), "bool flags do not take an explicit value"); - assert!(CliArgs::parse_from(["buzz-acp", "--lazy-pool"]).lazy_pool); + assert!(CliArgs::parse_from(["buzz-acp", "--private-key", &key, "--lazy-pool"]).lazy_pool); } #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 3c4d7ab501..862732f478 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1511,6 +1511,7 @@ async fn tokio_main() -> Result<()> { .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), + relay_url: config.relay_url.clone(), }); if !config.memory_enabled { @@ -1656,9 +1657,16 @@ async fn tokio_main() -> Result<()> { } loop { + // Whether buffered work is waiting on a lazy pool. Also gates the + // retry-deadline sleep arm below: a `Failed` lifecycle keeps its + // (possibly past) `retry_at` until the next wake, so sleeping on it + // unconditionally would complete instantly on every iteration — a + // busy spin — whenever the queued work drained after a failed wake. + let mut lazy_wake_work_pending = false; if config.lazy_pool && !pool_ready { + lazy_wake_work_pending = queue.has_flushable_work(); if let Some(attempt) = pool_lifecycle - .start_wake_if_due(queue.has_flushable_work(), tokio::time::Instant::now()) + .start_wake_if_due(lazy_wake_work_pending, tokio::time::Instant::now()) { emit_runtime_lifecycle( observer.as_ref(), @@ -1791,9 +1799,15 @@ async fn tokio_main() -> Result<()> { Some((attempt, result)) = wake_rx.recv(), if config.lazy_pool && !pool_ready => { Some(PoolEvent::Wake(attempt, result)) } + // Gated on pending work: with an empty queue there is nothing + // for the retry to dispatch, and a past `retry_at` would + // otherwise complete instantly on every iteration (busy spin). + // The next accepted event re-enables the arm. _ = async { match pool_lifecycle.retry_at() { - Some(retry_at) if !pool_ready => tokio::time::sleep_until(retry_at).await, + Some(retry_at) if lazy_wake_work_pending => { + tokio::time::sleep_until(retry_at).await + } _ => std::future::pending().await, } } => None, @@ -2509,7 +2523,24 @@ async fn tokio_main() -> Result<()> { } } - wake_tasks.shutdown().await; + // Drain wake tasks gracefully rather than aborting: an in-flight + // initialize_agent_pool observes the shutdown watch at its biased per-slot + // select and reaps its partially-spawned agents itself. `shutdown()` here + // would abort the task mid-init and drop those AcpClients via best-effort + // Drop — the exact zombie class the eager path's spawn-outside-the-timeout + // comment exists to prevent. Fire the watch first so exits that bypass the + // signal handlers (result channel closed, LoopAction::Exit) cancel the wake + // just as promptly. Timeout is a backstop for a slot stuck outside the + // select (e.g. in spawn); only then do we fall back to aborting. + let _ = shutdown_tx.send(()); + let wake_drain = tokio::time::timeout(Duration::from_secs(30), async { + while wake_tasks.join_next().await.is_some() {} + }) + .await; + if wake_drain.is_err() { + tracing::warn!("wake task did not drain within grace period — aborting"); + wake_tasks.shutdown().await; + } while let Ok((_attempt, result)) = wake_rx.try_recv() { if let Ok(mut awakened_pool) = result { shutdown_agent_pool(&mut awakened_pool).await; diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 61ecbff6c6..fb08096792 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -473,6 +473,10 @@ pub struct PromptContext { /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, + /// Relay URL this harness is connected to. Rides in observer payloads that + /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, + /// mirroring the `managed_agent_runtime_lifecycle` frames. + pub relay_url: String, } impl AgentPool { @@ -855,6 +859,9 @@ async fn create_session_and_apply_model( "modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null), "models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null), "modelOverridden": agent.model_overridden && switch_succeeded, + // Pair identity for the desktop session-config cache, which is + // keyed by (agent, relay) like the lifecycle frames. + "relayUrl": ctx.relay_url, }), ); @@ -5254,6 +5261,7 @@ mod tests { agent_owner_pubkey: owner_pubkey, memory_enabled: false, harness_name: "goose".to_string(), + relay_url: "ws://127.0.0.1:3000".to_string(), } } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 7b1f5040e8..6eec4710f7 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -479,7 +479,11 @@ const overrides = new Map([ // get_agent_config_surface derive the ManagedAgentRuntimeKey (relay-URL // fallback resolution) and put_agent_session_config gains a relay_url param. // Load-bearing identity plumbing; queued to split. - ["src-tauri/src/commands/agent_config.rs", 1032], + // +18 (1032 -> 1050): review fix — put_agent_session_config reads the pair + // relay from the harness-attached payload relayUrl (with effective-relay + // fallback for older harnesses) instead of a required arg the frontend + // wrapper never passed, which silently broke the session-config cache. + ["src-tauri/src/commands/agent_config.rs", 1050], // codex-install-auto-restart review-fixes: should_restart_after_install // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy // cache tests replaced with 6 pure availability_drift predicate tests; diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 9e70a311f8..05fb179125 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -395,21 +395,39 @@ pub async fn get_agent_config_surface( #[tauri::command] pub fn put_agent_session_config( pubkey: String, - relay_url: String, payload: serde_json::Value, app: AppHandle, state: State<'_, AppState>, ) { - { + let record_relay_url = { let _guard = match state.managed_agents_store_lock.lock() { Ok(g) => g, Err(_) => return, }; match load_managed_agents(&app) { - Ok(records) if records.iter().any(|r| r.pubkey == pubkey) => {} + Ok(records) => match records.into_iter().find(|r| r.pubkey == pubkey) { + Some(record) => record.relay_url, + None => return, + }, _ => return, } - } + }; + + // Pair identity: prefer the relay URL the harness attached to the payload + // (same pattern as lifecycle frames). Older harnesses don't attach one; + // fall back to the record's effective relay — with no attached URL the + // frame can only have arrived over the active workspace relay, which is + // exactly what effective_agent_relay_url resolves to absent a pin. + let relay_url = payload + .get("relayUrl") + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| { + crate::relay::effective_agent_relay_url( + &record_relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ) + }); let config_options = parse_config_options(payload.get("configOptions")); let available_modes = parse_modes(&config_options, payload.get("modes")); diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 64a722e1aa..b60cd735a1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -24,9 +24,38 @@ fn status_for( ) -> ManagedAgentRuntimeStatus { let personas = load_personas(app).unwrap_or_default(); let global = load_global_agent_config(app).unwrap_or_default(); - let command = record_agent_command(record, &personas); + status_for_with( + app, + record, + key, + runtime, + requested_relay_url, + StatusInputs { + personas: &personas, + global: &global, + }, + ) +} + +/// Preloaded per-call-site inputs for [`status_for_with`], so multi-row +/// callers (list, reconcile) hit disk once instead of once per row. +struct StatusInputs<'a> { + personas: &'a [super::AgentDefinition], + global: &'a super::GlobalAgentConfig, +} + +fn status_for_with( + app: &AppHandle, + record: &super::ManagedAgentRecord, + key: &ManagedAgentRuntimeKey, + runtime: Option<&ManagedAgentPairRuntime>, + requested_relay_url: Option, + inputs: StatusInputs<'_>, +) -> ManagedAgentRuntimeStatus { + let StatusInputs { personas, global } = inputs; + let command = record_agent_command(record, personas); let metadata = super::known_acp_runtime(&command); - let effective = resolve_effective_agent_env(record, &personas, metadata, &global); + let effective = resolve_effective_agent_env(record, personas, metadata, global); let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready); ManagedAgentRuntimeStatus { pubkey: key.pubkey.clone(), @@ -112,6 +141,11 @@ pub fn put_managed_agent_runtime_lifecycle( pub fn list_managed_agent_runtimes( app: AppHandle, ) -> Result, String> { + // This command is polled whenever the members sidebar opens and refetched + // on every status event — load the per-row status inputs once, outside + // the locks, instead of hitting disk per row while holding them. + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); let state = app.state::(); let _transition = state .managed_agent_runtime_transition @@ -133,6 +167,7 @@ pub fn list_managed_agent_runtimes( Ok(None) => None, }) .collect(); + let records_changed = !exited_keys.is_empty(); let mut statuses = Vec::new(); for key in exited_keys { runtimes.remove(&key); @@ -144,7 +179,17 @@ pub fn list_managed_agent_runtimes( { record.updated_at = crate::util::now_iso(); record.last_stopped_at = Some(record.updated_at.clone()); - let status = status_for(&app, record, &key, None, None); + let status = status_for_with( + &app, + record, + &key, + None, + None, + StatusInputs { + personas: &personas, + global: &global, + }, + ); emit_status(&app, &status); statuses.push(status); } @@ -153,10 +198,24 @@ pub fn list_managed_agent_runtimes( let record = records .iter() .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; - Some(status_for(&app, record, key, Some(runtime), None)) + Some(status_for_with( + &app, + record, + key, + Some(runtime), + None, + StatusInputs { + personas: &personas, + global: &global, + }, + )) })); drop(runtimes); - save_managed_agents(&app, &records)?; + // Records are only mutated above when a runtime exited — skip the store + // rewrite on the common nothing-changed poll. + if records_changed { + save_managed_agents(&app, &records)?; + } Ok(statuses) } @@ -348,6 +407,33 @@ async fn probe_agent_relay_access( Ok((record, key, requested_relay_url)) } +/// Build the `Failed` status row for a probe failure whose requested relay URL +/// cannot even form a pair key (so there is no canonical `relay_url` to key on). +/// The raw requested URL stands in for both the identity and the requested +/// field so the batch still degrades this one community to a visible row +/// instead of aborting every other community's row. +fn unkeyable_failed_status( + record: &super::ManagedAgentRecord, + requested: String, + error: String, + personas: &[super::AgentDefinition], + global: &super::GlobalAgentConfig, +) -> ManagedAgentRuntimeStatus { + let command = record_agent_command(record, personas); + let metadata = super::known_acp_runtime(&command); + let effective = resolve_effective_agent_env(record, personas, metadata, global); + ManagedAgentRuntimeStatus { + pubkey: record.pubkey.clone(), + relay_url: requested.clone(), + requested_relay_url: Some(requested), + local_setup: matches!(agent_readiness(&effective), AgentReadiness::Ready), + lifecycle: ManagedAgentRuntimeLifecycle::Failed, + pid: None, + error: Some(error), + log_path: None, + } +} + /// True when `record` may run a pair on `relay_url`: unpinned records run on /// every configured community; a record with an explicit `relay_url` pin runs /// only on that relay (compared canonically). @@ -365,6 +451,16 @@ fn record_allows_relay(record: &super::ManagedAgentRecord, relay_url: &str) -> b } } +/// Spawn a lazy harness pair for every eligible (agent, community) pair. +/// +/// Eligibility is deliberately gated on `start_on_app_launch`: auto-start is +/// the *proactive fan-out* policy — "keep this agent warm in every community" — +/// not a correctness prerequisite. A manual-start agent still works on demand +/// everywhere: attaching it to a channel ensures its pair, an @mention wakes a +/// pair, the members sidebar and Settings controls start pairs, and restore +/// preserves running pairs across relaunch. Fanning out warm-socket pairs for +/// agents the user chose *not* to auto-start would contradict that choice, so +/// reconcile leaves them alone until something explicitly asks for them. #[tauri::command] pub async fn reconcile_managed_agent_runtimes( communities: Vec, @@ -400,39 +496,81 @@ pub async fn reconcile_managed_agent_runtimes( .collect() .await; - let mut rows = Vec::new(); - for probe in probes { - match probe { - Ok((record, key, requested)) => { - match start_pair( - record.pubkey.clone(), - key.relay_url.clone(), - true, - Some(&record.updated_at), - app.clone(), - ) { - Ok(mut status) => { - status.requested_relay_url = Some(requested); - rows.push(status); - } - Err(error) => { - let mut status = status_for(&app, &record, &key, None, Some(requested)); - status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - status.error = Some(error); - rows.push(status); + // start_pair does blocking work (std mutexes, process spawn, receipt + // writes, and up-to-2s exit polling in terminate_untracked_pair_runtime), + // so run the post-probe start loop off the async workers, matching the + // restart flows. + tokio::task::spawn_blocking(move || { + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let mut rows = Vec::new(); + for probe in probes { + match probe { + Ok((record, key, requested)) => { + match start_pair( + record.pubkey.clone(), + key.relay_url.clone(), + true, + Some(&record.updated_at), + app.clone(), + ) { + Ok(mut status) => { + status.requested_relay_url = Some(requested); + rows.push(status); + } + Err(error) => { + let mut status = status_for_with( + &app, + &record, + &key, + None, + Some(requested), + StatusInputs { + personas: &personas, + global: &global, + }, + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + rows.push(status); + } } } - } - Err((record, requested, error)) => { - let key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested)?; - let mut status = status_for(&app, &record, &key, None, Some(requested)); - status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; - status.error = Some(error); - rows.push(status); + Err((record, requested, error)) => { + // Per-community degradation: a relay URL that cannot even + // form a pair key gets a Failed row (with the raw + // requested URL) like any other probe failure, instead of + // aborting every other community's row. + let status = + match ManagedAgentRuntimeKey::new(record.pubkey.clone(), &requested) { + Ok(key) => { + let mut status = status_for_with( + &app, + &record, + &key, + None, + Some(requested), + StatusInputs { + personas: &personas, + global: &global, + }, + ); + status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; + status.error = Some(error); + status + } + Err(_) => unkeyable_failed_status( + &record, requested, error, &personas, &global, + ), + }; + rows.push(status); + } } } - } - Ok(rows) + rows + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } #[cfg(test)] @@ -489,6 +627,33 @@ mod tests { assert!(!record_allows_relay(&record, "wss://two.example")); } + #[test] + fn unkeyable_relay_degrades_to_failed_row() { + // A requested URL that cannot form a pair key must still yield a + // Failed row keyed by the raw requested string, so one bad community + // never aborts the rest of the reconcile batch. + let record = record_with_relay(""); + let status = unkeyable_failed_status( + &record, + "not a url".to_string(), + "relay access probe timed out".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + ); + assert!(matches!( + status.lifecycle, + ManagedAgentRuntimeLifecycle::Failed + )); + assert_eq!(status.relay_url, "not a url"); + assert_eq!(status.requested_relay_url.as_deref(), Some("not a url")); + assert_eq!(status.pubkey, record.pubkey); + assert_eq!( + status.error.as_deref(), + Some("relay access probe timed out") + ); + assert!(status.pid.is_none()); + } + #[test] fn runtime_key_rejects_non_hex_pubkeys() { assert!(ManagedAgentRuntimeKey::new("../not-a-key", "wss://relay.example").is_err()); diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index 3fae30cabf..95f9efc3c5 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -153,27 +153,26 @@ pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), Stri } let mut to_stop: Vec = Vec::new(); - for (idx, record) in records.iter_mut().enumerate() { + for (idx, record) in records.iter().enumerate() { if record.backend != BackendKind::Local { continue; } - if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { - continue; + // Drain every tracked pair for this record, not just the first — an + // agent can run one harness per community, and each pair gets the + // graceful SIGTERM → 2s wait → SIGKILL fan-out with a stop log + // marker, instead of falling through to the orphan sweep's 200ms + // grace below. + for key in managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey) { + let runtime = runtimes.remove(&key); + let Some(pid) = runtime + .as_ref() + .map(|rt| rt.child.id()) + .or(record.runtime_pid) + else { + continue; + }; + to_stop.push(AgentToStop { idx, pid, runtime }); } - let key = runtimes - .keys() - .find(|key| key.pubkey == record.pubkey) - .cloned() - .expect("checked above"); - let runtime = runtimes.remove(&key); - let Some(pid) = runtime - .as_ref() - .map(|rt| rt.child.id()) - .or(record.runtime_pid) - else { - continue; - }; - to_stop.push(AgentToStop { idx, pid, runtime }); } if !to_stop.is_empty() { diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 4eb951f6d4..edbb106817 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -132,8 +132,12 @@ export async function attachManagedAgentToChannel( if (ensureRunning) { // Running agents (local or provider) auto-discover new channel membership // via the harness's membership notifications — no restart needed. Only - // not-yet-running agents need a start/deploy call before the first - // mention can reach them. + // not-yet-running agents need a start/deploy call before the first mention + // can reach them. For a local agent the status check and the start are both + // pair-scoped to the active community: `agent.status` reflects that + // community's (agent, relay) pair, and `startManagedAgent` spawns that same + // pair — so this ensures the pair the caller is attaching to, never + // another community's. const isRemote = input.agent.backend.type === "provider"; if (isRemote && input.agent.status !== "deployed") { agent = await startManagedAgent(input.agent.pubkey); diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs new file mode 100644 index 0000000000..21327c30d9 --- /dev/null +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canonicalCommunityRelays, + classifyReconcileResult, + pendingReconcileRelays, + reconcileRetryDelayMs, +} from "./managedAgentReconciliationPlan.ts"; +import { canonicalRelayUrl } from "./managedAgentRuntimeStatus.ts"; + +test("reconcileRetryDelayMs walks a capped backoff then gives up", () => { + assert.equal(reconcileRetryDelayMs(1), 5_000); + assert.equal(reconcileRetryDelayMs(2), 30_000); + assert.equal(reconcileRetryDelayMs(3), 120_000); + assert.equal(reconcileRetryDelayMs(4), null); + assert.equal(reconcileRetryDelayMs(0), null); +}); + +test("canonicalCommunityRelays dedupes by canonical form, keeps stored spelling", () => { + const relays = canonicalCommunityRelays( + [ + { relayUrl: "ws://localhost:3000" }, + // Same relay, different spelling — folds onto the first entry. + { relayUrl: "ws://127.0.0.1:3000" }, + { relayUrl: "wss://relay.example" }, + // Unparsable entries are dropped rather than reconciled. + { relayUrl: "not a url" }, + ], + canonicalRelayUrl, + ); + assert.deepEqual( + [...relays.entries()], + [ + ["ws://127.0.0.1:3000", "ws://localhost:3000"], + ["wss://relay.example", "wss://relay.example"], + ], + ); +}); + +test("pendingReconcileRelays skips reconciled and in-flight relays", () => { + const canonicalToRequested = new Map([ + ["ws://127.0.0.1:3000", "ws://localhost:3000"], + ["wss://a.example", "wss://a.example"], + ["wss://b.example", "wss://b.example"], + ]); + const pending = pendingReconcileRelays( + canonicalToRequested, + new Set(["wss://a.example"]), + new Set(["ws://127.0.0.1:3000"]), + ); + assert.deepEqual(pending, ["wss://b.example"]); +}); + +test("classifyReconcileResult marks the whole batch failed when the call throws", () => { + const attempted = ["wss://a.example", "wss://b.example"]; + assert.deepEqual( + classifyReconcileResult(attempted, null, canonicalRelayUrl), + { + succeeded: [], + failed: attempted, + }, + ); +}); + +test("classifyReconcileResult splits by Failed rows, matching on requested URL", () => { + const attempted = ["ws://127.0.0.1:3000", "wss://b.example"]; + const rows = [ + // Started cleanly on the loopback relay — reconciled. + { + pubkey: "aa", + relayUrl: "ws://127.0.0.1:3000", + requestedRelayUrl: "ws://localhost:3000", + localSetup: true, + lifecycle: "starting", + pid: 1, + error: null, + logPath: null, + }, + // Failed on b.example — stays failing so it is retried. + { + pubkey: "aa", + relayUrl: "wss://b.example", + requestedRelayUrl: "wss://b.example", + localSetup: true, + lifecycle: "failed", + pid: null, + error: "relay access probe timed out", + logPath: null, + }, + ]; + assert.deepEqual( + classifyReconcileResult(attempted, rows, canonicalRelayUrl), + { + succeeded: ["ws://127.0.0.1:3000"], + failed: ["wss://b.example"], + }, + ); +}); + +test("classifyReconcileResult treats a relay with no rows as reconciled", () => { + // A community with no eligible auto-start agents produces no rows; it must + // still count as reconciled so the hook stops retrying it. + assert.deepEqual( + classifyReconcileResult(["wss://a.example"], [], canonicalRelayUrl), + { succeeded: ["wss://a.example"], failed: [] }, + ); +}); diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.ts b/desktop/src/features/agents/managedAgentReconciliationPlan.ts new file mode 100644 index 0000000000..58d9b58b73 --- /dev/null +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.ts @@ -0,0 +1,95 @@ +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; + +/** + * Pure planning core for incremental, retrying runtime reconciliation. + * + * `useManagedAgentRuntimeReconciliation` reconciles the configured community + * relays against the running harness pairs. Unlike the old one-shot-per-mount + * hook (which only re-ran on a community switch or relaunch) it must: + * - reconcile a newly configured relay as soon as it appears, without + * depending on the add flow also switching communities, and + * - retry a relay whose reconcile failed (relay unreachable at launch, laptop + * waking from sleep) with a capped backoff instead of leaving it un-spawned + * until the next switch or relaunch. + * + * These decisions live here with no React/timer/IPC dependencies so they can be + * unit-tested directly; the hook only owns the refs, timer, and IPC calls. + */ + +/** Capped retry backoff: 5s, then 30s, then 2m, then give up. */ +const RETRY_BACKOFF_MS = [5_000, 30_000, 120_000] as const; + +/** + * Delay before the next retry for a relay that has failed `failureCount` times + * (1-based: 1 = first failure). Returns null once the cap is exhausted, meaning + * stop retrying until the community set changes again. + */ +export function reconcileRetryDelayMs(failureCount: number): number | null { + if (failureCount < 1) return null; + return RETRY_BACKOFF_MS[failureCount - 1] ?? null; +} + +/** + * Canonicalize the configured community relays, dropping duplicates and + * unparsable entries. Maps canonical URL -> the raw `relayUrl` to submit to the + * backend (first occurrence wins), so the reconcile call still speaks the + * community's stored spelling while all bookkeeping is keyed canonically. + */ +export function canonicalCommunityRelays( + communities: readonly { relayUrl: string }[], + canonicalize: (url: string) => string | null, +): Map { + const byCanonical = new Map(); + for (const community of communities) { + const canonical = canonicalize(community.relayUrl); + if (canonical === null || byCanonical.has(canonical)) continue; + byCanonical.set(canonical, community.relayUrl); + } + return byCanonical; +} + +/** + * Configured relays that still need a reconcile attempt: not yet reconciled + * cleanly and not currently in flight. Returns canonical URLs. + */ +export function pendingReconcileRelays( + canonicalToRequested: ReadonlyMap, + reconciled: ReadonlySet, + inFlight: ReadonlySet, +): string[] { + const pending: string[] = []; + for (const canonical of canonicalToRequested.keys()) { + if (reconciled.has(canonical) || inFlight.has(canonical)) continue; + pending.push(canonical); + } + return pending; +} + +/** + * Split an attempted batch into relays that reconciled cleanly vs. those that + * still failed. A relay failed if the call threw (`rows === null` marks the + * whole batch failed) or it produced a `failed` lifecycle row. A relay that + * produced no rows (no eligible agents there) counts as reconciled. + */ +export function classifyReconcileResult( + attempted: readonly string[], + rows: readonly ManagedAgentRuntimeStatus[] | null, + canonicalize: (url: string) => string | null, +): { succeeded: string[]; failed: string[] } { + if (rows === null) { + return { succeeded: [], failed: [...attempted] }; + } + const failedRelays = new Set(); + for (const row of rows) { + if (row.lifecycle !== "failed") continue; + const canonical = canonicalize(row.requestedRelayUrl ?? row.relayUrl); + if (canonical !== null) failedRelays.add(canonical); + } + const succeeded: string[] = []; + const failed: string[] = []; + for (const relay of attempted) { + if (failedRelays.has(relay)) failed.push(relay); + else succeeded.push(relay); + } + return { succeeded, failed }; +} diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs index 57c0863f4c..edd368eccd 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { agentCommunityAvailability, agentCommunityStatusDetail, + canonicalRelayUrl, findManagedAgentRuntime, managedAgentRuntimeKey, } from "./managedAgentRuntimeStatus.ts"; @@ -75,3 +76,38 @@ test("selects one relay without collapsing same-pubkey pairs", () => { undefined, ); }); + +test("canonicalRelayUrl mirrors the backend pair-key normalization", () => { + // Loopback folding + default-port and trailing-slash stripping — the + // standard dev setup that previously broke pair matching. + assert.equal(canonicalRelayUrl("ws://localhost:3000"), "ws://127.0.0.1:3000"); + assert.equal( + canonicalRelayUrl("WSS://Relay.Example:443/"), + "wss://relay.example", + ); + assert.equal( + canonicalRelayUrl("ws://relay.example:80/"), + "ws://relay.example", + ); + assert.equal( + canonicalRelayUrl("wss://relay.example/path/"), + "wss://relay.example/path", + ); + assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://127.0.0.1:3000"); + assert.equal(canonicalRelayUrl("https://relay.example"), null); + assert.equal(canonicalRelayUrl("not a url"), null); +}); + +test("matches a stored community URL against canonical backend rows", () => { + const runtimes = [ + runtime({ relayUrl: "ws://127.0.0.1:3000", lifecycle: "ready" }), + ]; + assert.equal( + findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3000")?.lifecycle, + "ready", + ); + assert.equal( + findManagedAgentRuntime(runtimes, "aa", "ws://localhost:3001"), + undefined, + ); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index 5a6d23a9ba..a9f2734f21 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -62,15 +62,52 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< restart: "Restart", }; +/** + * Canonicalize a relay URL the way the backend keys runtime pairs, so a + * stored community URL (e.g. `ws://localhost:3000`) matches backend rows + * (`ws://127.0.0.1:3000`). Mirrors buzz-core's `normalize_relay_url` + * (`crates/buzz-core/src/relay.rs`): lowercase host, loopback hosts folded + * to 127.0.0.1, default ports and root-path trailing slash stripped. + * Returns null when the URL cannot be parsed as ws/wss. + */ +export function canonicalRelayUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return null; + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; + let host = url.hostname.toLowerCase(); + if (host === "localhost" || host === "[::1]" || host.startsWith("127.")) { + host = "127.0.0.1"; + } + const defaultPort = url.protocol === "ws:" ? "80" : "443"; + const port = url.port && url.port !== defaultPort ? `:${url.port}` : ""; + const path = url.pathname === "/" ? "" : url.pathname; + // The backend trims trailing slashes from the final rendered URL. + return `${url.protocol}//${host}${port}${path}${url.search}`.replace( + /\/+$/, + "", + ); +} + export function findManagedAgentRuntime( runtimes: readonly ManagedAgentRuntimeStatus[], pubkey: string, relayUrl: string, ): ManagedAgentRuntimeStatus | undefined { const normalizedPubkey = pubkey.toLowerCase(); + // Backend rows carry the canonical pair URL; the caller passes the + // community's stored URL, which may differ in spelling (localhost vs + // 127.0.0.1, default port, trailing slash). Compare canonically, keeping + // the exact-string checks as a fallback for unparsable stored URLs. + const canonical = canonicalRelayUrl(relayUrl); return runtimes.find( (runtime) => runtime.pubkey.toLowerCase() === normalizedPubkey && - (runtime.relayUrl === relayUrl || runtime.requestedRelayUrl === relayUrl), + (runtime.relayUrl === relayUrl || + runtime.requestedRelayUrl === relayUrl || + (canonical !== null && runtime.relayUrl === canonical)), ); } diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts index 698acd20dd..f2fb2416b9 100644 --- a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -1,35 +1,135 @@ import { useQueryClient } from "@tanstack/react-query"; import * as React from "react"; +import { + canonicalCommunityRelays, + classifyReconcileResult, + pendingReconcileRelays, + reconcileRetryDelayMs, +} from "@/features/agents/managedAgentReconciliationPlan"; import { cacheReconciledManagedAgentRuntimes, managedAgentRuntimesQueryKey, } from "@/features/agents/managedAgentRuntimeHooks"; +import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus"; import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents"; +/** + * Bootstrap a lazy harness pair for every auto-start local agent in every + * configured community, incrementally and with retry. + * + * Reconciliation is keyed by canonical relay URL: each configured relay is + * reconciled once it appears (so adding a community mid-session spawns pairs + * there without needing the add flow to also switch communities), and a relay + * whose reconcile fails is retried with a capped backoff (5s / 30s / 2m) rather + * than left un-spawned until the next switch or relaunch. Relays that reconcile + * cleanly are never re-hit; once nothing is outstanding, no timer is left + * running. + */ export function useManagedAgentRuntimeReconciliation( communities: readonly { relayUrl: string }[], ): void { const queryClient = useQueryClient(); - const reconciled = React.useRef(false); + // Canonical relay URLs that have reconciled cleanly — never re-hit. + const reconciledRef = React.useRef>(new Set()); + // Canonical relay URLs with a reconcile call in flight — not re-dispatched. + const inFlightRef = React.useRef>(new Set()); + // Consecutive failures per canonical relay URL, driving the retry backoff. + const failuresRef = React.useRef>(new Map()); + const retryTimerRef = React.useRef | null>( + null, + ); React.useEffect(() => { - if (reconciled.current) return; - reconciled.current = true; - - const baseline = queryClient.getQueryData( - managedAgentRuntimesQueryKey, - ); - void reconcileManagedAgentRuntimes(communities) - .then((runtimes) => { - cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes); - }) - .catch((error) => { - console.warn( - "[managed-agent-runtimes] startup reconcile failed:", - error, - ); - }); + let cancelled = false; + + const clearRetryTimer = () => { + if (retryTimerRef.current !== null) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + }; + + const scheduleRetry = (failed: readonly string[]) => { + // One shared timer fires at the soonest per-relay backoff; every failing + // relay is retried together (reconcile is idempotent), so re-hitting a + // longer-backoff relay early is harmless. + let soonest: number | null = null; + for (const relay of failed) { + const nextCount = (failuresRef.current.get(relay) ?? 0) + 1; + failuresRef.current.set(relay, nextCount); + const delay = reconcileRetryDelayMs(nextCount); + if (delay !== null && (soonest === null || delay < soonest)) { + soonest = delay; + } + } + clearRetryTimer(); + if (soonest === null) return; // all failing relays hit the retry cap + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + if (!cancelled) runReconcile(); + }, soonest); + }; + + const runReconcile = () => { + const canonicalToRequested = canonicalCommunityRelays( + communities, + canonicalRelayUrl, + ); + // Forget bookkeeping for relays that are no longer configured so the sets + // stay bounded and re-adding a removed community reconciles it afresh. + for (const done of [...reconciledRef.current]) { + if (!canonicalToRequested.has(done)) reconciledRef.current.delete(done); + } + for (const failing of [...failuresRef.current.keys()]) { + if (!canonicalToRequested.has(failing)) { + failuresRef.current.delete(failing); + } + } + + const pending = pendingReconcileRelays( + canonicalToRequested, + reconciledRef.current, + inFlightRef.current, + ); + if (pending.length === 0) { + clearRetryTimer(); + return; + } + + for (const relay of pending) inFlightRef.current.add(relay); + const targets = pending.map((relay) => ({ + relayUrl: canonicalToRequested.get(relay) as string, + })); + const baseline = queryClient.getQueryData( + managedAgentRuntimesQueryKey, + ); + + void reconcileManagedAgentRuntimes(targets) + .then((runtimes) => { + cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes); + return classifyReconcileResult(pending, runtimes, canonicalRelayUrl); + }) + .catch((error) => { + console.warn("[managed-agent-runtimes] reconcile failed:", error); + return classifyReconcileResult(pending, null, canonicalRelayUrl); + }) + .then(({ succeeded, failed }) => { + for (const relay of pending) inFlightRef.current.delete(relay); + for (const relay of succeeded) { + reconciledRef.current.add(relay); + failuresRef.current.delete(relay); + } + if (!cancelled && failed.length > 0) scheduleRetry(failed); + }); + }; + + runReconcile(); + + return () => { + cancelled = true; + clearRetryTimer(); + }; }, [communities, queryClient]); } diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index c3298cf289..121e2dbb1c 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -148,7 +148,7 @@ export function reconcileRefreshedCachedChannel( return upsertCachedChannel(refreshed, refreshedChannel ?? channel); } -async function invalidateChannelState( +export async function invalidateChannelState( queryClient: ReturnType, channelId: string | null | undefined, ) { diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 280bd31be3..c6349546a2 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -2,9 +2,11 @@ import * as React from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Bot, UserRoundPlus, X } from "lucide-react"; import { + invalidateChannelState, useAddChannelMembersMutation, useChannelMembersQuery, } from "@/features/channels/hooks"; +import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, isAgentIdentityInManagedList, @@ -542,6 +544,35 @@ export function MembersSidebar({ setAddingMemberPubkeys((prev) => new Set(prev).add(user.pubkey)); try { + // A local managed agent needs a running harness pair in this community, + // not just channel membership — a bare membership add leaves it deaf + // until someone @mentions it or manually starts the pair. Route it + // through the attach helper, which adds membership AND ensures the active + // community's pair is running (idempotent: a live pair is left alone). + // Humans and provider agents keep the plain membership add. + const managedAgent = managedAgentByPubkey.get( + normalizePubkey(user.pubkey), + ); + if (channelId && managedAgent?.backend.type === "local") { + try { + await attachManagedAgentToChannel(channelId, { + agent: managedAgent, + ensureRunning: true, + }); + await invalidateChannelState(queryClient, channelId); + } catch (error) { + setInviteSubmissionErrors((prev) => [ + ...prev, + { + pubkey: user.pubkey, + error: + error instanceof Error ? error.message : "Failed to add agent.", + }, + ]); + } + return; + } + const result = await addMembersMutation.mutateAsync({ pubkeys: [user.pubkey], role: user.isAgent ? "bot" : "member", From 481cb6187d4d1c2b3f8e6b8deb199a19d48dcd10 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 21 Jul 2026 15:10:15 -0700 Subject: [PATCH 26/28] refactor(desktop): split team hooks out of agents hooks.ts hooks.ts crossed the 1000-line file-size ceiling (1002 lines), failing the pre-push desktop-check gate. Move the team query/mutation hooks and teamsQueryKey into teamHooks.ts and re-export them from hooks.ts so existing importers are unchanged. Co-Authored-By: Claude Fable 5 --- desktop/src/features/agents/hooks.ts | 68 +++--------------------- desktop/src/features/agents/teamHooks.ts | 65 ++++++++++++++++++++++ 2 files changed, 73 insertions(+), 60 deletions(-) create mode 100644 desktop/src/features/agents/teamHooks.ts diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 12ff35a5bd..128ae9083b 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -60,24 +60,16 @@ import { setPersonaActive, updatePersona, } from "@/shared/api/tauriPersonas"; -import { - createTeam, - deleteTeam, - listTeams, - updateTeam, -} from "@/shared/api/tauriTeams"; +import { teamsQueryKey } from "@/features/agents/teamHooks"; import type { AcpRuntime, AgentPersona, - AgentTeam, Channel, CreateManagedAgentInput, CreatePersonaInput, - CreateTeamInput, ManagedAgent, UpdateManagedAgentInput, UpdatePersonaInput, - UpdateTeamInput, } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import type { @@ -90,6 +82,13 @@ import type { ProvisionChannelManagedAgentResult, } from "@/features/agents/channelAgents"; export { findReusableAgent } from "@/features/agents/agentReuse"; +export { + teamsQueryKey, + useCreateTeamMutation, + useDeleteTeamMutation, + useTeamsQuery, + useUpdateTeamMutation, +} from "@/features/agents/teamHooks"; export type { AttachManagedAgentToChannelInput, AttachManagedAgentToChannelResult, @@ -105,7 +104,6 @@ export type { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; -export const teamsQueryKey = ["teams"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; @@ -949,53 +947,3 @@ export function useBakedBuildEnvKeysQuery(options?: { enabled?: boolean }) { retry: false, }); } - -export function useTeamsQuery() { - return useQuery({ - queryKey: teamsQueryKey, - queryFn: listTeams, - staleTime: 30_000, - // No refetchInterval: inbound relay team changes emit `agents-data-changed` - // (handled by useAgentsDataRefresh). Same redundant-poll removal as - // usePersonasQuery. - }); -} - -export function useCreateTeamMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (input: CreateTeamInput) => createTeam(input), - onSuccess: (created) => { - queryClient.setQueryData(teamsQueryKey, (current) => { - const next = current ?? []; - return [created, ...next.filter((team) => team.id !== created.id)]; - }); - }, - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); - }, - }); -} - -export function useUpdateTeamMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (input: UpdateTeamInput) => updateTeam(input), - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); - }, - }); -} - -export function useDeleteTeamMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (id: string) => deleteTeam(id), - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); - }, - }); -} diff --git a/desktop/src/features/agents/teamHooks.ts b/desktop/src/features/agents/teamHooks.ts new file mode 100644 index 0000000000..5eb71a3cc9 --- /dev/null +++ b/desktop/src/features/agents/teamHooks.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createTeam, + deleteTeam, + listTeams, + updateTeam, +} from "@/shared/api/tauriTeams"; +import type { + AgentTeam, + CreateTeamInput, + UpdateTeamInput, +} from "@/shared/api/types"; + +export const teamsQueryKey = ["teams"] as const; + +export function useTeamsQuery() { + return useQuery({ + queryKey: teamsQueryKey, + queryFn: listTeams, + staleTime: 30_000, + // No refetchInterval: inbound relay team changes emit `agents-data-changed` + // (handled by useAgentsDataRefresh). Same redundant-poll removal as + // usePersonasQuery. + }); +} + +export function useCreateTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateTeamInput) => createTeam(input), + onSuccess: (created) => { + queryClient.setQueryData(teamsQueryKey, (current) => { + const next = current ?? []; + return [created, ...next.filter((team) => team.id !== created.id)]; + }); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }, + }); +} + +export function useUpdateTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdateTeamInput) => updateTeam(input), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }, + }); +} + +export function useDeleteTeamMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deleteTeam(id), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }, + }); +} From d8ff41cbb8ffb1fcea62e0e0279353039eed58aa Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 22 Jul 2026 10:13:52 -0700 Subject: [PATCH 27/28] fix(desktop): terminate orphan before erasing pair receipt on stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair-scoped stop_managed_agent_runtime removed the runtime receipt without any termination attempt when no runtime was tracked at the pair key. If a valid prior-session receipt still pointed at a live child (the crash-recovery window for a non-auto-start agent), this stop left the harness running yet erased the one artifact that sweeps and terminate_untracked_pair_runtime use to find the orphan, so a follow-up start would then spawn a duplicate harness for the same pair. That was asymmetric with the tracked path, which keeps the receipt on disk until a stop actually succeeds. Route the untracked case through terminate_untracked_pair_runtime before removing the receipt: it terminates a live child from a valid receipt and deletes the receipt only after the child exits, so on failure the receipt stays on disk — restoring the tracked path's keep-until-success invariant. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../src-tauri/src/managed_agents/runtime_commands.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index b60cd735a1..c02a0256e2 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -351,6 +351,18 @@ pub fn stop_managed_agent_runtime( return Err(error); } } + } else { + // No runtime is tracked at this key, but a valid prior-session + // receipt may still point at a live child (e.g. the crash-recovery + // window for a non-auto-start agent). Terminate that orphan before + // erasing its receipt — otherwise this "stop" leaves the harness + // running yet deletes the one artifact sweeps and + // terminate_untracked_pair_runtime use to find it, and a follow-up + // start would spawn a duplicate harness for the same pair. On + // failure the receipt stays on disk (terminate_untracked_pair_runtime + // only removes it after the child exits), mirroring the tracked + // path's keep-until-success invariant. + terminate_untracked_pair_runtime(&app, &key)?; } super::remove_agent_runtime_receipt(&app, &key); state.clear_agent_session_cache(&key); From 5e523991a4ebaa030de156a6b1c4a0fc877509cd Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Wed, 22 Jul 2026 12:14:46 -0700 Subject: [PATCH 28/28] Ignore legacy per-record relay pin for zero-touch cutover The per-record relay_url pin predates workspace-scoped runtimes. Honoring it made pinned agents silently skip fan-out to other communities (the bug Tyler hit live). Instead of migrating/clearing stored pins, ignore them: - effective_agent_relay_url() now always returns the workspace relay; the stored pin is parsed and persisted untouched, so old records read identically and rollback to a pin-honoring build is safe. - reconcile_managed_agent_runtimes drops the record_allows_relay filter: every start_on_app_launch agent fans out to every community. - Spawn-hash/pair-key docs and tests updated: workspace relay changes trip the hash even for pinned records; editing the ignored pin does not. - Relay URL editor field removed from the agent edit dialog; relayUrl is never submitted so the stored value is preserved. Part of #2122. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src-tauri/src/managed_agents/runtime.rs | 22 ++++---- .../src/managed_agents/runtime/tests.rs | 12 +++-- .../src/managed_agents/runtime_commands.rs | 49 ++++++----------- .../src/managed_agents/spawn_hash.rs | 9 ++-- .../src/managed_agents/spawn_hash/tests.rs | 29 ++++++---- desktop/src-tauri/src/relay.rs | 53 ++++++++----------- .../agents/ui/AgentInstanceEditDialog.tsx | 8 +-- .../agents/ui/EditAgentAdvancedFields.tsx | 37 ++----------- 8 files changed, 84 insertions(+), 135 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index a80a19f483..cef669ab25 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1338,9 +1338,10 @@ fn persona_drift_state( } /// Resolve the runtime-pair key this record maps to for the active -/// workspace: an explicit per-record relay pin wins, otherwise the active -/// workspace relay. Returns `None` for records that cannot form a valid -/// pair key yet (e.g. key-less agents that mint keys on first start). +/// workspace: always the active workspace relay (the legacy per-record relay +/// pin is ignored — see `effective_agent_relay_url`). Returns `None` for +/// records that cannot form a valid pair key yet (e.g. key-less agents that +/// mint keys on first start). pub(crate) fn workspace_pair_key( app: &AppHandle, record: &ManagedAgentRecord, @@ -1354,9 +1355,9 @@ pub(crate) fn workspace_pair_key( ) } -/// Pure core of [`workspace_pair_key`]: pin-else-workspace relay precedence -/// plus canonical key construction, kept `AppHandle`-free so summary/stop -/// scoping semantics are unit-testable. +/// Pure core of [`workspace_pair_key`]: workspace-relay resolution (legacy +/// record pins ignored) plus canonical key construction, kept `AppHandle`-free +/// so summary/stop scoping semantics are unit-testable. pub(crate) fn resolve_workspace_pair_key( pubkey: &str, record_relay_url: &str, @@ -1375,11 +1376,10 @@ pub fn build_managed_agent_summary( ) -> Result { use crate::managed_agents::BackendKind; - // Community-scoped truth: this summary describes the pair for the relay - // the record resolves to right now (pin, else active workspace). An agent - // running only in another community must read as stopped here — matching - // by pubkey alone would show every community a green light as long as any - // pair anywhere is alive. + // Community-scoped truth: this summary describes the pair for the active + // workspace relay. An agent running only in another community must read + // as stopped here — matching by pubkey alone would show every community a + // green light as long as any pair anywhere is alive. let pair_key = workspace_pair_key(app, record); let pair_runtime = pair_key.as_ref().and_then(|key| runtimes.get(key)); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 45b6b98a4e..d86b69938f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -914,9 +914,10 @@ fn unpinned_record_resolves_pair_key_per_workspace() { } #[test] -fn pinned_record_resolves_to_pin_in_every_workspace() { - // A pin means "this agent lives on that relay": the same pair is - // resolved (and judged for drift) no matter which community is viewed. +fn stored_relay_pin_is_ignored_in_pair_key_resolution() { + // Legacy pins are ignored (#2122): a record carrying a creation-era + // `relay_url` resolves the same per-workspace pair key an unpinned record + // does, so summaries/stop act on the community being viewed. let pubkey = "aa".repeat(32); let from_a = super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://one.example") @@ -924,8 +925,9 @@ fn pinned_record_resolves_to_pin_in_every_workspace() { let from_b = super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://two.example") .unwrap(); - assert_eq!(from_a, from_b); - assert_eq!(from_a.relay_url, "wss://pinned.example"); + assert_ne!(from_a, from_b); + assert_eq!(from_a.relay_url, "wss://one.example"); + assert_eq!(from_b.relay_url, "wss://two.example"); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c02a0256e2..c0e55184b1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -446,23 +446,6 @@ fn unkeyable_failed_status( } } -/// True when `record` may run a pair on `relay_url`: unpinned records run on -/// every configured community; a record with an explicit `relay_url` pin runs -/// only on that relay (compared canonically). -fn record_allows_relay(record: &super::ManagedAgentRecord, relay_url: &str) -> bool { - let pinned = record.relay_url.trim(); - if pinned.is_empty() { - return true; - } - match ( - buzz_core_pkg::relay::normalize_relay_url(pinned), - buzz_core_pkg::relay::normalize_relay_url(relay_url), - ) { - (Ok(pinned), Ok(requested)) => pinned == requested, - _ => false, - } -} - /// Spawn a lazy harness pair for every eligible (agent, community) pair. /// /// Eligibility is deliberately gated on `start_on_app_launch`: auto-start is @@ -486,9 +469,9 @@ pub async fn reconcile_managed_agent_runtimes( for record in records .iter() .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) - // Respect explicit relay pins: a pinned agent runs only on its - // pinned relay — never spawn it onto other communities. - .filter(|record| record_allows_relay(record, &community.relay_url)) + // The legacy per-record relay pin is deliberately ignored here — see + // `effective_agent_relay_url`. Every local auto-start agent fans out + // to every configured community. { jobs.push((record.clone(), community.relay_url.clone())); } @@ -624,19 +607,19 @@ mod tests { } #[test] - fn unpinned_record_allows_every_relay() { - let record = record_with_relay(""); - assert!(record_allows_relay(&record, "wss://one.example")); - assert!(record_allows_relay(&record, "wss://two.example")); - } - - #[test] - fn pinned_record_allows_only_its_canonical_relay() { - let record = record_with_relay("wss://one.example"); - assert!(record_allows_relay(&record, "wss://one.example")); - // Canonical comparison: scheme case, default port, trailing slash. - assert!(record_allows_relay(&record, "WSS://One.Example:443/")); - assert!(!record_allows_relay(&record, "wss://two.example")); + fn legacy_relay_pin_is_ignored_for_fan_out() { + // Zero-touch cutover (#2122): a record carrying a creation-era + // `relay_url` pin must fan out exactly like an unpinned one — the + // stored field is parsed but never consulted. See + // `effective_agent_relay_url`. + let unpinned = record_with_relay(""); + let pinned = record_with_relay("wss://one.example"); + for record in [&unpinned, &pinned] { + assert_eq!( + crate::relay::effective_agent_relay_url(&record.relay_url, "wss://two.example"), + "wss://two.example" + ); + } } #[test] diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 94b41c7555..4d80549288 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -16,8 +16,9 @@ //! re-snapshot. Harness command, args/mcp, env layering, and the record //! fields the spawn env writes read are hashed as spawn resolves them. //! - The relay URL is hashed in resolved form (`effective_agent_relay_url`): -//! a record with a blank relay spawns against the active workspace relay, -//! so a workspace relay change means a restart would change what runs. +//! every record spawns against the active workspace relay (legacy per-record +//! pins are ignored), so a workspace relay change means a restart would +//! change what runs. //! - Channel membership is not an input: agents pick up channel changes live //! (#1468), never via restart. //! @@ -105,8 +106,8 @@ pub(crate) fn spawn_config_hash( effective.env.hash(&mut hasher); // Record fields the spawn env writes read directly. The relay is hashed - // resolved: a blank record relay spawns on the workspace relay, so a - // workspace relay change must trip the badge. + // resolved: every record spawns on the workspace relay (legacy pins + // ignored), so a workspace relay change must trip the badge. crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); // Prompt and runtime-layered team instructions use the same resolver as spawn. effective_spawn_prompt(record).hash(&mut hasher); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 203863b0ed..6438b3b37d 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -178,11 +178,15 @@ fn persona_prompt_edit_changes_hash() { } #[test] -fn workspace_relay_change_trips_hash_for_blank_record_relay() { - // A blank record relay spawns against the active workspace relay, so a - // workspace relay change means a restart would change what runs. - let mut rec = record(); - rec.relay_url = String::new(); +fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { + // The legacy per-record relay pin is ignored (#2122): every record spawns + // against the active workspace relay, so a workspace relay change means a + // restart would change what runs — pinned records included. + let rec = record(); + assert!( + !rec.relay_url.is_empty(), + "fixture should carry a legacy pin" + ); assert_ne!( spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) @@ -190,13 +194,16 @@ fn workspace_relay_change_trips_hash_for_blank_record_relay() { } #[test] -fn workspace_relay_change_ignored_for_pinned_record_relay() { - // An explicit per-agent relay pins the agent regardless of workspace, so - // a workspace relay change must NOT badge a pinned agent. - let rec = record(); +fn stored_record_relay_does_not_affect_hash() { + // Editing the (ignored) stored pin must not badge a restart: what a + // restart would run is identical either way. + let mut a = record(); + let mut b = record(); + a.relay_url = String::new(); + b.relay_url = "wss://legacy-pin.example".into(); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + spawn_config_hash(&a, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&b, &[], &[], "wss://ws.example", &Default::default()) ); } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 7ae5426e39..e6463f5092 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -56,19 +56,18 @@ pub fn relay_api_base_url_with_override(state: &AppState) -> String { /// Selects the relay a managed agent should use for a relay operation. /// -/// An explicit per-agent `relay_url` always wins (highest precedence), pinning -/// the agent to that relay regardless of the active workspace. An empty or -/// whitespace-only `relay_url` falls back to the active workspace relay, which -/// resolves at read-time so a never-set record reconciles, spawns, and re-syncs -/// on the session's relay instead of a stale stored value. Uniform for both -/// Local and Provider backends. -pub fn effective_agent_relay_url(record_relay: &str, workspace_relay: &str) -> String { - let pinned = record_relay.trim(); - if pinned.is_empty() { - workspace_relay.to_string() - } else { - pinned.to_string() - } +/// Always the active workspace relay. The legacy per-record `relay_url` pin is +/// deliberately IGNORED (agents-everywhere, #2122): every agent is eligible on +/// every community, and the pair the caller is acting on is identified by the +/// workspace relay, never by a stored pin. The record field is still parsed +/// and persisted untouched — old records need no migration and a rollback to a +/// pin-honoring build reads the same file — so the parameter stays in the +/// signature as documentation of what is being ignored at the one choke point +/// all agent relay resolution flows through. Resolving at read-time also means +/// a stale stored value can never leak into reconcile, spawn, or profile sync. +/// Uniform for both Local and Provider backends. +pub fn effective_agent_relay_url(_record_relay: &str, workspace_relay: &str) -> String { + workspace_relay.to_string() } pub fn relay_http_base_url(relay_url: &str) -> String { @@ -484,9 +483,8 @@ pub async fn sync_managed_agent_profile( /// /// Queries the relay identified by `relay_url`. Callers uniformly pass the /// relay resolved by `effective_agent_relay_url` for every agent regardless of -/// backend — an explicit per-agent pin, or the active workspace relay when the -/// agent has none — so the query targets the host the profile is actually -/// published to. +/// backend — always the active workspace relay — so the query targets the host +/// the profile is actually published to. /// /// Returns the parsed profile content (display_name, picture) if a kind:0 event /// exists for the given pubkey, or `None` if no profile is published. @@ -791,29 +789,20 @@ mod tests { ); } - // ── effective_agent_relay_url: per-agent override precedence ───────────── + // ── effective_agent_relay_url: legacy pin ignored ───────────────────────── #[test] - fn explicit_relay_wins_over_workspace() { - // An explicit per-agent relay pins the agent there regardless of the - // active workspace — this is the override taking highest precedence. + fn stored_relay_pin_is_ignored() { + // Zero-touch cutover (#2122): a creation-era per-record relay pin is + // parsed and persisted but never consulted — the workspace relay wins. assert_eq!( effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), - "wss://relay.other.com" - ); - } - - #[test] - fn explicit_relay_wins_even_when_equal_to_workspace() { - // No special-casing when the pin happens to match the active workspace. - assert_eq!( - effective_agent_relay_url("wss://staging.example.com", "wss://staging.example.com"), "wss://staging.example.com" ); } #[test] - fn empty_relay_falls_back_to_workspace() { + fn empty_relay_resolves_to_workspace() { // A never-set record resolves to the active workspace relay at read-time, // so a stale stored default can never make it load-bearing. assert_eq!( @@ -823,8 +812,8 @@ mod tests { } #[test] - fn whitespace_only_relay_falls_back_to_workspace() { - // Whitespace-only is treated as unset, same as empty. + fn whitespace_only_relay_resolves_to_workspace() { + // Whitespace-only behaves identically — no value survives. assert_eq!( effective_agent_relay_url(" ", "wss://staging.example.com"), "wss://staging.example.com" diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index ec19f4ef8a..c2cc5de18b 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -114,7 +114,6 @@ export function AgentInstanceEditDialog({ const [name, setName] = React.useState(agent.name); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); - const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl); const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand); const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand); const [originalAgentCommand, setOriginalAgentCommand] = React.useState( @@ -171,7 +170,6 @@ export function AgentInstanceEditDialog({ React.useEffect(() => { if (open) { setName(agent.name); - setRelayUrl(agent.relayUrl); setAcpCommand(agent.acpCommand); setAgentCommand(agent.agentCommand); setOriginalAgentCommand(agent.agentCommand); @@ -655,8 +653,8 @@ export function AgentInstanceEditDialog({ const input: UpdateManagedAgentInput = { pubkey: agent.pubkey, name: name.trim() !== agent.name ? name.trim() : undefined, - relayUrl: - relayUrl.trim() !== agent.relayUrl ? relayUrl.trim() : undefined, + // relayUrl deliberately never submitted: the legacy per-record pin is + // ignored (#2122) and the stored value is preserved as-is. acpCommand: acpCommand.trim() !== agent.acpCommand ? acpCommand.trim() @@ -1151,7 +1149,6 @@ export function AgentInstanceEditDialog({ modelTuningRuntimeId={prospectiveRuntimeId} parallelism={parallelism} provider={effectiveProvider} - relayUrl={relayUrl} requiredEnvKeys={advancedRequiredEnvKeys} selectedRuntimeId={selectedRuntimeId} systemPrompt={systemPrompt} @@ -1162,7 +1159,6 @@ export function AgentInstanceEditDialog({ onEnvVarsChange={setEnvVars} onInheritHarnessChange={setInheritHarness} onParallelismChange={setParallelism} - onRelayUrlChange={setRelayUrl} onSystemPromptChange={setSystemPrompt} /> diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 7559db0a8a..b1d79495e1 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -28,7 +28,6 @@ export function EditAgentAdvancedFields({ modelTuningRuntimeId, parallelism, provider, - relayUrl, requiredEnvKeys, selectedRuntimeId, systemPrompt, @@ -38,7 +37,6 @@ export function EditAgentAdvancedFields({ onEnvVarsChange, onInheritHarnessChange, onParallelismChange, - onRelayUrlChange, onAutoRestartChange, onSystemPromptChange, }: { @@ -66,7 +64,6 @@ export function EditAgentAdvancedFields({ parallelism: string; /** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */ provider?: string; - relayUrl: string; requiredEnvKeys: readonly string[]; selectedRuntimeId: string; systemPrompt: string; @@ -76,7 +73,6 @@ export function EditAgentAdvancedFields({ onEnvVarsChange: (value: EnvVarsValue) => void; onInheritHarnessChange: (value: boolean) => void; onParallelismChange: (value: string) => void; - onRelayUrlChange: (value: string) => void; onAutoRestartChange: (value: boolean) => void; onSystemPromptChange: (value: string) => void; }) { @@ -219,35 +215,10 @@ export function EditAgentAdvancedFields({
- {/* Relay URL */} -
- -
- onRelayUrlChange(event.target.value)} - placeholder="Leave blank to use the community relay" - value={relayUrl} - /> -
-
+ {/* Relay URL: intentionally no editor. The legacy per-record relay pin + is ignored (#2122 agents-everywhere) — agents always run on the + active community relay — so offering a knob here would advertise a + setting with no effect. The stored field is preserved untouched. */} {/* ACP command */}