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, {