From 0764bbcea44df45304f7d2a5bd2ab2c3bbd4fe59 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Mon, 17 Aug 2026 11:25:51 -0400 Subject: [PATCH] fix(desktop): regenerate nest on archive, drop expect() in gate Archive/unarchive commands submitted the relay event and returned without refreshing AGENTS.md, so a just-archived agent lingered on the roster until an unrelated edit or app restart. Wire try_regenerate_nest into both commands after a successful submit, matching the ~20 other mutation sites; the regen is fire-and-forget and races the relay's kind:13535 snapshot, so a stale render self-heals on the next cycle. Replace the production expect() on the NestRegenGate commit mutex with a poison-to-io::Error mapping so a poisoned lock degrades to the same warn-and-continue path as any other commit failure instead of panicking the desktop process (root AGENTS.md: no new expect() in production paths). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/commands/identity_archive.rs | 19 +++++++++++--- desktop/src-tauri/src/managed_agents/nest.rs | 9 ++++--- .../src/managed_agents/nest/render_tests.rs | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index f8ed47f34f..5b5a64e89d 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -12,11 +12,12 @@ //! see §Owner-of-Agent Requests and §Relay Processing Algorithm. use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use crate::{ app_state::AppState, events, + managed_agents::try_regenerate_nest, relay::{ classify_request_error, query_relay, query_relay_at, relay_api_base_url, relay_http_base_url, relay_ws_url, relay_ws_url_with_override, submit_event, @@ -178,6 +179,7 @@ pub struct UnarchiveRequest { #[tauri::command] pub async fn archive_identity( req: ArchiveRequest, + app: AppHandle, state: State<'_, AppState>, ) -> Result { let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; @@ -190,13 +192,20 @@ pub async fn archive_identity( req.replaced_by.as_deref(), auth_ref, )?; - submit_event(builder, &state).await + let response = submit_event(builder, &state).await?; + // Refresh AGENTS.md so a just-archived agent drops from the roster without + // waiting for the next unrelated edit or app restart. Fire-and-forget and + // fail-open like every other mutation site; it races the relay's kind:13535 + // snapshot update, so a stale render self-heals on the next regen. + try_regenerate_nest(&app); + Ok(response) } /// Submit a `kind:9036` unarchive request to the relay. #[tauri::command] pub async fn unarchive_identity( req: UnarchiveRequest, + app: AppHandle, state: State<'_, AppState>, ) -> Result { let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; @@ -208,7 +217,11 @@ pub async fn unarchive_identity( req.reason.as_deref(), auth_ref, )?; - submit_event(builder, &state).await + let response = submit_event(builder, &state).await?; + // See `archive_identity`: refresh the roster so an unarchived agent + // reappears promptly, fail-open against the same snapshot race. + try_regenerate_nest(&app); + Ok(response) } /// If the current user is the verified NIP-OA owner of `target`, return the diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 7a269507ad..f95427cd14 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -711,7 +711,7 @@ impl NestRegenGate { let mut last = self .last_written .lock() - .expect("nest regen gate lock poisoned"); + .map_err(|_| io::Error::other("nest regen gate lock poisoned"))?; if generation < *last { return Ok(false); } @@ -765,9 +765,10 @@ pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result /// a stale AGENTS.md, so we warn and continue rather than propagating the error. /// The generation is claimed *here*, synchronously, so it encodes call order; /// the spawned task carries it into [`NestRegenGate::commit`], which drops -/// a stale render rather than letting a slow task overwrite a newer file. A -/// just-archived agent may linger for one regen cycle until the next regen (any -/// agent/team edit or the next launch). +/// a stale render rather than letting a slow task overwrite a newer file. +/// Archive/unarchive trigger this directly, but the regen races the relay's +/// `kind:13535` snapshot update, so a just-archived agent may still linger for +/// one cycle until the next regen (any agent/team edit or the next launch). pub fn try_regenerate_nest(app: &AppHandle) { let generation = NEST_REGEN.claim(); let app = app.clone(); diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index 4a92b5f1e4..7d10ab671a 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -597,3 +597,29 @@ fn commit_equal_generation_is_allowed() { let content = fs::read_to_string(&file).unwrap(); assert!(content.contains("second")); } + +#[test] +fn commit_poisoned_lock_returns_error_instead_of_panicking() { + // A poisoned gate lock must degrade to an io::Error so the fire-and-forget + // caller warns and continues, never panicking the desktop process (root + // AGENTS.md: no new expect() in production paths). Poison the lock by + // panicking a thread while it holds the guard, then assert commit yields + // Err rather than unwinding. + let gate = std::sync::Arc::new(NestRegenGate::new()); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + let gen = gate.claim(); + + let poisoner = gate.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoner.last_written.lock().unwrap(); + panic!("poison the gate lock"); + }) + .join(); + + let result = gate.commit(&file, "after poison", gen); + assert!( + result.is_err(), + "a poisoned lock must surface as an error, not a panic" + ); +}