From e77495120edd0f6630ccb110437ea033a8d45537 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 30 Jun 2026 12:10:15 -0600 Subject: [PATCH 1/3] perf(desktop): run blocking agent/persona/team commands off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These Tauri commands were synchronous (`pub fn`), so in Tauri v2 they ran on the main UI thread. Their blocking work — disk reads/writes of agent, persona, and team records plus per-process liveness syscalls — froze the UI (beachball) on the agents-menu mount and after every start/stop/edit, which invalidates and refetches `list_managed_agents`. Convert them to `async fn` and move each body into `tokio::task::spawn_blocking` so the blocking work runs on Tauri's worker pool, matching the pattern already used by `deploy_to_provider` and `probe_backend_provider`. State is re-derived from the owned `AppHandle` inside the closure because `State<'_, _>` is borrowed and `std::sync::MutexGuard` is not `Send`; the existing `managed_agents_store_lock` still serializes the on-disk store, so consistency is unchanged. Converted: list_managed_agents, stop_managed_agent, delete_managed_agent; list_personas, create_persona, update_persona, delete_persona, set_persona_active; list_teams, create_team, update_team, delete_team, install_team_from_directory, sync_team_directory. Left synchronous on purpose: reconcile_inbound_persona_event and put_agent_session_config (background per-relay-event paths), parse_team_file / parse_persona_files (pure CPU, rare import), and the trivial log/settings reads. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/src-tauri/src/commands/agents.rs | 239 ++++++----- .../src-tauri/src/commands/personas/mod.rs | 400 +++++++++--------- desktop/src-tauri/src/commands/teams.rs | 251 ++++++----- 3 files changed, 480 insertions(+), 410 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 03e8a1786..04eb1a854 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -452,35 +452,44 @@ async fn deploy_to_provider( Ok(()) } +// Async so the blocking body (disk reads of agent/persona records, per-agent +// process-liveness syscalls, and a possible save) runs on Tauri's worker pool +// via spawn_blocking instead of the main UI thread — it was a beachball on the +// agents menu mount and after every start/stop/edit refetch. State is re-derived +// from the owned AppHandle inside the closure because `State<'_, _>` is borrowed +// and `std::sync::MutexGuard` is not `Send`. #[tauri::command] -pub fn list_managed_agents( - app: AppHandle, - state: State<'_, AppState>, -) -> Result, String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; +pub async fn list_managed_agents(app: AppHandle) -> Result, String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); - } + let (sync_changed, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { + save_managed_agents(&app, &records)?; + } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } - let personas = load_personas(&app).unwrap_or_default(); - records - .iter() - .map(|record| build_managed_agent_summary(&app, record, &runtimes, &personas)) - .collect() + let personas = load_personas(&app).unwrap_or_default(); + records + .iter() + .map(|record| build_managed_agent_summary(&app, record, &runtimes, &personas)) + .collect() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] @@ -1243,60 +1252,17 @@ fn profile_needs_sync( } } +// Async so the blocking body (disk reads/writes + process termination) runs off +// the main UI thread via spawn_blocking. State is re-derived from the owned +// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send). #[tauri::command] -pub fn stop_managed_agent( +pub async fn stop_managed_agent( pubkey: String, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_session_cache(pubkey); - } - - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; - // Remote agents are stopped via !shutdown @mention from the frontend, - // not via this backend command. Reject the call. - if record.backend != BackendKind::Local { - return Err( - "remote agents are stopped via !shutdown message, not this command".to_string(), - ); - } - stop_managed_agent_process(&app, record, &mut runtimes)?; - } - state.clear_session_cache(&pubkey); - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary(&app, record, &runtimes, &personas) -} - -#[tauri::command] -pub fn delete_managed_agent( - pubkey: String, - force_remote_delete: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); let _store_guard = state .managed_agents_store_lock .lock() @@ -1316,43 +1282,104 @@ pub fn delete_managed_agent( state.clear_session_cache(pubkey); } - // Guard: reject deletion of deployed remote agents unless explicitly forced. - // This turns "don't orphan remote infra" from a UI convention into a backend - // invariant — a buggy or compromised IPC caller cannot silently orphan a live - // remote deployment. The frontend sends force_remote_delete: true only after - // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { - if record.backend != BackendKind::Local - && record.backend_agent_id.is_some() - && !force_remote_delete.unwrap_or(false) - { + { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + // Remote agents are stopped via !shutdown @mention from the frontend, + // not via this backend command. Reject the call. + if record.backend != BackendKind::Local { return Err( - "cannot delete a deployed remote agent without force_remote_delete: true" - .to_string(), + "remote agents are stopped via !shutdown message, not this command".to_string(), ); } - } - - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { stop_managed_agent_process(&app, record, &mut runtimes)?; } state.clear_session_cache(&pubkey); - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { - return Err(format!("agent {pubkey} not found")); - } save_managed_agents(&app, &records)?; - // Remove the agent's nsec from the keyring after the record is gone. - crate::managed_agents::delete_agent_key(&pubkey); - // Tombstone-after-validation: only reached past the deployed-remote - // guard above and a confirmed removal — never orphan a live remote - // deployment's relay record. Inside the lock, before the block closes - // (no .await here). Every agent published, so every delete tombstones. - tombstone_managed_agent_pending(&app, &state, &pubkey); - } - try_regenerate_nest(&app); - Ok(()) + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary(&app, record, &runtimes, &personas) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +// Async so the blocking body (disk reads/writes, process termination, keyring +// delete, nest regeneration) runs off the main UI thread via spawn_blocking. +#[tauri::command] +pub async fn delete_managed_agent( + pubkey: String, + force_remote_delete: Option, + app: AppHandle, +) -> Result<(), String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( + &mut records, + &mut runtimes, + ¤t_instance_id(&app), + ); + if sync_changed { + save_managed_agents(&app, &records)?; + } + for pubkey in &exited_pubkeys { + state.clear_session_cache(pubkey); + } + + // Guard: reject deletion of deployed remote agents unless explicitly forced. + // This turns "don't orphan remote infra" from a UI convention into a backend + // invariant — a buggy or compromised IPC caller cannot silently orphan a live + // remote deployment. The frontend sends force_remote_delete: true only after + // the user confirms the orphan warning. + if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + if record.backend != BackendKind::Local + && record.backend_agent_id.is_some() + && !force_remote_delete.unwrap_or(false) + { + return Err( + "cannot delete a deployed remote agent without force_remote_delete: true" + .to_string(), + ); + } + } + + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + state.clear_session_cache(&pubkey); + let initial_len = records.len(); + records.retain(|record| record.pubkey != pubkey); + if records.len() == initial_len { + return Err(format!("agent {pubkey} not found")); + } + save_managed_agents(&app, &records)?; + // Remove the agent's nsec from the keyring after the record is gone. + crate::managed_agents::delete_agent_key(&pubkey); + // Tombstone-after-validation: only reached past the deployed-remote + // guard above and a confirmed removal — never orphan a live remote + // deployment's relay record. Inside the lock, before the block closes + // (no .await here). Every agent published, so every delete tombstones. + tombstone_managed_agent_pending(&app, &state, &pubkey); + } + try_regenerate_nest(&app); + Ok(()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index ffd75206b..4fa517f93 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -150,129 +150,142 @@ pub(super) fn tombstone_persona_pending(app: &AppHandle, state: &AppState, d_tag } #[tauri::command] -pub fn list_personas( - app: AppHandle, - state: State<'_, AppState>, -) -> Result, String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_personas(&app) +pub async fn list_personas(app: AppHandle) -> Result, String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_personas(&app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn create_persona( +pub async fn create_persona( input: CreatePersonaRequest, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let display_name = trim_required(&input.display_name, "Display name")?; - // System prompt optional: core memory is auto-injected. Empty is valid. - let system_prompt = input.system_prompt.trim().to_string(); - let avatar_url = trim_optional(input.avatar_url); - let runtime = trim_optional(input.runtime); - let model = trim_optional(input.model); - let provider = trim_optional(input.provider); - let now = now_iso(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let name_pool: Vec = input - .name_pool - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - let persona = PersonaRecord { - id: Uuid::new_v4().to_string(), - display_name, - avatar_url, - system_prompt, - runtime, - model, - provider, - name_pool, - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - env_vars: input.env_vars, - created_at: now.clone(), - updated_at: now, - }; - personas.push(persona.clone()); - save_personas(&app, &personas)?; - retain_persona_pending(&app, &state, &persona); - try_regenerate_nest(&app); - Ok(persona) + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let display_name = trim_required(&input.display_name, "Display name")?; + // System prompt optional: core memory is auto-injected. Empty is valid. + let system_prompt = input.system_prompt.trim().to_string(); + let avatar_url = trim_optional(input.avatar_url); + let runtime = trim_optional(input.runtime); + let model = trim_optional(input.model); + let provider = trim_optional(input.provider); + let now = now_iso(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + let name_pool: Vec = input + .name_pool + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + crate::managed_agents::validate_user_env_keys(&input.env_vars)?; + let persona = PersonaRecord { + id: Uuid::new_v4().to_string(), + display_name, + avatar_url, + system_prompt, + runtime, + model, + provider, + name_pool, + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: input.env_vars, + created_at: now.clone(), + updated_at: now, + }; + personas.push(persona.clone()); + save_personas(&app, &personas)?; + retain_persona_pending(&app, &state, &persona); + try_regenerate_nest(&app); + Ok(persona) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn update_persona( +pub async fn update_persona( input: UpdatePersonaRequest, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let display_name = trim_required(&input.display_name, "Display name")?; - // Do not trim system_prompt: `compose_prompt` appends pack_instructions - // verbatim (including any trailing newline), and write_back_persona_md - // decomposes by suffix-stripping. Trimming would break that exact-suffix - // match for the common case where instructions.md has a trailing newline. - let system_prompt = input.system_prompt.clone(); - let avatar_url = trim_optional(input.avatar_url); - let runtime = trim_optional(input.runtime); - let model = trim_optional(input.model); - let provider = trim_optional(input.provider); - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let persona = personas - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} not found", input.id))?; - - if persona.is_builtin { - return Err("Built-in personas cannot be edited.".to_string()); - } - persona.display_name = display_name; - persona.avatar_url = avatar_url; - persona.system_prompt = system_prompt; - persona.runtime = runtime; - persona.model = model; - persona.provider = provider; - persona.name_pool = input - .name_pool - .into_iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - persona.env_vars = env_vars; - } - persona.updated_at = now_iso(); - - save_personas(&app, &personas)?; - let result = personas - .into_iter() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let display_name = trim_required(&input.display_name, "Display name")?; + // Do not trim system_prompt: `compose_prompt` appends pack_instructions + // verbatim (including any trailing newline), and write_back_persona_md + // decomposes by suffix-stripping. Trimming would break that exact-suffix + // match for the common case where instructions.md has a trailing newline. + let system_prompt = input.system_prompt.clone(); + let avatar_url = trim_optional(input.avatar_url); + let runtime = trim_optional(input.runtime); + let model = trim_optional(input.model); + let provider = trim_optional(input.provider); - // For pack-backed personas, also write the edit back to the source - // `.persona.md` so that launch sync (which reads the file) becomes a - // no-op rather than overwriting the record we just saved. - write_back_persona_md(&app, &result); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + let persona = personas + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("persona {} not found", input.id))?; - retain_persona_pending(&app, &state, &result); - try_regenerate_nest(&app); - Ok(result) + if persona.is_builtin { + return Err("Built-in personas cannot be edited.".to_string()); + } + persona.display_name = display_name; + persona.avatar_url = avatar_url; + persona.system_prompt = system_prompt; + persona.runtime = runtime; + persona.model = model; + persona.provider = provider; + persona.name_pool = input + .name_pool + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if let Some(env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + persona.env_vars = env_vars; + } + persona.updated_at = now_iso(); + + save_personas(&app, &personas)?; + let result = personas + .into_iter() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; + + // For pack-backed personas, also write the edit back to the source + // `.persona.md` so that launch sync (which reads the file) becomes a + // no-op rather than overwriting the record we just saved. + write_back_persona_md(&app, &result); + + retain_persona_pending(&app, &state, &result); + try_regenerate_nest(&app); + Ok(result) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } mod writeback; @@ -282,55 +295,57 @@ use writeback::write_back_persona_md; mod inbound_tests; #[tauri::command] -pub fn delete_persona( - id: String, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let persona = personas - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("persona {id} not found"))?; - let referenced_by_team = load_teams(&app)?.iter().any(|team| { - team.persona_ids +pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + let persona = personas .iter() - .any(|persona_id| persona_id == id.as_str()) - }); - validate_persona_deletion(persona, referenced_by_team)?; - // Capture the coordinate before the record leaves the list. Only reached - // for non-builtin, non-team personas (validate_persona_deletion rejects - // both), so every deleted persona here is one this owner published. - let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); - - let original_len = personas.len(); - personas.retain(|record| record.id != id); - if personas.len() == original_len { - return Err(format!("persona {id} not found")); - } - save_personas(&app, &personas)?; - tombstone_persona_pending(&app, &state, &d_tag); - - let mut agents = load_managed_agents(&app)?; - let mut changed_agents = false; - let now = now_iso(); - for agent in &mut agents { - if agent.persona_id.as_deref() == Some(id.as_str()) { - agent.persona_id = None; - agent.updated_at = now.clone(); - changed_agents = true; + .find(|record| record.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; + let referenced_by_team = load_teams(&app)?.iter().any(|team| { + team.persona_ids + .iter() + .any(|persona_id| persona_id == id.as_str()) + }); + validate_persona_deletion(persona, referenced_by_team)?; + // Capture the coordinate before the record leaves the list. Only reached + // for non-builtin, non-team personas (validate_persona_deletion rejects + // both), so every deleted persona here is one this owner published. + let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); + + let original_len = personas.len(); + personas.retain(|record| record.id != id); + if personas.len() == original_len { + return Err(format!("persona {id} not found")); } - } - if changed_agents { - save_managed_agents(&app, &agents)?; - } - try_regenerate_nest(&app); + save_personas(&app, &personas)?; + tombstone_persona_pending(&app, &state, &d_tag); + + let mut agents = load_managed_agents(&app)?; + let mut changed_agents = false; + let now = now_iso(); + for agent in &mut agents { + if agent.persona_id.as_deref() == Some(id.as_str()) { + agent.persona_id = None; + agent.updated_at = now.clone(); + changed_agents = true; + } + } + if changed_agents { + save_managed_agents(&app, &agents)?; + } + try_regenerate_nest(&app); - Ok(()) + Ok(()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } /// Apply an inbound kind:30175 persona event from the relay onto the local @@ -679,51 +694,56 @@ fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamE } #[tauri::command] -pub fn set_persona_active( +pub async fn set_persona_active( id: String, active: bool, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - let persona = personas - .iter_mut() - .find(|record| record.id == id) - .ok_or_else(|| format!("persona {id} not found"))?; + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut personas = load_personas(&app)?; + let persona = personas + .iter_mut() + .find(|record| record.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; - let referenced_by_managed_agent = !active - && load_managed_agents(&app)? - .iter() - .any(|agent| agent.persona_id.as_deref() == Some(id.as_str())); - let referenced_by_team = !active - && load_teams(&app)?.iter().any(|team| { - team.persona_ids + let referenced_by_managed_agent = !active + && load_managed_agents(&app)? .iter() - .any(|persona_id| persona_id == id.as_str()) - }); + .any(|agent| agent.persona_id.as_deref() == Some(id.as_str())); + let referenced_by_team = !active + && load_teams(&app)?.iter().any(|team| { + team.persona_ids + .iter() + .any(|persona_id| persona_id == id.as_str()) + }); - validate_persona_activation_change( - persona, - active, - referenced_by_managed_agent, - referenced_by_team, - )?; + validate_persona_activation_change( + persona, + active, + referenced_by_managed_agent, + referenced_by_team, + )?; - if persona.is_active == active { - return Ok(persona.clone()); - } + if persona.is_active == active { + return Ok(persona.clone()); + } - persona.is_active = active; - persona.updated_at = now_iso(); + persona.is_active = active; + persona.updated_at = now_iso(); - let updated = persona.clone(); - save_personas(&app, &personas)?; - try_regenerate_nest(&app); - Ok(updated) + let updated = persona.clone(); + save_personas(&app, &personas)?; + try_regenerate_nest(&app); + Ok(updated) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } const MAX_PNG_BYTES: usize = 10 * 1024 * 1024; diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index f7ea2fe15..3666d21bd 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -137,139 +137,162 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { } #[tauri::command] -pub fn list_teams(app: AppHandle, state: State<'_, AppState>) -> Result, String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_teams(&app) +pub async fn list_teams(app: AppHandle) -> Result, String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_teams(&app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn create_team( - input: CreateTeamRequest, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - let name = trim_required(&input.name, "Team name")?; - let description = trim_optional(input.description); - let now = now_iso(); - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let personas = load_personas(&app)?; - ensure_persona_ids_are_active(&personas, &input.persona_ids)?; - let mut teams = load_teams(&app)?; - let team = TeamRecord { - id: Uuid::new_v4().to_string(), - name, - description, - persona_ids: input.persona_ids, - is_builtin: false, - source_dir: None, - is_symlink: false, - symlink_target: None, - version: None, - created_at: now.clone(), - updated_at: now, - }; - teams.push(team.clone()); - save_teams(&app, &teams)?; - // Created teams are always non-builtin; publish to the relay. - retain_team_pending(&app, &state, &team); - Ok(team) +pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let name = trim_required(&input.name, "Team name")?; + let description = trim_optional(input.description); + let now = now_iso(); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app)?; + ensure_persona_ids_are_active(&personas, &input.persona_ids)?; + let mut teams = load_teams(&app)?; + let team = TeamRecord { + id: Uuid::new_v4().to_string(), + name, + description, + persona_ids: input.persona_ids, + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now.clone(), + updated_at: now, + }; + teams.push(team.clone()); + save_teams(&app, &teams)?; + // Created teams are always non-builtin; publish to the relay. + retain_team_pending(&app, &state, &team); + Ok(team) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn update_team( - input: UpdateTeamRequest, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - let name = trim_required(&input.name, "Team name")?; - let description = trim_optional(input.description); - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let personas = load_personas(&app)?; - ensure_persona_ids_are_active(&personas, &input.persona_ids)?; - let mut teams = load_teams(&app)?; - let team = teams - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("team {} not found", input.id))?; - - team.name = name; - team.description = description; - team.persona_ids = input.persona_ids; - team.updated_at = now_iso(); - - let updated = team.clone(); - save_teams(&app, &teams)?; - // Built-in teams are not owner-authored — never publish them. - if !updated.is_builtin { - retain_team_pending(&app, &state, &updated); - } - Ok(updated) +pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let name = trim_required(&input.name, "Team name")?; + let description = trim_optional(input.description); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app)?; + ensure_persona_ids_are_active(&personas, &input.persona_ids)?; + let mut teams = load_teams(&app)?; + let team = teams + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("team {} not found", input.id))?; + + team.name = name; + team.description = description; + team.persona_ids = input.persona_ids; + team.updated_at = now_iso(); + + let updated = team.clone(); + save_teams(&app, &teams)?; + // Built-in teams are not owner-authored — never publish them. + if !updated.is_builtin { + retain_team_pending(&app, &state, &updated); + } + Ok(updated) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?; - // delete_team_with_cascade rejects built-in teams via validate_team_deletion, - // so reaching here means this team was owner-published — tombstone it. The - // d_tag is the team id, captured before the record left the store. - tombstone_team_pending(&app, &state, &id); - // Tombstone the cascaded personas too, so their orphaned kind:30175 heads - // don't linger on the relay (F4). Each d-tag was captured pre-removal. - for persona_d_tag in &cascaded_persona_d_tags { - super::personas::tombstone_persona_pending(&app, &state, persona_d_tag); - } - try_regenerate_nest(&app); - Ok(()) +pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?; + // delete_team_with_cascade rejects built-in teams via validate_team_deletion, + // so reaching here means this team was owner-published — tombstone it. The + // d_tag is the team id, captured before the record left the store. + tombstone_team_pending(&app, &state, &id); + // Tombstone the cascaded personas too, so their orphaned kind:30175 heads + // don't linger on the relay (F4). Each d-tag was captured pre-removal. + for persona_d_tag in &cascaded_persona_d_tags { + super::personas::tombstone_persona_pending(&app, &state, persona_d_tag); + } + try_regenerate_nest(&app); + Ok(()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn install_team_from_directory( +pub async fn install_team_from_directory( app: AppHandle, - state: State<'_, AppState>, path: String, symlink: Option, ) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let source = std::path::PathBuf::from(&path); - if !source.is_dir() { - return Err(format!("team path is not a directory: {path}")); - } - let result = do_import_team(&app, &source, symlink.unwrap_or(false))?; - try_regenerate_nest(&app); - Ok(result) + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let source = std::path::PathBuf::from(&path); + if !source.is_dir() { + return Err(format!("team path is not a directory: {path}")); + } + let result = do_import_team(&app, &source, symlink.unwrap_or(false))?; + try_regenerate_nest(&app); + Ok(result) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn sync_team_directory( - app: AppHandle, - state: State<'_, AppState>, - team_id: String, -) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let result = do_sync_team(&app, &team_id)?; - try_regenerate_nest(&app); - Ok(result) +pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result { + use tauri::Manager; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let result = do_sync_team(&app, &team_id)?; + try_regenerate_nest(&app); + Ok(result) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] From 79b3ecb5e5880d3568cacf2139aa9045edfb853f Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 30 Jun 2026 12:10:24 -0600 Subject: [PATCH 2/3] fix(desktop): defer thread panel open reflow Move thread panel open/close state changes out of the discrete click task and into a transitioned timer. This removes the explicit flushSync path that forced the channel timeline width reflow to happen synchronously while opening a thread. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../channels/useChannelPaneHandlers.ts | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index e753f4cbb..0b77a3d42 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -1,7 +1,5 @@ import * as React from "react"; -import { flushSync } from "react-dom"; - import type { useDeleteMessageMutation, useEditMessageMutation, @@ -81,19 +79,26 @@ export function useChannelPaneHandlers({ const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync); toggleMutateRef.current = toggleReactionMutation.mutateAsync; + const deferPanelState = React.useCallback((update: () => void) => { + window.setTimeout(() => { + React.startTransition(update); + }, 0); + }, []); + const handleCancelThreadReply = React.useCallback(() => { setThreadReplyTargetId(openThreadHeadIdRef.current); }, [setThreadReplyTargetId]); const handleCloseThread = React.useCallback(() => { - flushSync(() => { + deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); + setOpenThreadHeadId(null); + setThreadReplyTargetId(null); + setThreadScrollTargetId(null); + setExpandedThreadReplyIds(new Set()); }); - setOpenThreadHeadId(null); - setThreadReplyTargetId(null); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); }, [ + deferPanelState, onOptimisticOpenThreadHeadIdChange, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -135,27 +140,28 @@ export function useChannelPaneHandlers({ const handleOpenThread = React.useCallback( (message: { id: string }) => { if (openThreadHeadIdRef.current === message.id) { - flushSync(() => { + deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); + setOpenThreadHeadId(null); + setThreadReplyTargetId(null); + setThreadScrollTargetId(null); + setExpandedThreadReplyIds(new Set()); }); - setOpenThreadHeadId(null); - setThreadReplyTargetId(null); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); setEditTargetId(null); return; } - flushSync(() => { + deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(message.id); + setOpenThreadHeadId(message.id); + setThreadReplyTargetId(message.id); + setThreadScrollTargetId(null); + setExpandedThreadReplyIds(new Set()); }); - setOpenThreadHeadId(message.id); - setThreadReplyTargetId(message.id); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); setEditTargetId(null); }, [ + deferPanelState, onOptimisticOpenThreadHeadIdChange, setEditTargetId, setExpandedThreadReplyIds, From 5f2e6402245816a242302d9a02d2a3b32dbfb85c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 30 Jun 2026 12:46:24 -0600 Subject: [PATCH 3/3] refactor(desktop): split backend-provider commands out of agents.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async conversions pushed agents.rs to 1464 lines, over its file-size guard ceiling (1437). Rather than bump the limit, extract the two self-contained backend-provider commands (`discover_backend_providers`, `probe_backend_provider`) into `commands/agent_providers.rs`, matching the existing `agent_discovery`/`agent_models`/`agent_config` split pattern. This brings agents.rs to 1420 lines (under the ceiling) with no logic change — the commands are glob-re-exported through `commands::*`, so the Tauri invoke-handler registration is unchanged. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src-tauri/src/commands/agent_providers.rs | 42 +++++++++++++++ desktop/src-tauri/src/commands/agents.rs | 53 ++----------------- desktop/src-tauri/src/commands/mod.rs | 2 + 3 files changed, 49 insertions(+), 48 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_providers.rs diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs new file mode 100644 index 000000000..a903e4bba --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -0,0 +1,42 @@ +use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; + +#[tauri::command] +pub fn discover_backend_providers() -> Vec { + discover_provider_candidates() + .into_iter() + .map(|(id, path)| BackendProviderInfo { + id, + binary_path: path.display().to_string(), + }) + .collect() +} + +#[tauri::command] +pub async fn probe_backend_provider(binary_path: String) -> Result { + // Validate that the requested path is actually a discovered buzz-backend-* binary. + // This prevents arbitrary binary execution via a compromised frontend or IPC. + let candidates = discover_provider_candidates(); + let path = std::path::PathBuf::from(&binary_path); + let canonical = path + .canonicalize() + .map_err(|e| format!("binary not found: {binary_path}: {e}"))?; + let is_known = candidates + .iter() + .any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical)); + if !is_known { + return Err(format!( + "binary '{binary_path}' is not a discovered buzz-backend-* provider" + )); + } + // request_id is for provider-side logging — not validated in the response + // (stdin→stdout is 1:1 per process invocation). + let request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + tokio::task::spawn_blocking(move || { + invoke_provider(&canonical, &request, std::time::Duration::from_secs(10)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 04eb1a854..df890931f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -5,14 +5,14 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents, - load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, + ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, + managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, normalize_agent_args, provider_deploy, read_log_tail, 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, - BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, - ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, - DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, + 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, @@ -1409,49 +1409,6 @@ pub fn get_managed_agent_log( }) } -// ── New backend-provider commands ──────────────────────────────────────────── - -#[tauri::command] -pub fn discover_backend_providers() -> Vec { - discover_provider_candidates() - .into_iter() - .map(|(id, path)| BackendProviderInfo { - id, - binary_path: path.display().to_string(), - }) - .collect() -} - -#[tauri::command] -pub async fn probe_backend_provider(binary_path: String) -> Result { - // Validate that the requested path is actually a discovered buzz-backend-* binary. - // This prevents arbitrary binary execution via a compromised frontend or IPC. - let candidates = discover_provider_candidates(); - let path = std::path::PathBuf::from(&binary_path); - let canonical = path - .canonicalize() - .map_err(|e| format!("binary not found: {binary_path}: {e}"))?; - let is_known = candidates - .iter() - .any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical)); - if !is_known { - return Err(format!( - "binary '{binary_path}' is not a discovered buzz-backend-* provider" - )); - } - // request_id is for provider-side logging — not validated in the response - // (stdin→stdout is 1:1 per process invocation). - let request = serde_json::json!({ - "op": "info", - "request_id": uuid::Uuid::new_v4().to_string(), - }); - tokio::task::spawn_blocking(move || { - invoke_provider(&canonical, &request, std::time::Duration::from_secs(10)) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - // Remote agent shutdown is handled entirely by the frontend: // 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key) // 2. Harness sees it, exits gracefully, sets presence to "offline" diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 445fe2956..7fea4604a 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ mod agent_config; mod agent_discovery; mod agent_models; +mod agent_providers; mod agent_settings; mod agents; mod canvas; @@ -34,6 +35,7 @@ mod workspace; pub use agent_config::*; pub use agent_discovery::*; pub use agent_models::*; +pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*;