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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-17 11:25:51 -04:00
parent d33042a957
commit 0764bbcea4
3 changed files with 47 additions and 7 deletions

View File

@@ -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<SubmitEventResponse, String> {
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<SubmitEventResponse, String> {
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

View File

@@ -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();

View File

@@ -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"
);
}