mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user