From 83aab8cb5c4fb1f7b73d8782ae44edc8f6df4dce Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 1 Oct 2026 14:43:21 -0400 Subject: [PATCH] feat(desktop): remove communities locally and stop their agent pairs (#7997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Leave contacts the relay before it removes anything locally: it checks the relay's `/info` for NIP-43 membership support, then waits for the relay to accept a signed leave request. When a relay has been torn down, that check fails, Leave throws, and the community stays in the rail. Removing a community also left its relay's managed agent pairs running, and they kept reconnecting to it until the app was quit. **Remove from this device** - A new community action, **Remove from this device**, sits under **Leave community** in the profile menu's community actions. It never contacts the relay. A confirm dialog explains that relay membership isn't revoked. - `useCommunityNavigationTransitions` splits the old `removeCommunity` into `removeCommunityFromDevice` (local-only cleanup and navigation to a fallback community or the discovery screen) and `leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same cleanup). Leave's flow hasn't changed. - When Leave fails, the error now adds: "If the community no longer exists, use Remove from this device." The error shown when community discovery state can't be saved no longer says "Membership was removed", so its wording fits both actions. **Agent pairs on a removed relay** - Once the local removal completes, the shared cleanup stops every live agent pair on that relay, including manually started ones. It matches relays with `canonicalRelayUrl`. Stopping is best-effort: failures are logged and the removal still completes. Pairs on other relays aren't touched, and nothing is stopped if another community still uses the same relay. - Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and `bootstrapManagedAgentRuntimePairs`) now go through `reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already running in Rust can't be cancelled, so after it resolves, this helper stops any pair it started whose relay is no longer in the saved community list. - Launch restore runs in the background after `apply_workspace` and can finish after its community is removed. `apply_workspace` takes a restore ticket (the current `launch_restore_generation` in `AppState`) before handing its lock to the restore task. Removing the active community calls the new `invalidate_launch_restore` command, which bumps that generation while holding the runtime transition lock. `restore_managed_agents_on_launch` re-checks its ticket under the same lock and spawns nothing if it changed. If restore takes the lock first, its pairs are registered before invalidation returns, and the removal stop then finds them. Removal never changes `relay_url_override`: the outgoing relay stays applied until the next `apply_workspace`, exactly as on `main`, so in-flight work never falls back to the default relay. Invalidation never takes `workspace_apply_lock`, which restore holds while waiting on the transition lock, so lock order is unchanged. - With no relay applied, backend readers fall back to the default relay, the same state as launch before the first `apply_workspace`. A normal quit and relaunch doesn't bring a pair back on a removed relay: restore targets the applied workspace, and the legacy per-record relay pin is ignored. **Tests** - A `community-rail` e2e case makes the mocked relay's membership check fail, the way an unreachable relay does. It checks that Leave shows the error and keeps the community, and that Remove from this device then removes it, switches to the fallback, stops the agent pair on the removed relay, and leaves the pair on the other relay running. - Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay matching, a stop or listing failure not throwing, and the reconcile fence stopping a pair that started on a relay removed mid-flight. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/src-tauri/src/app_state.rs | 5 +- .../src-tauri/src/commands/agent_discovery.rs | 8 +- .../src/commands/agent_models_update.rs | 5 + desktop/src-tauri/src/commands/agents.rs | 70 +++- .../src/commands/agents_admission_tests.rs | 71 ++++ .../src-tauri/src/commands/agents_pending.rs | 4 +- .../src-tauri/src/commands/agents_profile.rs | 4 +- .../src/commands/global_agent_config.rs | 14 +- desktop/src-tauri/src/commands/mesh_llm.rs | 20 +- .../src/commands/personas/inbound.rs | 4 + desktop/src-tauri/src/commands/workspace.rs | 54 ++- desktop/src-tauri/src/lib.rs | 2 + .../managed_agents/admission_test_support.rs | 58 +++ desktop/src-tauri/src/managed_agents/mod.rs | 4 + .../src/managed_agents/pair_admission.rs | 201 +++++++++ .../src-tauri/src/managed_agents/restore.rs | 385 ++++++++++++------ .../managed_agents/restore_admission_tests.rs | 65 +++ .../src-tauri/src/managed_agents/runtime.rs | 20 +- .../src/managed_agents/runtime/process.rs | 4 +- .../src/managed_agents/runtime_commands.rs | 71 +++- .../runtime_commands_admission_tests.rs | 61 +++ .../src-tauri/src/managed_agents/storage.rs | 19 +- desktop/src-tauri/src/mesh_llm/coordinator.rs | 5 +- desktop/src/app/AppShell.tsx | 8 +- .../app/useCommunityNavigationTransitions.ts | 91 ++++- .../lib/managedAgentControlActions.test.mjs | 35 ++ .../agents/lib/managedAgentControlActions.ts | 9 + ...oRestartPolicy.relayRemoved.jsdom-test.mjs | 47 +++ .../agents/lib/useAutoRestartPolicy.ts | 67 ++- .../managedAgentReconciliationPlan.test.mjs | 14 + .../agents/managedAgentReconciliationPlan.ts | 4 + .../agents/managedAgentRelayCleanup.test.mjs | 146 +++++++ .../agents/managedAgentRelayCleanup.ts | 163 ++++++++ .../agents/managedAgentRuntimeHooks.test.mjs | 32 ++ .../agents/managedAgentRuntimeHooks.ts | 13 +- .../agents/managedAgentRuntimeStatus.test.mjs | 10 + .../agents/managedAgentRuntimeStatus.ts | 12 +- .../agents/ui/AgentInstanceEditDialog.tsx | 6 +- ...edAgentActions.relayRemoved.jsdom-test.mjs | 211 ++++++++++ .../agents/ui/useManagedAgentActions.ts | 9 +- .../features/agents/ui/usePersonaActions.ts | 3 +- ...dAgentRuntimeReconciliation.jsdom-test.mjs | 84 ++++ .../useManagedAgentRuntimeReconciliation.ts | 15 +- .../channels/ui/useMembersSidebarActions.ts | 4 + .../legacyCommunityReadmit.jsdom-test.mjs | 118 ++++++ .../communities/legacyCommunityStorage.ts | 75 +++- .../communities/ui/CommunitySwitcher.tsx | 79 +++- .../updateCommunityReadmit.jsdom-test.mjs | 84 ++++ .../features/communities/useCommunities.tsx | 24 ++ .../features/communities/useCommunityInit.ts | 9 + .../features/profile/ui/UserProfilePanel.tsx | 5 +- .../profile/ui/useAgentLifecycleActions.ts | 13 +- .../src/features/sidebar/ui/AppSidebar.tsx | 6 +- .../features/sidebar/ui/AppSidebar.types.ts | 3 +- .../sidebar/ui/SidebarProfileCard.tsx | 9 +- desktop/src/shared/api/tauriWorkspace.ts | 8 + desktop/src/testing/e2eBridge.ts | 16 + desktop/tests/e2e/community-rail.spec.ts | 182 ++++++++- 58 files changed, 2489 insertions(+), 279 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agents_admission_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/admission_test_support.rs create mode 100644 desktop/src-tauri/src/managed_agents/pair_admission.rs create mode 100644 desktop/src-tauri/src/managed_agents/restore_admission_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime_commands_admission_tests.rs create mode 100644 desktop/src/features/agents/lib/useAutoRestartPolicy.relayRemoved.jsdom-test.mjs create mode 100644 desktop/src/features/agents/managedAgentRelayCleanup.test.mjs create mode 100644 desktop/src/features/agents/managedAgentRelayCleanup.ts create mode 100644 desktop/src/features/agents/ui/useManagedAgentActions.relayRemoved.jsdom-test.mjs create mode 100644 desktop/src/features/agents/useManagedAgentRuntimeReconciliation.jsdom-test.mjs create mode 100644 desktop/src/features/communities/legacyCommunityReadmit.jsdom-test.mjs create mode 100644 desktop/src/features/communities/updateCommunityReadmit.jsdom-test.mjs diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 3eac0d0816..782de8356b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -46,7 +46,8 @@ pub struct AppState { /// 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<()>, + /// Owns the per-relay admission record every local pair spawn re-checks. + pub managed_agent_runtime_transition: Mutex, pub managed_agents_store_lock: Mutex<()>, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, @@ -222,7 +223,7 @@ pub fn build_app_state() -> AppState { managed_agent_restore_pending: AtomicBool::new(false), managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState::default(), shutdown_started: AtomicBool::new(false), - managed_agent_runtime_transition: Mutex::new(()), + managed_agent_runtime_transition: Mutex::default(), identity_mutation: Mutex::new(()), managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 99dcc98145..5ff3407f37 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -514,7 +514,8 @@ async fn restart_single_agent_after_install( let app_for_stop = app.clone(); let pubkey_owned = pubkey.to_string(); let runtime_id_owned = runtime_id.to_string(); - + let state = app.state::(); + let gate = crate::managed_agents::AdmissionSnapshot::capture(&state); let stop_result = tokio::task::spawn_blocking(move || { let state = app_for_stop.state::(); @@ -610,9 +611,8 @@ async fn restart_single_agent_after_install( } }; - 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) + let urls: Vec<_> = runtime_keys.into_iter().map(|key| key.relay_url).collect(); + match super::agents::start_local_agent_pairs_with_preflight(app, &state, pubkey, &urls, &gate) .await { Ok(_) => { diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 2ef014d795..bc2bed54e8 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -145,6 +145,9 @@ pub async fn update_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + // Captured before Phase 1 stops the runtime for an access-policy change: a + // community removed while this update runs refuses the restart. + let admission = crate::managed_agents::AdmissionSnapshot::capture(&state); // Phase 1: local save (synchronous, under lock) let (mut summary, sync_params, rollback, access_policy_changed, access_restart_relays) = { let _store_guard = state @@ -430,6 +433,7 @@ pub async fn update_managed_agent( &state, &summary.pubkey, &access_restart_relays, + &admission, ) .await { @@ -456,6 +460,7 @@ pub async fn update_managed_agent( &state, &summary.pubkey, &access_restart_relays, + &admission, ) .await .map_err(|error| { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c610d8dad7..288334d750 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -59,8 +59,8 @@ mod create_fields; use create_fields::{normalize_relay_mesh, resolve_created_avatar_url, trim_to_optional_string}; #[cfg(feature = "mesh-llm")] -async fn ensure_relay_mesh_for_record( - app: &AppHandle, +async fn ensure_relay_mesh_for_record( + app: &AppHandle, model_id: Option<&str>, allow_fresh_create_start: bool, ) -> Result<(), String> { @@ -68,19 +68,23 @@ async fn ensure_relay_mesh_for_record( } #[cfg(not(feature = "mesh-llm"))] -async fn ensure_relay_mesh_for_record( - _app: &AppHandle, +async fn ensure_relay_mesh_for_record( + _app: &AppHandle, _model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { Ok(()) } +/// Start `pubkey`'s pairs on `relay_urls`. Restart flows capture `admission` +/// before they stop the pairs, so a community removed while the restart runs +/// refuses its start; those relays are skipped quietly. pub(super) async fn start_local_agent_pairs_with_preflight( app: &AppHandle, state: &AppState, pubkey: &str, relay_urls: &[String], + admission: &crate::managed_agents::AdmissionSnapshot, ) -> Result { let record_snapshot = { let _store_guard = state @@ -131,9 +135,13 @@ pub(super) async fn start_local_agent_pairs_with_preflight( if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy( pubkey.to_string(), relay_url.clone(), + admission, app.clone(), ) { - errors.push(format!("{relay_url}: {error}")); + // A relay removed since `admission` was captured has nothing to restart. + if error != crate::managed_agents::RELAY_REMOVED_ERROR { + errors.push(format!("{relay_url}: {error}")); + } } } if !errors.is_empty() { @@ -159,8 +167,8 @@ pub(super) async fn start_local_agent_pairs_with_preflight( summarize_from_disk(app, record, &runtimes) } -pub(super) async fn start_local_agent_with_preflight( - app: &AppHandle, +pub(super) async fn start_local_agent_with_preflight( + app: &AppHandle, state: &AppState, pubkey: &str, allow_fresh_create_start: bool, @@ -168,6 +176,40 @@ pub(super) async fn start_local_agent_with_preflight( expected_signer_pubkey: Option<&str>, replay_floor_unix: Option, ) -> Result { + start_local_agent_after_preflight( + app, + state, + pubkey, + expected_relay_url, + expected_signer_pubkey, + replay_floor_unix, + |mesh_model_id| async move { + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start) + .await + }, + ) + .await +} + +/// The ordinary start with its one awaited step, mesh preflight, supplied by +/// the caller so tests can hold it open across a removal. +pub(super) async fn start_local_agent_after_preflight( + app: &AppHandle, + state: &AppState, + pubkey: &str, + expected_relay_url: Option<&str>, + expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, + preflight: P, +) -> Result +where + R: tauri::Runtime, + P: FnOnce(Option) -> F, + F: std::future::Future>, +{ + // Captured before mesh preflight: a community removed while it awaits + // refuses this start below. + let admission = crate::managed_agents::AdmissionSnapshot::capture(state); let record_snapshot = { let _store_guard = state .managed_agents_store_lock @@ -200,7 +242,7 @@ pub(super) async fn start_local_agent_with_preflight( &personas, &global, ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + preflight(mesh_model_id).await?; // The mesh preflight above is the suspension window Projects callbacks // capture their scope against: a community switch during that await @@ -220,6 +262,14 @@ pub(super) async fn start_local_agent_with_preflight( let workspace_owner = crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?; + // Lock order matches `start_pair`: transition, then store, then runtime + // map. The transition lock is held until the pair is registered, so a + // removal either refuses this start or waits and its stop sweep finds it. + let transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let admitted = transition.admit(&admission, workspace_relay_url.as_str())?; let _store_guard = state .managed_agents_store_lock .lock() @@ -260,6 +310,7 @@ pub(super) async fn start_local_agent_with_preflight( &mut runtimes, Some(workspace_owner.as_str()), &workspace_relay_url, + &admitted, replay_floor_unix, )?; save_managed_agents(app, &records)?; @@ -1175,6 +1226,9 @@ pub(crate) use profile::*; #[cfg(test)] use profile::{profile_needs_sync, resolve_legacy_avatar}; +#[cfg(all(test, not(target_os = "windows")))] +#[path = "agents_admission_tests.rs"] +mod admission_tests; #[cfg(test)] #[path = "agents_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agents_admission_tests.rs b/desktop/src-tauri/src/commands/agents_admission_tests.rs new file mode 100644 index 0000000000..c85e94e8e4 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_admission_tests.rs @@ -0,0 +1,71 @@ +//! The ordinary local start against a removed relay, through the production +//! `start_local_agent_with_preflight` on a mock app, and across its preflight +//! await. + +use super::{start_local_agent_after_preflight, start_local_agent_with_preflight}; +use crate::app_state::AppState; +use crate::managed_agents::admission_test_support::{ + app_with_keyless_agent, reached_spawn, refused, RELAY, +}; +use crate::managed_agents::{readd_relay, remove_relay}; +use tauri::Manager; + +#[tokio::test] +async fn ordinary_start_refuses_a_removed_relay_until_readded() { + let test = app_with_keyless_agent(); + let (app, state) = (test.app.handle(), test.app.state::()); + let start = + || start_local_agent_with_preflight(app, &state, &test.pubkey, false, None, None, None); + + remove_relay(&state, RELAY).unwrap(); + let error = start().await.unwrap_err(); + assert!(refused(&error), "{error}"); + + readd_relay(&state, RELAY).unwrap(); + let error = start().await.unwrap_err(); + assert!(reached_spawn(&error), "{error}"); +} + +/// Runs the ordinary start with its preflight held open while `during` runs. +async fn start_across_preflight(during: impl FnOnce(&AppState)) -> String { + let test = app_with_keyless_agent(); + let (app, state) = (test.app.handle(), test.app.state::()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let start = start_local_agent_after_preflight( + app, + &state, + &test.pubkey, + None, + None, + None, + |_| async move { + entered_tx.send(()).unwrap(); + release_rx.await.unwrap(); + Ok(()) + }, + ); + let control = async { + entered_rx.await.unwrap(); + during(&state); + release_tx.send(()).unwrap(); + }; + let (result, ()) = tokio::join!(start, control); + result.unwrap_err() +} + +#[tokio::test] +async fn ordinary_start_removed_and_readded_during_preflight_is_refused() { + let error = start_across_preflight(|state| { + remove_relay(state, RELAY).unwrap(); + readd_relay(state, RELAY).unwrap(); + }) + .await; + assert!(refused(&error), "{error}"); +} + +#[tokio::test] +async fn ordinary_start_with_no_removal_during_preflight_reaches_spawn() { + let error = start_across_preflight(|_| {}).await; + assert!(reached_spawn(&error), "{error}"); +} diff --git a/desktop/src-tauri/src/commands/agents_pending.rs b/desktop/src-tauri/src/commands/agents_pending.rs index 0a7f91eb85..8c8683a3c4 100644 --- a/desktop/src-tauri/src/commands/agents_pending.rs +++ b/desktop/src-tauri/src/commands/agents_pending.rs @@ -22,8 +22,8 @@ use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; /// only runtime fields produces an identical row and never re-enqueues a /// publish. Best-effort: a failure here is logged and swallowed so a retention /// hiccup never blocks the disk-authoritative write. -pub(crate) fn retain_managed_agent_pending( - app: &AppHandle, +pub(crate) fn retain_managed_agent_pending( + app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) { diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 9726593923..909606c38b 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -190,9 +190,9 @@ pub(crate) fn mark_profile_reconciled( /// keeps deferred reconciliation from following a community switch it was /// never authorized for while honoring a deliberate per-agent pin wherever /// it points. -pub(crate) async fn reconcile_agent_profile( +pub(crate) async fn reconcile_agent_profile( state: &AppState, - app: &AppHandle, + app: &AppHandle, agent_pubkey: &str, data: &ProfileReconcileData, ) -> Result { diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 91219bafb9..835434bbaf 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -257,6 +257,9 @@ async fn restart_local_agent_on_config_change( let old_global_clone = old_global.clone(); let new_global_clone = new_global.clone(); let personas_owned = personas_snapshot.to_vec(); + use tauri::Manager; + // Captured before the stop: a community removed meanwhile refuses the start. + let admission = crate::managed_agents::AdmissionSnapshot::capture(&app.state::()); let stop_result = tokio::task::spawn_blocking(move || { use tauri::Manager; @@ -346,10 +349,15 @@ async fn restart_local_agent_on_config_change( }; 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 + match super::agents::start_local_agent_pairs_with_preflight( + app, + &state, + pubkey, + &relay_urls, + &admission, + ) + .await { Ok(_) => { eprintln!( diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 7356cd7fc0..04d899f6e1 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -37,7 +37,7 @@ fn one_shot_restart_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig checkpoint } -fn mesh_sharing_config_path(app: &AppHandle) -> Result { +fn mesh_sharing_config_path(app: &AppHandle) -> Result { Ok(app .path() .app_data_dir() @@ -45,7 +45,10 @@ fn mesh_sharing_config_path(app: &AppHandle) -> Result { .join("mesh-sharing.json")) } -fn save_mesh_sharing_config(app: &AppHandle, config: &MeshSharingConfig) -> Result<(), String> { +fn save_mesh_sharing_config( + app: &AppHandle, + config: &MeshSharingConfig, +) -> Result<(), String> { let path = mesh_sharing_config_path(app)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -56,7 +59,9 @@ fn save_mesh_sharing_config(app: &AppHandle, config: &MeshSharingConfig) -> Resu crate::managed_agents::atomic_write_json(&path, &payload) } -fn load_mesh_sharing_config(app: &AppHandle) -> Result, String> { +fn load_mesh_sharing_config( + app: &AppHandle, +) -> Result, String> { let path = mesh_sharing_config_path(app)?; match std::fs::read(&path) { Ok(payload) => serde_json::from_slice(&payload) @@ -337,7 +342,10 @@ async fn resolve_buzz_mesh_startup_at( } } -pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> CmdResult<()> { +pub(crate) async fn restore_mesh_sharing( + app: &AppHandle, + state: &AppState, +) -> CmdResult<()> { let Some(mut config) = load_mesh_sharing_config(app)? else { return Ok(()); }; @@ -678,8 +686,8 @@ fn pick_serve_target_for_model( /// a relay query failure ("could not refresh targets") is not the same as a /// relay that answered with no live target for this model ("peer offline"). /// Non relay-mesh records are a no-op. -pub(crate) async fn ensure_relay_mesh_for_record( - app: &AppHandle, +pub(crate) async fn ensure_relay_mesh_for_record( + app: &AppHandle, model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 20f46e640e..82892de7e3 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -77,6 +77,9 @@ pub async fn reconcile_inbound_persona_event( arrival_relay_url: String, app: AppHandle, ) -> Result<(), String> { + // Captured before the blocking reconcile stops the runtime: a community + // removed while this restart runs refuses its start. + let admission = crate::managed_agents::AdmissionSnapshot::capture(&app.state::()); let blocking_app = app.clone(); let restart = tokio::task::spawn_blocking(move || { reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, blocking_app) @@ -92,6 +95,7 @@ pub async fn reconcile_inbound_persona_event( &state, &pubkey, &relay_urls, + &admission, ) .await .map_err(|error| { diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index d87c8c11d6..f570d8e0b6 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -5,8 +5,8 @@ use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; use crate::managed_agents::{ - effective_repos_dir, ensure_repos_symlink, nest_dir, restore_managed_agents_on_launch, - try_regenerate_nest, write_persisted_repos_dir, + effective_repos_dir, ensure_repos_symlink, nest_dir, try_regenerate_nest, + write_persisted_repos_dir, }; use crate::relay; @@ -150,6 +150,34 @@ pub fn set_agent_avatar_communities( Ok(()) } +/// Refuse local agent pairs on a community's relay after it is removed from +/// this device; see `managed_agents::remove_relay`. The frontend calls this +/// only when no other saved community uses the relay, before its stop sweep. +/// +/// Leaves `relay_url_override` untouched: the outgoing relay stays applied +/// until the next `apply_workspace` replaces it. Never takes +/// `workspace_apply_lock`, which launch restore holds while waiting on the +/// transition lock this takes. +#[tauri::command] +pub async fn remove_community_relay(relay_url: String, app: AppHandle) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + crate::managed_agents::remove_relay(&app.state::(), &relay_url) + }) + .await + .map_err(|e| format!("remove_community_relay task failed: {e}"))? +} + +/// Admit local agent pairs on a relay again after a saved community on it is +/// explicitly re-added, whether or not it becomes the active community. +#[tauri::command] +pub async fn readd_community_relay(relay_url: String, app: AppHandle) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + crate::managed_agents::readd_relay(&app.state::(), &relay_url) + }) + .await + .map_err(|e| format!("readd_community_relay task failed: {e}"))? +} + /// Apply a workspace's configuration to the backend session. /// /// Called by the frontend on app init (after reload) to configure the @@ -331,6 +359,14 @@ pub async fn apply_workspace( let restore_pending = state .managed_agent_restore_pending .swap(false, Ordering::AcqRel); + // Scheduled (admission captured) before the task is spawned: a community + // removal that lands before restore begins must still refuse it. + let restore = restore_pending.then(|| { + crate::managed_agents::launch_restore_task( + restore_app.clone(), + crate::managed_agents::live_process_sweeps, + ) + }); // Transfer the apply guard to launch restoration. The command can return // promptly, but a queued workspace cannot mutate relay/identity until the @@ -350,10 +386,8 @@ pub async fn apply_workspace( } } crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await; - if restore_pending { - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await - { + if let Some(restore) = restore { + if let Err(error) = restore.await { eprintln!("buzz-desktop: failed to restore managed agents: {error}"); } } @@ -362,15 +396,11 @@ pub async fn apply_workspace( } #[cfg(not(feature = "mesh-llm"))] - if restore_pending { + if let Some(restore) = restore { let restore_lock = apply_guard; - let app = restore_app.clone(); tauri::async_runtime::spawn(async move { let _restore_lock = restore_lock; - let state = app.state::(); - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await - { + if let Err(error) = restore.await { eprintln!("buzz-desktop: failed to restore managed agents: {error}"); } }); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bd062960e0..e9c96a2fe3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -842,6 +842,8 @@ pub fn run() { confirm_pairing_sas, cancel_pairing, apply_workspace, + remove_community_relay, + readd_community_relay, set_agent_avatar_communities, validate_repos_dir, get_active_workspace, diff --git a/desktop/src-tauri/src/managed_agents/admission_test_support.rs b/desktop/src-tauri/src/managed_agents/admission_test_support.rs new file mode 100644 index 0000000000..7216662393 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/admission_test_support.rs @@ -0,0 +1,58 @@ +//! Mock-app fixture for the admission tests at the production spawn sites. +//! Its agent has no private key, so a start that passes admission reaches +//! `spawn_agent_child` and stops at the key refusal there β€” no process runs. +//! The keyring is swapped for keyring's in-memory mock, which holds no keys, +//! so the test never touches (or prompts for) the system keychain. + +use super::{save_managed_agents, ManagedAgentRecord, RELAY_REMOVED_ERROR}; +use tauri::test::MockRuntime; + +pub const RELAY: &str = "wss://removed.example"; + +pub struct TestApp { + pub app: tauri::App, + pub pubkey: String, + _data: tempfile::TempDir, +} + +/// A mock app whose store holds one keyless local agent that starts on launch. +pub fn app_with_keyless_agent() -> TestApp { + #[cfg(feature = "system-keyring")] + keyring::set_default_credential_builder(keyring::mock::default_credential_builder()); + let data = tempfile::tempdir().unwrap(); + let mut context = tauri::test::mock_context(tauri::test::noop_assets()); + // An absolute identifier replaces the app data base dir. + context.config_mut().identifier = data.path().to_str().unwrap().to_owned(); + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(RELAY.into()); + let app = tauri::test::mock_builder() + .manage(state) + .build(context) + .unwrap(); + let pubkey = nostr::Keys::generate().public_key().to_hex(); + let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": pubkey, "name": "Admission Test Agent", "relay_url": RELAY, + "acp_command": "", "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, "last_stopped_at": null, "last_exit_code": null, + "last_error": null, "start_on_app_launch": true + })) + .unwrap(); + save_managed_agents(app.handle(), &[record]).unwrap(); + TestApp { + app, + pubkey, + _data: data, + } +} + +/// A start error from the admission check rather than from spawning. +pub fn refused(error: &str) -> bool { + error == RELAY_REMOVED_ERROR +} + +/// A start error from `spawn_agent_child`'s key refusal: admission passed. +pub fn reached_spawn(error: &str) -> bool { + error.contains("has no private key available") +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index d58d943720..ad030769de 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -10,6 +10,8 @@ pub(crate) use agent_env::{ }; mod agent_description; pub(crate) use agent_description::{effective_agent_description, record_effective_description}; +#[cfg(all(test, not(target_os = "windows")))] +pub(crate) mod admission_test_support; mod backend; pub(crate) mod bestie_assignment; pub(crate) mod claude_config; @@ -23,6 +25,7 @@ pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; mod nest; +mod pair_admission; pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; @@ -88,6 +91,7 @@ pub(crate) use global_config::{ }; pub(crate) use managed_node_paths::*; pub use nest::*; +pub use pair_admission::*; pub use parallelism::{acp_agents_value, effective_parallelism, harness_max_parallelism}; pub use personas::*; #[cfg(windows)] diff --git a/desktop/src-tauri/src/managed_agents/pair_admission.rs b/desktop/src-tauri/src/managed_agents/pair_admission.rs new file mode 100644 index 0000000000..332f5f1638 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/pair_admission.rs @@ -0,0 +1,201 @@ +//! Per-relay admission for local agent pairs. +//! +//! Removing a community from this device stops every local pair on its relay, +//! but a start already in flight can await (mesh preflight, a restart's stop +//! phase), let that stop sweep finish, then resume and connect to the removed +//! relay. So every spawn site captures an [`AdmissionSnapshot`] before its +//! async work and re-checks it with [`RelayAdmissions::admit`] while holding +//! `managed_agent_runtime_transition` β€” the lock that owns this table β€” until +//! the pair is registered, where a removal's stop sweep will find it. +//! `spawn_agent_child` requires the [`Admitted`] proof `admit` returns, so no +//! spawn site can skip the check. + +use std::collections::HashMap; + +use crate::app_state::AppState; + +/// Refusal for a start whose relay was removed from this device. Callers that +/// start pairs in the background treat it as a quiet no-op, not a failure. +pub const RELAY_REMOVED_ERROR: &str = "relay was removed from this device"; + +/// Admission state per canonical relay URL (`buzz_core::relay::normalize_relay_url`). +#[derive(Default)] +pub struct RelayAdmissions(HashMap); + +#[derive(Clone, Copy, Default)] +struct RelayAdmission { + removed: bool, + /// Bumped on every removal, so work captured before a removal is never + /// admitted again, even after a re-add. + epoch: u64, +} + +/// Proof that a relay passed [`RelayAdmissions::admit`]. It borrows the table, +/// so it cannot outlive the transition lock it was checked under. +pub struct Admitted<'a> { + relay: String, + _table: std::marker::PhantomData<&'a RelayAdmissions>, +} + +impl Admitted<'_> { + /// Refuses a spawn on any relay other than the one admitted. + pub fn covers(&self, relay_url: &str) -> Result<(), String> { + if canonical(relay_url)? != self.relay { + return Err(format!("{relay_url} was not admitted for this spawn")); + } + Ok(()) + } +} + +/// Relay epochs captured by a start before its async work. +#[derive(Clone, Debug, Default)] +pub struct AdmissionSnapshot(HashMap); + +fn canonical(relay_url: &str) -> Result { + buzz_core_pkg::relay::normalize_relay_url(relay_url).map_err(|error| error.to_string()) +} + +impl RelayAdmissions { + pub fn snapshot(&self) -> AdmissionSnapshot { + AdmissionSnapshot( + self.0 + .iter() + .map(|(relay, admission)| (relay.clone(), admission.epoch)) + .collect(), + ) + } + + /// Refuse this relay's pairs until it is re-added, and invalidate every + /// snapshot taken so far. + pub fn remove(&mut self, relay_url: &str) -> Result<(), String> { + let admission = self.0.entry(canonical(relay_url)?).or_default(); + admission.removed = true; + admission.epoch += 1; + Ok(()) + } + + /// Admit a removed relay again. Work captured before the removal stays + /// refused: its epoch is older. + pub fn readd(&mut self, relay_url: &str) -> Result<(), String> { + if let Some(admission) = self.0.get_mut(&canonical(relay_url)?) { + admission.removed = false; + } + Ok(()) + } + + /// `Err(RELAY_REMOVED_ERROR)` if the relay is removed, or was removed + /// after `snapshot` was captured. + pub fn admit( + &self, + snapshot: &AdmissionSnapshot, + relay_url: &str, + ) -> Result, String> { + let relay = canonical(relay_url)?; + let current = self.0.get(&relay).copied().unwrap_or_default(); + let captured = snapshot.0.get(&relay).copied().unwrap_or_default(); + if current.removed || current.epoch != captured { + return Err(RELAY_REMOVED_ERROR.into()); + } + Ok(Admitted { + relay, + _table: std::marker::PhantomData, + }) + } +} + +impl AdmissionSnapshot { + /// Capture before any async work. Takes the transition lock briefly, so + /// never call it while holding that lock. A poisoned lock still yields + /// the table: capture must not be the reason a start skips its refusal. + pub fn capture(state: &AppState) -> Self { + state + .managed_agent_runtime_transition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot() + } +} + +/// Record that a community's relay was removed from this device. The caller +/// applies the shared-relay guard: only when no other saved community uses it. +/// Runs under the transition lock, so a start that already passed admission +/// has registered its pair before this returns and the caller's stop sweep +/// finds it. +pub fn remove_relay(state: &AppState, relay_url: &str) -> Result<(), String> { + state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())? + .remove(relay_url) +} + +/// Record that a saved community on this relay was explicitly added again. +pub fn readd_relay(state: &AppState, relay_url: &str) -> Result<(), String> { + state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())? + .readd(relay_url) +} + +#[cfg(test)] +mod tests { + use super::{AdmissionSnapshot, RelayAdmissions, RELAY_REMOVED_ERROR}; + + const RELAY: &str = "wss://relay.example"; + + fn refusal( + admissions: &RelayAdmissions, + snapshot: &AdmissionSnapshot, + relay: &str, + ) -> Result<(), String> { + admissions.admit(snapshot, relay).map(|_| ()) + } + + #[test] + fn admission_covers_only_its_own_relay() { + let admissions = RelayAdmissions::default(); + let admitted = admissions.admit(&admissions.snapshot(), RELAY).unwrap(); + assert_eq!(admitted.covers("WSS://relay.example/"), Ok(())); + assert!(admitted.covers("wss://other.example").is_err()); + } + + #[test] + fn removal_refuses_captured_and_fresh_work_until_readded() { + let mut admissions = RelayAdmissions::default(); + let before = admissions.snapshot(); + admissions.remove("WSS://Relay.Example/").unwrap(); + + assert_eq!( + refusal(&admissions, &before, RELAY), + Err(RELAY_REMOVED_ERROR.into()) + ); + assert_eq!( + refusal(&admissions, &admissions.snapshot(), RELAY), + Err(RELAY_REMOVED_ERROR.into()) + ); + admissions.readd(RELAY).unwrap(); + assert_eq!(refusal(&admissions, &admissions.snapshot(), RELAY), Ok(())); + } + + #[test] + fn work_captured_before_a_removal_stays_refused_after_readd() { + let mut admissions = RelayAdmissions::default(); + let before_removal = admissions.snapshot(); + admissions.remove(RELAY).unwrap(); + admissions.readd(RELAY).unwrap(); + + assert_eq!( + refusal(&admissions, &before_removal, RELAY), + Err(RELAY_REMOVED_ERROR.into()) + ); + } + + #[test] + fn removal_leaves_other_relays_admitted() { + let mut admissions = RelayAdmissions::default(); + let before = admissions.snapshot(); + admissions.remove(RELAY).unwrap(); + assert_eq!(refusal(&admissions, &before, "wss://other.example"), Ok(())); + } +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index d29bec4a78..714a16c594 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -86,21 +86,72 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> Ok(()) } +/// Schedule launch restore: captures admission now, at scheduling, and +/// returns the deferred restore that runs under that snapshot. A community +/// removed after this call refuses the restore even if it is re-added before +/// the task runs. `sweeps` is Phase A's live process sweeps. +pub fn launch_restore_task( + app: tauri::AppHandle, + sweeps: S, +) -> impl std::future::Future> +where + R: tauri::Runtime, + S: FnOnce(&tauri::AppHandle, &[u32]), +{ + let admission = super::AdmissionSnapshot::capture(&app.state::()); + async move { + let state = app.state::(); + restore_managed_agents_on_launch(&app, &state.shutdown_started, admission, sweeps).await + } +} + +/// Phase A's sweeps of live, untracked agent processes, skipping `tracked_pids`. +pub fn live_process_sweeps(app: &tauri::AppHandle, tracked_pids: &[u32]) { + super::sweep_orphaned_agent_processes(app, tracked_pids); + + // System-wide sweep: enumerate all user processes and kill any known + // agent binaries not tracked by this session. Catches orphans whose + // PID files were already cleaned up (e.g. agent workers in their own + // process group whose parent harness exited). + super::sweep_system_agent_processes(&super::current_instance_id(app), tracked_pids); + + // Dead-instance reaping: find agents belonging to Buzz instances + // whose desktop process is no longer running and reap them. + super::reap_dead_instance_agents(&super::current_instance_id(app), tracked_pids); + + // Exact-path sweep: kill any buzz-acp process whose executable path + // matches this bundle's harness binary but is not in the tracked set. + // Complements the env-var sweep above β€” catches orphans that predate + // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. + // + // TODO: the three sweeps above each walk the PID table independently. + // A future consolidation should collect a single shared process snapshot + // at the top of this block and thread it through all sweep functions, + // replacing the three separate kernel enumerations. + super::sweep_untracked_bundle_harnesses(tracked_pids); +} + /// Restore managed agents that were running before the app was closed. /// /// Split into three phases to minimise lock contention with the frontend: /// A (under lock): sync process state, cleanup, collect agents to start /// B (no locks): resolve commands and spawn processes in parallel /// C (re-lock): write back PIDs and status to records on disk -pub async fn restore_managed_agents_on_launch( - app: &tauri::AppHandle, +async fn restore_managed_agents_on_launch( + app: &tauri::AppHandle, shutdown_started: &AtomicBool, + admission: super::AdmissionSnapshot, + sweeps: impl FnOnce(&tauri::AppHandle, &[u32]), ) -> Result<(), String> { if shutdown_started.load(Ordering::SeqCst) { return Ok(()); } let state = app.state::(); + // `apply_workspace` still holds the apply lock for this task, so the relay + // cannot change underneath it; pin it for the spawn loop. + let restore_relay = crate::relay::relay_ws_url_with_override(&state); + let restore_relay = restore_relay.as_str(); // ── Phase A (under lock): housekeeping + collect agents to restore ── let mut agents_to_start: Vec; @@ -148,28 +199,7 @@ pub async fn restore_managed_agents_on_launch( }), ) .collect(); - super::sweep_orphaned_agent_processes(app, &tracked_pids); - - // System-wide sweep: enumerate all user processes and kill any known - // agent binaries not tracked by this session. Catches orphans whose - // PID files were already cleaned up (e.g. agent workers in their own - // process group whose parent harness exited). - super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); - - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); - - // Exact-path sweep: kill any buzz-acp process whose executable path - // matches this bundle's harness binary but is not in the tracked set. - // Complements the env-var sweep above β€” catches orphans that predate - // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. - // - // TODO: the three sweeps above each walk the PID table independently. - // A future consolidation should collect a single shared process snapshot - // at the top of this block and thread it through all sweep functions, - // replacing the three separate kernel enumerations. - super::sweep_untracked_bundle_harnesses(&tracked_pids); + sweeps(app, &tracked_pids); let candidates: Vec = records .iter() @@ -282,91 +312,139 @@ pub async fn restore_managed_agents_on_launch( return Ok(()); } + let reconcile_items = spawn_and_register_restored_agents( + app, + shutdown_started, + &admission, + restore_relay, + &agents_to_start, + owner_hex.as_deref(), + )?; + + // ── Profile reconciliation (fire-and-forget) ──────────────────────────── + // Spawn background tasks to ensure each restored agent's kind:0 profile is + // published on the relay. Same pattern as the UI start path. + for (pubkey, data) in reconcile_items { + let reconcile_app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = reconcile_app.state::(); + if let Err(e) = + crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) + .await + { + eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); + } + }); + } + + Ok(()) +} + +/// Phases B and C of launch restore: spawn `agents_to_start` on `restore_relay` +/// if it is still admitted under the snapshot `apply_workspace` scheduled the +/// restore with, then register them. Split from the sweeps above so tests can +/// drive the real admission and registration without sweeping live processes. +/// Returns the profile reconciliations to run for the agents it registered. +fn spawn_and_register_restored_agents( + app: &tauri::AppHandle, + shutdown_started: &AtomicBool, + admission: &super::AdmissionSnapshot, + restore_relay: &str, + agents_to_start: &[super::ManagedAgentRecord], + owner_hex: Option<&str>, +) -> Result, String> { + let state = app.state::(); // Serialize spawning and runtime registration with shutdown cleanup. The // shutdown flag is rechecked after taking the lock so shutdown either // prevents this transition or waits until every child is tracked and can // be terminated. - let restore_transition = state - .managed_agent_runtime_transition - .lock() - .map_err(|error| error.to_string())?; - if shutdown_started.load(Ordering::SeqCst) { - return Ok(()); - } - // ── 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 - .iter() - .filter(|_| !shutdown_started.load(Ordering::SeqCst)) - .map(|record| { - let handle = scope.spawn(move || { - 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 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() + let spawned = spawn_if_admitted( + &state, + shutdown_started, + admission, + restore_relay, + |admitted| { + std::thread::scope(|scope| { + let owner_hex_ref = owner_hex; + let handles: Vec<_> = agents_to_start + .iter() + .filter(|_| !shutdown_started.load(Ordering::SeqCst)) + .map(|record| { + let handle = scope.spawn(move || { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + restore_relay, + ); + 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, - None, - ) - }) { - Ok(process) => { - SpawnOutcome::Spawned(key, Box::new(process)) + .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, + admitted, + true, + owner_hex_ref, + None, + ) + }) { + Ok(process) => { + SpawnOutcome::Spawned(key, Box::new(process)) + } + Err(error) => SpawnOutcome::Failed(error), } - Err(error) => SpawnOutcome::Failed(error), } } - } - Err(error) => SpawnOutcome::Failed(error), - }; - (record.pubkey.clone(), outcome) - }); - handle - }) - .collect(); + Err(error) => SpawnOutcome::Failed(error), + }; + (record.pubkey.clone(), outcome) + }); + handle + }) + .collect(); - handles.into_iter().map(|h| h.join().unwrap()).collect() - }); + handles + .into_iter() + .map(|h| h.join().unwrap()) + .collect::>() + }) + }, + )?; + let Some((restore_transition, spawn_results)) = spawned else { + return Ok(Vec::new()); + }; if spawn_results.is_empty() { - return Ok(()); + return Ok(Vec::new()); } // ── Phase C (re-acquire lock): write back PIDs and status to records ── @@ -475,23 +553,7 @@ pub async fn restore_managed_agents_on_launch( drop(_store_guard); drop(restore_transition); - // ── Profile reconciliation (fire-and-forget) ──────────────────────────── - // Spawn background tasks to ensure each restored agent's kind:0 profile is - // published on the relay. Same pattern as the UI start path. - for (pubkey, data) in reconcile_items { - let reconcile_app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = reconcile_app.state::(); - if let Err(e) = - crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) - .await - { - eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); - } - }); - } - - Ok(()) + Ok(reconcile_items) } fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome) -> bool { @@ -546,8 +608,8 @@ pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, work } #[cfg(feature = "mesh-llm")] -fn persist_restore_error( - app: &tauri::AppHandle, +fn persist_restore_error( + app: &tauri::AppHandle, state: &AppState, pubkey: &str, error: String, @@ -563,6 +625,36 @@ fn persist_restore_error( save_managed_agents(app, &records) } +/// Restore's check-then-spawn step. Takes the runtime transition lock and runs +/// `spawn` only if shutdown has not started and `restore_relay` is still +/// admitted under the snapshot `apply_workspace` captured when it scheduled +/// this restore; otherwise spawns nothing and returns `None`. On success the +/// lock is returned still held, so the caller registers the spawned children +/// before shutdown or a removal's stop sweep can run. +fn spawn_if_admitted<'a, T>( + state: &'a AppState, + shutdown_started: &AtomicBool, + admission: &super::AdmissionSnapshot, + restore_relay: &str, + spawn: impl FnOnce(&super::Admitted<'_>) -> T, +) -> Result, T)>, String> { + let transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; + if shutdown_started.load(Ordering::SeqCst) { + return Ok(None); + } + let spawned = match transition.admit(admission, restore_relay) { + Ok(admitted) => spawn(&admitted), + Err(error) => { + eprintln!("buzz-desktop: skipping managed agent restore: {error}"); + return Ok(None); + } + }; + Ok(Some((transition, spawned))) +} + #[cfg(test)] mod profile_reconcile_tests { use super::profile_reconcile_completed; @@ -578,3 +670,64 @@ mod profile_reconcile_tests { )); } } + +// `build_app_state()` pulls in native Windows DLLs unavailable on the CI runner. +#[cfg(all(test, not(target_os = "windows")))] +mod launch_restore_admission_tests { + use super::spawn_if_admitted; + use crate::app_state::{build_app_state, AppState}; + use crate::managed_agents::AdmissionSnapshot; + use crate::relay::{relay_api_base_url_with_override, relay_ws_url_with_override}; + use std::cell::Cell; + use std::sync::atomic::AtomicBool; + + const RELAY: &str = "wss://removed.example"; + + /// Runs restore's spawn step with `admission` and returns how many times it spawned. + fn spawns(state: &AppState, shutdown: &AtomicBool, admission: &AdmissionSnapshot) -> u32 { + let count = Cell::new(0); + let gate = spawn_if_admitted(state, shutdown, admission, RELAY, |_| { + count.set(count.get() + 1) + }); + assert_eq!(gate.unwrap().is_some(), count.get() == 1); + count.get() + } + + fn remove(state: &AppState) { + crate::managed_agents::remove_relay(state, RELAY).unwrap(); + } + + #[test] + fn restore_scheduled_before_a_removal_spawns_nothing_even_after_readd() { + let state = build_app_state(); + let shutdown = AtomicBool::new(false); + *state.relay_url_override.lock().unwrap() = Some(RELAY.into()); + // What `apply_workspace` captures when it schedules the restore. + let scheduled = AdmissionSnapshot::capture(&state); + + remove(&state); + crate::managed_agents::readd_relay(&state, RELAY).unwrap(); + + assert_eq!(spawns(&state, &shutdown, &scheduled), 0); + assert_eq!(relay_ws_url_with_override(&state), RELAY); + assert_eq!( + relay_api_base_url_with_override(&state), + "https://removed.example" + ); + // A restore scheduled after the re-add spawns normally. + let fresh = AdmissionSnapshot::capture(&state); + assert_eq!(spawns(&state, &shutdown, &fresh), 1); + } + + #[test] + fn shutdown_spawns_nothing_even_when_admitted() { + let state = build_app_state(); + let shutdown = AtomicBool::new(true); + let admission = AdmissionSnapshot::capture(&state); + assert_eq!(spawns(&state, &shutdown, &admission), 0); + } +} + +#[cfg(all(test, not(target_os = "windows")))] +#[path = "restore_admission_tests.rs"] +mod admission_entry_tests; diff --git a/desktop/src-tauri/src/managed_agents/restore_admission_tests.rs b/desktop/src-tauri/src/managed_agents/restore_admission_tests.rs new file mode 100644 index 0000000000..91e65f26cb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/restore_admission_tests.rs @@ -0,0 +1,65 @@ +//! Launch restore against a removed relay: scheduled through the production +//! `launch_restore_task`, run through the real restore wrapper, with only +//! Phase A's live process sweeps replaced by an inert stand-in. + +use super::{launch_restore_task, spawn_and_register_restored_agents}; +use crate::app_state::AppState; +use crate::managed_agents::admission_test_support::{ + app_with_keyless_agent, reached_spawn, TestApp, RELAY, +}; +use crate::managed_agents::{load_managed_agents, readd_relay, remove_relay, AdmissionSnapshot}; +use std::sync::atomic::AtomicBool; +use tauri::Manager; + +fn last_error(test: &TestApp) -> Option { + load_managed_agents(test.app.handle()).unwrap()[0] + .last_error + .clone() +} + +/// Schedules restore, runs `before_run` before the deferred task executes, +/// then runs it and returns the agent's recorded start error. +async fn schedule_and_restore(before_run: impl FnOnce(&AppState)) -> Option { + let test = app_with_keyless_agent(); + let restore = launch_restore_task(test.app.handle().clone(), |_, _| {}); + before_run(&test.app.state::()); + restore.await.unwrap(); + last_error(&test) +} + +#[tokio::test] +async fn restore_scheduled_before_a_remove_and_readd_starts_nothing() { + let error = schedule_and_restore(|state| { + remove_relay(state, RELAY).unwrap(); + readd_relay(state, RELAY).unwrap(); + }) + .await; + assert_eq!(error, None); +} + +#[tokio::test] +async fn restore_scheduled_with_no_removal_reaches_spawn() { + let error = schedule_and_restore(|_| {}).await.unwrap(); + assert!(reached_spawn(&error), "{error}"); +} + +#[test] +fn restore_spawn_phase_refuses_a_stale_snapshot() { + let test = app_with_keyless_agent(); + let state = test.app.state::(); + let app = test.app.handle(); + let scheduled = AdmissionSnapshot::capture(&state); + remove_relay(&state, RELAY).unwrap(); + readd_relay(&state, RELAY).unwrap(); + let agents = load_managed_agents(app).unwrap(); + spawn_and_register_restored_agents( + app, + &AtomicBool::new(false), + &scheduled, + RELAY, + &agents, + None, + ) + .unwrap(); + assert_eq!(last_error(&test), None); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 07690ff5a8..038ff16955 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -145,8 +145,8 @@ fn persona_drift_state( /// 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, +pub(crate) fn workspace_pair_key( + app: &AppHandle, record: &ManagedAgentRecord, ) -> Option { let state = app.state::(); @@ -170,8 +170,8 @@ pub(crate) fn resolve_workspace_pair_key( ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() } -pub fn build_managed_agent_summary( - app: &AppHandle, +pub fn build_managed_agent_summary( + app: &AppHandle, record: &ManagedAgentRecord, runtimes: &HashMap, personas: &[crate::managed_agents::types::AgentDefinition], @@ -481,14 +481,16 @@ pub(crate) fn spawn_with_effort_proof( /// publishes the triggering message before this spawn and passes its send /// timestamp here so the harness's first REQ replays past that message no /// matter how long the spawn takes. buzz-acp clamps stale floors to ~15 min. -pub fn spawn_agent_child( - app: &AppHandle, +pub fn spawn_agent_child( + app: &AppHandle, record: &ManagedAgentRecord, relay_url: &str, + admitted: &super::Admitted<'_>, lazy: bool, owner_hex: Option<&str>, replay_floor_unix: Option, ) -> Result { + admitted.covers(relay_url)?; if let Some(error) = spawn_key_refusal(record) { return Err(error); } @@ -899,12 +901,13 @@ pub fn spawn_agent_child( /// exact workspace-relay read the caller's scope assertion passed on; it never /// re-reads the mutable override (see `relay::scope`). The key comes from /// [`bound_runtime_key`] β€” the seam the spawn-key regressions exercise. -pub fn start_managed_agent_process( - app: &AppHandle, +pub fn start_managed_agent_process( + app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, + admitted: &super::Admitted<'_>, replay_floor_unix: Option, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; @@ -929,6 +932,7 @@ pub fn start_managed_agent_process( app, record, &key.relay_url, + admitted, false, owner_hex, replay_floor_unix, diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs index 26aa26f074..88153e0415 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/process.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -445,8 +445,8 @@ pub(super) fn terminate_runtime_receipt_with( /// 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, +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, key: &ManagedAgentRuntimeKey, ) -> Result<(), String> { let instance_id = current_instance_id(app); diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7..e32786aa59 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -15,8 +15,8 @@ use crate::app_state::AppState; const STATUS_EVENT: &str = "managed-agent-runtime-status"; -fn status_for( - app: &AppHandle, +fn status_for( + app: &AppHandle, record: &super::ManagedAgentRecord, key: &ManagedAgentRuntimeKey, runtime: Option<&ManagedAgentPairRuntime>, @@ -44,8 +44,8 @@ struct StatusInputs<'a> { global: &'a super::GlobalAgentConfig, } -fn status_for_with( - app: &AppHandle, +fn status_for_with( + app: &AppHandle, record: &super::ManagedAgentRecord, key: &ManagedAgentRuntimeKey, runtime: Option<&ManagedAgentPairRuntime>, @@ -73,7 +73,7 @@ fn status_for_with( } } -fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { +fn emit_status(app: &AppHandle, status: &ManagedAgentRuntimeStatus) { let _ = app.emit(STATUS_EVENT, status); } @@ -227,9 +227,10 @@ pub async fn list_managed_agent_runtimes( pub(crate) fn start_managed_agent_runtime_pair_lazy( pubkey: String, relay_url: String, + admission: &super::AdmissionSnapshot, app: AppHandle, ) -> Result { - start_pair(pubkey, relay_url, true, None, app) + start_pair(pubkey, relay_url, true, None, admission, app) } #[tauri::command] @@ -238,24 +239,27 @@ pub fn start_managed_agent_runtime( relay_url: String, app: AppHandle, ) -> Result { - start_managed_agent_runtime_pair_lazy(pubkey, relay_url, app) + let admission = super::AdmissionSnapshot::capture(&app.state::()); + start_managed_agent_runtime_pair_lazy(pubkey, relay_url, &admission, app) } -fn start_pair( +fn start_pair( pubkey: String, relay_url: String, lazy: bool, expected_updated_at: Option<&str>, - app: AppHandle, + admission: &super::AdmissionSnapshot, + app: AppHandle, ) -> Result { let state = app.state::(); - let _transition = 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 admitted = transition.admit(admission, &relay_url)?; let _store = state .managed_agents_store_lock .lock() @@ -288,8 +292,15 @@ fn start_pair( .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = - spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; + let mut process = spawn_agent_child( + &app, + record, + &key.relay_url, + &admitted, + lazy, + owner.as_deref(), + None, + )?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(), @@ -320,6 +331,14 @@ pub fn stop_managed_agent_runtime( pubkey: String, relay_url: String, app: AppHandle, +) -> Result { + stop_pair(pubkey, relay_url, app) +} + +fn stop_pair( + pubkey: String, + relay_url: String, + app: AppHandle, ) -> Result { let state = app.state::(); let _transition = state @@ -388,8 +407,23 @@ pub fn restart_managed_agent_runtime( relay_url: String, app: AppHandle, ) -> Result { - stop_managed_agent_runtime(pubkey.clone(), relay_url.clone(), app.clone())?; - start_pair(pubkey, relay_url, true, None, app) + let stop_app = app.clone(); + let (stop_pubkey, stop_relay) = (pubkey.clone(), relay_url.clone()); + restart_pair(pubkey, relay_url, app, move || { + stop_pair(stop_pubkey, stop_relay, stop_app).map(drop) + }) +} + +fn restart_pair( + pubkey: String, + relay_url: String, + app: AppHandle, + stop: impl FnOnce() -> Result<(), String>, +) -> Result { + // Captured before the stop, so a removal landing between the two refuses the start. + let admission = super::AdmissionSnapshot::capture(&app.state::()); + stop()?; + start_pair(pubkey, relay_url, true, None, &admission, app) } /// Probe whether this agent can operate on `requested_relay_url`. @@ -469,6 +503,8 @@ pub async fn reconcile_managed_agent_runtimes( ) -> Result, String> { use futures_util::{stream, StreamExt}; + // Captured before the probes: a community removed while they run is refused. + let admission = super::AdmissionSnapshot::capture(&app.state::()); let records = load_managed_agents(&app)?; let mut jobs = Vec::new(); for community in communities { @@ -513,12 +549,15 @@ pub async fn reconcile_managed_agent_runtimes( key.relay_url.clone(), true, Some(&record.updated_at), + &admission, app.clone(), ) { Ok(mut status) => { status.requested_relay_url = Some(requested); rows.push(status); } + // Removed mid-reconcile: nothing to start and nothing to report. + Err(error) if error == super::RELAY_REMOVED_ERROR => {} Err(error) => { let mut status = status_for_with( &app, @@ -732,3 +771,7 @@ mod tests { assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); } } + +#[cfg(all(test, not(target_os = "windows")))] +#[path = "runtime_commands_admission_tests.rs"] +mod admission_tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands_admission_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_commands_admission_tests.rs new file mode 100644 index 0000000000..37b46f31eb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_commands_admission_tests.rs @@ -0,0 +1,61 @@ +//! `start_pair` and the native pair restart against a removed relay, driven +//! through the production admission check on a mock app. + +use super::{restart_pair, start_pair}; +use crate::app_state::AppState; +use crate::managed_agents::admission_test_support::{ + app_with_keyless_agent, reached_spawn, refused, RELAY, +}; +use crate::managed_agents::{readd_relay, remove_relay, AdmissionSnapshot}; +use tauri::Manager; + +fn start( + test: &crate::managed_agents::admission_test_support::TestApp, + admission: &AdmissionSnapshot, +) -> String { + start_pair( + test.pubkey.clone(), + RELAY.into(), + true, + None, + admission, + test.app.handle().clone(), + ) + .unwrap_err() +} + +#[test] +fn start_pair_refuses_a_removed_relay_until_readded() { + let test = app_with_keyless_agent(); + let state = test.app.state::(); + let before = AdmissionSnapshot::capture(&state); + remove_relay(&state, RELAY).unwrap(); + assert!(refused(&start(&test, &AdmissionSnapshot::capture(&state)))); + + readd_relay(&state, RELAY).unwrap(); + // Work captured before the removal stays refused after the re-add. + assert!(refused(&start(&test, &before))); + // Fresh work after the re-add reaches the spawn. + assert!(reached_spawn(&start( + &test, + &AdmissionSnapshot::capture(&state) + ))); +} + +#[test] +fn restart_straddling_a_remove_and_readd_starts_nothing() { + let test = app_with_keyless_agent(); + let app = test.app.handle().clone(); + let stop_app = app.clone(); + let error = restart_pair(test.pubkey.clone(), RELAY.into(), app.clone(), move || { + let state = stop_app.state::(); + remove_relay(&state, RELAY)?; + readd_relay(&state, RELAY) + }) + .unwrap_err(); + assert!(refused(&error), "{error}"); + + // A restart that crosses no removal reaches the spawn. + let error = restart_pair(test.pubkey.clone(), RELAY.into(), app, || Ok(())).unwrap_err(); + assert!(reached_spawn(&error), "{error}"); +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f8a2c1039a..6090d47d8a 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -48,7 +48,7 @@ pub(crate) fn managed_agents_store_path( Ok(managed_agents_base_dir(app)?.join("managed-agents.json")) } -fn managed_agents_logs_dir(app: &AppHandle) -> Result { +fn managed_agents_logs_dir(app: &AppHandle) -> Result { let dir = managed_agents_base_dir(app)?.join("logs"); fs::create_dir_all(&dir).map_err(|error| format!("failed to create logs dir: {error}"))?; Ok(dir) @@ -82,14 +82,17 @@ fn is_safe_id_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '-' || c == '_' } -pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { +pub fn managed_agent_log_path( + app: &AppHandle, + pubkey: &str, +) -> Result { Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log"))) } /// Pair-scoped log path for a managed runtime. The relay URL never appears in /// the filename; the suffix is a hash of the canonical URL. -pub fn managed_agent_runtime_log_path( - app: &AppHandle, +pub fn managed_agent_runtime_log_path( + app: &AppHandle, key: &ManagedAgentRuntimeKey, ) -> Result { Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) @@ -813,8 +816,8 @@ fn agent_pids_dir(app: &AppHandle) -> Result( + app: &AppHandle, receipt: &ManagedAgentRuntimeReceipt, ) -> Result<(), String> { let path = agent_pids_dir(app)?.join(format!("{}.json", receipt.key.runtime_id())); @@ -836,8 +839,8 @@ pub fn remove_agent_runtime_receipt_path(path: &Path) { let _ = fs::remove_file(path); } -pub fn read_all_agent_runtime_receipts( - app: &AppHandle, +pub fn read_all_agent_runtime_receipts( + app: &AppHandle, ) -> Vec<(PathBuf, ManagedAgentRuntimeReceipt)> { let Ok(dir) = agent_pids_dir(app) else { return Vec::new(); diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index 066fa46373..d8b200ccb5 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -347,7 +347,10 @@ async fn reconcile_roster( Ok(()) } -pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) { +pub(crate) async fn publish_current_status_once( + app: &AppHandle, + reason: &str, +) { let state = app.state::(); match tokio::time::timeout( STATUS_PUBLISH_TIMEOUT, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 74398288a7..97f3767647 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -163,7 +163,8 @@ export function AppShell() { [location.pathname], ); const { - removeCommunity: handleRemoveCommunity, + leaveAndRemoveCommunity: handleLeaveCommunity, + removeCommunityFromDevice: removeFromDevice, switchCommunity: handleSwitchCommunity, } = useCommunityNavigationTransitions({ communities: communitiesHook, @@ -205,7 +206,7 @@ export function AppShell() { ); useAgentsDataRefresh(); // Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON). - useAutoRestartPolicy(); + useAutoRestartPolicy(communitiesHook.activeCommunity?.relayUrl); // Owner-global observer ingestion: receives + decrypts agent observer // frames and keeps derived active-turn liveness in sync app-wide, so no // individual screen/panel has to mount its own bridge for ingestion. @@ -860,7 +861,8 @@ export function AppShell() { onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={handleRemoveCommunity} + onLeaveCommunity={handleLeaveCommunity} + onRemoveCommunityFromDevice={removeFromDevice} onSwitchCommunity={handleSwitchCommunity} onCreateAgent={() => requestOpenCreateAgent()} selfPresenceStatus={presenceSession.currentStatus} diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts index d2cf39fbc7..0ab4c5af69 100644 --- a/desktop/src/app/useCommunityNavigationTransitions.ts +++ b/desktop/src/app/useCommunityNavigationTransitions.ts @@ -12,6 +12,12 @@ import { markPendingCommunityRestore, saveCommunityDestination, } from "@/features/communities/communityNavigationStorage"; +import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus"; +import { + markRelayRemoved, + refuseRelayAdmission, + stopManagedAgentPairsOnRelay, +} from "@/features/agents/managedAgentRelayCleanup"; import { markCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage"; import type { useCommunities } from "@/features/communities/useCommunities"; import { leaveCommunity } from "@/features/communities/leaveCommunity"; @@ -71,41 +77,64 @@ export function useCommunityNavigationTransitions({ [communities, goHome, router.history, saveActiveDestination], ); - const removeCommunity = React.useCallback( + // Local-only cleanup shared by Leave and "Remove from this device". It never + // contacts the relay, so it still works when the relay is gone. Once the + // community is gone, its relay's agent pairs are stopped so none keep + // reconnecting to it. + const removeCommunityFromDevice = React.useCallback( async (id: string) => { const target = communities.communities.find( (community) => community.id === id, ); if (!target) return; + // Another community can point at the same relay; its pairs stay up. + const relayStillUsed = communities.communities.some( + (community) => + community.id !== id && + canonicalRelayUrl(community.relayUrl) === + canonicalRelayUrl(target.relayUrl), + ); + // Fences any reconcile in flight for this relay; see markRelayRemoved. + const markRemoved = () => { + if (!relayStillUsed) markRelayRemoved(target.relayUrl); + }; + const stopRelayPairs = () => { + if (!relayStillUsed) void stopManagedAgentPairsOnRelay(target.relayUrl); + }; + // Refuses every local pair start on the relay in Rust, including a + // launch restore or start already in flight; pairs registered first are + // caught by the stop. Relay routing is left untouched. + const refuseRelay = async () => { + if (!relayStillUsed) await refuseRelayAdmission(target.relayUrl); + }; + + if (id !== communities.activeCommunity?.id) { + markRemoved(); + await refuseRelay(); + communities.removeCommunity(id); + stopRelayPairs(); + return; + } const fallback = communities.communities.find( (community) => community.id !== id, ); - - // Do not touch local state until this relay has explicitly accepted the - // signed NIP-43 leave request. Rejections and timeouts bubble back to the - // dialog so the person can retry without losing their community config. - const leaveResult = await leaveCommunity( - target.relayUrl, - communities.activeCommunity?.relayUrl, - ); - - if (id !== communities.activeCommunity?.id) { - communities.removeCommunity(id); - return leaveResult; - } - if (!fallback) { if (!markCommunityDiscoveryAfterLeave()) { throw new Error( - "Membership was removed, but community discovery state could not be saved. Restart Buzz and try again.", + "Couldn't finish removing the community from this device because community discovery state could not be saved. Restart Buzz and try again.", ); } + markRemoved(); + await refuseRelay(); await goHome({ replace: true }); communities.removeCommunity(id); - return leaveResult; + stopRelayPairs(); + return; } + markRemoved(); + await refuseRelay(); await runCommunityViewTransition(async () => { saveActiveDestination(); await goHome({ replace: true }); @@ -119,10 +148,34 @@ export function useCommunityNavigationTransitions({ } communities.removeCommunity(id); }); - return leaveResult; + stopRelayPairs(); }, [communities, goHome, router.history, saveActiveDestination], ); - return { removeCommunity, switchCommunity }; + const leaveAndRemoveCommunity = React.useCallback( + async (id: string) => { + const target = communities.communities.find( + (community) => community.id === id, + ); + if (!target) return; + + // Do not touch local state until this relay has explicitly accepted the + // signed NIP-43 leave request. Rejections and timeouts bubble back to the + // menu so the person can retry without losing their community config. + const leaveResult = await leaveCommunity( + target.relayUrl, + communities.activeCommunity?.relayUrl, + ); + await removeCommunityFromDevice(id); + return leaveResult; + }, + [communities, removeCommunityFromDevice], + ); + + return { + leaveAndRemoveCommunity, + removeCommunityFromDevice, + switchCommunity, + }; } diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..57082909fa 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -166,3 +166,38 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// --- respawn bound to the workspace relay ----------------------------------- + +const { markRelayRemoved } = await import("../managedAgentRelayCleanup.ts"); + +async function respawnAcrossStop(relayUrl, duringStop) { + const started = []; + const result = respawnManagedAgentWithRules({ + agent: agent({ status: "running" }), + relayUrl, + stopManagedAgent: async () => duringStop(), + startManagedAgent: async (pubkey) => { + started.push(pubkey); + }, + }); + return { result, started }; +} + +test("a respawn whose own relay is removed and re-added during its stop starts nothing", async () => { + const { result, started } = await respawnAcrossStop( + "wss://respawn-own.example", + () => markRelayRemoved("wss://respawn-own.example"), + ); + await assert.rejects(result, /relay was removed from this device/); + assert.deepEqual(started, []); +}); + +test("removing an unrelated relay during a respawn's stop still restarts it", async () => { + const { result, started } = await respawnAcrossStop( + "wss://respawn-kept.example", + () => markRelayRemoved("wss://respawn-unrelated.example"), + ); + await result; + assert.equal(started.length, 1); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index aaf10075e0..98ddf8b71a 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -1,3 +1,4 @@ +import { captureRelayRemovals } from "@/features/agents/managedAgentRelayCleanup"; import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; import type { AgentAvailabilityReader } from "./useAgentAvailability"; @@ -87,11 +88,15 @@ export async function startManagedAgentWithRules({ export async function respawnManagedAgentWithRules({ agent, + relayUrl, startManagedAgent, stopManagedAgent, onStopped, }: { agent: ManagedAgent; + /** The active community's relay: the workspace pair the native stop and + * start target. A removal of it during the stop cancels the start. */ + relayUrl: string | undefined; startManagedAgent: StartManagedAgent; stopManagedAgent: StopManagedAgent; /** Called after a successful stop and before start begins β€” use this to @@ -99,8 +104,12 @@ export async function respawnManagedAgentWithRules({ onStopped?: () => void; }) { if (agent.backend.type === "local" && isManagedAgentActive(agent)) { + const assertRelayNotRemoved = relayUrl + ? captureRelayRemovals(relayUrl) + : () => {}; await stopManagedAgent(agent.pubkey); onStopped?.(); + assertRelayNotRemoved(); } await startManagedAgent(agent.pubkey); diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.relayRemoved.jsdom-test.mjs b/desktop/src/features/agents/lib/useAutoRestartPolicy.relayRemoved.jsdom-test.mjs new file mode 100644 index 0000000000..58ed9a5f97 --- /dev/null +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.relayRemoved.jsdom-test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { restartDriftedAgent } from "./useAutoRestartPolicy.ts"; +import { markRelayRemoved } from "../managedAgentRelayCleanup.ts"; + +const PUBKEY = "cd".repeat(32); + +// Auto-restart fires only for a running, drifted, opted-in agent. +async function autoRestartAcrossFetch(relayUrl, duringFetch) { + const calls = []; + const ops = { + listManagedAgents: async () => { + duringFetch(); + return [ + { + pubkey: PUBKEY, + needsRestart: true, + autoRestartOnConfigChange: true, + status: "running", + }, + ]; + }, + stopManagedAgent: async () => calls.push("stop"), + startManagedAgent: async () => calls.push("start"), + }; + const result = restartDriftedAgent(PUBKEY, relayUrl, ops); + return { result, calls }; +} + +test("an auto-restart whose own relay is removed during its pre-fire fetch starts nothing", async () => { + const { result, calls } = await autoRestartAcrossFetch( + "wss://auto-own.example", + () => markRelayRemoved("wss://auto-own.example"), + ); + await assert.rejects(result, /relay was removed from this device/); + assert.deepEqual(calls, ["stop"]); +}); + +test("removing an unrelated relay during the pre-fire fetch still restarts the agent", async () => { + const { result, calls } = await autoRestartAcrossFetch( + "wss://auto-kept.example", + () => markRelayRemoved("wss://auto-unrelated.example"), + ); + await result; + assert.deepEqual(calls, ["stop", "start"]); +}); diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index 652617dee3..3be81ec225 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -5,6 +5,7 @@ import { managedAgentsQueryKey, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { captureRelayRemovals } from "@/features/agents/managedAgentRelayCleanup"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; import { startManagedAgent, @@ -21,6 +22,42 @@ import { type AutoRestartEdgeState, } from "./autoRestartPolicy"; +const defaultRestartOps = { + listManagedAgents, + stopManagedAgent, + startManagedAgent, +}; + +/** + * One auto-restart attempt, bound to `relayUrl` β€” the active community's + * relay, which the workspace-pair stop and start target β€” before its first + * await. Only a removal of that relay cancels the start. + */ +export async function restartDriftedAgent( + pubkey: string, + relayUrl: string | undefined, + ops = defaultRestartOps, +): Promise { + const assertRelayNotRemoved = relayUrl + ? captureRelayRemovals(relayUrl) + : () => {}; + // Pre-fire re-fetch: shrink the stale-decision window to ~0. + const fresh = await ops.listManagedAgents(); + const current = fresh.find((a) => a.pubkey === pubkey); + if ( + !current?.needsRestart || + !current.autoRestartOnConfigChange || + current.status !== "running" || + getAgentWorkingState(pubkey).source !== "none" + ) { + return; + } + await ops.stopManagedAgent(pubkey); + clearActiveTurnsForAgentOnStop(pubkey); + assertRelayNotRemoved(); + await ops.startManagedAgent(pubkey); +} + /** How often the policy re-evaluates between summary refetches. Keeps the * continuity clock honest without waiting for the next 5s poll. */ const POLICY_TICK_MS = 15_000; @@ -33,9 +70,10 @@ const POLICY_TICK_MS = 15_000; * tested). This hook only wires inputs, owns per-pubkey edge state, and * calls the existing stop/start commands β€” both idempotent and serialized * on the backend store lock, so a cross-window double-fire is benign (and - * further shrunk by the pre-fire summary re-fetch). + * further shrunk by the pre-fire summary re-fetch). `relayUrl` is the active + * community's relay, which the restarts target. */ -export function useAutoRestartPolicy() { +export function useAutoRestartPolicy(relayUrl: string | undefined) { const queryClient = useQueryClient(); const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data; const edgesRef = React.useRef(new Map()); @@ -101,32 +139,17 @@ export function useAutoRestartPolicy() { // until needsRestart cycles (edge-triggered debounce, no retry loops). edges.set(agent.pubkey, { consumed: true, armedAt: null }); - void (async () => { - try { - // Pre-fire re-fetch: shrink the stale-decision window to ~0. - const fresh = await listManagedAgents(); - const current = fresh.find((a) => a.pubkey === agent.pubkey); - if ( - !current?.needsRestart || - !current.autoRestartOnConfigChange || - current.status !== "running" || - getAgentWorkingState(agent.pubkey).source !== "none" - ) { - return; - } - await stopManagedAgent(agent.pubkey); - clearActiveTurnsForAgentOnStop(agent.pubkey); - await startManagedAgent(agent.pubkey); - } catch { + void restartDriftedAgent(agent.pubkey, relayUrl) + .catch(() => { // Failed attempt: edge stays consumed β€” badge-only until the // needsRestart edge cycles. No retry loops by design. - } finally { + }) + .finally(() => { inFlightRef.current.delete(agent.pubkey); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey, }); - } - })(); + }); } // Drop edge state for agents that no longer exist. diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs index 21327c30d9..378119cf06 100644 --- a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs @@ -106,3 +106,17 @@ test("classifyReconcileResult treats a relay with no rows as reconciled", () => { succeeded: ["wss://a.example"], failed: [] }, ); }); + +test("classifyReconcileResult leaves a fenced relay neither reconciled nor failed", () => { + // Its community was removed mid-call: marking it reconciled would stop a + // same-session re-add from starting its pairs. + assert.deepEqual( + classifyReconcileResult( + ["wss://removed.example", "wss://alive.example"], + [], + canonicalRelayUrl, + new Set(["wss://removed.example"]), + ), + { succeeded: ["wss://alive.example"], failed: [] }, + ); +}); diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.ts b/desktop/src/features/agents/managedAgentReconciliationPlan.ts index 58d9b58b73..8540881ce3 100644 --- a/desktop/src/features/agents/managedAgentReconciliationPlan.ts +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.ts @@ -75,6 +75,9 @@ export function classifyReconcileResult( attempted: readonly string[], rows: readonly ManagedAgentRuntimeStatus[] | null, canonicalize: (url: string) => string | null, + // Relays whose community was removed mid-call; the fence stopped their + // pairs, so they count as neither reconciled nor due a retry. + fenced: ReadonlySet = new Set(), ): { succeeded: string[]; failed: string[] } { if (rows === null) { return { succeeded: [], failed: [...attempted] }; @@ -88,6 +91,7 @@ export function classifyReconcileResult( const succeeded: string[] = []; const failed: string[] = []; for (const relay of attempted) { + if (fenced.has(relay)) continue; if (failedRelays.has(relay)) failed.push(relay); else succeeded.push(relay); } diff --git a/desktop/src/features/agents/managedAgentRelayCleanup.test.mjs b/desktop/src/features/agents/managedAgentRelayCleanup.test.mjs new file mode 100644 index 0000000000..e54966726d --- /dev/null +++ b/desktop/src/features/agents/managedAgentRelayCleanup.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { + markRelayRemoved, + readmitRelay, + reconcileConfiguredManagedAgentRuntimes, + refuseRelayAdmission, + stopManagedAgentPairsOnRelay, +} from "./managedAgentRelayCleanup.ts"; + +function pair(pubkey, relayUrl, lifecycle = "ready") { + return { pubkey, relayUrl, lifecycle }; +} + +test("stops every live pair on the removed relay and no others", async () => { + const stopped = []; + await stopManagedAgentPairsOnRelay("wss://Dead.example/", { + list: async () => [ + pair("a", "wss://dead.example"), + pair("b", "wss://dead.example", "failed"), + pair("c", "wss://dead.example", "stopped"), + pair("d", "wss://alive.example"), + ], + stop: async (pubkey, relayUrl) => stopped.push([pubkey, relayUrl]), + }); + assert.deepEqual(stopped, [ + ["a", "wss://dead.example"], + ["b", "wss://dead.example"], + ]); +}); + +test("a failed stop is logged and does not throw", async () => { + const warn = mock.method(console, "warn", () => {}); + const stopped = []; + await stopManagedAgentPairsOnRelay("wss://dead.example", { + list: async () => [ + pair("a", "wss://dead.example"), + pair("b", "wss://dead.example"), + ], + stop: async (pubkey) => { + if (pubkey === "a") throw new Error("boom"); + stopped.push(pubkey); + }, + }); + assert.deepEqual(stopped, ["b"]); + assert.equal(warn.mock.callCount(), 1); + warn.mock.restore(); +}); + +test("a failed runtime listing is logged and does not throw", async () => { + const warn = mock.method(console, "warn", () => {}); + await stopManagedAgentPairsOnRelay("wss://dead.example", { + list: async () => { + throw new Error("ipc down"); + }, + stop: async () => assert.fail("nothing to stop"), + }); + assert.equal(warn.mock.callCount(), 1); + warn.mock.restore(); +}); + +test("reconcile stops live pairs on a relay removed mid-flight and reports it", async () => { + const stopped = []; + const { runtimes, removedRelays } = + await reconcileConfiguredManagedAgentRuntimes( + [ + { relayUrl: "wss://Dead.example/" }, + { relayUrl: "wss://alive.example" }, + ], + { + reconcile: async () => { + // The community is removed while the reconcile is in flight. + markRelayRemoved("wss://dead.example"); + return [ + pair("a", "wss://dead.example"), + pair("b", "wss://dead.example", "failed"), + pair("c", "wss://alive.example"), + ]; + }, + stop: async (pubkey, relayUrl) => stopped.push([pubkey, relayUrl]), + }, + ); + // The failed row has no live child, so it is left alone. + assert.deepEqual(stopped, [["a", "wss://dead.example"]]); + assert.deepEqual([...removedRelays], ["wss://dead.example"]); + assert.equal(runtimes.length, 3); +}); + +test("reconcile stops nothing without a removal, whatever storage holds", async () => { + // A storage failure used to look like "every relay removed". The fence now + // needs a removal recorded during the call, so it stops nothing here, even + // for a relay removed before the call started. + markRelayRemoved("wss://earlier.example"); + const stopped = []; + const { removedRelays } = await reconcileConfiguredManagedAgentRuntimes( + [ + { relayUrl: "wss://earlier.example" }, + { relayUrl: "wss://alive.example" }, + ], + { + reconcile: async () => [ + pair("a", "wss://earlier.example"), + pair("b", "wss://alive.example"), + ], + stop: async (pubkey) => stopped.push(pubkey), + }, + ); + assert.deepEqual(stopped, []); + assert.equal(removedRelays.size, 0); +}); + +test("removing a 127.* hostname does not fence the real loopback relay", async () => { + const stopped = []; + const { removedRelays } = await reconcileConfiguredManagedAgentRuntimes( + [ + { relayUrl: "wss://127.preview.example" }, + { relayUrl: "ws://127.0.0.1:3000" }, + ], + { + reconcile: async () => { + markRelayRemoved("wss://127.preview.example"); + return [ + pair("a", "wss://127.preview.example"), + pair("b", "ws://127.0.0.1:3000"), + ]; + }, + stop: async (pubkey) => stopped.push(pubkey), + }, + ); + assert.deepEqual(stopped, ["a"]); + assert.deepEqual([...removedRelays], ["wss://127.preview.example"]); +}); + +test("a failed admission write rejects for its caller and the queue keeps running", async () => { + // No Tauri runtime here, so every native admission write fails. + await assert.rejects(refuseRelayAdmission("wss://write-fails.example")); + await assert.rejects(readmitRelay("wss://write-fails.example")); + // The queue recovered: a reconcile waiting on it still runs. + const { runtimes } = await reconcileConfiguredManagedAgentRuntimes([], { + list: async () => [], + reconcile: async () => [], + stop: async () => {}, + }); + assert.deepEqual(runtimes, []); +}); diff --git a/desktop/src/features/agents/managedAgentRelayCleanup.ts b/desktop/src/features/agents/managedAgentRelayCleanup.ts new file mode 100644 index 0000000000..4c666c4504 --- /dev/null +++ b/desktop/src/features/agents/managedAgentRelayCleanup.ts @@ -0,0 +1,163 @@ +import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus"; +import { + listManagedAgentRuntimes, + reconcileManagedAgentRuntimes, + stopManagedAgentRuntime, +} from "@/shared/api/tauriManagedAgents"; +import { + readdCommunityRelay, + removeCommunityRelay, +} from "@/shared/api/tauriWorkspace"; +import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; + +type Dependencies = { + list: () => Promise; + reconcile: typeof reconcileManagedAgentRuntimes; + stop: (pubkey: string, relayUrl: string) => Promise; +}; + +const defaultDependencies: Dependencies = { + list: listManagedAgentRuntimes, + reconcile: reconcileManagedAgentRuntimes, + stop: stopManagedAgentRuntime, +}; + +// Per canonical relay URL, how many times its community was removed from this +// device this session. A reconcile compares these before and after its call, +// so only a positive removal β€” never a storage read β€” fences its result. +const relayRemovals = new Map(); + +/** Record that the last community on `relayUrl` was removed from this device. */ +export function markRelayRemoved(relayUrl: string): void { + const relay = canonicalRelayUrl(relayUrl); + if (!relay) return; + relayRemovals.set(relay, (relayRemovals.get(relay) ?? 0) + 1); +} + +/** Native refusal for a start on a relay removed from this device. */ +export const RELAY_REMOVED_ERROR = "relay was removed from this device"; + +/** + * A start that a relay removal cancelled: a quiet no-op, not a failure. + * Accepts a thrown error or a returned `spawnError` message. + */ +export function isRelayRemovedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : error; + return message === RELAY_REMOVED_ERROR; +} + +/** + * Capture `relayUrl`'s removal counter before a stop/start sequence awaits. + * The returned check throws `RELAY_REMOVED_ERROR` if that relay was removed + * since. Call it immediately before the start, with no await in between, so + * a removal followed by a re-add cannot readmit the stale continuation. + */ +export function captureRelayRemovals(relayUrl: string): () => void { + // An unparseable relay is never marked removed, so its check never throws. + const relay = canonicalRelayUrl(relayUrl) ?? ""; + const before = relayRemovals.get(relay) ?? 0; + return () => { + if ((relayRemovals.get(relay) ?? 0) !== before) { + throw new Error(RELAY_REMOVED_ERROR); + } + }; +} + +// Writes to Rust's per-relay admission record run in order, and a reconcile +// waits for them, so a reconcile issued after a re-add is admitted. +let admissionWrites: Promise = Promise.resolve(); + +// A failed write rejects for its own caller; the queue itself recovers so +// later writes still run. +function queueAdmissionWrite(write: () => Promise): Promise { + const result = admissionWrites.then(write); + admissionWrites = result.catch(() => {}); + return result; +} + +/** Refuse local pairs on a relay no saved community uses any more. */ +export const refuseRelayAdmission = (relayUrl: string) => + queueAdmissionWrite(() => removeCommunityRelay(relayUrl)); + +/** Admit local pairs on the relay of an explicitly re-added community. */ +export const readmitRelay = (relayUrl: string) => + queueAdmissionWrite(() => readdCommunityRelay(relayUrl)); + +async function stopPairs( + pairs: readonly ManagedAgentRuntimeStatus[], + stop: Dependencies["stop"], +): Promise { + const results = await Promise.allSettled( + pairs.map((pair) => stop(pair.pubkey, pair.relayUrl)), + ); + for (const result of results) { + if (result.status === "rejected") { + console.warn("[managed-agent-runtimes] stop failed:", result.reason); + } + } +} + +/** + * Stop every live agent pair on a relay whose community was removed. + * Best-effort: failures are logged so community removal still completes. + */ +export async function stopManagedAgentPairsOnRelay( + relayUrl: string, + dependencies: Dependencies = defaultDependencies, +): Promise { + const relay = canonicalRelayUrl(relayUrl); + if (!relay) return; + try { + const runtimes = await dependencies.list(); + await stopPairs( + runtimes.filter( + (runtime) => + runtime.lifecycle !== "stopped" && + canonicalRelayUrl(runtime.relayUrl) === relay, + ), + dependencies.stop, + ); + } catch (error) { + console.warn("[managed-agent-runtimes] list failed:", error); + } +} + +/** + * Reconcile auto-start pairs, then stop any pair the reconcile started on a + * relay whose community was removed while the call was in flight. Rust cannot + * be cancelled mid-reconcile, so the result is fenced instead. Returns the + * relays the fence stopped; they must not be treated as reconciled. + */ +export async function reconcileConfiguredManagedAgentRuntimes( + communities: readonly { relayUrl: string }[], + dependencies: Dependencies = defaultDependencies, +): Promise<{ + runtimes: ManagedAgentRuntimeStatus[]; + removedRelays: Set; +}> { + await admissionWrites; + const before = new Map(relayRemovals); + const runtimes = await dependencies.reconcile(communities); + const removedRelays = new Set(); + for (const { relayUrl } of communities) { + const relay = canonicalRelayUrl(relayUrl); + if (relay && relayRemovals.get(relay) !== before.get(relay)) { + removedRelays.add(relay); + } + } + await stopPairs( + runtimes.filter((runtime) => { + const relay = canonicalRelayUrl(runtime.relayUrl); + // `failed` rows have no live child; stopping one only rewrites its + // record and can fail a concurrent reconcile's `expected_updated_at`. + return ( + relay !== null && + removedRelays.has(relay) && + runtime.lifecycle !== "stopped" && + runtime.lifecycle !== "failed" + ); + }), + dependencies.stop, + ); + return { runtimes, removedRelays }; +} diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs index 3e961b5854..149a605c1c 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.test.mjs @@ -108,3 +108,35 @@ test("test_pair_restart_strict_stop_clear_start_ordering", async () => { "operations must fire in stop β†’ clear β†’ start order", ); }); + +test("a pair restart whose relay is removed during its stop never starts, even after a re-add", async () => { + const { markRelayRemoved } = await import("./managedAgentRelayCleanup.ts"); + const relay = "wss://removed-during-restart.example"; + let releaseStop; + let started = 0; + const run = () => + restartManagedAgentPair( + PUBKEY, + relay, + () => + new Promise((resolve) => (releaseStop = () => resolve(makeStatus()))), + () => {}, + async () => { + started += 1; + return makeStatus(); + }, + ); + + const stale = run(); + // Remove, then re-add: the re-add never resets the removal counter. + markRelayRemoved(relay); + releaseStop(); + await assert.rejects(stale, /relay was removed from this device/); + assert.equal(started, 0); + + // Positive control: a restart begun after the re-add starts. + const fresh = run(); + releaseStop(); + await fresh; + assert.equal(started, 1); +}); diff --git a/desktop/src/features/agents/managedAgentRuntimeHooks.ts b/desktop/src/features/agents/managedAgentRuntimeHooks.ts index 96a3abc78d..0614b5dfc8 100644 --- a/desktop/src/features/agents/managedAgentRuntimeHooks.ts +++ b/desktop/src/features/agents/managedAgentRuntimeHooks.ts @@ -12,11 +12,14 @@ import { } from "@/features/communities/communityStorage"; import { listManagedAgentRuntimes, - reconcileManagedAgentRuntimes, startManagedAgentRuntime, stopManagedAgentRuntime, } from "@/shared/api/tauriManagedAgents"; import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; +import { + captureRelayRemovals, + reconcileConfiguredManagedAgentRuntimes, +} from "./managedAgentRelayCleanup"; import { canonicalRelayUrl } from "./managedAgentRuntimeStatus"; export const managedAgentRuntimesQueryKey = ["managed-agent-runtimes"] as const; @@ -85,8 +88,8 @@ export function bootstrapManagedAgentRuntimePairs( const communities = loadCommunities().map((community) => ({ relayUrl: community.relayUrl, })); - void reconcileManagedAgentRuntimes(communities) - .then((runtimes) => { + void reconcileConfiguredManagedAgentRuntimes(communities) + .then(({ runtimes }) => { cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes); }) .catch((error) => { @@ -159,6 +162,8 @@ export function clearActiveTurnsForAgentOnStop( * - Clear fires only when stop succeeds. * - A failed start occurs after the clear β€” the badge is already gone. * - No clear can fire after start begins, so genuinely-new turns are safe. + * - A removal of the relay during the stop abandons the start with + * `RELAY_REMOVED_ERROR`, even if the community was re-added since. */ export async function restartManagedAgentPair( pubkey: string, @@ -173,8 +178,10 @@ export async function restartManagedAgentPair( relayUrl: string, ) => Promise, ): Promise { + const assertRelayNotRemoved = captureRelayRemovals(relayUrl); await stop(pubkey, relayUrl); clear(pubkey, relayUrl); + assertRelayNotRemoved(); return start(pubkey, relayUrl); } diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs index edd368eccd..74ceebdbc4 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.test.mjs @@ -94,6 +94,16 @@ test("canonicalRelayUrl mirrors the backend pair-key normalization", () => { "wss://relay.example/path", ); assert.equal(canonicalRelayUrl("ws://[::1]:3000"), "ws://127.0.0.1:3000"); + assert.equal( + canonicalRelayUrl("ws://127.8.9.10:3000"), + "ws://127.0.0.1:3000", + ); + // Only loopback IP addresses fold, as in buzz-core: a domain whose first + // label is "127" is an ordinary host. + assert.equal( + canonicalRelayUrl("wss://127.preview.example"), + "wss://127.preview.example", + ); assert.equal(canonicalRelayUrl("https://relay.example"), null); assert.equal(canonicalRelayUrl("not a url"), null); }); diff --git a/desktop/src/features/agents/managedAgentRuntimeStatus.ts b/desktop/src/features/agents/managedAgentRuntimeStatus.ts index c3a952f7d5..6278d7df7e 100644 --- a/desktop/src/features/agents/managedAgentRuntimeStatus.ts +++ b/desktop/src/features/agents/managedAgentRuntimeStatus.ts @@ -66,8 +66,8 @@ export const MANAGED_AGENT_PAIR_ACTION_LABELS: Record< * 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. + * (`crates/buzz-core/src/relay.rs`): lowercase host, `localhost` and loopback IP + * addresses 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 { @@ -79,7 +79,13 @@ export function canonicalRelayUrl(raw: string): string | null { } if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; let host = url.hostname.toLowerCase(); - if (host === "localhost" || host === "[::1]" || host.startsWith("127.")) { + // `URL` normalizes IP literals, so only a real 127.0.0.0/8 address or the + // IPv6 loopback matches here; a domain such as `127.preview.example` does not. + if ( + host === "localhost" || + host === "[::1]" || + /^127\.\d+\.\d+\.\d+$/.test(host) + ) { host = "127.0.0.1"; } const defaultPort = url.protocol === "ws:" ? "80" : "443"; diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 866d89b3a4..8c0e95df35 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,3 +1,4 @@ +import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown } from "lucide-react"; @@ -808,10 +809,9 @@ export function AgentInstanceEditDialog({ startMutation.mutate(result.agent.pubkey, { onSuccess: () => toast.success(`${startedName} started.`), onError: (error) => + isRelayRemovedError(error) || toast.error( - error instanceof Error - ? `${startedName} failed to start: ${error.message}` - : `${startedName} failed to start.`, + `${startedName} failed to start${error instanceof Error ? `: ${error.message}` : "."}`, ), }); }, diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.relayRemoved.jsdom-test.mjs b/desktop/src/features/agents/ui/useManagedAgentActions.relayRemoved.jsdom-test.mjs new file mode 100644 index 0000000000..3ac7ae092a --- /dev/null +++ b/desktop/src/features/agents/ui/useManagedAgentActions.relayRemoved.jsdom-test.mjs @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const PUBKEY = "ab".repeat(32); +const RELAY = "wss://removed.example"; +const UNRELATED_RELAY = "wss://unrelated.example"; +const calls = []; +let releaseStop; +let createSpawnError = null; +const rawAgent = { + pubkey: PUBKEY, + name: "Scout", + persona_id: null, + relay_url: RELAY, + acp_command: "acp", + agent_command: "agent", + agent_args: [], + mcp_command: "mcp", + turn_timeout_seconds: 60, + idle_timeout_seconds: 60, + max_turn_duration_seconds: 60, + parallelism: 1, + system_prompt: null, + model: null, + status: "running", + pid: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + log_path: "/tmp/log", + start_on_app_launch: false, + backend: { type: "local" }, + backend_agent_id: null, +}; +const tauriMock = { + invoke(command) { + calls.push(command); + if (command === "list_managed_agents") return Promise.resolve([rawAgent]); + if (command === "stop_managed_agent") { + return new Promise((resolve) => { + releaseStop = () => resolve({ ...rawAgent, status: "stopped" }); + }); + } + if (command === "start_managed_agent") return Promise.resolve(rawAgent); + if (command === "discover_acp_providers") { + return Promise.resolve([ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/bin/buzz-agent", + default_args: [], + mcp_command: null, + }, + ]); + } + if (command === "create_managed_agent") { + return Promise.resolve({ + agent: { ...rawAgent, status: "stopped" }, + private_key_nsec: "nsec1test", + profile_sync_error: null, + spawn_error: createSpawnError, + }); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback() { + return Math.random(); + }, +}; +globalThis.__TAURI_INTERNALS__ = tauriMock; +globalThis.window.__TAURI_INTERNALS__ = tauriMock; + +const React = (await import("react")).default; +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { useManagedAgentActions } = await import("./useManagedAgentActions.ts"); +const { markRelayRemoved } = await import("../managedAgentRelayCleanup.ts"); +const { CommunitiesProvider } = await import( + "../../communities/useCommunities.tsx" +); +const { saveActiveCommunityId, saveCommunities } = await import( + "../../communities/communityStorage.ts" +); +const { RELAY_REMOVED_ERROR } = await import("../managedAgentRelayCleanup.ts"); + +// The active community is the workspace pair the restart targets. +saveCommunities([ + { id: "b", name: "B", relayUrl: RELAY }, + { id: "a", name: "A", relayUrl: UNRELATED_RELAY }, +]); +saveActiveCommunityId("b"); + +async function renderActions() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRoot(document.createElement("div")); + const latest = {}; + function Harness() { + Object.assign(latest, useManagedAgentActions()); + return null; + } + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement(Harness), + ), + ), + ); + }); + for (let i = 0; i < 20 && !latest.managedAgents?.length; i += 1) { + await act(() => new Promise((resolve) => setTimeout(resolve, 5))); + } + assert.equal(latest.managedAgents?.length, 1, "agent list loaded"); + return { latest, unmount: () => act(async () => root.unmount()) }; +} + +async function restartAcrossStop(duringStop) { + calls.length = 0; + const { latest, unmount } = await renderActions(); + let restart; + await act(async () => { + restart = latest.handleRestart(PUBKEY); + }); + for (let i = 0; i < 20 && !releaseStop; i += 1) { + await act(() => new Promise((resolve) => setTimeout(resolve, 5))); + } + duringStop(); + await act(async () => { + releaseStop(); + await restart; + }); + releaseStop = undefined; + const result = { + started: calls.includes("start_managed_agent"), + error: latest.actionErrorMessage, + }; + await unmount(); + return result; +} + +test("a Settings restart whose relay is removed and re-added during its stop starts nothing and shows no error", async () => { + // Re-adding never resets the counter, so only the removal matters here. + const result = await restartAcrossStop(() => markRelayRemoved(RELAY)); + assert.equal(result.started, false); + assert.equal(result.error, null); +}); + +test("removing an unrelated community during the stop still restarts the agent", async () => { + const result = await restartAcrossStop(() => + markRelayRemoved(UNRELATED_RELAY), + ); + assert.equal(result.started, true); + assert.equal(result.error, null); +}); + +test("a Settings restart with no removal during its stop starts the agent", async () => { + const result = await restartAcrossStop(() => {}); + assert.equal(result.started, true); + assert.equal(result.error, null); +}); + +async function startPersonaWithSpawnError(spawnError) { + createSpawnError = spawnError; + calls.length = 0; + const { latest, unmount } = await renderActions(); + await act(async () => { + await latest.handleStartPersona({ + id: "persona-1", + displayName: "Scout", + systemPrompt: "help", + avatarUrl: null, + runtime: null, + }); + }); + const result = { + created: calls.includes("create_managed_agent"), + error: latest.actionErrorMessage, + }; + await unmount(); + createSpawnError = null; + return result; +} + +test("a persona Start whose spawn a relay removal cancelled shows no error", async () => { + const result = await startPersonaWithSpawnError(RELAY_REMOVED_ERROR); + assert.equal(result.created, true); + assert.equal(result.error, null); +}); + +test("a persona Start with a genuine spawn error still shows it", async () => { + const result = await startPersonaWithSpawnError( + "runtime executable not found", + ); + assert.equal(result.created, true); + assert.equal(result.error, "runtime executable not found"); +}); diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 9c06044c5a..a993c3e4d8 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -1,3 +1,5 @@ +import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup"; +import { useCommunities } from "@/features/communities/useCommunities"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -40,6 +42,7 @@ import { export function useManagedAgentActions() { const queryClient = useQueryClient(); + const relayUrl = useCommunities().activeCommunity?.relayUrl; const { globalConfig } = useGlobalAgentConfig(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); @@ -182,6 +185,7 @@ export function useManagedAgentActions() { startManagedAgent: startMutation.mutateAsync, }); } catch (error) { + if (isRelayRemovedError(error)) return; setActionErrorMessage( error instanceof Error ? error.message : "Failed to start agent.", ); @@ -200,11 +204,13 @@ export function useManagedAgentActions() { assertStartNotBlockedByPresence(agent); await respawnManagedAgentWithRules({ agent, + relayUrl, startManagedAgent: startMutation.mutateAsync, stopManagedAgent: stopMutation.mutateAsync, onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey), }); } catch (error) { + if (isRelayRemovedError(error)) return; setActionErrorMessage( error instanceof Error ? error.message : "Failed to restart agent.", ); @@ -243,7 +249,7 @@ export function useManagedAgentActions() { toast.success("Agent created"); const notices = [...warnings]; - if (created.spawnError) { + if (created.spawnError && !isRelayRemovedError(created.spawnError)) { setActionErrorMessage(created.spawnError); } @@ -257,6 +263,7 @@ export function useManagedAgentActions() { void managedAgentsQuery.refetch(); void relayAgentsQuery.refetch(); } catch (error) { + if (isRelayRemovedError(error)) return; setActionErrorMessage( error instanceof Error ? error.message : "Failed to start agent.", ); diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 86e3713738..617a122686 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -249,7 +250,7 @@ export function usePersonaActions() { created, targetChannel, ); - if (created.spawnError) { + if (created.spawnError && !isRelayRemovedError(created.spawnError)) { setPersonaErrorMessage( `${persona.displayName} was created, but it did not start: ${created.spawnError}`, ); diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.jsdom-test.mjs b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.jsdom-test.mjs new file mode 100644 index 0000000000..112659ce41 --- /dev/null +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.jsdom-test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const calls = []; +let resolveFirstReconcile; +const tauriMock = { + invoke(command, args) { + calls.push(command); + if (command === "reconcile_managed_agent_runtimes") { + if (!resolveFirstReconcile) { + return new Promise((resolve) => { + resolveFirstReconcile = resolve; + }); + } + return Promise.resolve([]); + } + if (command === "stop_managed_agent_runtime") { + return Promise.resolve({ ...args, lifecycle: "stopped" }); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback() { + return Math.random(); + }, +}; +globalThis.__TAURI_INTERNALS__ = tauriMock; +globalThis.window.__TAURI_INTERNALS__ = tauriMock; + +const React = (await import("react")).default; +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { useManagedAgentRuntimeReconciliation } = await import( + "./useManagedAgentRuntimeReconciliation.ts" +); +const { markRelayRemoved } = await import("./managedAgentRelayCleanup.ts"); + +const RELAY = "wss://removed.example"; + +function Harness({ communities }) { + useManagedAgentRuntimeReconciliation(communities); + return null; +} + +test("a reconcile that finishes after its community was removed does not block re-adding it", async () => { + const queryClient = new QueryClient(); + const root = createRoot(document.createElement("div")); + const render = (communities) => + act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(Harness, { communities }), + ), + ); + }); + const reconciles = () => + calls.filter((command) => command === "reconcile_managed_agent_runtimes") + .length; + + await render([{ relayUrl: RELAY }]); + assert.equal(reconciles(), 1); + + // The community is removed while its reconcile is still in flight. + markRelayRemoved(RELAY); + await render([]); + + // The late reconcile finishes with a pair on the removed relay. + await act(async () => { + resolveFirstReconcile([ + { pubkey: "a".repeat(64), relayUrl: RELAY, lifecycle: "ready" }, + ]); + }); + assert.ok(calls.includes("stop_managed_agent_runtime")); + + // Re-adding the community must reconcile it again. + await render([{ relayUrl: RELAY }]); + assert.equal(reconciles(), 2); + + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts index f2fb2416b9..db9278f7bf 100644 --- a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -13,7 +13,7 @@ import { } from "@/features/agents/managedAgentRuntimeHooks"; import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus"; import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; -import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents"; +import { reconcileConfiguredManagedAgentRuntimes } from "@/features/agents/managedAgentRelayCleanup"; /** * Bootstrap a lazy harness pair for every auto-start local agent in every @@ -106,10 +106,17 @@ export function useManagedAgentRuntimeReconciliation( managedAgentRuntimesQueryKey, ); - void reconcileManagedAgentRuntimes(targets) - .then((runtimes) => { + void reconcileConfiguredManagedAgentRuntimes(targets) + .then(({ runtimes, removedRelays }) => { cacheReconciledManagedAgentRuntimes(queryClient, baseline, runtimes); - return classifyReconcileResult(pending, runtimes, canonicalRelayUrl); + // A relay fenced mid-call is not marked reconciled, so re-adding + // its community reconciles it afresh. + return classifyReconcileResult( + pending, + runtimes, + canonicalRelayUrl, + removedRelays, + ); }) .catch((error) => { console.warn("[managed-agent-runtimes] reconcile failed:", error); diff --git a/desktop/src/features/channels/ui/useMembersSidebarActions.ts b/desktop/src/features/channels/ui/useMembersSidebarActions.ts index ced4836d6b..9ff6e01b09 100644 --- a/desktop/src/features/channels/ui/useMembersSidebarActions.ts +++ b/desktop/src/features/channels/ui/useMembersSidebarActions.ts @@ -1,3 +1,4 @@ +import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup"; import { agentPresenceStartBlockReason, type AgentAvailabilityReader, @@ -134,6 +135,7 @@ export function useMembersSidebarActions({ successCount += 1; } catch (error) { + if (isRelayRemovedError(error)) continue; failures.push({ error: error instanceof Error ? error.message : failureMessage, name: agent.name, @@ -212,6 +214,7 @@ export function useMembersSidebarActions({ }); setActionNoticeMessage(getLifecycleSuccessMessage(agent)); } catch (error) { + if (isRelayRemovedError(error)) return; setActionErrorMessage( error instanceof Error ? error.message : "Failed to control agent.", ); @@ -226,6 +229,7 @@ export function useMembersSidebarActions({ assertStartNotBlockedByPresence(agent, isManagedAgentActive(agent)); await respawnManagedAgentWithRules({ agent, + relayUrl, startManagedAgent: startManagedAgentMutation.mutateAsync, stopManagedAgent: stopManagedAgentMutation.mutateAsync, onStopped: () => clearActiveTurnsForAgentOnStop(agent.pubkey), diff --git a/desktop/src/features/communities/legacyCommunityReadmit.jsdom-test.mjs b/desktop/src/features/communities/legacyCommunityReadmit.jsdom-test.mjs new file mode 100644 index 0000000000..3fabd1d3d5 --- /dev/null +++ b/desktop/src/features/communities/legacyCommunityReadmit.jsdom-test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const LOCAL_RELAY = "ws://localhost:3000"; +const LEGACY_RELAY = "wss://legacy.example"; +const calls = []; +// Mirrors the native admission record, which survives a webview reload. +const removedRelays = new Set(); +let legacyWorkspaces; +let onboardingCompletions; +const tauriMock = { + invoke(command, args) { + calls.push(command); + if (command === "get_legacy_workspace_storage") { + return Promise.resolve({ + workspaces: JSON.stringify(legacyWorkspaces), + activeWorkspaceId: "legacy", + onboardingCompletions, + }); + } + if (command === "readd_community_relay") { + // Mirrors only native's credential and fragment rejection, not full validation. + const url = new URL(args.relayUrl); + if (url.username || url.password || args.relayUrl.includes("#")) { + return Promise.reject(new Error("invalid relay url")); + } + removedRelays.delete(args.relayUrl); + return Promise.resolve(); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback() { + return Math.random(); + }, +}; +globalThis.__TAURI_INTERNALS__ = tauriMock; +globalThis.window.__TAURI_INTERNALS__ = tauriMock; + +const { migrateLegacyCommunityStorageBeforeRender } = await import( + "./legacyCommunityStorage.ts" +); + +const COMPLETION_KEY_PREFIX = "buzz-onboarding-complete.v1:"; + +// Only the localhost community is left after the legacy relay was removed. +async function reloadWithLegacy({ + workspaces, + completions = [], + savedLocalRelay = LOCAL_RELAY, +}) { + localStorage.clear(); + localStorage.setItem( + "buzz-communities", + JSON.stringify([{ id: "local", name: "Local", relayUrl: savedLocalRelay }]), + ); + localStorage.setItem("buzz-active-community-id", "local"); + removedRelays.clear(); + removedRelays.add(LEGACY_RELAY); + legacyWorkspaces = workspaces; + onboardingCompletions = completions; + calls.length = 0; + await migrateLegacyCommunityStorageBeforeRender(); + return calls.filter((c) => c === "readd_community_relay").length; +} + +const legacy = { id: "legacy", name: "Legacy", relayUrl: LEGACY_RELAY }; +const local = { id: "local", name: "Local", relayUrl: LOCAL_RELAY }; + +test("a reload whose legacy migration brings back a removed relay re-admits it once", async () => { + const readds = await reloadWithLegacy({ workspaces: [legacy, local] }); + assert.match(localStorage.getItem("buzz-communities"), /legacy\.example/); + assert.equal(removedRelays.has(LEGACY_RELAY), false, "legacy relay admitted"); + // The unchanged localhost community is not re-admitted. + assert.equal(readds, 1); +}); + +test("a failed onboarding-completion write after the list is saved still re-admits the relay once", async () => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + if (key.startsWith(COMPLETION_KEY_PREFIX)) { + throw new DOMException("full", "QuotaExceededError"); + } + return setItem.call(this, key, value); + }; + try { + const readds = await reloadWithLegacy({ + workspaces: [legacy], + completions: [{ pubkey: "ab".repeat(32), value: "1" }], + }); + assert.equal(localStorage.getItem("buzz-active-community-id"), "legacy"); + assert.equal( + removedRelays.has(LEGACY_RELAY), + false, + "legacy relay admitted", + ); + assert.equal(readds, 1); + } finally { + Storage.prototype.setItem = setItem; + } +}); + +for (const alias of [`${LEGACY_RELAY}/#old`, "wss://user@legacy.example"]) { + test(`an invalid alias ${alias} listed before the valid relay still re-admits it`, async () => { + await reloadWithLegacy({ + workspaces: [{ ...legacy, id: "alias", relayUrl: alias }, legacy], + }); + assert.equal( + removedRelays.has(LEGACY_RELAY), + false, + "legacy relay admitted", + ); + }); +} + +test("the same relay URL listed twice is re-admitted once", async () => { + const readds = await reloadWithLegacy({ workspaces: [legacy, legacy] }); + assert.equal(readds, 1); +}); diff --git a/desktop/src/features/communities/legacyCommunityStorage.ts b/desktop/src/features/communities/legacyCommunityStorage.ts index b871ec0bbe..260a114265 100644 --- a/desktop/src/features/communities/legacyCommunityStorage.ts +++ b/desktop/src/features/communities/legacyCommunityStorage.ts @@ -1,5 +1,6 @@ import { invokeTauri } from "@/shared/api/tauri"; import { getStorageItem } from "@/shared/lib/safeStorage"; +import { readmitRelay } from "@/features/agents/managedAgentRelayCleanup"; import { migrateLegacyCommunityStorage } from "./communityStorage"; const BUZZ_COMMUNITIES_KEY = "buzz-communities"; @@ -74,34 +75,66 @@ function shouldWriteLegacyCommunities({ return !hasNonLocalCurrentCommunities(currentCommunitiesRaw); } +/** Relay URLs in `nextRaw` that aren't already saved in `currentRaw`. */ +function newRelayUrls(currentRaw: string | null, nextRaw: string): string[] { + // Deliberately raw: native canonicalizes and validates on re-add, and a + // JavaScript notion of "same relay" can skip a URL native would admit. + const seen = new Set( + (parseCommunityList(currentRaw) ?? []).map((c) => c.relayUrl), + ); + const introduced: string[] = []; + for (const { relayUrl } of parseCommunityList(nextRaw) ?? []) { + if (typeof relayUrl !== "string" || seen.has(relayUrl)) continue; + seen.add(relayUrl); + introduced.push(relayUrl); + } + return introduced; +} + export function applyLegacyCommunityStorage( legacyStorage: LegacyCommunityStorageSnapshot, storage: Storage = window.localStorage, -): void { +): string[] { const currentCommunitiesRaw = storage.getItem(BUZZ_COMMUNITIES_KEY); const shouldWriteCommunities = shouldWriteLegacyCommunities({ currentCommunitiesRaw, legacyCommunitiesRaw: legacyStorage.workspaces, }); + let introducedRelayUrls: string[] = []; if (shouldWriteCommunities && legacyStorage.workspaces) { storage.setItem(BUZZ_COMMUNITIES_KEY, legacyStorage.workspaces); + introducedRelayUrls = newRelayUrls( + currentCommunitiesRaw, + legacyStorage.workspaces, + ); } - const currentActiveCommunityId = storage.getItem(BUZZ_ACTIVE_COMMUNITY_KEY); - if ( - legacyStorage.activeWorkspaceId && - (!currentActiveCommunityId || shouldWriteCommunities) - ) { - storage.setItem(BUZZ_ACTIVE_COMMUNITY_KEY, legacyStorage.activeWorkspaceId); - } - - for (const completion of legacyStorage.onboardingCompletions) { - const key = `${BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${completion.pubkey}`; - if (storage.getItem(key) === null) { - storage.setItem(key, completion.value); + // The list is saved, so its new relays must still be re-admitted even if + // these follow-up writes fail; a later reload would skip the migration. + try { + const currentActiveCommunityId = storage.getItem(BUZZ_ACTIVE_COMMUNITY_KEY); + if ( + legacyStorage.activeWorkspaceId && + (!currentActiveCommunityId || shouldWriteCommunities) + ) { + storage.setItem( + BUZZ_ACTIVE_COMMUNITY_KEY, + legacyStorage.activeWorkspaceId, + ); } + + for (const completion of legacyStorage.onboardingCompletions) { + const key = `${BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${completion.pubkey}`; + if (storage.getItem(key) === null) { + storage.setItem(key, completion.value); + } + } + } catch (error) { + console.warn("Failed to migrate legacy Sprout onboarding state.", error); } + + return introducedRelayUrls; } /** @@ -129,13 +162,27 @@ export async function migrateLegacyCommunityStorageBeforeRender(): Promise return; } + let introducedRelayUrls: string[]; try { - applyLegacyCommunityStorage( + introducedRelayUrls = applyLegacyCommunityStorage( await invokeTauri( "get_legacy_workspace_storage", ), ); } catch (error) { console.warn("Failed to read legacy Sprout community storage.", error); + return; } + // A webview reload keeps the native process, so a relay removed earlier in + // this session stays refused unless the migration re-admits it here. + await Promise.all( + introducedRelayUrls.map((relayUrl) => + readmitRelay(relayUrl).catch((error) => { + console.error( + "[communities] re-admitting local agents on a migrated relay failed:", + error, + ); + }), + ), + ); } diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index 4d6f2d258b..14e358b288 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -8,6 +8,7 @@ import { Settings2, LogOut, Ticket, + Trash2, WifiOff, } from "lucide-react"; import * as React from "react"; @@ -15,6 +16,16 @@ import { toast } from "sonner"; import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity"; import type { Community } from "@/features/communities/types"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; import { DropdownMenu, DropdownMenuContent, @@ -66,7 +77,8 @@ type CommunitySwitcherProps = { id: string, updates: Partial>, ) => void; - onRemoveCommunity: (id: string) => Promise; + onLeaveCommunity: (id: string) => Promise; + onRemoveCommunityFromDevice: (id: string) => Promise; }; export function CommunityEmojiIcon({ @@ -107,13 +119,16 @@ export function CommunitySwitcher({ onSwitchCommunity, onAddCommunity, onUpdateCommunity, - onRemoveCommunity, + onLeaveCommunity, + onRemoveCommunityFromDevice, }: CommunitySwitcherProps) { const [editingCommunity, setEditingCommunity] = React.useState(null); const [dropdownOpen, setDropdownOpen] = React.useState(false); const [leaveError, setLeaveError] = React.useState(null); const [isLeaving, setIsLeaving] = React.useState(false); + const [removingCommunity, setRemovingCommunity] = + React.useState(null); const profileMenuHoverTimer = React.useRef(null); const connectionState = useRelayConnection(); const degraded = isRelayConnectionDegraded(connectionState); @@ -170,7 +185,7 @@ export function CommunitySwitcher({ setIsLeaving(true); setLeaveError(null); try { - const result = await onRemoveCommunity(activeCommunity.id); + const result = await onLeaveCommunity(activeCommunity.id); setDropdownOpen(false); if (result?.status === "already-absent") { toast("Community removed", { @@ -179,16 +194,33 @@ export function CommunitySwitcher({ }); } } catch (error) { - setLeaveError( + const message = error instanceof Error ? error.message - : "Couldn't leave the community. Try again.", + : "Couldn't leave the community. Try again."; + setLeaveError( + `${message} If the community no longer exists, use Remove from this device.`, ); setDropdownOpen(true); } finally { setIsLeaving(false); } - }, [activeCommunity, isLeaving, onRemoveCommunity]); + }, [activeCommunity, isLeaving, onLeaveCommunity]); + + const handleRemoveFromDevice = React.useCallback(async () => { + if (!removingCommunity) return; + const { id } = removingCommunity; + setRemovingCommunity(null); + try { + await onRemoveCommunityFromDevice(id); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Couldn't remove the community from this device. Try again.", + ); + } + }, [onRemoveCommunityFromDevice, removingCommunity]); const triggerContent = ( <> @@ -331,6 +363,19 @@ export function CommunitySwitcher({ {isLeaving ? "Leaving…" : "Leave community"} + {leaveError ? (

+ + { + if (!open) setRemovingCommunity(null); + }} + open={removingCommunity !== null} + > + + + Remove from this device + + {`Remove "${removingCommunity?.name ?? ""}" from this device? Buzz won't contact the relay, so your membership isn't revoked. You can add the community again later.`} + + + + Cancel + void handleRemoveFromDevice()}> + Remove + + + + ); } diff --git a/desktop/src/features/communities/updateCommunityReadmit.jsdom-test.mjs b/desktop/src/features/communities/updateCommunityReadmit.jsdom-test.mjs new file mode 100644 index 0000000000..f9f81da7bb --- /dev/null +++ b/desktop/src/features/communities/updateCommunityReadmit.jsdom-test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const RELAY_A = "wss://a.example"; +const RELAY_B = "wss://b.example"; +const calls = []; +// Mirrors the native admission record: removed relays refuse local starts. +const removedRelays = new Set(); +const tauriMock = { + invoke(command, args) { + calls.push(command); + if (command === "remove_community_relay") { + removedRelays.add(args.relayUrl); + return Promise.resolve(); + } + if (command === "readd_community_relay") { + removedRelays.delete(args.relayUrl); + return Promise.resolve(); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback() { + return Math.random(); + }, +}; +globalThis.__TAURI_INTERNALS__ = tauriMock; +globalThis.window.__TAURI_INTERNALS__ = tauriMock; + +const React = (await import("react")).default; +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { CommunitiesProvider, useCommunities } = await import( + "./useCommunities.tsx" +); +const { saveActiveCommunityId, saveCommunities } = await import( + "./communityStorage.ts" +); +const { refuseRelayAdmission } = await import( + "../agents/managedAgentRelayCleanup.ts" +); + +test("editing a community onto a removed relay re-admits that relay once", async () => { + // B was removed from this device earlier; only A is still saved. + saveCommunities([{ id: "a", name: "A", relayUrl: RELAY_A }]); + saveActiveCommunityId("a"); + await refuseRelayAdmission(RELAY_B); + assert.ok(removedRelays.has(RELAY_B)); + + const latest = {}; + function Harness() { + Object.assign(latest, useCommunities()); + return null; + } + const root = createRoot(document.createElement("div")); + await act(async () => { + root.render( + React.createElement( + CommunitiesProvider, + null, + React.createElement(Harness), + ), + ); + }); + + calls.length = 0; + let result; + await act(async () => { + result = latest.updateCommunity("a", { relayUrl: RELAY_B }); + }); + // Any reconcile queued after the edit waits on the admission write queue. + await refuseRelayAdmission("wss://unrelated.example"); + + assert.equal(result.kind, "updated"); + assert.equal(removedRelays.has(RELAY_B), false, "B admits local starts"); + assert.equal(calls.filter((c) => c === "readd_community_relay").length, 1); + + // A name-only edit does not re-admit. + calls.length = 0; + await act(async () => { + latest.updateCommunity("a", { name: "Renamed" }); + }); + assert.equal(calls.includes("readd_community_relay"), false); + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index f9d69f1e99..b630ec0442 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -23,6 +23,7 @@ import { removeProjectSnapshotForRelay } from "@/features/projects/projectSnapsh import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore"; +import { readmitRelay } from "@/features/agents/managedAgentRelayCleanup"; import { clearCommunityDestinations, removeCommunityDestination, @@ -213,6 +214,13 @@ function useCommunitiesInternal(): UseCommunitiesReturn { saveCommunities(next); return next; }); + // A relay removed from this device earlier admits local agent pairs again. + readmitRelay(community.relayUrl).catch((error) => { + console.error( + "[communities] re-admitting local agents on this relay failed; their starts stay refused:", + error, + ); + }); return resolvedId; }, []); @@ -298,6 +306,9 @@ function useCommunitiesInternal(): UseCommunitiesReturn { ); if (result.kind === "updated") { + const previousRelayUrl = communitiesRef.current.find( + (w) => w.id === id, + )?.relayUrl; setCommunitiesState((prev) => { const next = prev.map((w) => w.id === id ? { ...w, ...updates } : w, @@ -306,6 +317,19 @@ function useCommunitiesInternal(): UseCommunitiesReturn { return next; }); + if ( + updates.relayUrl !== undefined && + updates.relayUrl !== previousRelayUrl + ) { + // Queued before the reinit so its reconcile on the new relay is admitted. + readmitRelay(updates.relayUrl).catch((error) => { + console.error( + "[communities] re-admitting local agents on the edited relay failed; their starts stay refused:", + error, + ); + }); + } + if (result.requiresReinit) { setReinitKey((k) => k + 1); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a9f0b1f1fc..64ce9ab09d 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -5,6 +5,7 @@ import { isMacPlatform } from "@/shared/lib/platform"; import { relayClient } from "@/shared/api/relayClient"; import { resetChannelMembershipWrites } from "@/shared/api/channelMembershipWrites"; import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate"; +import { readmitRelay } from "@/features/agents/managedAgentRelayCleanup"; import { autoConnectDefaultRelayEnabled, getDefaultRelayUrl, @@ -230,6 +231,14 @@ export function useCommunityInit( identity.pubkey, ); if (community && !cancelled) { + // Removing the last community refused this relay in the native + // process, which survives the reload. + await readmitRelay(community.relayUrl).catch((error) => { + console.error( + "[communities] re-admitting local agents on the default relay failed:", + error, + ); + }); window.location.reload(); return; } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index bad18369fe..311e3bb8c5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -464,7 +465,9 @@ export function UserProfilePanel({ try { const created = await createManagedAgentForPersona(resolvedPersona); - if (created.spawnError) { + if (isRelayRemovedError(created.spawnError)) { + // Its community was removed meanwhile: created, not started, no error. + } else if (created.spawnError) { toast.error(created.spawnError); } else { toast.success(`Started ${created.agent.name}.`); diff --git a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts index 745df71768..41d4cc420f 100644 --- a/desktop/src/features/profile/ui/useAgentLifecycleActions.ts +++ b/desktop/src/features/profile/ui/useAgentLifecycleActions.ts @@ -9,6 +9,8 @@ import { } from "@/features/agents/lib/managedAgentControlActions"; import { agentPresenceStartBlockReason } from "@/features/agents/lib/useAgentAvailability"; import { clearActiveTurnsForAgentOnStop } from "@/features/agents/managedAgentRuntimeHooks"; +import { isRelayRemovedError } from "@/features/agents/managedAgentRelayCleanup"; +import { useCommunities } from "@/features/communities/useCommunities"; import type { Channel, ManagedAgent, @@ -31,6 +33,7 @@ export function useAgentLifecycleActions({ startManagedAgent: (pubkey: string) => Promise; stopManagedAgent: (pubkey: string) => Promise; }) { + const relayUrl = useCommunities().activeCommunity?.relayUrl; const handleAgentPrimaryAction = React.useCallback(async () => { if (!managedAgent) return; @@ -85,17 +88,25 @@ export function useAgentLifecycleActions({ if (blockReason) throw new Error(blockReason); await respawnManagedAgentWithRules({ agent: managedAgent, + relayUrl, startManagedAgent, stopManagedAgent, onStopped: () => clearActiveTurnsForAgentOnStop(managedAgent.pubkey), }); toast.success(`Restarted ${managedAgent.name}.`); } catch (error) { + if (isRelayRemovedError(error)) return; toast.error( error instanceof Error ? error.message : "Agent restart failed.", ); } - }, [availability, managedAgent, startManagedAgent, stopManagedAgent]); + }, [ + availability, + managedAgent, + relayUrl, + startManagedAgent, + stopManagedAgent, + ]); return { handleAgentPrimaryAction, handleAgentRestart }; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 0bc6186e06..4cc918e219 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -114,7 +114,8 @@ export function AppSidebar({ onBrowseChannels, onOpenDm, onUpdateCommunity, - onRemoveCommunity, + onLeaveCommunity, + onRemoveCommunityFromDevice, onCreateAgent, onSelectAgents, onSelectProjects, @@ -864,7 +865,8 @@ export function AppSidebar({ onOpenAddCommunity={onOpenAddCommunity} onOpenSettings={onSelectSettings} onSendFeedback={onSendFeedback} - onRemoveCommunity={onRemoveCommunity} + onLeaveCommunity={onLeaveCommunity} + onRemoveCommunityFromDevice={onRemoveCommunityFromDevice} onSetPresenceStatus={onSetPresenceStatus} onSetUserStatus={onSetUserStatus} onClearUserStatus={onClearUserStatus} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index d884c6d553..029bafe634 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -82,7 +82,8 @@ export type AppSidebarProps = { id: string, updates: Partial>, ) => void; - onRemoveCommunity: (id: string) => Promise; + onLeaveCommunity: (id: string) => Promise; + onRemoveCommunityFromDevice: (id: string) => Promise; onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index be54e65cff..591f621d83 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -24,7 +24,8 @@ type SidebarProfileCardProps = { isPresencePending?: boolean; onOpenAddCommunity: () => void; onOpenSettings: (section?: SettingsSection) => void; - onRemoveCommunity: (id: string) => Promise; + onLeaveCommunity: (id: string) => Promise; + onRemoveCommunityFromDevice: (id: string) => Promise; onSendFeedback?: () => void; onSetPresenceStatus?: (status: PresenceStatus) => void; onSetUserStatus: (status: UserStatusInput) => void; @@ -47,7 +48,8 @@ export function SidebarProfileCard({ onOpenAddCommunity, onOpenSettings, onSendFeedback, - onRemoveCommunity, + onLeaveCommunity, + onRemoveCommunityFromDevice, onSetPresenceStatus, onSetUserStatus, onClearUserStatus, @@ -177,7 +179,8 @@ export function SidebarProfileCard({ setProfilePopoverOpen(false); onOpenSettings("community-members"); }} - onRemoveCommunity={onRemoveCommunity} + onLeaveCommunity={onLeaveCommunity} + onRemoveCommunityFromDevice={onRemoveCommunityFromDevice} onSwitchCommunity={onSwitchCommunity} onUpdateCommunity={onUpdateCommunity} variant="profile-menu" diff --git a/desktop/src/shared/api/tauriWorkspace.ts b/desktop/src/shared/api/tauriWorkspace.ts index 6a141508e4..937a208701 100644 --- a/desktop/src/shared/api/tauriWorkspace.ts +++ b/desktop/src/shared/api/tauriWorkspace.ts @@ -16,6 +16,14 @@ export async function applyCommunity( }); } +/** Refuse local agent pairs on a removed community's relay, including starts already in flight. */ +export const removeCommunityRelay = (relayUrl: string) => + invokeTauri("remove_community_relay", { relayUrl }); + +/** Admit local agent pairs on a relay again after its community is re-added. */ +export const readdCommunityRelay = (relayUrl: string) => + invokeTauri("readd_community_relay", { relayUrl }); + export const setAgentManagedProfiles = (enabled: boolean) => invokeTauri("set_agent_managed_profiles", { enabled }); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3e0328f4ab..e4627ed8d8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -508,6 +508,8 @@ type E2eConfig = { oaOwnerIsMe?: boolean; /** Whether the mock relay advertises NIP-43 membership support. Defaults to false. */ relayRequiresMembership?: boolean; + /** Reject the membership probe, as an unreachable relay's `/info` does. */ + relayRequiresMembershipError?: string; /** Delay EOSE for membership snapshots after delivering the event. */ relayMembershipEoseDelayMs?: number; relayRole?: "owner" | "admin" | "member" | null; @@ -1259,6 +1261,8 @@ declare global { command: string; payload: unknown; }>; + /** Community ids still saved when `remove_community_relay` ran. */ + __BUZZ_E2E_SAVED_COMMUNITIES_AT_RELAY_REMOVAL__?: string[]; /** Release a mock media proxy held at port 0 and return its ready port. */ __BUZZ_E2E_RELEASE_MEDIA_PROXY__?: () => number; /** Release mock send events that were stored but withheld from live subscribers. */ @@ -12906,6 +12910,15 @@ export function maybeInstallE2eTauriMocks() { } return; } + case "readd_community_relay": + return undefined; + case "remove_community_relay": + window.__BUZZ_E2E_SAVED_COMMUNITIES_AT_RELAY_REMOVAL__ = ( + JSON.parse( + window.localStorage.getItem("buzz-communities") ?? "[]", + ) as Array<{ id: string }> + ).map((community) => community.id); + return undefined; case "update_tray_agent_activity": case "clear_tray_agent_activity": case "requeue_tray_actions": @@ -13590,6 +13603,9 @@ export function maybeInstallE2eTauriMocks() { case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": + if (activeConfig?.mock?.relayRequiresMembershipError) { + throw new Error(activeConfig.mock.relayRequiresMembershipError); + } return activeConfig?.mock?.relayRequiresMembership ?? false; case "discover_acp_providers": return handleDiscoverAcpRuntimes(activeConfig); diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 57b35db386..1f08f42337 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -11,6 +11,7 @@ function snapshotKey(relayUrl: string) { return `buzz-channels.v1:${relayUrl}:${OWNER_PUBKEY.toLowerCase()}`; } +const AGENT_PUBKEY = "c".repeat(64); const COMMUNITY_A = { id: "ws-a", name: "Alpha", @@ -1094,6 +1095,147 @@ test.describe("community rail", () => { .toBe(COMMUNITY_B.id); }); + test("removes an unreachable community from this device when Leave cannot", async ({ + page, + }) => { + await installMockBridge( + page, + { + relayRequiresMembershipError: "relay info returned 404", + managedAgents: [{ pubkey: AGENT_PUBKEY, name: "Scout" }], + managedAgentRuntimes: [ + { pubkey: AGENT_PUBKEY, relayUrl: COMMUNITY_A.relayUrl }, + { pubkey: AGENT_PUBKEY, relayUrl: COMMUNITY_B.relayUrl }, + ], + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + + const menu = page.getByRole("menu", { name: "Community actions" }); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await menu.getByRole("menuitem", { name: "Leave community" }).click(); + await expect(menu.getByRole("alert")).toContainText( + "relay info returned 404 If the community no longer exists, use Remove from this device.", + ); + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`), + ).toBeVisible(); + + await menu + .getByRole("menuitem", { name: "Remove from this device" }) + .click(); + const dialog = page.getByRole("alertdialog", { + name: "Remove from this device", + }); + await expect(dialog).toContainText("your membership isn't revoked"); + await dialog.getByRole("button", { name: "Remove" }).click(); + + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`), + ).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => ({ + active: window.localStorage.getItem("buzz-active-community-id"), + ids: JSON.parse( + window.localStorage.getItem("buzz-communities") ?? "[]", + ).map((community) => community.id), + })), + ) + .toEqual({ active: COMMUNITY_B.id, ids: [COMMUNITY_B.id] }); + // Removal stops the agent pair on the removed relay, and only that one. + await expect + .poll(() => + page.evaluate(async () => + ( + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__( + "list_managed_agent_runtimes", + ) + ).map(({ relayUrl, lifecycle }) => [relayUrl, lifecycle]), + ), + ) + .toEqual([ + [COMMUNITY_A.relayUrl, "stopped"], + [COMMUNITY_B.relayUrl, "ready"], + ]); + + // The relay is refused while the community is still saved, and before + // the stop sweep runs. + const order = await page.evaluate(() => ({ + savedAtRelayRemoval: + window.__BUZZ_E2E_SAVED_COMMUNITIES_AT_RELAY_REMOVAL__, + commands: (window.__BUZZ_E2E_COMMANDS__ ?? []).filter((command) => + ["remove_community_relay", "stop_managed_agent_runtime"].includes( + command, + ), + ), + })); + expect(order).toEqual({ + savedAtRelayRemoval: [COMMUNITY_A.id, COMMUNITY_B.id], + commands: ["remove_community_relay", "stop_managed_agent_runtime"], + }); + }); + + test("removing one of two communities on a relay leaves that relay's agents admitted", async ({ + page, + }) => { + // Same relay as COMMUNITY_A, spelled differently so both stay saved. + const alias = { + ...COMMUNITY_B, + id: "ws-a-alias", + relayUrl: `${COMMUNITY_A.relayUrl}/`, + }; + await installMockBridge( + page, + { + relayRequiresMembershipError: "relay info returned 404", + managedAgents: [{ pubkey: AGENT_PUBKEY, name: "Scout" }], + managedAgentRuntimes: [ + { pubkey: AGENT_PUBKEY, relayUrl: COMMUNITY_A.relayUrl }, + ], + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, alias], COMMUNITY_A.id); + await page.goto("/"); + + const menu = page.getByRole("menu", { name: "Community actions" }); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await menu.getByRole("menuitem", { name: "Leave community" }).click(); + await menu + .getByRole("menuitem", { name: "Remove from this device" }) + .click(); + await page + .getByRole("alertdialog", { name: "Remove from this device" }) + .getByRole("button", { name: "Remove" }) + .click(); + + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(alias.id); + const after = await page.evaluate(async () => ({ + commands: (window.__BUZZ_E2E_COMMANDS__ ?? []).filter((command) => + ["remove_community_relay", "stop_managed_agent_runtime"].includes( + command, + ), + ), + runtimes: ( + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__( + "list_managed_agent_runtimes", + ) + ).map(({ lifecycle }) => lifecycle), + })); + expect(after).toEqual({ commands: [], runtimes: ["ready"] }); + }); + test("shows the quiet switch gate, not the boot splash, while switching", async ({ page, }) => { @@ -1136,10 +1278,16 @@ test.describe("community rail", () => { context, page, }) => { - await installMockBridge(page, undefined, { - autoConnectDefaultRelay: true, - skipCommunitySeed: true, - }); + await installMockBridge( + page, + { + managedAgents: [{ pubkey: AGENT_PUBKEY, name: "Scout" }], + managedAgentRuntimes: [ + { pubkey: AGENT_PUBKEY, relayUrl: COMMUNITY_A.relayUrl }, + ], + }, + { autoConnectDefaultRelay: true, skipCommunitySeed: true }, + ); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); await page.goto("/"); @@ -1173,6 +1321,32 @@ test.describe("community rail", () => { ), ) .toBe("1"); + // The relay is refused while the last community is still saved, and + // before its relay's agent pair is stopped. + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMANDS__ ?? []).includes( + "stop_managed_agent_runtime", + ), + ), + ) + .toBe(true); + const order = await page.evaluate(() => { + const commands = window.__BUZZ_E2E_COMMANDS__ ?? []; + return { + savedAtRelayRemoval: + window.__BUZZ_E2E_SAVED_COMMUNITIES_AT_RELAY_REMOVAL__, + refusedBeforeStop: + commands.includes("remove_community_relay") && + commands.indexOf("remove_community_relay") < + commands.indexOf("stop_managed_agent_runtime"), + }; + }); + expect(order).toEqual({ + savedAtRelayRemoval: [COMMUNITY_A.id], + refusedBeforeStop: true, + }); const relaunchPage = await context.newPage(); await installMockBridge(relaunchPage, undefined, {