Files
buzz/desktop/src-tauri/src/app_state_accessors.rs
Matt Toohey 40220d5618 perf(desktop): publish mention sends before waking agents (#7154)
## Summary

Cuts perceived agent-mention send latency by publishing the message
first and waking the agent afterwards, instead of blocking the send on a
synchronous agent start/deploy round-trip. A send that mentions a
stopped or undeployed managed agent now shows the message immediately;
the wake runs fire-and-forget after the relay accepts the publish. The
already-running-agent send also gets faster via revalidation dedupe and
NIP-11 caching.

## Changes

### Publish-first agent wake

- Wakes for mentioned managed agents are collected during send
preparation and flushed fire-and-forget only after `await send(...)`
resolves. No start can fire — and no "your message was sent" toast can
appear — for a message the relay never accepted; every abort path
(cancel, readiness error, publish rejection, dismissed non-member
prompt) simply drops the queue. Persona-create wakes ride the pending
draft behind the non-member prompt for the same reason.
- Each wake is bound to the tenant scope captured at send time: the new
`useDetachedAgentStart` hook passes `expectedRelayUrl` +
`expectedSignerPubkey` with every start, so a wake that outlives a
community switch fails closed at the backend instead of spawning against
the new tenant. A wake whose scope has not resolved yet (identity query
still loading, blank stored relay URL) is refused with a recoverable
toast rather than fired unscoped.
- In-flight wakes are deduped through a module-level map keyed by
`(relay URL, pubkey)` — the same tenant pair the backend keys on — so
two quick sends or two composers cannot double-spawn a cold agent during
the seconds-long start window. Entries are deliberately retained across
community switches (the key *is* the tenant scope, so a retained entry
can never affect another community, and clearing it let an A→B→A round
trip deploy a provider agent twice) and self-clean when the start
settles.
- Wake-failure toasts are fenced to the community they fired in via a
module-level scope mirror: a start that settles after a community switch
logs instead of rendering community A's failure over community B's UI,
and an A→B→A return re-delivers the warning where it is actionable.
- Membership attach and access-policy writes stay synchronous, so the
harness's first kind-39002 read still sees the channel.

### Replay floor

- The send timestamp travels with the wake as `BUZZ_ACP_REPLAY_FLOOR`,
threaded through both local spawns (`spawn_agent_child`) and provider
deploys (`deploy_to_provider` injects it into `launch.policy_env`), so
the harness's startup watermark replays back past the just-published
triggering message no matter how long the spawn takes. `buzz-acp` clamps
the floor to `[now − 15min, now]`.
- The floor is captured at enqueue time, not flush time — the flush runs
post-publish, so a flush-time stamp could exceed the message's
`created_at` and skip the very message the floor exists to cover.
- On local spawns the caller's floor is asserted *after* the user env
layering (and the ambient parent-process value is stripped
unconditionally), so a saved persona/global/agent env entry cannot
shadow this send's floor — mirroring the shadow-strip the provider path
applies to `launch.env`. Both halves share one `REPLAY_FLOOR_ENV_VAR`
const.

### Send-path latency reductions (already-running agents)

- Mention revalidation is deduped: the publish-boundary pass reuses the
pre-side-effect authorization pass unless an awaited round-trip actually
separated the two (background upload, link-preview settlement, DM
expansion, a real access-policy/membership write, or active-huddle
enrollment). This preserves the #5681 authorization boundary while
making the common send single-pass.
- NIP-11 `self` lookups are cached per relay URL for 5 minutes. Only
verified values are cached — non-2xx and malformed responses stay
retryable — and URL keying keeps community switches from serving another
relay's identity.
- `applyReusableAgentAccessPolicy` now reports its relay write
explicitly (`{ agent, wrote }`) instead of signalling through object
identity, so the revalidation trigger above is load-bearing by
construction.

### File splits

Four files crossed the repository file-size ratchet during this work;
one cohesive unit was extracted from each rather than raising a ceiling
— `runtime/setup_payload.rs`, `commands/agents_create_fields.rs`,
`app_state_accessors.rs`, and `useEnsureAgentMentionsReady.ts`. The
ratchet is green at the tip.

### Review follow-ups

The three concrete findings from the first review round are fixed at the
tip: the pre-publish wake and its false "your message was sent" toast
(fixed by queueing wakes behind the publish), the stale cross-community
failure toast (fixed by the scope-mirror fence), and the A→B→A duplicate
provider deploy (fixed by retaining the tenant-keyed in-flight entries
across switches). The fast-path admission-staleness point is answered in
the review thread: deferred paths already re-validate at the publish
boundary, and the remaining fast-path window is milliseconds against an
irreducible network-transit race.

Mid-branch send-perf instrumentation was added to attribute the residual
spinner latency and reverted once that analysis concluded — it is
net-zero in this diff.

### Deferred follow-ups

Durable mention catch-up via `event_mentions` (option 2 step 3) and
backend deploy-epoch coalescing for the wake paths that do not funnel
through `useDetachedAgentStart` (Agents-panel Start, restore,
inbound-persona deploys) are intentionally left for separate changes.

## Testing

- `cargo test --lib` on desktop/src-tauri: 3054 passed; clippy `-D
warnings` + fmt clean
- Desktop unit tests: 5856 passed (the 5 failures are the pre-existing
`inboxReopenNavigation` / `useRetainedProjectGitViews` baseline, present
on origin/main); `tsc --noEmit` and biome clean
- Full mentions (87), channels (89), and community-rail (25) Playwright
smoke suites against `pnpm build:e2e` bundles, with 3× stress reruns of
each new spec
- The load-bearing regression specs were confirmed red on the pre-fix
code: publish-failure → zero starts and no false toast, the dedupe hold
(1 call vs 2), the fail-closed scope refusal, the rail-switch toast
fence, and the A→B→A retention spec (1 deploy vs 2)
- New unit coverage pins the queue contract (enqueue-time floors,
attach-seam queueing), the scope capture and verbatim relay-URL handoff,
the dedupe map's keying and settle-then-repermit behavior, the unscoped
refusal, the toast-scope mirror, the `{ agent, wrote }` contract, and
the replay-floor env layering on both spawn paths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 14:46:11 +10:00

88 lines
3.3 KiB
Rust

//! Convenience accessors over [`AppState`]'s lock-guarded fields.
//!
//! Kept apart from `app_state.rs`, which owns the struct, its builder, and the
//! identity-key resolution that populates it.
use nostr::Keys;
use crate::app_state::AppState;
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::ManagedAgentRuntimeKey;
impl AppState {
/// Lock the huddle state mutex, converting a poisoned-lock error to a String.
///
/// Convenience wrapper — replaces 15+ instances of
/// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the
/// huddle module.
pub fn huddle(&self) -> Result<std::sync::MutexGuard<'_, crate::huddle::HuddleState>, String> {
self.huddle_state.lock().map_err(|e| e.to_string())
}
pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option<SessionConfigCache> {
self.session_config_cache.lock().ok()?.get(key).cloned()
}
pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) {
if let Ok(mut map) = self.session_config_cache.lock() {
map.insert(key, cache);
}
}
pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) {
if let Ok(mut map) = self.session_config_cache.lock() {
map.remove(key);
}
}
pub fn clear_agent_session_caches(&self, pubkey: &str) {
if let Ok(mut map) = self.session_config_cache.lock() {
map.retain(|key, _| key.pubkey != pubkey);
}
}
/// Return the active identity keys if they are in a signable state.
///
/// Returns `Err` when the identity is in a lost state (`identity_lost`
/// — ephemeral key, user must re-import their nsec) or when the keyring
/// is locked (`keyring_locked` — key is held in a keyring that is
/// unavailable this boot). All signing and publish commands must call
/// this instead of locking `state.keys` directly, so that recovery mode
/// blocks publishing under an invalid or inaccessible identity.
pub fn signing_keys(&self) -> Result<Keys, String> {
if self
.identity_lost
.load(std::sync::atomic::Ordering::Acquire)
|| self
.keyring_locked
.load(std::sync::atomic::Ordering::Acquire)
{
return Err("identity is in recovery mode; event signing is disabled \
until the identity is restored and Buzz is relaunched"
.to_string());
}
self.keys
.lock()
.map_err(|e| e.to_string())
.map(|k| k.clone())
}
/// Emit the current huddle state to the frontend via Tauri event.
///
/// Acquires both locks (app_handle + huddle_state), clones a snapshot,
/// releases both, then emits. Best-effort — no-op if either lock is
/// poisoned or the app_handle hasn't been set yet.
pub fn emit_huddle_state_changed(&self) {
let app = match self.app_handle.lock() {
Ok(guard) => guard.clone(),
Err(_) => return,
};
let Some(app) = app else { return };
let snapshot = match self.huddle_state.lock() {
Ok(hs) => hs.clone(),
Err(_) => return,
};
crate::huddle::state::emit_huddle_state(&app, &snapshot);
}
}