From c936cdab8aa49e8a8f4da1f98a5bf550ff79bf34 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 11:13:36 +1000 Subject: [PATCH 01/18] feat(desktop): publish mention sends before waking agents Mentioning a stopped or undeployed managed agent used to block the send on the full agent start/deploy round-trip, so the message publish waited seconds behind process spawn. Detach the start instead (option 2, steps 1 and 2): the publish proceeds immediately and the wake runs fire-and-forget off the critical path. - useMentionSendFlow: start/deploy for mentioned managed agents now runs detached via a startAgentDetached callback; failures surface as a post-send toast instead of failing the send. Membership attach and access-policy relay writes stay synchronous so the harness's first kind-39002 read still sees the channel. A pending background start no longer gates the composer's next send. - attachManagedAgentToChannel / createChannelManagedAgent accept an opt-in detachedStart hook; other callers keep the awaited behavior. - Replay floor: the send captures its timestamp and passes it through start_managed_agent -> spawn_agent_child as BUZZ_ACP_REPLAY_FLOOR, so the spawned harness's startup watermark replays back past the just-published message. buzz-acp clamps the floor to now-15min (and ignores future floors) before deriving startup_watermark. - E2E: the five "starts it before sending" mentions specs now pin the new ordering (publish lands while start_managed_agent is held pending behind an injected delay) and the replayFloorUnix payload; new spec covers the post-send start-failure toast; the channels DM spec now pins that a failed start no longer drops the expanded DM. Step 3 (durable catch-up via event_mentions) and the revalidation dedupe / NIP-11 caching are intentionally left for follow-ups. Verified: cargo test -p buzz-acp --lib (835 passed), clippy + fmt on buzz-acp and desktop/src-tauri, desktop tsc --noEmit, desktop unit tests (5799 passed), and the mentions + channels Playwright smoke specs against a pnpm build:e2e bundle. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- crates/buzz-acp/src/config.rs | 17 +++ crates/buzz-acp/src/lib.rs | 75 +++++++++- desktop/src-tauri/src/commands/agents.rs | 7 +- .../src-tauri/src/managed_agents/restore.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 29 +++- .../src/managed_agents/runtime_commands.rs | 3 +- desktop/src/features/agents/channelAgents.ts | 33 +++-- desktop/src/features/agents/hooks.ts | 2 + .../messages/ui/useMentionSendFlow.ts | 43 +++++- desktop/src/shared/api/tauriManagedAgents.ts | 6 + desktop/tests/e2e/channels.spec.ts | 76 ++++------ desktop/tests/e2e/mentions.spec.ts | 140 ++++++++++++++++-- 12 files changed, 354 insertions(+), 78 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index e0e424f0185..5b7e27131ec 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -516,6 +516,15 @@ pub struct CliArgs { /// Requires `--lazy-pool`; ignored otherwise. 0 disables idle re-sleep. #[arg(long, env = "BUZZ_ACP_IDLE_POOL_SLEEP", default_value_t = 0)] pub idle_pool_sleep: u64, + + /// Unix-seconds replay floor for the startup watermark. A publish-first + /// mention send publishes the triggering message and then spawns this + /// harness, passing the send timestamp here so the first REQ replays past + /// that message however long the spawn takes. Floors older than 15 minutes + /// are clamped to 15 minutes before startup; floors in the future are + /// ignored (the watermark stays at startup time). + #[arg(long, env = "BUZZ_ACP_REPLAY_FLOOR")] + pub replay_floor: Option, } /// Merged NIP-01 subscription filter for a single channel. @@ -605,6 +614,12 @@ pub struct Config { /// woken lazy pool is torn back down to the empty-slot state. 0 = disabled. /// Only meaningful when `lazy_pool` is true. pub idle_pool_sleep_secs: u64, + /// Optional unix-seconds replay floor for the startup watermark + /// (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`), set by a publish-first + /// mention send so the first REQ replays past the already-published + /// triggering message. Clamped where consumed — see + /// `startup_watermark_with_floor`. + pub replay_floor_unix: Option, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, @@ -1185,6 +1200,7 @@ impl Config { exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, + replay_floor_unix: args.replay_floor, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, base_prompt_content, @@ -1560,6 +1576,7 @@ mod tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index af504a11768..d72d4cf482a 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2387,6 +2387,63 @@ mod idle_pool_sleep_tests { } } +/// Oldest a caller-supplied replay floor may reach back from startup. Bounds +/// the stale-event burst when a spawn request sat around (e.g. the desktop +/// slept between the send and this spawn actually running). +const REPLAY_FLOOR_MAX_AGE_SECS: u64 = 15 * 60; + +/// Resolve the startup watermark from process-start time and an optional +/// replay floor (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`). +/// +/// A publish-first mention send publishes the triggering message BEFORE this +/// harness spawns, so the watermark must reach back to the send timestamp for +/// the first REQ (`since = watermark − 5s`) to replay that message. Floors +/// older than [`REPLAY_FLOOR_MAX_AGE_SECS`] clamp to that bound; floors in +/// the future clamp to `now` (a skewed sender must not push the watermark +/// forward past startup and re-open the blind spot the watermark closes). +fn startup_watermark_with_floor(now_unix: u64, replay_floor: Option) -> u64 { + match replay_floor { + Some(floor) => floor.clamp(now_unix.saturating_sub(REPLAY_FLOOR_MAX_AGE_SECS), now_unix), + None => now_unix, + } +} + +#[cfg(test)] +mod replay_floor_tests { + use super::{startup_watermark_with_floor, REPLAY_FLOOR_MAX_AGE_SECS}; + + const NOW: u64 = 1_700_000_000; + + #[test] + fn no_floor_keeps_startup_time() { + assert_eq!(startup_watermark_with_floor(NOW, None), NOW); + } + + #[test] + fn recent_floor_moves_watermark_back_to_the_send_timestamp() { + // The publish-first case: message sent 4s before the harness booted. + assert_eq!(startup_watermark_with_floor(NOW, Some(NOW - 4)), NOW - 4); + } + + #[test] + fn stale_floor_clamps_to_the_max_age_bound() { + assert_eq!( + startup_watermark_with_floor(NOW, Some(NOW - REPLAY_FLOOR_MAX_AGE_SECS - 1)), + NOW - REPLAY_FLOOR_MAX_AGE_SECS + ); + } + + #[test] + fn future_floor_is_ignored() { + assert_eq!(startup_watermark_with_floor(NOW, Some(NOW + 60)), NOW); + } + + #[test] + fn early_epoch_now_does_not_underflow() { + assert_eq!(startup_watermark_with_floor(10, Some(0)), 0); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -2484,10 +2541,24 @@ async fn tokio_main() -> Result<()> { // the initial subscribe_since for channels discovered at startup. The Subscribe // handler falls back to subscribe_since when last_seen is None, closing the // blind spot between "agents ready" and "first REQ sent". - let startup_watermark: u64 = std::time::SystemTime::now() + // + // A publish-first mention send passes the triggering message's send + // timestamp as a replay floor (`--replay-floor` / `BUZZ_ACP_REPLAY_FLOOR`): + // the message is already on the relay when this process spawns, so the + // watermark must reach back to it for the first REQ to replay it — however + // long the spawn took. + let now_unix: u64 = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); + let startup_watermark = startup_watermark_with_floor(now_unix, config.replay_floor_unix); + if let Some(floor) = config.replay_floor_unix { + tracing::info!( + floor, + startup_watermark, + "applying replay floor to startup watermark" + ); + } let pubkey_hex = config.keys.public_key().to_hex(); @@ -8862,6 +8933,7 @@ mod build_mcp_servers_tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, @@ -9087,6 +9159,7 @@ mod error_outcome_emission_tests { exit_after_inactivity_secs: 0, lazy_pool: false, idle_pool_sleep_secs: 0, + replay_floor_unix: None, agent_owner: None, no_base_prompt: false, base_prompt_content: None, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 296123f470d..c22898b8612 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -209,6 +209,7 @@ pub(super) async fn start_local_agent_with_preflight( allow_fresh_create_start: bool, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, ) -> Result { let record_snapshot = { let _store_guard = state @@ -302,6 +303,7 @@ pub(super) async fn start_local_agent_with_preflight( &mut runtimes, Some(workspace_owner.as_str()), &workspace_relay_url, + replay_floor_unix, )?; save_managed_agents(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { @@ -753,7 +755,8 @@ pub async fn create_managed_agent( // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; let agent = if input.spawn_after_create && input.backend == BackendKind::Local { - match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await { + match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None, None).await + { Ok(agent) => agent, Err(error) => { let _store_guard = state @@ -862,6 +865,7 @@ pub async fn start_managed_agent( pubkey: String, expected_relay_url: Option, expected_signer_pubkey: Option, + replay_floor_unix: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -961,6 +965,7 @@ pub async fn start_managed_agent( false, expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), + replay_floor_unix, ) .await } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 1d7fb34120e..5b79ccac27f 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -344,6 +344,7 @@ pub async fn restore_managed_agents_on_launch( &key.relay_url, true, owner_hex_ref, + None, ) }) { Ok(process) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b10271ea1c7..9683461fc3b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -435,12 +435,19 @@ pub(crate) fn spawn_with_effort_proof( /// /// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy /// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`. +/// +/// `replay_floor_unix`: optional unix-seconds replay floor for the harness's +/// startup watermark (`BUZZ_ACP_REPLAY_FLOOR`). A publish-first mention send +/// 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, record: &ManagedAgentRecord, relay_url: &str, lazy: bool, owner_hex: Option<&str>, + replay_floor_unix: Option, ) -> Result { if let Some(error) = spawn_key_refusal(record) { return Err(error); @@ -565,6 +572,18 @@ pub fn spawn_agent_child( command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); + // Publish-first mention sends: the triggering message is already on the + // relay when this spawn runs, so hand the harness the send timestamp as a + // startup replay floor. Clear any ambient value on the None path so a + // floor from the parent environment can't leak into an unrelated spawn. + match replay_floor_unix { + Some(floor) => { + command.env("BUZZ_ACP_REPLAY_FLOOR", floor.to_string()); + } + None => { + command.env_remove("BUZZ_ACP_REPLAY_FLOOR"); + } + } command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -962,6 +981,7 @@ pub fn start_managed_agent_process( runtimes: &mut HashMap, owner_hex: Option<&str>, workspace_relay: &crate::relay::ScopedWorkspaceRelay, + replay_floor_unix: Option, ) -> Result<(), String> { let key = bound_runtime_key(record, workspace_relay)?; if let Some(runtime) = runtimes.get_mut(&key) { @@ -981,7 +1001,14 @@ pub fn start_managed_agent_process( // Scalar PIDs are migration-only and never establish pair liveness. record.runtime_pid = None; - let mut process = spawn_agent_child(app, record, &key.relay_url, false, owner_hex)?; + let mut process = spawn_agent_child( + app, + record, + &key.relay_url, + false, + owner_hex, + replay_floor_unix, + )?; let now = now_iso(); let receipt = super::ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 135224d01db..ba0f91c9f7a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -288,7 +288,8 @@ 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())?; + let mut process = + spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; let now = crate::util::now_iso(); let receipt = ManagedAgentRuntimeReceipt { key: key.clone(), diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 3387d135af1..5b7f312c685 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -36,6 +36,15 @@ export type AttachManagedAgentToChannelInput = { agent: ManagedAgent; role?: Exclude; ensureRunning?: boolean; + /** + * When set, a needed start/deploy is handed to this callback instead of + * being awaited: the attach resolves as soon as the membership write lands + * and the callback owns the fire-and-forget start, including surfacing its + * failure. The message-send path uses this so a publish never blocks on an + * agent start — the spawned harness replays the already-published message + * via its startup replay floor. + */ + detachedStart?: (agent: ManagedAgent) => void; }; export type AttachManagedAgentToChannelResult = { @@ -85,6 +94,9 @@ export type CreateChannelManagedAgentInput = { respondToAllowlist?: string[]; /** Skip reuse logic and always create a fresh agent instance. */ forceNewInstance?: boolean; + /** Detached start hook forwarded to the channel attach — see + * `AttachManagedAgentToChannelInput.detachedStart`. */ + detachedStart?: (agent: ManagedAgent) => void; }; export type CreateChannelManagedAgentResult = @@ -177,16 +189,16 @@ export async function attachManagedAgentToChannel( // pair — so this ensures the pair the caller is attaching to, never // another community's. const isRemote = input.agent.backend.type === "provider"; - if (isRemote && input.agent.status !== "deployed") { - agent = await startManagedAgent(input.agent.pubkey); - started = true; - } else if ( - !isRemote && - input.agent.status !== "running" && - input.agent.status !== "deployed" - ) { - agent = await startManagedAgent(input.agent.pubkey); - started = true; + const needsStart = isRemote + ? input.agent.status !== "deployed" + : input.agent.status !== "running" && input.agent.status !== "deployed"; + if (needsStart) { + if (input.detachedStart) { + input.detachedStart(input.agent); + } else { + agent = await startManagedAgent(input.agent.pubkey); + started = true; + } } } @@ -411,6 +423,7 @@ export async function createChannelManagedAgent( agent: provisioned.agent, role: input.role ?? "bot", ensureRunning: input.ensureRunning ?? true, + detachedStart: input.detachedStart, }); return { diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 3daf4fa78cc..ec1ccd262e8 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -594,6 +594,7 @@ export function useStartManagedAgentMutation() { pubkey: string; expectedRelayUrl?: string; expectedSignerPubkey?: string; + replayFloorUnix?: number; }, ) => typeof input === "string" @@ -601,6 +602,7 @@ export function useStartManagedAgentMutation() { : startManagedAgent(input.pubkey, { expectedRelayUrl: input.expectedRelayUrl, expectedSignerPubkey: input.expectedSignerPubkey, + replayFloorUnix: input.replayFloorUnix, }), onSuccess: (updated) => { queryClient.setQueryData( diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 021b4ea441a..c968062504e 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -131,6 +131,26 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); + const startAgentDetached = React.useCallback( + (agent: ManagedAgent) => { + // Publish-first: the send no longer waits for the agent start. The + // replay floor tells the spawned harness to replay at least back to + // this moment, so the about-to-publish message is inside its first + // subscription window however long the spawn takes. + const replayFloorUnix = Math.floor(Date.now() / 1000); + void startAgentMutation + .mutateAsync({ pubkey: agent.pubkey, replayFloorUnix }) + .catch((error: unknown) => { + toast.error( + `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${getErrorMessage( + error, + "Could not start agent.", + )}`, + ); + }); + }, + [startAgentMutation], + ); const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -178,13 +198,17 @@ export function useMentionSendFlow({ (!isProviderBackedAgent(readyAgent) && !isManagedAgentRunning(readyAgent)) ) { - await startAgentMutation.mutateAsync(readyAgent.pubkey); + startAgentDetached(readyAgent); } } else { + // The membership write must land before the publish (the harness + // only subscribes to channels it's a member of); only the start + // itself is detached. await attachAgentMutation.mutateAsync({ channelId: capturedChannelId, agent: readyAgent, role: "bot", + detachedStart: startAgentDetached, }); } pubkeys.push(pubkey); @@ -201,7 +225,7 @@ export function useMentionSendFlow({ getManagedAgentsByPubkey, getPersonas, mentions.memberPubkeys, - startAgentMutation, + startAgentDetached, ], ); const createMentionedPersonaAgents = React.useCallback( @@ -249,6 +273,7 @@ export function useMentionSendFlow({ model: persona.model ?? undefined, role: "bot", ensureRunning: true, + detachedStart: startAgentDetached, }; const result = shouldProvisionForDm ? await provisionPersonaAgentMutation.mutateAsync(input) @@ -282,6 +307,7 @@ export function useMentionSendFlow({ mentions.registerMentionPubkey, onPrepareSendChannel, provisionPersonaAgentMutation, + startAgentDetached, ], ); const clearComposer = React.useCallback(() => { @@ -488,8 +514,8 @@ export function useMentionSendFlow({ if (agentReadiness.errors.length > 0) { const message = agentReadiness.errors.length === 1 - ? `Could not start agent mention: ${agentReadiness.errors[0]}` - : `Could not start agent mentions: ${agentReadiness.errors.join( + ? `Could not prepare agent mention: ${agentReadiness.errors[0]}` + : `Could not prepare agent mentions: ${agentReadiness.errors.join( "; ", )}`; setNonMemberPromptError(message); @@ -950,12 +976,14 @@ export function useMentionSendFlow({ setNonMemberPromptError(null); }, []); return { + // Agent starts are detached (publish-first), so startAgentMutation.isPending + // deliberately does not gate the composer — a background start must not + // block the next send. isPreparingMentionSend: isMentionSendPending || isCompleteSendPending || attachAgentMutation.isPending || - createPersonaAgentMutation.isPending || - startAgentMutation.isPending, + createPersonaAgentMutation.isPending, nonMemberPromptProps: { canInvite: canInviteNonMembers, error: nonMemberPromptError, @@ -964,8 +992,7 @@ export function useMentionSendFlow({ isCompleteSendPending || addMembersMutation.isPending || attachAgentMutation.isPending || - createPersonaAgentMutation.isPending || - startAgentMutation.isPending, + createPersonaAgentMutation.isPending, names: pendingNonMemberNames, onDismiss: dismissNonMemberPrompt, onDoNothing: handleSendWithoutInviting, diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 8afd03c4a0f..f3443ace620 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -18,12 +18,18 @@ export async function startManagedAgent( /** Signer identity captured with the relay scope; the backend fails * closed when the active workspace identity no longer matches. */ expectedSignerPubkey?: string; + /** Unix-seconds replay floor for a publish-first mention send: the + * spawned harness's first REQ replays at least back to this moment, so + * the already-published triggering message lands in its window however + * long the spawn takes. Local spawns only; provider deploys ignore it. */ + replayFloorUnix?: number; }, ): Promise { const response = await invokeTauri("start_managed_agent", { pubkey, expectedRelayUrl: options?.expectedRelayUrl ?? null, expectedSignerPubkey: options?.expectedSignerPubkey ?? null, + replayFloorUnix: options?.replayFloorUnix ?? null, }); return fromRawManagedAgent(response); } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index c3303f2e630..7f4c0180fca 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1098,8 +1098,13 @@ test("drops an expanded DM after the first message fails", async ({ page }) => { ).toHaveAttribute("data-channel-id", retryChannelId ?? ""); }); -test("drops an expanded DM after agent startup fails", async ({ page }) => { - const retryMessage = "Retry after agent startup failed"; +test("publishes into an expanded DM even when agent startup fails", async ({ + page, +}) => { + // Agent starts are detached from the send: the message publishes into the + // expanded DM and the start failure surfaces as a post-send toast. Failures + // that still block the publish (and drop the expanded DM) are covered by + // the preceding "drops an expanded DM after the first message fails" spec. const startError = "Mock agent startup failed."; await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], @@ -1125,53 +1130,32 @@ test("drops an expanded DM after agent startup fails", async ({ page }) => { await page.keyboard.type(" before startup fails"); await page.getByTestId("send-message").click(); - await expect( - page.getByText(startError, { exact: false }).first(), - ).toBeVisible(); - await expect(input).toContainText("Fizz"); - - const commandsAfterFailure = await readCommandPayloadLog(page); - const openDmCallsAfterFailure = commandsAfterFailure.filter( - (entry) => entry.command === "open_dm", - ); - expect(openDmCallsAfterFailure).toHaveLength(2); - expect( - (openDmCallsAfterFailure.at(-1)?.payload as { pubkeys?: string[] }) - ?.pubkeys, - ).toEqual(expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey])); - expect( - (openDmCallsAfterFailure.at(-1)?.payload as { pubkeys?: string[] }) - ?.pubkeys, - ).toHaveLength(2); - - await input.fill(retryMessage); - const retryBaseline = commandsAfterFailure.length; - // The first send left the cursor parked over the bottom-right error toast, - // which overlaps the send button. Sonner pauses its dismiss timer while the - // toaster is hovered, so move the cursor away and let the transient toast - // clear before retrying — otherwise the retry click is intercepted for the - // full timeout. - await page.mouse.move(0, 0); - await expect(page.locator("[data-sonner-toast]")).toHaveCount(0, { - timeout: 10_000, - }); - await page.getByTestId("send-message").click(); - - await expect(page.getByTestId("chat-title")).toHaveText("charlie"); + // The message lands in the expanded DM despite the failed start. + await expect(page.getByTestId("chat-title")).toContainText("Fizz"); await expect(page.getByTestId("message-timeline")).toContainText( - retryMessage, + "before startup fails", ); - const retryCommands = (await readCommandPayloadLog(page)).slice( - retryBaseline, - ); - const retryOpenDm = retryCommands.find( - (entry) => entry.command === "open_dm", - ); - expect( - (retryOpenDm?.payload as { pubkeys?: string[] } | undefined)?.pubkeys, - ).toEqual([TEST_IDENTITIES.charlie.pubkey]); - await expect(page.getByTestId("chat-title")).not.toContainText("Fizz"); + // The start failure surfaces as a toast, and the sent text is not restored + // into the composer — the send succeeded, so there is nothing to retry. + // (The persistent agent audience may legitimately re-seed a "@Fizz" + // auto-mention, so only the message body proves there was no restore.) + await expect( + page.getByText(startError, { exact: false }).first(), + ).toBeVisible(); + await expect(input).not.toContainText("before startup fails"); + + const commands = await readCommandPayloadLog(page); + const lastOpenDm = commands + .filter((entry) => entry.command === "open_dm") + .at(-1); + const openDmPubkeys = ( + lastOpenDm?.payload as { pubkeys?: string[] } | undefined + )?.pubkeys; + expect(openDmPubkeys).toEqual( + expect.arrayContaining([TEST_IDENTITIES.charlie.pubkey]), + ); + expect(openDmPubkeys).toHaveLength(2); }); test("closes direct message results while opening", async ({ page }) => { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ddde6607ba6..2152dd72361 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1335,11 +1335,14 @@ test("selecting a managed agent mention inserts @Name into input", async ({ await expect(agentMentionChip).toHaveCSS("border-top-width", "0px"); }); -test("selecting a persona mention creates a channel agent before sending", async ({ +test("selecting a persona mention creates a channel agent before sending and starts it detached", async ({ page, }) => { await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], + // Far longer than the test runs: sign_event landing below proves the + // publish no longer waits for start_managed_agent to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -1401,11 +1404,19 @@ test("selecting a persona mention creates a channel agent before sending", async const commandsAfterSend = (await readCommandLog(page)).slice( baselineCommands.length, ); - const startIndex = commandsAfterSend.indexOf("start_managed_agent"); + const createIndex = commandsAfterSend.indexOf("create_managed_agent"); + const addIndex = commandsAfterSend.indexOf("add_channel_members"); const sendIndex = commandsAfterSend.indexOf("sign_event"); - expect(startIndex).toBeGreaterThanOrEqual(0); + expect(createIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeGreaterThanOrEqual(0); expect(sendIndex).toBeGreaterThanOrEqual(0); - expect(startIndex).toBeLessThan(sendIndex); + // Publish-first: creation and the membership write still precede the + // publish (the outgoing tags need the agent's pubkey, and the harness only + // subscribes to channels it is a member of), but the start is detached — + // sign_event landed while start_managed_agent was still pending behind the + // injected 45s delay, which the old start-blocking send could never do. + expect(createIndex).toBeLessThan(sendIndex); + expect(addIndex).toBeLessThan(sendIndex); const mentionChip = page .getByTestId("message-row") @@ -2378,7 +2389,7 @@ test("shared agents wait for initial directory authorization", async ({ }); }); -test("mentioning an in-channel stopped managed agent starts it before sending", async ({ +test("mentioning an in-channel stopped managed agent publishes first and starts it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2390,6 +2401,9 @@ test("mentioning an in-channel stopped managed agent starts it before sending", channelNames: ["general"], }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for start_managed_agent to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2404,18 +2418,36 @@ test("mentioning an in-channel stopped managed agent starts it before sending", await input.press("Enter"); await page.keyboard.type(" can you help?"); + const baselineCommands = await readCommandLog(page); const baselineStartCount = commandCount( - await readCommandLog(page), + baselineCommands, "start_managed_agent", ); + const baselineSignCount = commandCount(baselineCommands, "sign_event"); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; await page.getByTestId("send-message").click(); + // Publish-first: the message signs and renders while start_managed_agent + // is still pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(baselineSignCount); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), ) .toBeGreaterThan(baselineStartCount); + // The detached start carries a replay floor so the spawned harness's first + // REQ replays past the just-published message. + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect( + (startCall?.payload as { replayFloorUnix?: number } | undefined) + ?.replayFloorUnix, + ).toBeGreaterThan(0); + const mentionChip = page .getByTestId("message-row") .last() @@ -2423,7 +2455,52 @@ test("mentioning an in-channel stopped managed agent starts it before sending", await expect(mentionChip).toBeVisible(); }); -test("mentioning an in-channel provider managed agent deploys it before sending", async ({ +test("a detached agent start failure surfaces as a toast after the message sends", async ({ + page, +}) => { + const startError = "Mock agent startup failed."; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + startManagedAgentErrors: [startError], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @fizz"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" can you help?"); + + await page.getByTestId("send-message").click(); + + // The message still publishes — the start runs off the critical path. + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); + await expect(mentionChip).toBeVisible(); + + // The failed start surfaces as a post-send toast instead of blocking the + // send, and the sent text is not restored into the composer. (The + // persistent agent audience may legitimately re-seed an "@fizz" + // auto-mention, so only the message body proves there was no + // failed-send restore.) + await expect(page.getByText(startError, { exact: false })).toBeVisible(); + await expect(input).not.toContainText("can you help"); +}); + +test("mentioning an in-channel provider managed agent publishes first and deploys it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2440,6 +2517,9 @@ test("mentioning an in-channel provider managed agent deploys it before sending" }, }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for the deploy to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2454,12 +2534,19 @@ test("mentioning an in-channel provider managed agent deploys it before sending" await input.press("Enter"); await page.keyboard.type(" can you help?"); + const baselineCommands = await readCommandLog(page); const baselineStartCount = commandCount( - await readCommandLog(page), + baselineCommands, "start_managed_agent", ); + const baselineSignCount = commandCount(baselineCommands, "sign_event"); await page.getByTestId("send-message").click(); + // Publish-first: the message signs and renders while the deploy is still + // pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(baselineSignCount); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), @@ -2473,7 +2560,7 @@ test("mentioning an in-channel provider managed agent deploys it before sending" await expect(mentionChip).toBeVisible(); }); -test("mentioning a non-member managed agent adds and starts it before sending", async ({ +test("mentioning a non-member managed agent adds it before sending and starts it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2494,6 +2581,9 @@ test("mentioning a non-member managed agent adds and starts it before sending", respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey], }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for start_managed_agent to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2527,6 +2617,11 @@ test("mentioning a non-member managed agent adds and starts it before sending", commandCount(await readCommandLog(page), "add_channel_members"), ) .toBeGreaterThan(baselineAddCount); + // Publish-first: the message signs while start_managed_agent is still + // pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(commandCount(baselineCommands, "sign_event")); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), @@ -2545,9 +2640,16 @@ test("mentioning a non-member managed agent adds and starts it before sending", const startIndex = sendCommands.findIndex( (entry) => entry.command === "start_managed_agent", ); + const sendIndex = sendCommands.findIndex( + (entry) => entry.command === "sign_event", + ); expect(updateIndex).toBeGreaterThanOrEqual(0); expect(updateIndex).toBeLessThan(addIndex); expect(updateIndex).toBeLessThan(startIndex); + // The access-policy write and the membership write stay ahead of the + // publish; only the start itself is detached from the send. + expect(sendIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeLessThan(sendIndex); expect(sendCommands[updateIndex]?.payload).toMatchObject({ input: { pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY, @@ -2555,6 +2657,15 @@ test("mentioning a non-member managed agent adds and starts it before sending", respondToAllowlist: [], }, }); + // The detached start carries a replay floor so the spawned harness's first + // REQ replays past the just-published message. + expect( + ( + sendCommands[startIndex]?.payload as + | { replayFloorUnix?: number } + | undefined + )?.replayFloorUnix, + ).toBeGreaterThan(0); const mentionChip = page .getByTestId("message-row") @@ -2584,7 +2695,7 @@ test("mentioning a non-member managed agent adds and starts it before sending", }); }); -test("mentioning a non-member provider managed agent deploys it before sending", async ({ +test("mentioning a non-member provider managed agent adds it before sending and deploys it detached", async ({ page, }) => { await installMockBridge(page, { @@ -2600,6 +2711,9 @@ test("mentioning a non-member provider managed agent deploys it before sending", }, }, ], + // Far longer than the test runs: the publish landing below proves the + // send no longer waits for the deploy to resolve. + startManagedAgentDelayMs: 45_000, }); await page.goto("/"); await page.getByTestId("channel-general").click(); @@ -2623,6 +2737,7 @@ test("mentioning a non-member provider managed agent deploys it before sending", baselineCommands, "start_managed_agent", ); + const baselineSignCount = commandCount(baselineCommands, "sign_event"); await page.getByTestId("send-message").click(); await expect(page.getByRole("alertdialog")).toHaveCount(0); @@ -2632,6 +2747,11 @@ test("mentioning a non-member provider managed agent deploys it before sending", commandCount(await readCommandLog(page), "add_channel_members"), ) .toBeGreaterThan(baselineAddCount); + // Publish-first: the message signs and renders while the deploy is still + // pending behind the injected delay. + await expect + .poll(async () => commandCount(await readCommandLog(page), "sign_event")) + .toBeGreaterThan(baselineSignCount); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), From 70ed56f3b06893b6da830ba7adac86305d0b0e84 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 11:36:04 +1000 Subject: [PATCH 02/18] perf(desktop): dedupe send-path mention revalidation and cache NIP-11 self MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the common already-running-agent send, the ~1s of latency was not the agent wake but two identical revalidateMentionPubkeys passes (~8 sequential relay round-trips), each fronted by an uncached NIP-11 HTTP GET. This lands the independent quick win from the faster-agent-sends analysis on top of the publish-first change. - useMentionSendFlow: the publish-boundary pass now reuses the pre-side-effect pass's admitted result on the immediate path, and only re-validates when a deferred wait (background media upload, link-preview settlement) separated the two — preserving the #5681 authorization boundary where revocation can actually race the publish. Inputs are already normalized/deduped, so the substitution is behaviorally identical on the fast path. - fetch_relay_self_at: NIP-11 `self` lookups are now cached per relay URL in AppState for 5 minutes. Only verified Some values are cached; non-2xx responses and missing/malformed `self` stay retryable so an outage is never pinned for the TTL. Keying by URL keeps community switches from ever serving another relay's identity. - E2E: the two specs pinning revalidate_relay_agents at +2 per send now pin +1; a new spec pins the deferred-upload path still revalidating at the publish boundary (revocation injected mid-upload strips the p tag, +2 calls). Three new Rust tests pin cache hit, non-success no-cache, and TTL-expiry refetch. Verified: cargo test -p buzz-lib --lib (3008 passed), clippy + fmt on desktop/src-tauri, desktop tsc --noEmit, biome check, desktop unit tests (5799 passed), and the full mentions Playwright smoke suite (80/81; the one failure is the pre-existing under-load flake in the publish-first provider-deploy spec, green in isolation) plus a 3x stress rerun of the dedupe-affected specs. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/src-tauri/src/app_state.rs | 10 ++ .../src/commands/identity_archive.rs | 132 +++++++++++++++++- .../messages/ui/useMentionSendFlow.ts | 10 +- desktop/tests/e2e/mentions.spec.ts | 108 +++++++++++++- 4 files changed, 256 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 08028ddbe96..c25dbe3a16b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -129,6 +129,15 @@ pub struct AppState { /// bounded and letting a later leave correctly flip the channel back to /// `is_member=false`. pub pending_owned_channels: Mutex>, + /// NIP-11 `self` pubkeys keyed by relay WS URL, each with its fetch + /// instant. A relay's signing identity is effectively static, yet every + /// send-time agent revalidation used to re-GET the document — one of the + /// dominant costs of agent-mention send latency. Entries expire after + /// `identity_archive::RELAY_SELF_CACHE_TTL` so a relay-side key rotation + /// still converges. Keyed by URL, so switching communities can never serve + /// another relay's identity; only verified `Some` values are stored (an + /// outage or a document without `self` must stay retryable). + pub relay_self_cache: Mutex>, pub archive_db: crate::archive::ArchiveDb, } @@ -231,6 +240,7 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), + relay_self_cache: Mutex::new(HashMap::new()), archive_db: crate::archive::ArchiveDb::default(), } } diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index 0cc5679bf7b..bf66b761d32 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -336,14 +336,38 @@ pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await } +/// How long a fetched NIP-11 `self` pubkey stays valid in +/// [`AppState::relay_self_cache`]. The relay's signing identity changes only +/// on an operator-driven key rotation, so minutes of staleness are safe; the +/// TTL exists so even that rare rotation converges without an app restart. +pub(crate) const RELAY_SELF_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300); + +/// Read a still-fresh cached `self` pubkey for `relay_url`, if any. Fails open +/// (cache miss) on a poisoned lock — the fetch path never depends on the cache. +fn cached_relay_self(state: &AppState, relay_url: &str) -> Option { + let cache = state.relay_self_cache.lock().ok()?; + let (fetched_at, relay_self) = cache.get(relay_url)?; + (fetched_at.elapsed() < RELAY_SELF_CACHE_TTL).then(|| relay_self.clone()) +} + /// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL /// instead of re-resolving the workspace override. Used by /// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot /// query belong to the same captured relay target. +/// +/// Successful lookups are cached per relay URL for [`RELAY_SELF_CACHE_TTL`]: +/// send-time agent revalidation calls this on every agent-mention send, and +/// the uncached GET was a measurable slice of that latency. Only a verified +/// `Some` is cached — `Ok(None)` covers transient states (non-2xx status, a +/// document momentarily missing `self`) that must be re-tried, not pinned. pub(crate) async fn fetch_relay_self_at( state: &AppState, relay_url: &str, ) -> Result, String> { + if let Some(cached) = cached_relay_self(state, relay_url) { + return Ok(Some(cached)); + } + let http_url = relay_http_base_url(relay_url); let response = state .http_client @@ -367,6 +391,12 @@ pub(crate) async fn fetch_relay_self_at( }; if relay_self.len() == 64 && relay_self.chars().all(|c| c.is_ascii_hexdigit()) { + if let Ok(mut cache) = state.relay_self_cache.lock() { + cache.insert( + relay_url.to_string(), + (std::time::Instant::now(), relay_self.clone()), + ); + } Ok(Some(relay_self)) } else { Ok(None) @@ -476,7 +506,6 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - #[cfg(not(target_os = "windows"))] use std::sync::atomic::{AtomicUsize, Ordering}; /// Counting [`NestRegenTrigger`] double: records how many times the core @@ -575,6 +604,107 @@ mod tests { ); } + /// Spawn a loopback NIP-11 endpoint that counts hits and serves `self_hex` + /// (or a bare 503 when `self_hex` is `None`). Returns the `ws://` base and + /// the shared hit counter. + async fn spawn_nip11_relay(self_hex: Option) -> (String, std::sync::Arc) { + use axum::{http::StatusCode, routing::get, Json, Router}; + + let hits = std::sync::Arc::new(AtomicUsize::new(0)); + let route_hits = hits.clone(); + let router = Router::new().route( + "/", + get(move || { + let self_hex = self_hex.clone(); + let route_hits = route_hits.clone(); + async move { + route_hits.fetch_add(1, Ordering::SeqCst); + match self_hex { + Some(self_hex) => Ok(Json(serde_json::json!({ "self": self_hex }))), + None => Err(StatusCode::SERVICE_UNAVAILABLE), + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + (format!("ws://{addr}"), hits) + } + + /// The send path revalidates agent mentions on every agent-mention send, + /// and each pass used to re-GET the NIP-11 document. A second lookup + /// within the TTL must be served from [`AppState::relay_self_cache`] + /// without touching the relay. RED-on-revert: drop the `cached_relay_self` + /// check and the hit counter reads 2. + #[tokio::test] + async fn relay_self_second_fetch_within_ttl_is_served_from_cache() { + let self_hex = Keys::generate().public_key().to_hex(); + let (relay_url, hits) = spawn_nip11_relay(Some(self_hex.clone())).await; + let state = crate::app_state::build_app_state(); + + let first = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + let second = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + + assert_eq!(first.as_deref(), Some(self_hex.as_str())); + assert_eq!(second.as_deref(), Some(self_hex.as_str())); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "the second in-TTL lookup must not re-GET the NIP-11 document" + ); + } + + /// A non-success NIP-11 response yields `Ok(None)` and MUST stay + /// retryable: caching the outage would blank the agent directory (and + /// every send-time revalidation) for the full TTL after one relay blip. + #[tokio::test] + async fn relay_self_non_success_response_is_not_cached() { + let (relay_url, hits) = spawn_nip11_relay(None).await; + let state = crate::app_state::build_app_state(); + + assert_eq!(fetch_relay_self_at(&state, &relay_url).await.unwrap(), None); + assert_eq!(fetch_relay_self_at(&state, &relay_url).await.unwrap(), None); + assert_eq!( + hits.load(Ordering::SeqCst), + 2, + "a failed lookup must retry the relay, never pin the outage" + ); + } + + /// An entry older than [`RELAY_SELF_CACHE_TTL`] must be refetched so a + /// relay-side key rotation converges without an app restart. + #[tokio::test] + async fn relay_self_expired_cache_entry_is_refetched() { + let self_hex = Keys::generate().public_key().to_hex(); + let (relay_url, hits) = spawn_nip11_relay(Some(self_hex.clone())).await; + let state = crate::app_state::build_app_state(); + // `Instant` is opaque, so expiry is staged by planting an already-stale + // entry rather than sleeping through the TTL. Skip (vacuous pass) if + // the platform clock cannot represent an instant that far back. + let Some(stale_instant) = std::time::Instant::now() + .checked_sub(RELAY_SELF_CACHE_TTL + std::time::Duration::from_secs(1)) + else { + return; + }; + state + .relay_self_cache + .lock() + .unwrap() + .insert(relay_url.clone(), (stale_instant, "b".repeat(64))); + + let refreshed = fetch_relay_self_at(&state, &relay_url).await.unwrap(); + + assert_eq!(refreshed.as_deref(), Some(self_hex.as_str())); + assert_eq!( + hits.load(Ordering::SeqCst), + 1, + "an expired entry must be refetched from the relay" + ); + } + /// Spec test-vector regression for gotcha #3: the NIP-OA preimage subject /// is the *target/agent* pubkey, not the request signer. The vectors in /// `docs/nips/NIP-IA.md` §Test Vectors fix concrete values; verifying the diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index c968062504e..03bc2a98211 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -564,8 +564,16 @@ export function useMentionSendFlow({ ); if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; + // Mention authorization was already established by the pre-side-effect + // pass above. Re-validate at the publish boundary only when a deferred + // wait (background media upload, link-preview settlement) separated + // that pass from this publish and authorization could have been + // revoked meanwhile; on the immediate path a second pass would repeat + // the same relay round-trips with the same inputs. const revalidatedMentionPubkeys = - await mentions.revalidateMentionPubkeys(mentionPubkeys); + preparedUpload || draft.preparedLinkPreviews + ? await mentions.revalidateMentionPubkeys(mentionPubkeys) + : admittedMentionPubkeys; if (signal?.aborted || isSendCancelled()) return; const finalTagsWithAgentAddress = [ ...finalOutgoingTags, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 2152dd72361..728f353e5d4 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1919,8 +1919,10 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); + // Exactly one targeted revalidation: the pre-side-effect pass doubles as the + // publish-boundary check on the immediate (no deferred upload/preview) path. expect(commandCount(commands, "revalidate_relay_agents")).toBe( - commandCount(baselineCommands, "revalidate_relay_agents") + 2, + commandCount(baselineCommands, "revalidate_relay_agents") + 1, ); expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), @@ -2029,7 +2031,7 @@ test("targeted revocation before send causes no agent side effects", async ({ .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); expect(commandCount(commands, "revalidate_relay_agents")).toBe( - commandCount(baselineCommands, "revalidate_relay_agents") + 2, + commandCount(baselineCommands, "revalidate_relay_agents") + 1, ); expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), @@ -2046,6 +2048,108 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("deferred-upload sends revalidate agent authorization at the publish boundary", async ({ + page, +}) => { + // The immediate send path reuses the pre-side-effect authorization pass, but + // a background media upload can hold the publish open for arbitrarily long — + // authorization revoked during that window must still strip the p tag. This + // pins the second, publish-boundary revalidation on the deferred path. + await installMockBridge(page, { + deferredComposerUploads: true, + uploadDelayMs: 1_500, + uploadDescriptors: [ + { + url: `https://mock.relay/media/${"c".repeat(64)}.mp4`, + sha256: "c".repeat(64), + size: 1024 * 1024, + type: "video/mp4", + uploaded: Math.floor(Date.now() / 1000), + filename: "upload-race.mp4", + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + + // Only video files queue until send; anything else uploads at attach time. + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.alloc(1024 * 1024, 1), + mimeType: "video/mp4", + name: "upload-race.mp4", + }); + await expect( + page.getByTestId("composer-queued-media-attachment"), + ).toBeVisible(); + + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + + // Revoke after the pre-side-effect pass has been admitted but while the + // deferred upload (1.5s mock delay) still holds the publish open. + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill( + "mock directory revoked during deferred upload", + ); + }); + + const outgoingContent = `@quinn hello\n![video](https://mock.relay/media/${"c".repeat(64)}.mp4)`; + await expect + .poll(() => readOutgoingMentionPubkeys(page, outgoingContent)) + .not.toBeNull(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, outgoingContent)) + .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); +}); + test("selected relay agents are invited as bots before sending", async ({ page, }) => { From ce12de8515f2347bc5deff31c2f492e3cf522bdc Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 12:14:38 +1000 Subject: [PATCH 03/18] fix(desktop): thread replay floor into provider deploys and revalidate after send-path side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two review warnings on the publish-first mention-send stack (f3df4be4f, f0a9915b5), keeping its design intact. 1) Provider replay-floor gap — fixed via the preferred route: thread the floor into the deploy payload. start_managed_agent's provider branch now passes replay_floor_unix through deploy_to_provider, which injects it into the payload REBUILT after the deploy lock as launch.policy_env.BUZZ_ACP_REPLAY_FLOOR — the same env var a local spawn sets — so a cold remote harness's startup watermark replays back past the already-published triggering mention instead of booting blind to it. No synchronous fallback was needed: launch.policy_env already transports per-spawn env (effort level, model, session title) to providers, so the deploy handoff carries the floor cleanly. Any same-named launch.env key is stripped when a caller floor is present (that tier later-wins remotely); the floor is invocation state, never persisted, so redeploys without one never inherit a stale floor. Other deploy callers (create flow, access reconciliation, inbound personas) pass None and are unchanged. 2) Revalidation-at-publish window: the single-pass dedupe now reuses the pre-side-effect authorization pass only when nothing separated it from the publish. completeSend tracks whether an awaited relay round-trip actually ran in between — DM expansion (onPrepareSendChannel), managed-agent access-policy or membership attach writes (reported by ensureManagedAgentMentionsReady; a matching policy that returns the same record does not count), or active-huddle enrollment (sync_agents_to_active_huddle's matched_active_huddle; with no active huddle it returns before touching the relay) — and re-validates at the publish boundary when any did. The common send (member agent, no expansion, no huddle) stays single-pass. 3) startAgentDetached now depends on startAgentMutation.mutateAsync (stable) instead of the whole mutation object (fresh each render, repo gotcha #6), keeping the downstream useCallback chain reference-stable. Tests: five Rust unit tests pin the payload injection (policy_env ride, case-insensitive launch.env shadow strip, None passthrough, tolerance for missing launch/policy_env); the in-channel provider E2E spec now also pins replayFloorUnix on the detached deploy invoke. New E2E specs pin publish-boundary revalidation for the attach path (revocation injected while a delayed membership write holds the publish open strips the p tag; +2 passes with no update_managed_agent) and for a live huddle on the channel (+2 passes, +1 huddle sync); the managed relay-agent DM-expansion spec pins +2; the two fast-path specs keep pinning +1. Verified: cargo test --lib on desktop/src-tauri (3013 passed), clippy -D warnings + fmt, desktop tsc --noEmit, biome, desktop unit tests (5799 passed), full mentions Playwright smoke (81/83 — both failures are pre-existing under-load flakes, green in isolation), a 3x stress rerun of the new specs, and the five DM-expansion channels specs. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/src-tauri/src/commands/agents.rs | 6 +- .../src/commands/agents/provider_access.rs | 1 + .../src/commands/agents/provider_deploy.rs | 136 ++++++++++++- .../src/commands/personas/inbound.rs | 1 + .../messages/ui/useMentionSendFlow.ts | 76 +++++-- desktop/src/shared/api/tauriManagedAgents.ts | 3 +- desktop/tests/e2e/channels.spec.ts | 7 + desktop/tests/e2e/mentions.spec.ts | 185 +++++++++++++++++- 8 files changed, 393 insertions(+), 22 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c22898b8612..99db75fce25 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -817,7 +817,7 @@ pub async fn create_managed_agent( build_deploy_payload(&app, &state, rec)? }; match deploy_to_provider( - &app, &state, &pubkey, id, config, agent_json, None, None, None, + &app, &state, &pubkey, id, config, agent_json, None, None, None, None, ) .await { @@ -978,6 +978,9 @@ pub async fn start_managed_agent( // against the payload rebuilt after the deploy lock — the exact // payload invoked — so a switch racing the lock wait cannot deploy // the agent into the new tenant on behalf of a stale callback. + // The replay floor rides along so a publish-first mention send's + // remote harness replays past the already-published message, same + // as the local spawn path. deploy_to_provider( &app, &state, @@ -988,6 +991,7 @@ pub async fn start_managed_agent( cached_binary_path.as_deref(), expected_relay_url.as_deref(), expected_signer_pubkey.as_deref(), + replay_floor_unix, ) .await?; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 34c06d25919..69c2d2f7f83 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -100,6 +100,7 @@ pub(crate) async fn reconcile_on_workspace_apply( cached_binary_path.as_deref(), None, None, + None, ) .await { diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index bb56a67eaa4..d7d31991f25 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -31,6 +31,13 @@ use super::build_deploy_payload; /// deployment fails closed instead of deploying a stale start into the new /// tenant under the new tenant's owner identity. `None` preserves the /// unscoped behavior for callers without a tenant boundary. +/// +/// `replay_floor_unix`: optional unix-seconds replay floor from a +/// publish-first mention send. It is injected into the rebuilt payload's +/// `launch.policy_env` as `BUZZ_ACP_REPLAY_FLOOR`, so the remote harness's +/// startup watermark replays back past the already-published triggering +/// message exactly like a local spawn. Per-invocation only — never persisted +/// on the record, so later redeploys do not carry a stale floor. #[allow(clippy::too_many_arguments)] pub(crate) async fn deploy_to_provider( app: &AppHandle, @@ -42,6 +49,7 @@ pub(crate) async fn deploy_to_provider( _cached_binary_path: Option<&str>, expected_relay_url: Option<&str>, expected_signer_pubkey: Option<&str>, + replay_floor_unix: Option, ) -> Result<(), String> { let deploy_lock = { let mut locks = state @@ -58,7 +66,7 @@ pub(crate) async fn deploy_to_provider( // The payload may have waited behind another deployment. Rebuild it from // the current record so the final provider invocation always carries the // newest saved policy rather than the stale snapshot captured by its caller. - let (provider_id, config, cached_binary_path, agent_json) = { + let (provider_id, config, cached_binary_path, mut agent_json) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -83,6 +91,9 @@ pub(crate) async fn deploy_to_provider( // Assert the caller's captured scope against THIS payload — the exact // value invoked below — not the pre-lock snapshot its caller validated. assert_payload_scope(&agent_json, expected_relay_url, expected_signer_pubkey)?; + // The floor is invocation state, not record state, so the post-lock + // rebuild cannot restore it — inject it into the payload actually invoked. + apply_replay_floor(&mut agent_json, replay_floor_unix); // Resolve via discovered candidates only. Cached path must match BOTH // "is a discovered candidate" AND "belongs to this provider_id". A tampered // record cannot redirect deploys to a different provider's binary. @@ -159,6 +170,56 @@ fn assert_payload_scope( Ok(()) } +/// Inject a caller-supplied replay floor into the deploy payload so the +/// remote harness consumes it exactly like a local spawn: as the +/// `BUZZ_ACP_REPLAY_FLOOR` environment variable. The floor rides +/// `launch.policy_env` (tier 1); any same-named key in `launch.env` (tier 2) +/// is stripped because that tier later-wins and a persisted user value must +/// not shadow this send's floor. With no caller floor the payload is left +/// untouched — a user-supplied `launch.env` value passes through, and plain +/// redeploys never carry a stale floor. +fn apply_replay_floor(agent_json: &mut serde_json::Value, replay_floor_unix: Option) { + let Some(floor) = replay_floor_unix else { + return; + }; + let Some(launch) = agent_json + .get_mut("launch") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + if let Some(env) = launch + .get_mut("env") + .and_then(serde_json::Value::as_object_mut) + { + let shadowed: Vec = env + .keys() + .filter(|key| key.eq_ignore_ascii_case("BUZZ_ACP_REPLAY_FLOOR")) + .cloned() + .collect(); + for key in shadowed { + env.remove(&key); + } + } + match launch + .get_mut("policy_env") + .and_then(serde_json::Value::as_object_mut) + { + Some(policy_env) => { + policy_env.insert( + "BUZZ_ACP_REPLAY_FLOOR".to_string(), + serde_json::Value::String(floor.to_string()), + ); + } + None => { + launch.insert( + "policy_env".to_string(), + serde_json::json!({ "BUZZ_ACP_REPLAY_FLOOR": floor.to_string() }), + ); + } + } +} + fn policy_matches_payload( record: &crate::managed_agents::ManagedAgentRecord, deployed_agent_json: &serde_json::Value, @@ -283,6 +344,79 @@ mod tests { assert_payload_scope(&serde_json::json!({}), None, None).unwrap(); } + // ── apply_replay_floor: publish-first floor threading into the payload ── + + fn launch_payload() -> serde_json::Value { + serde_json::json!({ + "launch": { + "env": { "KEEP_ME": "yes" }, + "policy_env": { "BUZZ_ACP_LAZY_POOL": "true" }, + }, + }) + } + + #[test] + fn caller_replay_floor_rides_launch_policy_env() { + // A publish-first mention send's floor must reach the remote harness + // as BUZZ_ACP_REPLAY_FLOOR, exactly like a local spawn's env. + let mut payload = launch_payload(); + apply_replay_floor(&mut payload, Some(1_756_600_000)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "1756600000" + ); + assert_eq!(payload["launch"]["env"]["KEEP_ME"], "yes"); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_LAZY_POOL"], + "true" + ); + } + + #[test] + fn caller_replay_floor_strips_user_env_shadow() { + // launch.env later-wins over policy_env in the remote three-tier + // model; a persisted user floor must not shadow this send's floor. + let mut payload = launch_payload(); + payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"] = "1".into(); + payload["launch"]["env"]["buzz_acp_replay_floor"] = "2".into(); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "42" + ); + assert!(payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"].is_null()); + assert!(payload["launch"]["env"]["buzz_acp_replay_floor"].is_null()); + assert_eq!(payload["launch"]["env"]["KEEP_ME"], "yes"); + } + + #[test] + fn no_caller_floor_leaves_payload_untouched() { + // Create-flow deploys and plain redeploys carry no floor: user env + // passthrough stands and no stale floor is invented. + let mut payload = launch_payload(); + payload["launch"]["env"]["BUZZ_ACP_REPLAY_FLOOR"] = "1".into(); + let before = payload.clone(); + apply_replay_floor(&mut payload, None); + assert_eq!(payload, before); + } + + #[test] + fn replay_floor_tolerates_payload_without_launch() { + let mut payload = serde_json::json!({}); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!(payload, serde_json::json!({})); + } + + #[test] + fn replay_floor_creates_missing_policy_env() { + let mut payload = serde_json::json!({ "launch": {} }); + apply_replay_floor(&mut payload, Some(42)); + assert_eq!( + payload["launch"]["policy_env"]["BUZZ_ACP_REPLAY_FLOOR"], + "42" + ); + } + #[test] fn successful_deploy_acknowledges_pending_policy() { let mut record = record(); diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index b4438b67b7a..fe9fcbe406a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -131,6 +131,7 @@ pub async fn reconcile_inbound_persona_event( cached_binary_path.as_deref(), None, None, + None, ) .await .map_err(|error| { diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 03bc2a98211..b63b6a6766f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -131,6 +131,10 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); + // Depend on the stable mutateAsync method, not the mutation result object — + // React Query returns a fresh object each render, which would re-create this + // callback and every useCallback chained off it (repo gotcha #6). + const startAgentMutateAsync = startAgentMutation.mutateAsync; const startAgentDetached = React.useCallback( (agent: ManagedAgent) => { // Publish-first: the send no longer waits for the agent start. The @@ -138,18 +142,19 @@ export function useMentionSendFlow({ // this moment, so the about-to-publish message is inside its first // subscription window however long the spawn takes. const replayFloorUnix = Math.floor(Date.now() / 1000); - void startAgentMutation - .mutateAsync({ pubkey: agent.pubkey, replayFloorUnix }) - .catch((error: unknown) => { - toast.error( - `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${getErrorMessage( - error, - "Could not start agent.", - )}`, - ); - }); + void startAgentMutateAsync({ + pubkey: agent.pubkey, + replayFloorUnix, + }).catch((error: unknown) => { + toast.error( + `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${getErrorMessage( + error, + "Could not start agent.", + )}`, + ); + }); }, - [startAgentMutation], + [startAgentMutateAsync], ); const ensureManagedAgentMentionsReady = React.useCallback( async ( @@ -162,6 +167,7 @@ export function useMentionSendFlow({ return { errors: [] as string[], pubkeys: [] as string[], + wroteRelayState: false, }; } const [managedAgentsByPubkey, personas] = await Promise.all([ @@ -180,6 +186,9 @@ export function useMentionSendFlow({ ]); const errors: string[] = []; const pubkeys: string[] = []; + // Reported to the caller so the publish boundary knows whether awaited + // relay writes separated it from the pre-side-effect authorization pass. + let wroteRelayState = false; for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); if (!agent) continue; @@ -191,6 +200,11 @@ export function useMentionSendFlow({ {}, personas.find((persona) => persona.id === agent.personaId), ); + if (readyAgent !== agent) { + // A distinct record back means the access-policy update hit the + // relay; a matching policy returns `agent` itself without writing. + wroteRelayState = true; + } if (participants.has(pubkey)) { if ( (isProviderBackedAgent(readyAgent) && @@ -210,6 +224,7 @@ export function useMentionSendFlow({ role: "bot", detachedStart: startAgentDetached, }); + wroteRelayState = true; } pubkeys.push(pubkey); } catch (error) { @@ -218,7 +233,11 @@ export function useMentionSendFlow({ ); } } - return { errors, pubkeys: uniqueNormalizedPubkeys(pubkeys) }; + return { + errors, + pubkeys: uniqueNormalizedPubkeys(pubkeys), + wroteRelayState, + }; }, [ attachAgentMutation, @@ -487,8 +506,15 @@ export function useMentionSendFlow({ ...agentMentionPubkeys, ]); let sendChannelId = draft.capturedChannelId; + // The pre-side-effect authorization pass above only stands at the + // publish boundary while nothing separates the two. Track whether any + // awaited relay round-trip (DM-expansion channel resolution, agent + // membership/access-policy writes, huddle enrollment) actually ran in + // between — a revocation can land during those waits (#5681). + let relaySideEffectsRan = false; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); + relaySideEffectsRan = true; if (isSendCancelled()) return restoreComposerAfterFailure(); if (!sendChannelId) { return restoreComposerAfterFailure(); @@ -506,6 +532,9 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); + if (agentReadiness.wroteRelayState) { + relaySideEffectsRan = true; + } if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); @@ -524,10 +553,19 @@ export function useMentionSendFlow({ } if (preparedAgentPubkeys.length > 0 && sendChannelId) { try { - await invokeTauri("sync_agents_to_active_huddle", { + const huddleSync = await invokeTauri<{ + matched_active_huddle: boolean; + added: string[]; + }>("sync_agents_to_active_huddle", { channelId: sendChannelId, agentPubkeys: preparedAgentPubkeys, }); + if (huddleSync.matched_active_huddle) { + // A matched huddle means the sync did relay work (membership + // read at minimum, enrollment writes for missing agents); no + // active huddle returns before touching the relay. + relaySideEffectsRan = true; + } if (isSendCancelled()) return restoreComposerAfterFailure(); } catch (error) { if (isSendCancelled()) return restoreComposerAfterFailure(); @@ -565,13 +603,15 @@ export function useMentionSendFlow({ if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; // Mention authorization was already established by the pre-side-effect - // pass above. Re-validate at the publish boundary only when a deferred - // wait (background media upload, link-preview settlement) separated - // that pass from this publish and authorization could have been - // revoked meanwhile; on the immediate path a second pass would repeat + // pass above. Re-validate at the publish boundary only when something + // separated that pass from this publish — a deferred wait (background + // media upload, link-preview settlement) or an awaited relay side + // effect (DM expansion, membership/access-policy writes, huddle + // enrollment) — since authorization could have been revoked during + // the gap. On the remaining immediate path a second pass would repeat // the same relay round-trips with the same inputs. const revalidatedMentionPubkeys = - preparedUpload || draft.preparedLinkPreviews + preparedUpload || draft.preparedLinkPreviews || relaySideEffectsRan ? await mentions.revalidateMentionPubkeys(mentionPubkeys) : admittedMentionPubkeys; if (signal?.aborted || isSendCancelled()) return; diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index f3443ace620..ed7e053f259 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -21,7 +21,8 @@ export async function startManagedAgent( /** Unix-seconds replay floor for a publish-first mention send: the * spawned harness's first REQ replays at least back to this moment, so * the already-published triggering message lands in its window however - * long the spawn takes. Local spawns only; provider deploys ignore it. */ + * long the spawn takes. Local spawns receive it as process env; provider + * deploys carry it in the payload's launch.policy_env. */ replayFloorUnix?: number; }, ): Promise { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 7f4c0180fca..9db4370b7cd 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -935,6 +935,13 @@ test("routes a managed relay-agent mention from an existing DM to the expanded c expect(sendCommands.map((entry) => entry.command)).not.toContain( "add_channel_members", ); + // The awaited DM expansion is a relay round-trip between the + // pre-side-effect authorization pass and the publish, so the publish + // boundary re-validates instead of reusing the earlier pass. + expect( + sendCommands.filter((entry) => entry.command === "revalidate_relay_agents") + .length, + ).toBe(2); }); test("does not reroute an expanded DM after the user navigates away", async ({ diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 728f353e5d4..5802f5a1c52 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -33,6 +33,7 @@ const PROFILE_ONLY_AGENT_PUBKEY = "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; const OWNED_AGENT_PROFILE_PUBKEY = "1212121212121212121212121212121212121212121212121212121212121212"; +const HUDDLE_EPHEMERAL_CHANNEL_ID = "3f9f2c4e-8b7a-4b1c-9d2e-5a6f7c8d9e0f"; const SYSTEM_MESSAGE_KIND = 40099; const DM_THREAD_AGENT_MENTION_ERROR_TEXT = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; @@ -1920,7 +1921,8 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ const commands = await readCommandLog(page); // Exactly one targeted revalidation: the pre-side-effect pass doubles as the - // publish-boundary check on the immediate (no deferred upload/preview) path. + // publish-boundary check on the fast path — no deferred upload/preview and + // no relay-writing side effect (member agent, no DM expansion, no huddle). expect(commandCount(commands, "revalidate_relay_agents")).toBe( commandCount(baselineCommands, "revalidate_relay_agents") + 1, ); @@ -2150,6 +2152,175 @@ test("deferred-upload sends revalidate agent authorization at the publish bounda ); }); +test("sends that attach a mentioned agent revalidate at the publish boundary", async ({ + page, +}) => { + // The awaited membership write for a non-member managed agent is a relay + // round-trip between the pre-side-effect authorization pass and the publish + // — authorization revoked during that window must still strip the p tag. + await installMockBridge(page, { + managedAgents: [ + { + pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "running", + // Already matching the reusable-agent policy: no update_managed_agent + // write below, so the attach write alone re-opens the window. + respondTo: "owner-only", + respondToAllowlist: [], + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("@fizz"); + const fizzRow = autocomplete(page).locator("button", { hasText: "fizz" }); + await expect(fizzRow).toBeVisible(); + await expect(fizzRow.getByText("not in channel")).toBeVisible(); + await fizzRow.click(); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn @fizz hello"); + + // Hold the attach's membership write open so the revocation below lands + // inside the pass-to-publish window. + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.addChannelMembersDelayMs = 1_500; + }); + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + + // Revoke quinn after the pre-side-effect pass has been admitted but while + // the attach still holds the publish open. + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn @fizz hello")) + .not.toBeNull(); + const outgoingPubkeys = await readOutgoingMentionPubkeys( + page, + "@quinn @fizz hello", + ); + expect(outgoingPubkeys).toContain(OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY); + expect(outgoingPubkeys).not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + // The policy already matched, so the second pass was owed to the membership + // write alone. + expect(commandCount(commands, "update_managed_agent")).toBe( + commandCount(baselineCommands, "update_managed_agent"), + ); +}); + +test("sends that enroll agents into an active huddle revalidate at the publish boundary", async ({ + page, +}) => { + // With a huddle live on the channel, the awaited huddle enrollment is a + // relay round-trip between the authorization pass and the publish, so the + // publish boundary re-validates instead of reusing the earlier pass. + await installMockBridge(page, { + huddle: { + parentChannelId: GENERAL_CHANNEL_ID, + ephemeralChannelId: HUDDLE_EPHEMERAL_CHANNEL_ID, + members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }], + }, + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const commands = await readCommandLog(page); + expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe( + commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1, + ); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); +}); + test("selected relay agents are invited as bots before sending", async ({ page, }) => { @@ -2644,6 +2815,7 @@ test("mentioning an in-channel provider managed agent publishes first and deploy "start_managed_agent", ); const baselineSignCount = commandCount(baselineCommands, "sign_event"); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; await page.getByTestId("send-message").click(); // Publish-first: the message signs and renders while the deploy is still @@ -2657,6 +2829,17 @@ test("mentioning an in-channel provider managed agent publishes first and deploy ) .toBeGreaterThan(baselineStartCount); + // The detached deploy carries the replay floor too — the backend threads it + // into the provider payload's launch.policy_env so the remote harness + // replays past the just-published message like a local spawn. + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect( + (startCall?.payload as { replayFloorUnix?: number } | undefined) + ?.replayFloorUnix, + ).toBeGreaterThan(0); + const mentionChip = page .getByTestId("message-row") .last() From 3c71ea20e81fbe8a9893691e0a581ac47970a888 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 13:17:22 +1000 Subject: [PATCH 04/18] feat(desktop): add send-perf instrumentation across the message-send path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send path had zero logging on either side of the bridge, so "the send still feels slow after publish-first" could only be answered by guessing which awaited step dominated the spinner. Add info-level, per-send-click timing on both halves so one test run attributes the latency. - New `sendPerfLog.ts`: `createSendPerfTimer` wraps each awaited step and emits one `[send-perf] completeSend` summary. `useMentionSendFlow`'s `completeSend` — the whole spinner window — now times revalidate1, managedAgentsLookup, prepareSendChannel, ensureAgentsReady, huddleSync, resolvePreviewTags, revalidate2 and publish, alongside the flags that decide which of them run at all: channelType, mention/addressed-agent counts, isReply, hasAttachments, hasLinkPreviews, wroteRelayState, matchedActiveHuddle, detachedStarts, and which trigger (upload / linkPreviews / relaySideEffects) fired the second revalidation. The summary emits from the `finally`, so a send that bails early still reports the steps it reached. - New `send_perf.rs`: `log` + a `Phase` stopwatch, plus a `log_send_perf` command that mirrors the frontend summary into the same stderr stream. WKWebView drops console output logged before a Web Inspector attaches, and `tracing::info!` is silent here (the desktop binary installs no subscriber), so a plain `just dev` terminal is the only place both halves of one send can be read in order. A missing `[send-perf]` line also proves the running app predates this build. - Backend phases, same `buzz-desktop: send-perf:` prefix: the directory rebuild (relay-self lookup, membership query + page count, concurrent 10100/kind-0 batches, managed-policy batch, and whether the caller was the send path or a full autocomplete rebuild); NIP-11 `self` cache hit vs miss with the fetch cost, which directly verifies that cache; the send command split into event build vs submit; the shared submit split into rate-limiter wait vs HTTP round trip — the limiter being the one hidden sleeper nothing previously exposed; and `start_managed_agent` entry with its replay floor, so the stream shows the detached wake landing after the publish. - `detachedStart` is routed through a counting wrapper so the summary reports whether the send fired an agent wake at all. - Two Rust tests pin `Phase` resolution and monotonicity; six JS tests pin value passthrough, recording on a thrown step, step-name accumulation, fact merging, idempotent finish, and the total spanning its steps. The fast-path mentions spec now also pins exactly one `log_send_perf` per send, and the E2E bridge mocks the command. Behavior is unchanged: the revalidation-trigger refactor evaluates the same three conditions in the same order, and the probe swallows its own errors so it can never fail a send. Note: the repository file-size ratchet is red on this branch for app_state.rs, agents.rs, managed_agents/runtime.rs and useMentionSendFlow.ts. All four were already over before this change; this commit keeps identity_archive.rs under the limit it would otherwise have crossed, but does not split the pre-existing four. Verified: cargo test --lib on desktop/src-tauri (3015 passed), clippy -D warnings + fmt, desktop tsc --noEmit, biome, desktop unit tests (5805 passed), and the mentions + channels Playwright smoke suites (171/172 — the one failure is the pre-existing under-load flake in the publish-first provider-deploy spec, green 3x in isolation), plus a 3x stress rerun of the spec carrying the new assertion. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../agent_discovery/relay_directory.rs | 40 ++++++- desktop/src-tauri/src/commands/agents.rs | 12 ++ .../src/commands/identity_archive.rs | 6 + desktop/src-tauri/src/commands/messages.rs | 14 +++ desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/relay/submit.rs | 14 +++ desktop/src-tauri/src/send_perf.rs | 77 ++++++++++++ .../features/messages/ui/sendPerfLog.test.mjs | 103 ++++++++++++++++ .../src/features/messages/ui/sendPerfLog.ts | 94 +++++++++++++++ .../messages/ui/useMentionSendFlow.ts | 113 +++++++++++++----- desktop/src/testing/e2eBridge.ts | 5 + desktop/tests/e2e/mentions.spec.ts | 6 + 12 files changed, 450 insertions(+), 36 deletions(-) create mode 100644 desktop/src-tauri/src/send_perf.rs create mode 100644 desktop/src/features/messages/ui/sendPerfLog.test.mjs create mode 100644 desktop/src/features/messages/ui/sendPerfLog.ts diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index eac91f5b71d..57f05acb4c3 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -93,21 +93,26 @@ pub(super) fn advance_relay_cursor(filter: &mut serde_json::Value, page: &[nostr filter["before_id"] = serde_json::json!(last.id.to_hex()); } +/// Returns the events plus the number of relay pages it took to read them — +/// membership paging is sequential, so the page count is the multiplier on this +/// phase's contribution to send latency. async fn query_all_relay_pages( state: &AppState, mut filter: serde_json::Value, -) -> Result, String> { +) -> Result<(Vec, usize), String> { filter["limit"] = serde_json::json!(RELAY_DIRECTORY_PAGE_SIZE); let mut events = Vec::new(); + let mut pages = 0; loop { let page = query_relay(state, &[filter.clone()]).await?; + pages += 1; let done = page.len() < RELAY_DIRECTORY_PAGE_SIZE; if !done { advance_relay_cursor(&mut filter, &page); } events.extend(page); if done { - return Ok(events); + return Ok((events, pages)); } } } @@ -129,10 +134,15 @@ async fn list_relay_agents_for_selection( requested_pubkeys: Option<&std::collections::HashSet>, channel_id: Option<&str>, ) -> Result, String> { + // Every agent-mention send runs this rebuild at least once, and its phases + // are sequential, so each one is directly on the send's critical path. + let rebuild = crate::send_perf::Phase::start(); let viewer_pubkey = current_user_pubkey(state)?; + let relay_self = crate::send_perf::Phase::start(); let relay_pubkey = identity_archive::fetch_relay_self(state) .await? .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; + let relay_self_ms = relay_self.ms(); // Owned identities are relay state, even when this Desktop has never run // them or they have not joined a channel yet. Owner-authored coordinates @@ -145,7 +155,7 @@ async fn list_relay_agents_for_selection( if let Some(requested_pubkeys) = requested_pubkeys { owned_filter["#d"] = serde_json::json!(requested_pubkeys); } - let owned_events = query_all_relay_pages(state, owned_filter) + let (owned_events, _owned_pages) = query_all_relay_pages(state, owned_filter) .await .map_err(|error| format!("relay owned-agent query failed: {error}"))?; let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); @@ -161,9 +171,11 @@ async fn list_relay_agents_for_selection( if let Some(channel_id) = channel_id { membership_filter["#d"] = serde_json::json!([channel_id]); } - let membership_events = query_all_relay_pages(state, membership_filter) + let membership = crate::send_perf::Phase::start(); + let (membership_events, membership_pages) = query_all_relay_pages(state, membership_filter) .await .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; + let membership_ms = membership.ms(); let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( &membership_events, &relay_pubkey, @@ -172,6 +184,21 @@ async fn list_relay_agents_for_selection( if let Some(requested_pubkeys) = requested_pubkeys { member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); } + // `requested=all` is the autocomplete-facing full rebuild; a number is the + // send path revalidating exactly the mentions it is about to publish. + let requested = + requested_pubkeys.map_or_else(|| "all".to_string(), |pubkeys| pubkeys.len().to_string()); + let log_rebuild = |candidates: usize, batches_ms: f64, policy_ms: f64| { + crate::send_perf::log( + "relay_directory", + &format!( + "requested={requested} candidates={candidates} membership_pages={membership_pages} \ + relay_self_ms={relay_self_ms:.1} membership_ms={membership_ms:.1} \ + batches_ms={batches_ms:.1} policy_ms={policy_ms:.1} total_ms={:.1}", + rebuild.ms() + ), + ); + }; let candidate_pubkeys: Vec = member_agent_channel_ids .keys() .cloned() @@ -180,6 +207,7 @@ async fn list_relay_agents_for_selection( .into_iter() .collect(); if candidate_pubkeys.is_empty() { + log_rebuild(0, 0.0, 0.0); return Ok(Vec::new()); } @@ -189,6 +217,7 @@ async fn list_relay_agents_for_selection( // phases, so its runtime-directory and owner-profile phases below stay // within the ceiling even though `try_join!` runs them concurrently. let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); + let batches = crate::send_perf::Phase::start(); let (directory_events, profile_events) = tokio::try_join!( query_filter_batches( state, @@ -203,6 +232,7 @@ async fn list_relay_agents_for_selection( "relay agent owner-profile query failed", ), )?; + let batches_ms = batches.ms(); // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current @@ -210,6 +240,7 @@ async fn list_relay_agents_for_selection( // the authentic policy out of a bounded result page. let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); + let policy = crate::send_perf::Phase::start(); let managed_agent_events = query_filter_batches( state, &semaphore, @@ -217,6 +248,7 @@ async fn list_relay_agents_for_selection( "relay agent managed-policy query failed", ) .await?; + log_rebuild(candidate_pubkeys.len(), batches_ms, policy.ms()); let mut agents = nostr_convert::relay_agents_from_directory_events( &directory_events, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 99db75fce25..aa6c1abcd7d 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -869,6 +869,18 @@ pub async fn start_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + // Publish-first sends detach this start, so where this line lands relative + // to `send_channel_message` in the stream is the proof the wake really is + // off the critical path; `replay_floor` is what puts the already-published + // mention inside the spawned harness's first subscription window. + crate::send_perf::log( + "start_managed_agent", + &format!( + "pubkey={} replay_floor={}", + pubkey.chars().take(8).collect::(), + replay_floor_unix.map_or_else(|| "none".to_string(), |floor| floor.to_string()) + ), + ); // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index bf66b761d32..d2bc0d2c6e9 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -365,9 +365,13 @@ pub(crate) async fn fetch_relay_self_at( relay_url: &str, ) -> Result, String> { if let Some(cached) = cached_relay_self(state, relay_url) { + crate::send_perf::log("relay_self", "cache=hit"); return Ok(Some(cached)); } + // A miss fronts an agent revalidation pass with an uncached HTTP GET — the + // exact send-path cost the cache exists to remove, so log either outcome. + let fetch = crate::send_perf::Phase::start(); let http_url = relay_http_base_url(relay_url); let response = state .http_client @@ -376,6 +380,8 @@ pub(crate) async fn fetch_relay_self_at( .send() .await .map_err(|e| classify_request_error(&e))?; + let fetch_ms = fetch.ms(); + crate::send_perf::log("relay_self", &format!("cache=miss fetch_ms={fetch_ms:.1}")); if !response.status().is_success() { return Ok(None); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1e221b6bd18..bd45061aac6 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -457,6 +457,10 @@ pub async fn send_channel_message( let mut resolved_root: Option = None; + // Replies (`is_reply=true` below) resolve their thread root against the + // relay before the event can be built, so a "slow publish" may really be an + // extra read rather than the write. + let build = crate::send_perf::Phase::start(); let builder = match kind_num { buzz_core_pkg::kind::KIND_FORUM_POST => events::build_forum_post( channel_uuid, @@ -523,8 +527,18 @@ pub async fn send_channel_message( // Submit through the base resolved (and scope-checked) above and the // identity snapshotted (and signer-checked) above — a re-resolve or key // re-read here would reopen the mid-command switch window. + let build_ms = build.ms(); + let submit = crate::send_perf::Phase::start(); let (result, created_at) = submit_event_at_created_at(builder, &state, &relay_base, &signing_keys).await?; + crate::send_perf::log( + "send_channel_message", + &format!( + "kind={kind_num} is_reply={} build_ms={build_ms:.1} submit_ms={:.1}", + parent_event_id.is_some(), + submit.ms() + ), + ); let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..bf1977c56ea 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ mod relay; mod relay_admission; mod reset; mod secret_store; +mod send_perf; mod shutdown; mod team_catalog; mod templates; @@ -521,6 +522,7 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + send_perf::log_send_perf, terminal_runtime::terminal_attach, terminal_runtime::terminal_detach, terminal_runtime::terminal_close, diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index b6a5703fd96..7737a1f6e27 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -22,12 +22,18 @@ pub async fn submit_signed_event_at_with_keys( if event.pubkey != keys.public_key() { return Err("signed event does not match the publishing identity".to_string()); } + // The client-side admission gate can park a publish for seconds after a + // relay 429, and nothing else surfaces that wait — split it from the HTTP + // round trip so a slow send can be attributed to one or the other. + let gate = crate::send_perf::Phase::start(); crate::relay_admission::wait_for_rate_limit().await; + let rate_limit_wait_ms = gate.ms(); let url = format!("{}/events", api_base_url.trim_end_matches('/')); let body_bytes = event.as_json().into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; + let http = crate::send_perf::Phase::start(); let response = state .http_client .post(&url) @@ -37,6 +43,14 @@ pub async fn submit_signed_event_at_with_keys( .send() .await .map_err(|e| classify_request_error(&e))?; + crate::send_perf::log( + "submit_event", + &format!( + "kind={} rate_limit_wait_ms={rate_limit_wait_ms:.1} http_ms={:.1}", + event.kind.as_u16(), + http.ms() + ), + ); if !response.status().is_success() { return Err(relay_error_message(response).await); diff --git a/desktop/src-tauri/src/send_perf.rs b/desktop/src-tauri/src/send_perf.rs new file mode 100644 index 00000000000..8482d03f248 --- /dev/null +++ b/desktop/src-tauri/src/send_perf.rs @@ -0,0 +1,77 @@ +//! Timing probe for the message-send path. +//! +//! The send spinner spans every awaited step between the composer clearing and +//! the relay accepting the event, and until now none of those steps logged +//! anything on either side of the bridge — so "the send still feels slow" could +//! only be answered by guessing. These lines name the phase that dominated. +//! +//! The desktop binary installs no `tracing` subscriber, so output goes straight +//! to stderr behind the repo's `buzz-desktop:` prefix; `tracing::info!` here +//! would be silent. Lines are emitted once per send (never per keystroke), so +//! the probe stays on by default without tripping the render-perf measurement +//! guidance in AGENTS.md. +//! +//! [`log_send_perf`] is the frontend's mirror into this same stream. WKWebView +//! drops `console` output produced before a Web Inspector attaches, so the +//! composer's `[send-perf]` summary would otherwise be invisible in a terminal +//! running `just dev` — and impossible to read in order against the backend +//! phases that explain it. + +use std::time::Instant; + +/// Emit one line: `buzz-desktop: send-perf: `. +/// +/// `fields` is free-form `key=value` text; keeping the prefix and scope fixed +/// is what makes a `just dev` terminal greppable down to a single send. +pub fn log(scope: &str, fields: &str) { + eprintln!("buzz-desktop: send-perf: {scope} {fields}"); +} + +/// Millisecond stopwatch for one phase of the send path. +pub struct Phase(Instant); + +impl Phase { + pub fn start() -> Self { + Self(Instant::now()) + } + + /// Elapsed milliseconds to a tenth — finer than the differences being + /// chased, and stable enough to compare two runs by eye. + pub fn ms(&self) -> f64 { + (self.0.elapsed().as_secs_f64() * 10_000.0).round() / 10.0 + } +} + +/// Write the frontend's `[send-perf]` summary into the backend stderr stream. +/// +/// Called fire-and-forget from the composer; `payload` is the already-encoded +/// JSON summary, reproduced verbatim so the two halves of one send read as one +/// record. Async so the write never lands on the UI thread. +#[tauri::command] +pub async fn log_send_perf(label: String, payload: String) { + log(&label, &payload); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase_reports_elapsed_milliseconds_at_tenth_resolution() { + let phase = Phase::start(); + let ms = phase.ms(); + + assert!(ms >= 0.0, "elapsed must not be negative: {ms}"); + // A tenth-resolution value survives a round-trip through tenths. + assert_eq!(ms, (ms * 10.0).round() / 10.0); + } + + #[test] + fn phase_is_monotonic_across_reads() { + let phase = Phase::start(); + let first = phase.ms(); + let second = phase.ms(); + + assert!(second >= first, "{second} < {first}"); + } +} diff --git a/desktop/src/features/messages/ui/sendPerfLog.test.mjs b/desktop/src/features/messages/ui/sendPerfLog.test.mjs new file mode 100644 index 00000000000..48ac98e1afc --- /dev/null +++ b/desktop/src/features/messages/ui/sendPerfLog.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createSendPerfTimer } from "./sendPerfLog.ts"; + +function captureSink() { + const emitted = []; + return { + emitted, + sink: (label, payload) => emitted.push({ label, payload }), + }; +} + +test("a step's resolved value passes straight through", async () => { + const { emitted, sink } = captureSink(); + const timer = createSendPerfTimer("completeSend", {}, sink); + + const value = await timer.step("revalidate1", async () => ["abc"]); + timer.finish(); + + assert.deepEqual(value, ["abc"]); + assert.equal(emitted.length, 1); + assert.equal(emitted[0].label, "completeSend"); + assert.equal(typeof emitted[0].payload.steps.revalidate1, "number"); +}); + +test("a throwing step still records its time and rethrows", async () => { + const { emitted, sink } = captureSink(); + const timer = createSendPerfTimer("completeSend", {}, sink); + + await assert.rejects( + timer.step("publish", async () => { + throw new Error("relay rejected event"); + }), + /relay rejected event/, + ); + timer.finish(); + + // A failed send is exactly when the timing matters, so the step must appear. + assert.equal(typeof emitted[0].payload.steps.publish, "number"); +}); + +test("repeating a step name accumulates rather than overwrites", async () => { + const { emitted, sink } = captureSink(); + const timer = createSendPerfTimer("completeSend", {}, sink); + + await timer.step("revalidate", async () => null); + await timer.step("revalidate", async () => null); + timer.finish(); + + assert.equal(Object.keys(emitted[0].payload.steps).length, 1); + assert.ok(emitted[0].payload.steps.revalidate >= 0); +}); + +test("facts merge across construction, note, and finish", async () => { + const { emitted, sink } = captureSink(); + const timer = createSendPerfTimer( + "completeSend", + { channelType: "dm" }, + sink, + ); + + timer.note({ revalidateTrigger: null, detachedStarts: 0 }); + timer.note({ revalidateTrigger: "relaySideEffects" }); + timer.finish({ detachedStarts: 1 }); + + const { payload } = emitted[0]; + assert.equal(payload.channelType, "dm"); + assert.equal(payload.revalidateTrigger, "relaySideEffects"); + assert.equal(payload.detachedStarts, 1); +}); + +test("finish is idempotent so a finally block cannot double-report", () => { + const { emitted, sink } = captureSink(); + const timer = createSendPerfTimer("completeSend", {}, sink); + + timer.finish(); + timer.finish({ detachedStarts: 9 }); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0].payload.detachedStarts, undefined); +}); + +test("the total spans the whole timer, not just its steps", async () => { + const { emitted, sink } = captureSink(); + const timer = createSendPerfTimer("completeSend", {}, sink); + + await timer.step("revalidate1", async () => null); + await timer.step("publish", async () => null); + timer.finish(); + + const { payload } = emitted[0]; + const stepTotal = Object.values(payload.steps).reduce( + (sum, ms) => sum + ms, + 0, + ); + // Sequential steps, so the total covers them both — allow a tenth of slack + // for the rounding each recorded value already went through. + assert.ok( + payload.totalMs + 0.2 >= stepTotal, + `totalMs ${payload.totalMs} < steps ${stepTotal}`, + ); +}); diff --git a/desktop/src/features/messages/ui/sendPerfLog.ts b/desktop/src/features/messages/ui/sendPerfLog.ts new file mode 100644 index 00000000000..5b6b0e8032c --- /dev/null +++ b/desktop/src/features/messages/ui/sendPerfLog.ts @@ -0,0 +1,94 @@ +/** + * One-summary-per-send timing probe for the composer's send path. + * + * The send spinner spans every awaited step between the composer clearing and + * the relay accepting the event, and none of those steps logged anything — so + * "the send still feels slow" could only be answered by guessing which step + * dominated. `createSendPerfTimer` wraps each awaited step and emits a single + * `[send-perf]` line naming the winner, alongside the flags that decide which + * steps ran at all (deferred upload, link previews, relay side effects). + * + * The summary is mirrored into the backend's stderr stream through the + * `log_send_perf` command: WKWebView drops `console` output produced before a + * Web Inspector attaches, and the Rust half of the same send already logs its + * phases there. A terminal running `just dev` then shows both halves in order + * with no inspector attached — which also makes a missing `[send-perf]` line + * proof that the running app predates this build. + * + * These fire once per send click, never per keystroke, so they stay on by + * default without disturbing the render-perf measurement guidance in AGENTS.md. + */ + +import { invokeTauri } from "@/shared/api/tauri"; + +/** Non-timing values recorded with a send: counts, flags, the branch taken. */ +export type SendPerfFacts = Record; + +export type SendPerfPayload = SendPerfFacts & { + totalMs: number; + /** Awaited step name to milliseconds, in the order the steps first ran. */ + steps: Record; +}; + +/** Destination for a finished summary. Overridden in tests. */ +export type SendPerfSink = (label: string, payload: SendPerfPayload) => void; + +export type SendPerfTimer = { + /** Time one awaited step. Its resolved value — or thrown error — passes through. */ + step(name: string, run: () => Promise): Promise; + /** Record a fact about this send. Later notes win on a repeated key. */ + note(facts: SendPerfFacts): void; + /** Emit the summary. Repeat calls are ignored, so `finally` blocks are safe. */ + finish(facts?: SendPerfFacts): void; +}; + +function now(): number { + return typeof performance === "undefined" ? Date.now() : performance.now(); +} + +/** Tenths of a millisecond — finer than the differences being chased. */ +function roundMs(elapsed: number): number { + return Math.round(elapsed * 10) / 10; +} + +export const defaultSendPerfSink: SendPerfSink = (label, payload) => { + console.info(`[send-perf] ${label}`, payload); + void invokeTauri("log_send_perf", { + label, + payload: JSON.stringify(payload), + }).catch(() => { + // A probe must never be able to fail — or even warn about — a send. + }); +}; + +export function createSendPerfTimer( + label: string, + facts: SendPerfFacts = {}, + sink: SendPerfSink = defaultSendPerfSink, +): SendPerfTimer { + const startedAt = now(); + const steps: Record = {}; + const recorded: SendPerfFacts = { ...facts }; + let finished = false; + return { + async step(name, run) { + const stepStartedAt = now(); + try { + return await run(); + } finally { + // Accumulate rather than overwrite: a step that runs twice in one send + // should read as its total contribution, not just the last attempt. + steps[name] = roundMs((steps[name] ?? 0) + (now() - stepStartedAt)); + } + }, + note(next) { + Object.assign(recorded, next); + }, + finish(next) { + if (finished) return; + finished = true; + Object.assign(recorded, next); + sink(label, { ...recorded, totalMs: roundMs(now() - startedAt), steps }); + }, + }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index b63b6a6766f..83e1962d5f1 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -25,6 +25,7 @@ import { type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; +import { createSendPerfTimer } from "./sendPerfLog"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; @@ -165,6 +166,7 @@ export function useMentionSendFlow({ ) => { if (!capturedChannelId || mentionPubkeys.length === 0) { return { + detachedStarts: 0, errors: [] as string[], pubkeys: [] as string[], wroteRelayState: false, @@ -189,6 +191,13 @@ export function useMentionSendFlow({ // Reported to the caller so the publish boundary knows whether awaited // relay writes separated it from the pre-side-effect authorization pass. let wroteRelayState = false; + // Reported for the send-perf summary: an agent wake is detached, so a + // send that fired one should still read as fast as one that did not. + let detachedStarts = 0; + const countDetachedStart = (agent: ManagedAgent) => { + detachedStarts += 1; + startAgentDetached(agent); + }; for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); if (!agent) continue; @@ -212,7 +221,7 @@ export function useMentionSendFlow({ (!isProviderBackedAgent(readyAgent) && !isManagedAgentRunning(readyAgent)) ) { - startAgentDetached(readyAgent); + countDetachedStart(readyAgent); } } else { // The membership write must land before the publish (the harness @@ -222,7 +231,7 @@ export function useMentionSendFlow({ channelId: capturedChannelId, agent: readyAgent, role: "bot", - detachedStart: startAgentDetached, + detachedStart: countDetachedStart, }); wroteRelayState = true; } @@ -234,6 +243,7 @@ export function useMentionSendFlow({ } } return { + detachedStarts, errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), wroteRelayState, @@ -376,6 +386,16 @@ export function useMentionSendFlow({ if (isSendCancelled()) return draft.preparedLinkPreviews?.release(); isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); + // `completeSend` is the whole spinner window: it starts with the composer + // clearing and ends after the relay accepts the event. + const perf = createSendPerfTimer("completeSend", { + channelType, + mentions: mentionPubkeys.length, + addressedAgents: draft.addressedAgentPubkeys.length, + isReply: draft.capturedThreadContext != null, + hasAttachments: draft.queuedAttachments.length > 0, + hasLinkPreviews: draft.preparedLinkPreviews != null, + }); const preparedUpload = draft.queuedAttachments.length > 0 ? prepareBackgroundMediaUpload(draft.queuedAttachments) @@ -474,7 +494,9 @@ export function useMentionSendFlow({ let uploadStarted = false; try { const admittedMentionPubkeys = uniqueNormalizedPubkeys( - await mentions.revalidateMentionPubkeys(mentionPubkeys), + await perf.step("revalidate1", () => + mentions.revalidateMentionPubkeys(mentionPubkeys), + ), ); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) return persistPreflightDraft(); @@ -484,7 +506,10 @@ export function useMentionSendFlow({ (pubkey) => admittedMentionPubkeySet.has(pubkey), ), ); - const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + const managedAgentsByPubkey = await perf.step( + "managedAgentsLookup", + getManagedAgentsByPubkey, + ); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); @@ -513,7 +538,9 @@ export function useMentionSendFlow({ // between — a revocation can land during those waits (#5681). let relaySideEffectsRan = false; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { - sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); + sendChannelId = await perf.step("prepareSendChannel", () => + onPrepareSendChannel(preparedAgentPubkeys), + ); relaySideEffectsRan = true; if (isSendCancelled()) return restoreComposerAfterFailure(); if (!sendChannelId) { @@ -524,14 +551,20 @@ export function useMentionSendFlow({ return; } } - const agentReadiness = await ensureManagedAgentMentionsReady( - managedMentionPubkeys.filter( - (pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)), + const agentReadiness = await perf.step("ensureAgentsReady", () => + ensureManagedAgentMentionsReady( + managedMentionPubkeys.filter( + (pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)), + ), + sendChannelId ?? "", + onPrepareSendChannel ? preparedAgentPubkeys : [], + [...managedAgentsByPubkey.values()], ), - sendChannelId ?? "", - onPrepareSendChannel ? preparedAgentPubkeys : [], - [...managedAgentsByPubkey.values()], ); + perf.note({ + wroteRelayState: agentReadiness.wroteRelayState, + detachedStarts: agentReadiness.detachedStarts, + }); if (agentReadiness.wroteRelayState) { relaySideEffectsRan = true; } @@ -553,12 +586,17 @@ export function useMentionSendFlow({ } if (preparedAgentPubkeys.length > 0 && sendChannelId) { try { - const huddleSync = await invokeTauri<{ - matched_active_huddle: boolean; - added: string[]; - }>("sync_agents_to_active_huddle", { - channelId: sendChannelId, - agentPubkeys: preparedAgentPubkeys, + const huddleSync = await perf.step("huddleSync", () => + invokeTauri<{ + matched_active_huddle: boolean; + added: string[]; + }>("sync_agents_to_active_huddle", { + channelId: sendChannelId, + agentPubkeys: preparedAgentPubkeys, + }), + ); + perf.note({ + matchedActiveHuddle: huddleSync.matched_active_huddle, }); if (huddleSync.matched_active_huddle) { // A matched huddle means the sync did relay work (membership @@ -595,10 +633,8 @@ export function useMentionSendFlow({ ), ]), ); - const finalOutgoingTags = await resolvePreviewTags( - draft, - mediaTags, - outgoingTags, + const finalOutgoingTags = await perf.step("resolvePreviewTags", () => + resolvePreviewTags(draft, mediaTags, outgoingTags), ); if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; @@ -610,10 +646,19 @@ export function useMentionSendFlow({ // enrollment) — since authorization could have been revoked during // the gap. On the remaining immediate path a second pass would repeat // the same relay round-trips with the same inputs. - const revalidatedMentionPubkeys = - preparedUpload || draft.preparedLinkPreviews || relaySideEffectsRan - ? await mentions.revalidateMentionPubkeys(mentionPubkeys) - : admittedMentionPubkeys; + const revalidateTrigger = preparedUpload + ? "upload" + : draft.preparedLinkPreviews + ? "linkPreviews" + : relaySideEffectsRan + ? "relaySideEffects" + : null; + perf.note({ revalidateTrigger }); + const revalidatedMentionPubkeys = revalidateTrigger + ? await perf.step("revalidate2", () => + mentions.revalidateMentionPubkeys(mentionPubkeys), + ) + : admittedMentionPubkeys; if (signal?.aborted || isSendCancelled()) return; const finalTagsWithAgentAddress = [ ...finalOutgoingTags, @@ -622,13 +667,15 @@ export function useMentionSendFlow({ revalidatedMentionPubkeys, ), ]; - await send( - finalContent, - revalidatedMentionPubkeys, - finalTagsWithAgentAddress, - sendChannelId, - draft.capturedThreadContext, - draft.preparedLinkPreviews != null, + await perf.step("publish", () => + send( + finalContent, + revalidatedMentionPubkeys, + finalTagsWithAgentAddress, + sendChannelId, + draft.capturedThreadContext, + draft.preparedLinkPreviews != null, + ), ); if (signal?.aborted || isSendCancelled()) return; const sentMentionPubkeys = new Set( @@ -707,6 +754,7 @@ export function useMentionSendFlow({ restoreComposerAfterFailure(); throw error; } finally { + perf.finish(); if (draft.preparedLinkPreviews) { activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); } @@ -719,6 +767,7 @@ export function useMentionSendFlow({ } }, [ + channelType, clearComposer, contentRef, drafts, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 63ae9f7df83..3a72d84bf1d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11956,6 +11956,11 @@ export function maybeInstallE2eTauriMocks() { await emitMockHuddleState(); return structuredClone(settings); } + // Fire-and-forget stderr mirror of the composer's `[send-perf]` summary. + // The real command only writes a log line, so the mock is a no-op — it + // exists so send specs do not trip the unsupported-command guard below. + case "log_send_perf": + return null; case "sync_agents_to_active_huddle": { const request = payload as { channelId?: string; diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 5802f5a1c52..e318a35f076 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1929,6 +1929,12 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), ); + // The send-perf probe mirrors exactly one summary per send. Zero would mean + // the instrumentation went silent; two would mean a step was double-counted + // or `completeSend`'s `finally` reported more than once. + expect(commandCount(commands, "log_send_perf")).toBe( + commandCount(baselineCommands, "log_send_perf") + 1, + ); }); test("managed agents keep their p tag when relay discovery fails before send", async ({ From 24b1e6633e480cd68305b5420cfc15eb7f7df9ec Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 10:02:47 +1000 Subject: [PATCH 05/18] fix(desktop): assert the replay floor after user env on local spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spawn_agent_child` wrote BUZZ_ACP_REPLAY_FLOOR near the top of the command build, but the fully-layered user env (`descriptor.env`) is written last — "so user-explicit values win over Buzz-set env" — and the key is not in RESERVED_ENV_KEYS. A persona/global/agent env entry named BUZZ_ACP_REPLAY_FLOOR therefore overrode this send's floor, and defeated the `env_remove` on the None path too. A saved future-dated value clamps to `now` in `startup_watermark_with_floor`, so the harness booted blind to the just-published mention that triggered the spawn — the precise failure the floor exists to prevent. This is the same shadowing that `apply_replay_floor` already strips from `launch.env` for provider deploys and pins with `caller_replay_floor_strips_user_env_shadow`. Fixed via the post-loop write rather than a reserved key, because it reproduces the provider path's semantics exactly: - New `apply_replay_floor_env` in `runtime/metadata.rs`, called after the `descriptor.env` loop alongside `apply_effort_env` (B5) — a caller floor wins over any user-supplied entry. With no caller floor the key is left as `descriptor.env` wrote it, matching the provider payload where a floorless deploy passes a user `launch.env` value through. - The pre-loop write becomes an unconditional `env_remove`, so the ambient parent-process value is stripped on both paths (previously only the None path cleared it) before user env and the caller floor are layered on. `None` can no longer inherit the floor from the environment Desktop itself was launched with. - Adding the key to RESERVED_ENV_KEYS was the alternative; it was not taken because that list is deliberately narrow and security-scoped, and it would additionally reject the key at env save time, diverging from the provider path's passthrough on a floorless deploy. Also lifts the env var name into a shared `REPLAY_FLOOR_ENV_VAR` const consumed by both the local spawn and `provider_deploy::apply_replay_floor`, matching how SESSION_TITLE_ENV_VAR and EFFORT_LEVEL_ENV_VAR are named from one place, so the two halves cannot drift. Four unit tests pin the contract: caller floor wins over a user env collision, user value survives with no caller floor, the ambient strip holds when neither supplies one, and a caller floor re-asserts after the strip. The behavior is backend-only, so the existing E2E specs pinning `replayFloorUnix` on the invoke payload are unaffected. The repository file-size ratchet stays red on this branch for app_state.rs, agents.rs, managed_agents/runtime.rs and useMentionSendFlow.ts. All four were already over before this change; runtime.rs is unchanged at +27 lines here (the new helper and its tests live in runtime/metadata.rs), so this commit adds nothing to the ratchet and does not split the pre-existing four. Verified: cargo test --lib on desktop/src-tauri (3019 passed), clippy -D warnings on --all-targets, cargo fmt --check, and the desktop file-size ratchet re-run to confirm runtime.rs holds at its inherited delta. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Matt Toohey --- .../src/commands/agents/provider_deploy.rs | 18 +-- .../src-tauri/src/managed_agents/runtime.rs | 29 ++--- .../src/managed_agents/runtime/metadata.rs | 107 +++++++++++++++++- 3 files changed, 131 insertions(+), 23 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index d7d31991f25..15db4dec5aa 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -6,7 +6,7 @@ use crate::{ app_state::AppState, managed_agents::{ discover_provider_candidates, load_managed_agents, provider_deploy, - resolve_provider_binary, save_managed_agents, BackendKind, + resolve_provider_binary, save_managed_agents, BackendKind, REPLAY_FLOOR_ENV_VAR, }, util::now_iso, }; @@ -172,12 +172,14 @@ fn assert_payload_scope( /// Inject a caller-supplied replay floor into the deploy payload so the /// remote harness consumes it exactly like a local spawn: as the -/// `BUZZ_ACP_REPLAY_FLOOR` environment variable. The floor rides +/// [`REPLAY_FLOOR_ENV_VAR`] environment variable. The floor rides /// `launch.policy_env` (tier 1); any same-named key in `launch.env` (tier 2) /// is stripped because that tier later-wins and a persisted user value must -/// not shadow this send's floor. With no caller floor the payload is left -/// untouched — a user-supplied `launch.env` value passes through, and plain -/// redeploys never carry a stale floor. +/// not shadow this send's floor — the remote mirror of +/// `apply_replay_floor_env`'s post-`descriptor.env` write on the local spawn. +/// With no caller floor the payload is left untouched — a user-supplied +/// `launch.env` value passes through, and plain redeploys never carry a stale +/// floor. fn apply_replay_floor(agent_json: &mut serde_json::Value, replay_floor_unix: Option) { let Some(floor) = replay_floor_unix else { return; @@ -194,7 +196,7 @@ fn apply_replay_floor(agent_json: &mut serde_json::Value, replay_floor_unix: Opt { let shadowed: Vec = env .keys() - .filter(|key| key.eq_ignore_ascii_case("BUZZ_ACP_REPLAY_FLOOR")) + .filter(|key| key.eq_ignore_ascii_case(REPLAY_FLOOR_ENV_VAR)) .cloned() .collect(); for key in shadowed { @@ -207,14 +209,14 @@ fn apply_replay_floor(agent_json: &mut serde_json::Value, replay_floor_unix: Opt { Some(policy_env) => { policy_env.insert( - "BUZZ_ACP_REPLAY_FLOOR".to_string(), + REPLAY_FLOOR_ENV_VAR.to_string(), serde_json::Value::String(floor.to_string()), ); } None => { launch.insert( "policy_env".to_string(), - serde_json::json!({ "BUZZ_ACP_REPLAY_FLOOR": floor.to_string() }), + serde_json::json!({ (REPLAY_FLOOR_ENV_VAR): floor.to_string() }), ); } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 9683461fc3b..3926ea9d33f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -23,8 +23,8 @@ pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondT mod metadata; pub(crate) use metadata::{ - apply_agent_display_env, child_rust_log_filter, resolve_session_title, - runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, + apply_agent_display_env, apply_replay_floor_env, child_rust_log_filter, resolve_session_title, + runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, REPLAY_FLOOR_ENV_VAR, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -572,18 +572,12 @@ pub fn spawn_agent_child( command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); - // Publish-first mention sends: the triggering message is already on the - // relay when this spawn runs, so hand the harness the send timestamp as a - // startup replay floor. Clear any ambient value on the None path so a - // floor from the parent environment can't leak into an unrelated spawn. - match replay_floor_unix { - Some(floor) => { - command.env("BUZZ_ACP_REPLAY_FLOOR", floor.to_string()); - } - None => { - command.env_remove("BUZZ_ACP_REPLAY_FLOOR"); - } - } + // Publish-first mention sends hand the harness the send timestamp as a + // startup replay floor. Strip any ambient value here — before the + // `descriptor.env` loop — so a floor from the parent environment can never + // leak into an unrelated spawn; the caller's floor is asserted AFTER that + // loop by `apply_replay_floor_env` so saved user env cannot shadow it. + command.env_remove(REPLAY_FLOOR_ENV_VAR); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -867,6 +861,13 @@ pub fn spawn_agent_child( let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command); crate::build_identity::apply_demo_config_home(&mut command)?; + // Publish-first replay floor: written AFTER the `descriptor.env` loop, the + // same post-loop authority ordering the A1 model write uses. This send's + // floor is invocation state and must win over a saved + // BUZZ_ACP_REPLAY_FLOOR — the shadow `apply_replay_floor` strips from the + // provider payload's `launch.env` tier for the same reason. + apply_replay_floor_env(&mut command, replay_floor_unix); + // A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority. // BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env // would be ambiguous). diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index a94e71b222f..769e20cedf6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -45,6 +45,40 @@ pub(crate) fn apply_agent_display_env(command: &mut std::process::Command, title } } +/// Env var carrying the startup replay floor to the harness. Shared with the +/// provider deploy path (`commands::agents::provider_deploy`) so the local +/// spawn and the remote `launch.policy_env` injection name the key from one +/// place. +pub(crate) const REPLAY_FLOOR_ENV_VAR: &str = "BUZZ_ACP_REPLAY_FLOOR"; + +/// Apply the publish-first replay floor: inject [`REPLAY_FLOOR_ENV_VAR`] from +/// `replay_floor_unix` (or leave the key untouched if `None`). +/// +/// Must be called **after** `descriptor.env` is written so this send's floor +/// wins over any user-supplied `BUZZ_ACP_REPLAY_FLOOR` entry — the same +/// authority ordering [`super::apply_effort_env`] asserts for effort, and the +/// same shadow strip `apply_replay_floor` performs on the provider payload's +/// `launch.env` tier. Without it a persona/global/agent env entry would +/// override the floor and the harness's startup watermark would be computed +/// from a stale (or `now`-clamped future) value, missing the mention that +/// triggered the spawn. +/// +/// When `replay_floor_unix` is `None` there is no floor to assert; the key is +/// left as `descriptor.env` wrote it, matching the provider path where a +/// user-supplied `launch.env` value passes through on a floorless deploy. The +/// caller strips the ambient parent-process value before the `descriptor.env` +/// loop, so `None` never inherits a floor from the environment Desktop itself +/// was launched with. +pub(crate) fn apply_replay_floor_env( + command: &mut std::process::Command, + replay_floor_unix: Option, +) { + if let Some(floor) = replay_floor_unix { + command.env(REPLAY_FLOOR_ENV_VAR, floor.to_string()); + } + // None: no floor to assert — leave whatever descriptor.env wrote intact. +} + /// Resolve the session title for an agent: its `display_name` when it has one, /// otherwise its unique `name` handle. `None` when both are blank, so the /// caller clears the env var rather than exporting an empty title. @@ -87,7 +121,78 @@ pub(crate) fn child_rust_log_filter() -> String { #[cfg(test)] mod tests { - use super::resolve_session_title; + use super::{apply_replay_floor_env, resolve_session_title, REPLAY_FLOOR_ENV_VAR}; + + fn replay_floor_of(cmd: &std::process::Command) -> Option { + cmd.get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(REPLAY_FLOOR_ENV_VAR)) + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + } + + /// The publish-first floor must win over a persona/global/agent env entry + /// written by the `descriptor.env` loop. Before the post-loop application + /// the saved value shadowed the floor and the harness booted blind to the + /// mention that triggered the spawn. + #[test] + fn caller_replay_floor_wins_over_user_env_collision() { + let mut cmd = std::process::Command::new("true"); + // Simulate the descriptor.env loop writing a saved user value. + cmd.env(REPLAY_FLOOR_ENV_VAR, "1"); + + apply_replay_floor_env(&mut cmd, Some(1_756_600_000)); + + assert_eq!( + replay_floor_of(&cmd).as_deref(), + Some("1756600000"), + "this send's floor must win over the user-supplied value" + ); + } + + /// No caller floor: the user value passes through, matching the provider + /// payload path where a floorless deploy leaves `launch.env` untouched. + #[test] + fn user_replay_floor_env_survives_when_no_caller_floor() { + let mut cmd = std::process::Command::new("true"); + cmd.env(REPLAY_FLOOR_ENV_VAR, "1756600000"); + + apply_replay_floor_env(&mut cmd, None); + + assert_eq!( + replay_floor_of(&cmd).as_deref(), + Some("1756600000"), + "a user-supplied floor must survive when the caller supplies none" + ); + } + + /// The ambient strip the spawn does before the `descriptor.env` loop must + /// stay stripped when neither the caller nor user env supplies a floor. + #[test] + fn removed_replay_floor_stays_removed_without_caller_floor() { + let mut cmd = std::process::Command::new("true"); + // Simulate the spawn's pre-loop ambient strip with no user env entry. + cmd.env_remove(REPLAY_FLOOR_ENV_VAR); + + apply_replay_floor_env(&mut cmd, None); + + assert_eq!( + replay_floor_of(&cmd), + None, + "a floorless spawn must not inherit an ambient parent-process floor" + ); + } + + /// A caller floor re-asserts the key even after the pre-loop ambient strip + /// removed it — the common publish-first send with no saved user entry. + #[test] + fn caller_replay_floor_injected_after_ambient_strip() { + let mut cmd = std::process::Command::new("true"); + cmd.env_remove(REPLAY_FLOOR_ENV_VAR); + + apply_replay_floor_env(&mut cmd, Some(42)); + + assert_eq!(replay_floor_of(&cmd).as_deref(), Some("42")); + } #[test] fn resolve_session_title_prefers_display_name() { From 8069310ed780c32af2643c7401f143d464b3f412 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 10:22:42 +1000 Subject: [PATCH 06/18] fix(desktop): bind the detached agent start to the send's tenant scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish-first wake called `startManagedAgent` with no `expectedRelayUrl`/`expectedSignerPubkey`, so `bind_expected_relay_scope(None, …)` let the backend resolve whatever workspace relay and signing identity were current at execution time. Awaiting the start used to bound that window to the send; detaching it means the call outlives the send, the channel, and — since community switching only remounts the React subtree — the community itself. A user who mentions a stopped agent and immediately switches communities could have it spawned or deployed against the new tenant's relay, carrying the old community's replay floor. `submitProjectAgentMessage` passes both values for exactly this outlives-its-caller reason. - New `useDetachedAgentStart`: captures the active community's relay URL and the identity pubkey per render and passes them with every detached start, so `start_managed_agent` fails closed when either has moved. Both are captured because a switch mutates the relay and the signing keys under separate locks — pinning only the relay would still let the new identity act for the old tenant. Capture is per render, never re-read after the switch it guards against: a send in flight holds the callback from the render that fired it. - The relay URL is handed over verbatim rather than through the shared `normalizeRelayUrl` (which lowercases for storage keys) — the backend's `relay_http_base_url` comparison is case-sensitive past the scheme, so a stored `wss://Relay.Example` would otherwise mismatch forever and refuse every wake. The signer check is case-insensitive, so that side is canonicalized. - Scope-mismatch failures get their own toast detail. The backend's message ends in "not sent", which is wrong here: publish-first means the message did publish and only the wake was refused. - `startAgentDetached` moves out of `useMentionSendFlow.ts` into the new hook, which also drops that file by 21 lines. Two unit tests drive the real hook under the real `CommunitiesProvider`: one pins the scope (and the surviving replay floor) on a normal start, using a mixed-case relay URL to pin the verbatim handoff; the other captures the callback, switches community, and asserts the stale wake still names the community it was fired in. Both fail without the fix. The in-channel publish-first E2E spec now also pins both scope fields on the detached invoke, and a new spec moves the active community while the start is held behind an injected delay and asserts the message stays published while the wake is refused with the reworded toast. The repository file-size ratchet stays red on this branch for app_state.rs, agents.rs, managed_agents/runtime.rs and useMentionSendFlow.ts. All four were already over before this change; this commit shrinks useMentionSendFlow.ts (1098 -> 1077) and adds nothing to the other three. Verified: desktop tsc --noEmit, biome check over src + tests, desktop unit tests (5807 passed; the two new tests re-run green after the final verbatim- relay-URL edit), and the full mentions (84) and channels (89) Playwright smoke suites against a pnpm build:e2e bundle, plus a 3x rerun of the new fail-closed spec. Both new E2E assertions were confirmed to fail with the scope threading removed. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Matt Toohey --- .../ui/useDetachedAgentStart.test.mjs | 174 ++++++++++++++++++ .../messages/ui/useDetachedAgentStart.ts | 79 ++++++++ .../messages/ui/useMentionSendFlow.ts | 38 +--- desktop/tests/e2e/mentions.spec.ts | 94 ++++++++++ 4 files changed, 355 insertions(+), 30 deletions(-) create mode 100644 desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs create mode 100644 desktop/src/features/messages/ui/useDetachedAgentStart.ts diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs new file mode 100644 index 00000000000..cc66b717a4d --- /dev/null +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -0,0 +1,174 @@ +/** + * Tenant scoping for the publish-first detached agent wake. + * + * `useMentionSendFlow` no longer awaits `start_managed_agent`, so the call + * outlives the send — and, because a community switch only remounts the React + * subtree, it can outlive the community too. `start_managed_agent` resolves + * the workspace relay and signing identity at execution time, so without a + * captured scope a wake fired in community A can spawn/deploy the agent + * against community B (carrying A's replay floor). These tests drive the real + * hook against the real CommunitiesProvider and assert the invoke payload + * carries the scope the backend fails closed on, including after a switch. + */ + +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const SELF = "1".repeat(64); +const AGENT = "a".repeat(64); +// Mixed case on purpose: the backend's scope comparison is case-sensitive +// past the scheme, so the stored URL must reach it verbatim. +const RELAY_A = "wss://Tenant-A.example"; +const RELAY_B = "wss://tenant-b.example"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** Every `start_managed_agent` payload seen, in call order. */ +let startCalls = []; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + localStorage: dom.window.localStorage, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (command, args) => { + if (command === "get_identity") { + return Promise.resolve({ pubkey: SELF, display_name: "Me" }); + } + if (command === "start_managed_agent") { + startCalls.push(args); + return Promise.resolve({ pubkey: args.pubkey, status: "running" }); + } + return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); + }, + transformCallback: () => 1, + }; + globalThis.__TAURI_INTERNALS__ = dom.window.__TAURI_INTERNALS__; +}); + +after(() => dom.window.close()); + +beforeEach(() => { + startCalls = []; + window.localStorage.clear(); + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "community-a", + name: "Tenant A", + relayUrl: RELAY_A, + pubkey: SELF, + addedAt: "2026-01-01T00:00:00Z", + }, + { + id: "community-b", + name: "Tenant B", + relayUrl: RELAY_B, + pubkey: SELF, + addedAt: "2026-01-02T00:00:00Z", + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", "community-a"); +}); + +/** + * Renders the real hook under the real communities provider, exposing the + * detached-start callback alongside `switchCommunity` so a test can move the + * active community out from under an already-captured callback. + */ +async function renderDetachedStart() { + const { default: React } = await import("react"); + const { act, renderHook } = await import("@testing-library/react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider, useCommunities } = await import( + "@/features/communities/useCommunities.tsx" + ); + const { useDetachedAgentStart } = await import("./useDetachedAgentStart.ts"); + + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { gcTime: 0 }, + }, + }); + const wrapper = ({ children }) => + React.createElement( + QueryClientProvider, + { client }, + React.createElement(CommunitiesProvider, null, children), + ); + const rendered = renderHook( + () => ({ + startDetached: useDetachedAgentStart(), + switchCommunity: useCommunities().switchCommunity, + }), + { wrapper }, + ); + // Let the identity query resolve so the signer scope is populated. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return { act, rendered }; +} + +const AGENT_RECORD = { pubkey: AGENT, name: "fizz" }; + +test("a detached start carries the active community and identity as its scope", async () => { + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(startCalls.length, 1); + assert.equal( + startCalls[0].expectedRelayUrl, + RELAY_A, + "the stored relay URL must reach the case-sensitive backend check verbatim", + ); + assert.equal(startCalls[0].expectedSignerPubkey, SELF); + // The replay floor still rides along — scoping must not displace it. + assert.ok(startCalls[0].replayFloorUnix > 0); + rendered.unmount(); +}); + +test("a start captured before a community switch keeps the pre-switch scope", async () => { + const { act, rendered } = await renderDetachedStart(); + // What a send in flight holds: the callback from the render that fired it. + const capturedStart = rendered.result.current.startDetached; + + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + assert.notEqual( + rendered.result.current.startDetached, + capturedStart, + "the switch must produce a new callback, so the captured one is stale", + ); + + await act(async () => { + capturedStart(AGENT_RECORD); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(startCalls.length, 1); + assert.equal( + startCalls[0].expectedRelayUrl, + RELAY_A, + "the stale wake must name the community it was fired in, not the active one", + ); + rendered.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts new file mode 100644 index 00000000000..cd109f2d605 --- /dev/null +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -0,0 +1,79 @@ +import * as React from "react"; +import { toast } from "sonner"; +import { useStartManagedAgentMutation } from "@/features/agents/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getErrorMessage } from "./useMentionSendFlow.helpers"; + +/** + * The backend fails a scope-mismatched start closed with a message ending in + * "not sent". That reads wrong here: publish-first means the message *was* + * published — only the wake was refused — so say what actually happened. + */ +function detachedStartFailureDetail(error: unknown): string { + const message = getErrorMessage(error, "Could not start agent."); + return message.includes("active community changed") || + message.includes("active identity changed") + ? "You switched community or identity before it could start." + : message; +} + +/** + * Fire-and-forget managed-agent start for the publish-first mention send, + * bound to the tenant scope that was active when the send fired. + * + * Detaching the start means the call outlives the send, the channel, and — + * since a community switch only remounts the React subtree — the community + * itself. `start_managed_agent` resolves the workspace relay and the signing + * identity at *execution* time, so an unscoped detached start can spawn or + * deploy the agent against whichever tenant is active when it lands, carrying + * the previous community's replay floor. The relay URL and the signing keys + * change under separate locks during a switch, so both are captured (the + * relay alone would still let the new identity act for the old tenant) and + * `start_managed_agent` fails closed when either no longer matches. This is + * the same binding `submitProjectAgentMessage` applies for the same + * outlives-its-caller reason. + * + * Capture is per render: the callback closes over the community and identity + * that were active when the composer last rendered, which is the send the + * user pressed — never a value re-read after the switch it guards against. + */ +export function useDetachedAgentStart(): (agent: ManagedAgent) => void { + const startAgentMutateAsync = useStartManagedAgentMutation().mutateAsync; + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + // Handed over verbatim: `assert_expected_relay_scope` runs both sides + // through `relay_http_base_url` (trim, strip trailing slash, ws→http), and + // that comparison is case-sensitive. Lowercasing here — as the shared + // storage-key normalizer does — would turn a stored `wss://Relay.Example` + // into a permanent spurious mismatch that refuses every wake. + const expectedRelayUrl = activeCommunity?.relayUrl || undefined; + // The signer check is case-insensitive, so canonicalizing is free here. + const expectedSignerPubkey = identityQuery.data?.pubkey + ? normalizePubkey(identityQuery.data.pubkey) + : undefined; + return React.useCallback( + (agent: ManagedAgent) => { + // Publish-first: the send no longer waits for the agent start. The + // replay floor tells the spawned harness to replay at least back to + // this moment, so the about-to-publish message is inside its first + // subscription window however long the spawn takes. + const replayFloorUnix = Math.floor(Date.now() / 1000); + void startAgentMutateAsync({ + pubkey: agent.pubkey, + expectedRelayUrl, + expectedSignerPubkey, + replayFloorUnix, + }).catch((error: unknown) => { + toast.error( + `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${detachedStartFailureDetail( + error, + )}`, + ); + }); + }, + [expectedRelayUrl, expectedSignerPubkey, startAgentMutateAsync], + ); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 83e1962d5f1..7d12a5d16b7 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -8,7 +8,6 @@ import { useManagedAgentsQuery, usePersonasQuery, useProvisionChannelManagedAgentMutation, - useStartManagedAgentMutation, } from "@/features/agents/hooks"; import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; @@ -25,6 +24,7 @@ import { type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; +import { useDetachedAgentStart } from "./useDetachedAgentStart"; import { createSendPerfTimer } from "./sendPerfLog"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; @@ -100,7 +100,10 @@ export function useMentionSendFlow({ const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); - const startAgentMutation = useStartManagedAgentMutation(); + // Detached (publish-first) agent wake, bound to the community and identity + // active at this render so a start that outlives a community switch fails + // closed instead of spawning against the new tenant. + const startAgentDetached = useDetachedAgentStart(); const getManagedAgentsByPubkey = React.useCallback(async () => { const agents = managedAgentsQuery.data ?? @@ -132,31 +135,6 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); - // Depend on the stable mutateAsync method, not the mutation result object — - // React Query returns a fresh object each render, which would re-create this - // callback and every useCallback chained off it (repo gotcha #6). - const startAgentMutateAsync = startAgentMutation.mutateAsync; - const startAgentDetached = React.useCallback( - (agent: ManagedAgent) => { - // Publish-first: the send no longer waits for the agent start. The - // replay floor tells the spawned harness to replay at least back to - // this moment, so the about-to-publish message is inside its first - // subscription window however long the spawn takes. - const replayFloorUnix = Math.floor(Date.now() / 1000); - void startAgentMutateAsync({ - pubkey: agent.pubkey, - replayFloorUnix, - }).catch((error: unknown) => { - toast.error( - `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${getErrorMessage( - error, - "Could not start agent.", - )}`, - ); - }); - }, - [startAgentMutateAsync], - ); const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -1073,9 +1051,9 @@ export function useMentionSendFlow({ setNonMemberPromptError(null); }, []); return { - // Agent starts are detached (publish-first), so startAgentMutation.isPending - // deliberately does not gate the composer — a background start must not - // block the next send. + // Agent starts are detached (publish-first), so useDetachedAgentStart's + // in-flight state deliberately does not gate the composer — a background + // start must not block the next send. isPreparingMentionSend: isMentionSendPending || isCompleteSendPending || diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e318a35f076..99f70de42d2 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2728,6 +2728,24 @@ test("mentioning an in-channel stopped managed agent publishes first and starts (startCall?.payload as { replayFloorUnix?: number } | undefined) ?.replayFloorUnix, ).toBeGreaterThan(0); + // It also carries the tenant scope active at the send. The start now + // outlives the send, and a community switch only remounts the React + // subtree, so an unscoped wake would spawn against whichever relay/identity + // is current when it lands; the backend fails closed on these instead. + const activeRelayUrl = await page.evaluate(() => { + const communities = JSON.parse( + window.localStorage.getItem("buzz-communities") ?? "[]", + ) as { id: string; relayUrl: string }[]; + const activeId = window.localStorage.getItem("buzz-active-community-id"); + return ( + communities.find((community) => community.id === activeId)?.relayUrl ?? "" + ); + }); + expect(activeRelayUrl).not.toBe(""); + expect(startCall?.payload).toMatchObject({ + expectedRelayUrl: activeRelayUrl, + expectedSignerPubkey: MOCK_VIEWER_PUBKEY, + }); const mentionChip = page .getByTestId("message-row") @@ -2781,6 +2799,82 @@ test("a detached agent start failure surfaces as a toast after the message sends await expect(input).not.toContainText("can you help"); }); +test("a detached start fired before a community switch fails closed instead of waking the agent in the new tenant", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Holds the start open long enough for the community to move under it — + // the window the detached (publish-first) wake opened. + startManagedAgentDelayMs: 2_000, + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @fizz"); + await expect(autocomplete(page).getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" can you help?"); + + const baselineStartCount = commandCount( + await readCommandLog(page), + "start_managed_agent", + ); + await page.getByTestId("send-message").click(); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBeGreaterThan(baselineStartCount); + + // Move the active community while the start is still held. The stored + // community is what the mock start reads for its scope check (as the + // backend reads the live workspace relay), so writing it directly models + // the switch without tearing down the mounted app the toast renders in. + await page.evaluate(() => { + const communities = JSON.parse( + window.localStorage.getItem("buzz-communities") ?? "[]", + ) as { id: string; relayUrl: string }[]; + communities.push({ + id: "other-community", + name: "Other", + relayUrl: "wss://other-tenant.example", + pubkey: "deadbeef".repeat(8), + addedAt: new Date().toISOString(), + } as (typeof communities)[number]); + window.localStorage.setItem( + "buzz-communities", + JSON.stringify(communities), + ); + window.localStorage.setItem("buzz-active-community-id", "other-community"); + }); + + // The message published regardless — only the wake is refused, and the + // toast says so rather than repeating the backend's "not sent". + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); + await expect(mentionChip).toBeVisible(); + await expect( + page.getByText( + "You switched community or identity before it could start.", + { + exact: false, + }, + ), + ).toBeVisible({ timeout: 10_000 }); +}); + test("mentioning an in-channel provider managed agent publishes first and deploys it detached", async ({ page, }) => { From fca6b977884f0b6691ec950e2ebf477cdd43a089 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 10:48:11 +1000 Subject: [PATCH 07/18] refactor(desktop): report the access-policy relay write explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `applyReusableAgentAccessPolicy` signalled "this hit the relay" by returning a different object than it was handed: a matching policy returned the caller's `agent`, a diverging one returned the fresh record from `updateManagedAgent`. `useMentionSendFlow` read that with `readyAgent !== agent` to decide whether an awaited relay round-trip separated its pre-side-effect mention-authorization pass from the publish, and therefore whether to revalidate at the publish boundary (#5681). The contract held today, and the safe failure direction (a gratuitous new object) only costs a redundant pass — but the unsafe direction is silent: a future in-place cache update that writes to the relay and returns the caller's object would skip the publish-boundary revalidation, and nothing pinned the convention. Make the signal load-bearing by construction. - `applyReusableAgentAccessPolicy` now returns `{ agent, wrote }` (`ApplyReusableAgentAccessPolicyResult`), with `wrote` set from whether `updateManagedAgent` actually ran rather than from anything about the returned record. Its doc comment names the send-path consumer so the flag is not mistaken for incidental. - The send path destructures `{ agent: readyAgent, wrote }` and sets `wroteRelayState` from `wrote`; the existing-member branch supplies `{ agent, wrote: false }`. Behaviour is identical — for the current implementation the two signals agree case for case. - `provisionChannelManagedAgent`'s two reuse branches destructure the agent out; they never consulted the identity signal. Three unit tests pin the contract in a new `channelAgents.accessPolicy.test.mjs`: a matching policy reports `wrote: false`, returns the input agent, and issues no command; a diverging policy reports `wrote: true` and invokes `update_managed_agent` with the resolved policy; and a write whose response is content-identical to the input still reports `wrote: true`, which is the case an identity or content comparison would miss. The repository file-size ratchet stays red on this branch for app_state.rs, agents.rs, managed_agents/runtime.rs and useMentionSendFlow.ts. All four were already over before this change; useMentionSendFlow.ts holds at its inherited 1077 lines here, so this commit adds nothing to the ratchet and does not split the pre-existing four. Verified: desktop tsc --noEmit, biome check over src + tests (exit 0), desktop unit tests (5810 tests, 5804 passed; the 5 failures are the pre-existing inboxReopenNavigation and useRetainedProjectGitViews loader failures, present on origin/main), and the full mentions (84) and channels + agent-access-warning (93) Playwright smoke suites against a pnpm build:e2e bundle. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Matt Toohey --- .../channelAgents.accessPolicy.test.mjs | 149 ++++++++++++++++++ desktop/src/features/agents/channelAgents.ts | 35 ++-- .../messages/ui/useMentionSendFlow.ts | 10 +- 3 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 desktop/src/features/agents/channelAgents.accessPolicy.test.mjs diff --git a/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs b/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs new file mode 100644 index 00000000000..26bf6fe46a4 --- /dev/null +++ b/desktop/src/features/agents/channelAgents.accessPolicy.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyReusableAgentAccessPolicy } from "./channelAgents.ts"; + +const AGENT_PUBKEY = "a".repeat(64); +const ALLOWED_PUBKEY = "b".repeat(64); + +// `wrote` is load-bearing: the message-send path (useMentionSendFlow) uses it +// to decide whether an awaited relay round-trip separated its pre-side-effect +// mention-authorization pass from the publish, and therefore whether it must +// revalidate at the publish boundary (#5681). These tests pin the flag against +// the relay write itself, not against the identity of the returned record. + +function rawAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "fizz", + persona_id: null, + relay_url: "wss://relay.example", + acp_command: "buzz-acp", + agent_command: "goose", + agent_args: [], + mcp_command: "", + turn_timeout_seconds: 0, + idle_timeout_seconds: 0, + max_turn_duration_seconds: 0, + parallelism: 1, + system_prompt: null, + model: null, + status: "running", + pid: null, + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + last_started_at: null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + log_path: null, + start_on_app_launch: false, + backend: { type: "local" }, + backend_agent_id: null, + respond_to: "owner-only", + respond_to_allowlist: [], + ...overrides, + }; +} + +function managedAgent(overrides = {}) { + return { + pubkey: AGENT_PUBKEY, + name: "fizz", + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function installTauriInvoke(handler) { + const prior = globalThis.window; + globalThis.window ??= {}; + window.__TAURI_INTERNALS__ = { invoke: handler }; + return () => { + globalThis.window = prior; + }; +} + +test("a matching access policy reports no write and returns the agent untouched", async (t) => { + const calls = []; + t.after( + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve(null); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, {}); + + assert.equal(result.wrote, false); + assert.equal(result.agent, agent); + assert.deepEqual(calls, []); +}); + +test("a diverging access policy reports the write and returns the updated agent", async (t) => { + const calls = []; + t.after( + installTauriInvoke((command, args) => { + calls.push([command, args]); + return Promise.resolve({ + agent: rawAgent({ + respond_to: "allowlist", + respond_to_allowlist: [ALLOWED_PUBKEY], + }), + profile_sync_error: null, + }); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, { + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }); + + assert.equal(result.wrote, true); + assert.equal(result.agent.respondTo, "allowlist"); + assert.deepEqual(result.agent.respondToAllowlist, [ALLOWED_PUBKEY]); + assert.deepEqual(calls, [ + [ + "update_managed_agent", + { + input: { + pubkey: AGENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [ALLOWED_PUBKEY], + }, + }, + ], + ]); +}); + +test("the write is reported even when the update hands back an unchanged record", async (t) => { + // Callers must not re-derive the write by comparing the returned record + // against the one they passed in — a backend that normalizes the policy + // away, or a cache layer that mutates in place and hands the caller's own + // object back, still wrote to the relay. Under such a comparison the send + // path would silently skip the publish-boundary revalidation. + let invoked = 0; + t.after( + installTauriInvoke(() => { + invoked += 1; + return Promise.resolve({ + agent: rawAgent(), + profile_sync_error: null, + }); + }), + ); + + const agent = managedAgent(); + const result = await applyReusableAgentAccessPolicy(agent, { + respondTo: "anyone", + }); + + assert.equal(invoked, 1); + assert.equal(result.wrote, true); + assert.equal(result.agent.respondTo, agent.respondTo); + assert.deepEqual(result.agent.respondToAllowlist, agent.respondToAllowlist); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 5b7f312c685..40c714791df 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -132,11 +132,25 @@ type ChannelAgentReuseContext = { >[]; }; +export type ApplyReusableAgentAccessPolicyResult = { + agent: ManagedAgent; + /** + * True when reconciling the policy required a relay write. Callers that + * sequence authorization around this call — the message-send path revalidates + * mention authorization at the publish boundary whenever an awaited relay + * round-trip separated it from its earlier pass — depend on this flag rather + * than on comparing the returned record's identity against the input, so the + * signal survives any future change to whether an update returns a fresh + * object. + */ + wrote: boolean; +}; + export async function applyReusableAgentAccessPolicy( agent: ManagedAgent, request: Pick, persona?: Pick, -) { +): Promise { const policy = resolveReusableAgentAccessPolicy(request, persona); const matches = agent.respondTo === policy.respondTo && @@ -144,14 +158,13 @@ export async function applyReusableAgentAccessPolicy( agent.respondToAllowlist.every( (pubkey, index) => pubkey === policy.respondToAllowlist[index], ); - if (matches) return agent; - - return ( - await updateManagedAgent({ - pubkey: agent.pubkey, - ...policy, - }) - ).agent; + if (matches) return { agent, wrote: false }; + + const { agent: updatedAgent } = await updateManagedAgent({ + pubkey: agent.pubkey, + ...policy, + }); + return { agent: updatedAgent, wrote: true }; } export async function attachManagedAgentToChannel( @@ -329,7 +342,7 @@ export async function provisionChannelManagedAgent( const definition = context.personas.find( (persona) => persona.id === input.personaId, ); - const updatedAgent = await applyReusableAgentAccessPolicy( + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( reusable, input, definition, @@ -358,7 +371,7 @@ export async function provisionChannelManagedAgent( context.channelMemberPubkeys, ); if (reusable) { - const updatedAgent = await applyReusableAgentAccessPolicy( + const { agent: updatedAgent } = await applyReusableAgentAccessPolicy( reusable, input, ); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 7d12a5d16b7..5a5d7b4265a 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -180,16 +180,16 @@ export function useMentionSendFlow({ const agent = managedAgentsByPubkey.get(pubkey); if (!agent) continue; try { - const readyAgent = existingMembers.has(pubkey) - ? agent + const { agent: readyAgent, wrote } = existingMembers.has(pubkey) + ? { agent, wrote: false } : await applyReusableAgentAccessPolicy( agent, {}, personas.find((persona) => persona.id === agent.personaId), ); - if (readyAgent !== agent) { - // A distinct record back means the access-policy update hit the - // relay; a matching policy returns `agent` itself without writing. + if (wrote) { + // The access-policy reconciliation hit the relay; a matching + // policy reports `wrote: false` and stays on the fast path. wroteRelayState = true; } if (participants.has(pubkey)) { From ccd4b8672acf176339b7dc7d3ace15ee2a2d6700 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 11:12:42 +1000 Subject: [PATCH 08/18] fix(desktop): dedupe in-flight detached agent starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Awaiting the start used to make a duplicate wake unreachable, via three things stacked up: the start ran inside `completeSend`; the mutation's `isPending` fed `isPreparingMentionSend`, a hard early return in the composer's send handler, so *no* send could begin while one was in flight; and by the time that gate lifted `onSuccess` had written the running/deployed record into the query cache, so the next send read `running` and never re-fired. Publish-first removed the first two by design, and that breaks the third: the cache is only updated on success, so for the whole in-flight window `getManagedAgentsByPubkey` keeps returning the stale `stopped` / `not_deployed` record and `ensureManagedAgentMentionsReady` fires again. The window is longest exactly where it matters — a cold local spawn or a first remote deploy is seconds long. Reachable by two quick sends in one composer (`@fizz do X` → `@fizz also Y`), or by two composers at once (channel, thread panel, `NewMessageScreen` each hold their own `useMentionSendFlow`). Implements option A: a module-level in-flight map in `useDetachedAgentStart`, the single callback every path funnels through (including the `attachAgentMutation` and persona-create paths that take it as `detachedStart`). - Keyed by `(scoped relay URL, normalized pubkey)`, mirroring the backend's own runtime pair key, so a wake in one community can never suppress one in another — the same tenant boundary `expectedRelayUrl` already enforces. Module-level rather than a ref because the cross-composer overlap is one of the two cases worth collapsing; a ref dies on remount and would miss it. - No synchronisation is needed or possible: JS is run-to-completion and the check, the call and the registration sit in one synchronous block with no `await` between them. The real hazards are elsewhere — the `delete` is in `finally`, not `then`, so a failed start does not latch the agent for the session, and it is identity-guarded so an A→B→A switch (where `resetDetachedAgentStarts` cleared the map and a newer start re-registered the key) cannot drop the newer entry. - Suppressing rather than queueing a re-run is correct because the wake is per-agent, not per-message: the first start's replay floor predates the second message and the floor is a lower bound, so one harness boot covers both. It also collapses two failure toasts into one, whose wording ("your message was sent, but the agent may not respond") is accurate for both messages. - `resetDetachedAgentStarts()` is registered in `resetCommunityState()` per the module-singleton rule, and is semantically right: those starts belong to the community being left and would fail closed at the backend's scope assertion anyway. - The callback now returns whether it fired, and `countDetachedStart` counts only real fires — otherwise the send-perf summary this branch added for attributing latency would report wakes that never happened. What this does not cover: the Agents-panel Start button, the restore path, and inbound-persona deploys all call `startManagedAgent` outside this hook. Closing that class needs backend coalescing (a per-pubkey deploy epoch snapshotted before the deploy lock, so an explicit redeploy still forces) — deferred as its own change. One residual on the covered path: if a second send updates a provider agent's access policy while the first deploy is in flight, the suppressed deploy would have carried the newer policy; `apply_deploy_result` only clears `provider_policy_pending` on a matching payload and `reconcile_on_workspace_apply` retries pending records, so it self-heals at the next workspace apply, just later. Five unit tests pin the contract, each confirmed to fail under the mutation it guards: a second wake for the same agent is suppressed while the first is held open (fails with the map removed); different agents in one window are not collapsed (fails with a relay-only key); the same agent in another community is not suppressed (fails with a pubkey-only key); and a wake fires again after the previous one resolves *or* rejects (both fail without the `finally` delete). A new E2E spec sends twice with the same mention behind the 45s `startManagedAgentDelayMs` and asserts both messages publish while `start_managed_agent` stays at exactly one call — also confirmed to fail (2 vs 1) with the map removed. The repository file-size ratchet stays red on this branch for app_state.rs, agents.rs, managed_agents/runtime.rs and useMentionSendFlow.ts. All four were already over before this change; useMentionSendFlow.ts holds at its inherited 1077 lines here, so this commit adds nothing to the ratchet and does not split the pre-existing four. Verified: desktop tsc --noEmit, biome check over src + tests (exit 0), desktop unit tests (5815 passed, 0 failed), and the full mentions (85) and channels (89) Playwright smoke suites against a pnpm build:e2e bundle — 2 mentions specs failed under load and are green in isolation (the inline-code-span literal and system-agent-avatar-stack specs, both untouched here), plus a 3x stress rerun of the new spec. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../features/communities/useCommunityInit.ts | 2 + .../ui/useDetachedAgentStart.test.mjs | 171 +++++++++++++++++- .../messages/ui/useDetachedAgentStart.ts | 75 +++++++- .../messages/ui/useMentionSendFlow.ts | 4 +- desktop/tests/e2e/mentions.spec.ts | 63 +++++++ 5 files changed, 294 insertions(+), 21 deletions(-) diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a3b17f5ea36..f2aa7013e3f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -25,6 +25,7 @@ import { resetAudioMediaLoadScheduler } from "@/features/messages/lib/audioMedia import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; import { resetPersistentAgentAudienceStore } from "@/features/messages/lib/persistentAgentAudience"; +import { resetDetachedAgentStarts } from "@/features/messages/ui/useDetachedAgentStart"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -80,6 +81,7 @@ async function resetCommunityState({ resetBackgroundMediaUploads(); resetLinkPreviewPreparations(); resetPersistentAgentAudienceStore(); + resetDetachedAgentStarts(); clearSearchHitEventCache(); clearMarkdownNodeCache(); resetMessageLinkMetadataCache(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index cc66b717a4d..39fbd2a4607 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -1,23 +1,31 @@ /** - * Tenant scoping for the publish-first detached agent wake. + * Tenant scoping and in-flight deduplication for the publish-first detached + * agent wake. * * `useMentionSendFlow` no longer awaits `start_managed_agent`, so the call * outlives the send — and, because a community switch only remounts the React - * subtree, it can outlive the community too. `start_managed_agent` resolves - * the workspace relay and signing identity at execution time, so without a - * captured scope a wake fired in community A can spawn/deploy the agent - * against community B (carrying A's replay floor). These tests drive the real - * hook against the real CommunitiesProvider and assert the invoke payload - * carries the scope the backend fails closed on, including after a switch. + * subtree, it can outlive the community too. Two consequences are pinned here: + * + * 1. `start_managed_agent` resolves the workspace relay and signing identity at + * execution time, so without a captured scope a wake fired in community A + * can spawn/deploy the agent against community B (carrying A's replay + * floor). + * 2. Nothing else dedupes concurrent wakes any more — the awaited version was + * covered by the composer's `isPending` gate plus the mutation's success + * cache write, and for the whole detached window the cache still reads + * `stopped`, so a second send re-fires. + * + * These tests drive the real hook against the real CommunitiesProvider. */ import assert from "node:assert/strict"; -import { after, before, beforeEach, test } from "node:test"; +import { after, afterEach, before, beforeEach, test } from "node:test"; import { JSDOM } from "jsdom"; const SELF = "1".repeat(64); const AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); // Mixed case on purpose: the backend's scope comparison is case-sensitive // past the scheme, so the stored URL must reach it verbatim. const RELAY_A = "wss://Tenant-A.example"; @@ -29,6 +37,13 @@ const dom = new JSDOM("", { /** Every `start_managed_agent` payload seen, in call order. */ let startCalls = []; +/** + * Settlers for `start_managed_agent` calls held open by `holdStarts`, in call + * order. A held start is what the dedupe exists for: the seconds-long window + * where a cold spawn or first deploy has not yet updated the agent record. + */ +let heldStarts = []; +let holdStarts = false; before(() => { Object.assign(globalThis, { @@ -45,7 +60,14 @@ before(() => { } if (command === "start_managed_agent") { startCalls.push(args); - return Promise.resolve({ pubkey: args.pubkey, status: "running" }); + const started = { pubkey: args.pubkey, status: "running" }; + if (!holdStarts) return Promise.resolve(started); + return new Promise((resolve, reject) => { + heldStarts.push({ + resolve: () => resolve(started), + reject: () => reject(new Error("spawn failed")), + }); + }); } return Promise.reject(new Error(`unmocked Tauri command: ${command}`)); }, @@ -56,8 +78,25 @@ before(() => { after(() => dom.window.close()); -beforeEach(() => { +afterEach(async () => { + // Tests that pin suppression leave their wake deliberately in flight, and + // node:test never finishes a file with a promise that will not settle. + const outstanding = heldStarts; + heldStarts = []; + for (const held of outstanding) held.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +beforeEach(async () => { startCalls = []; + heldStarts = []; + holdStarts = false; + // The in-flight map is a module singleton, so a start held open by one test + // would otherwise suppress the next test's. + const { resetDetachedAgentStarts } = await import( + "./useDetachedAgentStart.ts" + ); + resetDetachedAgentStarts(); window.localStorage.clear(); window.localStorage.setItem( "buzz-communities", @@ -124,6 +163,10 @@ async function renderDetachedStart() { } const AGENT_RECORD = { pubkey: AGENT, name: "fizz" }; +const OTHER_AGENT_RECORD = { pubkey: OTHER_AGENT, name: "buzz" }; + +/** Lets queued microtasks (the mutation, and the map's `finally`) run. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); test("a detached start carries the active community and identity as its scope", async () => { const { act, rendered } = await renderDetachedStart(); @@ -172,3 +215,111 @@ test("a start captured before a community switch keeps the pre-switch scope", as ); rendered.unmount(); }); + +test("a second wake for the same agent is suppressed while the first is in flight", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + let first; + let second; + + await act(async () => { + first = rendered.result.current.startDetached(AGENT_RECORD); + second = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(startCalls.length, 1, "one wake serves both messages"); + assert.equal(first, true); + assert.equal( + second, + false, + "the suppressed call must report that it fired nothing, so the send-perf summary does not count a wake that never happened", + ); + rendered.unmount(); +}); + +test("wakes for different agents in one window are not collapsed", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + rendered.result.current.startDetached(OTHER_AGENT_RECORD); + await settle(); + }); + + assert.deepEqual( + startCalls.map((call) => call.pubkey), + [AGENT, OTHER_AGENT], + "the key is per agent, not a global lock on wakes", + ); + rendered.unmount(); +}); + +test("a wake fires again once the previous one has settled", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + heldStarts[0].resolve(); + await settle(); + }); + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(startCalls.length, 2, "suppression must not outlive the start"); + rendered.unmount(); +}); + +test("a failed wake clears the key instead of latching the agent", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + // The user saw "your message was sent, but the agent may not respond" and + // retries; clearing on success only would refuse every retry for the session. + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), true); + await settle(); + }); + + assert.equal(startCalls.length, 2); + rendered.unmount(); +}); + +test("a wake for the same agent in another community is not suppressed", async () => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.deepEqual( + startCalls.map((call) => call.expectedRelayUrl), + [RELAY_A, RELAY_B], + "the key carries the relay, so one tenant's in-flight wake never suppresses another's", + ); + rendered.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index cd109f2d605..56e902161d8 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -7,6 +7,33 @@ import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { getErrorMessage } from "./useMentionSendFlow.helpers"; +/** + * Detached starts still in flight, keyed by `(scoped relay URL, pubkey)`. + * + * Awaiting the start used to make a duplicate unreachable: `isPending` was a + * hard early return in the composer's send handler, so no second send could + * begin, and by the time it lifted the mutation's `onSuccess` had written the + * `running`/`deployed` record into the query cache. Detaching removes both — + * for the whole in-flight window the cache still reads `stopped`, so a second + * send re-fires. Module-level rather than a ref because the overlaps worth + * collapsing include cross-composer ones (channel composer, thread panel, + * `NewMessageScreen` each hold their own `useMentionSendFlow`). + * + * Keyed by relay as well as pubkey to mirror the backend's own runtime pair + * key, so a start in one community can never suppress one in another — the + * same tenant boundary `expectedRelayUrl` enforces below. + */ +const inFlightDetachedStarts = new Map>(); + +/** + * Drops every tracked in-flight start. Registered in `resetCommunityState`: + * these starts belong to the community being left and are about to fail closed + * at the backend's scope assertion anyway. + */ +export function resetDetachedAgentStarts(): void { + inFlightDetachedStarts.clear(); +} + /** * The backend fails a scope-mismatched start closed with a message ending in * "not sent". That reads wrong here: publish-first means the message *was* @@ -39,8 +66,14 @@ function detachedStartFailureDetail(error: unknown): string { * Capture is per render: the callback closes over the community and identity * that were active when the composer last rendered, which is the send the * user pressed — never a value re-read after the switch it guards against. + * + * Returns whether this call actually fired a wake: a start already in flight + * for the same agent in the same tenant is suppressed, since the wake is + * per-agent rather than per-message and the first start's replay floor is + * earlier than the second message. Callers use the result to report only real + * fires (the send-perf summary counts them). */ -export function useDetachedAgentStart(): (agent: ManagedAgent) => void { +export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { const startAgentMutateAsync = useStartManagedAgentMutation().mutateAsync; const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); @@ -56,23 +89,47 @@ export function useDetachedAgentStart(): (agent: ManagedAgent) => void { : undefined; return React.useCallback( (agent: ManagedAgent) => { + // No synchronisation is needed or possible: the check, the call and the + // registration below sit in one synchronous block, so no other send can + // interleave between "is it in the set?" and "put it in the set". + const key = `${expectedRelayUrl ?? ""}\u0000${normalizePubkey(agent.pubkey)}`; + if (inFlightDetachedStarts.has(key)) { + // One wake serves both messages. A local duplicate is a backend no-op + // anyway, but a provider redeploy can replace a harness that had just + // come up to answer the first message — and the user would get two + // failure toasts for one problem. + return false; + } // Publish-first: the send no longer waits for the agent start. The // replay floor tells the spawned harness to replay at least back to // this moment, so the about-to-publish message is inside its first // subscription window however long the spawn takes. const replayFloorUnix = Math.floor(Date.now() / 1000); - void startAgentMutateAsync({ + const started = startAgentMutateAsync({ pubkey: agent.pubkey, expectedRelayUrl, expectedSignerPubkey, replayFloorUnix, - }).catch((error: unknown) => { - toast.error( - `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${detachedStartFailureDetail( - error, - )}`, - ); - }); + }) + .catch((error: unknown) => { + toast.error( + `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${detachedStartFailureDetail( + error, + )}`, + ); + }) + .finally(() => { + // Identity-guarded: `resetDetachedAgentStarts` may have cleared the + // map and a later start re-registered this key while this one was in + // flight (an A→B→A community switch), and an unguarded delete would + // drop that newer entry. Clearing in `finally` rather than on success + // is what keeps a failed start from latching the agent permanently. + if (inFlightDetachedStarts.get(key) === started) { + inFlightDetachedStarts.delete(key); + } + }); + inFlightDetachedStarts.set(key, started); + return true; }, [expectedRelayUrl, expectedSignerPubkey, startAgentMutateAsync], ); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 5a5d7b4265a..3c1a8ed952b 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -171,10 +171,10 @@ export function useMentionSendFlow({ let wroteRelayState = false; // Reported for the send-perf summary: an agent wake is detached, so a // send that fired one should still read as fast as one that did not. + // Only real fires count — a wake already in flight is suppressed. let detachedStarts = 0; const countDetachedStart = (agent: ManagedAgent) => { - detachedStarts += 1; - startAgentDetached(agent); + if (startAgentDetached(agent)) detachedStarts += 1; }; for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 99f70de42d2..c4c8edf4628 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2754,6 +2754,69 @@ test("mentioning an in-channel stopped managed agent publishes first and starts await expect(mentionChip).toBeVisible(); }); +test("a second mention while the first wake is in flight does not start the agent twice", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Held open for the whole test. Awaiting the start used to make a + // duplicate unreachable — the composer refused to send while one was + // pending, and by the time it lifted the success handler had cached a + // running record. Detached, the record keeps reading "stopped" for the + // whole spawn, which is precisely when a second send re-fires. + startManagedAgentDelayMs: 45_000, + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + const dropdown = autocomplete(page); + const baselineCommands = await readCommandLog(page); + const baselineStartCount = commandCount( + baselineCommands, + "start_managed_agent", + ); + + await input.fill("Hey @fizz"); + await expect(dropdown.getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" do X"); + await page.getByTestId("send-message").click(); + await expect( + page.getByTestId("message-row").filter({ hasText: "do X" }), + ).toBeVisible(); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount + 1); + + await input.fill("Hey @fizz"); + await expect(dropdown.getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" also Y"); + await page.getByTestId("send-message").click(); + + // The second message publishes on its own — suppression is of the wake, not + // of the send; the composer is never gated on a pending start again. + await expect( + page.getByTestId("message-row").filter({ hasText: "also Y" }), + ).toBeVisible(); + // One wake serves both messages: its replay floor predates the first + // message, and the floor is a lower bound, so one harness boot covers both. + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount + 1, + ); +}); + test("a detached agent start failure surfaces as a toast after the message sends", async ({ page, }) => { From 3d1cd840b76089bca0d27cda5089981c0ebcca22 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 11:35:35 +1000 Subject: [PATCH 09/18] fix(desktop): refuse an unscoped detached agent start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useDetachedAgentStart` fell back to `undefined` for both halves of its tenant scope when the active community or the identity query had not resolved. The backend reads a missing `expectedRelayUrl` / `expectedSignerPubkey` as "no assertion" (`assert_expected_relay_scope` returns `Ok(())` for `None`), so in that window the wake fired fully unscoped — silently degrading the fail-closed guarantee the hook exists to provide, and collapsing its dedupe key to a relay-less one shared across communities. A blank stored relay URL had the same effect: the backend discards a whitespace-only scope exactly as it discards a missing one, and only `""` was screened out here. Refuse the wake instead of firing it: - Both scope values are checked before anything else in the callback; a missing one returns `false` (so the send-perf summary counts no wake) and raises the toast. Emptiness is now judged on the trimmed relay URL, matching the backend, while the value still reaches it verbatim so the case-sensitive comparison holds. - Deferring until the query resolves was the alternative and is wrong here: reading the scope after the send is exactly the post-switch re-read the per-render capture rules out. Refusing is recoverable — the user's next send re-fires the wake. - The refusal toast reuses the failure path's wording via a shared `warnAgentMayNotRespond`, because publish-first means the message went out in both cases and the user is owed the same warning; only the trailing detail differs ("Buzz is still connecting to this community — mention the agent again in a moment."). Three unit tests pin it, each confirmed red without the guard: a wake fired while `get_identity` is held open makes no `start_managed_agent` call, reports that it fired nothing, warns the user, and still fires fully scoped once the query lands; a wake with no active community is refused; a whitespace-only relay URL is refused rather than passed as no assertion. The harness's "wait one tick for the identity query" also became load-bearing with this change — an under-wait now reads as a phantom suppression bug rather than passing on an unscoped call — so `renderDetachedStart` waits on the rendered identity value instead. That under-wait was already happening: the existing dedupe test fails against the fix until the harness waits properly. The repository file-size ratchet stays red on this branch for app_state.rs, agents.rs, managed_agents/runtime.rs and useMentionSendFlow.ts. All four were already over before this change; this commit touches none of them. Verified: desktop tsc --noEmit, biome check over src + tests, desktop unit tests (5818 passed, 0 failed), and the mentions (86) and channels (89) Playwright smoke suites against a pnpm build:e2e bundle. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../ui/useDetachedAgentStart.test.mjs | 143 +++++++++++++++++- .../messages/ui/useDetachedAgentStart.ts | 54 +++++-- 2 files changed, 180 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index 39fbd2a4607..fac5d6ed4ac 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -14,6 +14,9 @@ * covered by the composer's `isPending` gate plus the mutation's success * cache write, and for the whole detached window the cache still reads * `stopped`, so a second send re-fires. + * 3. A scope that is not yet known is not a scope: the backend reads a missing + * relay or signer as "no assertion", so the wake is refused rather than + * fired unscoped. * * These tests drive the real hook against the real CommunitiesProvider. */ @@ -44,6 +47,13 @@ let startCalls = []; */ let heldStarts = []; let holdStarts = false; +/** + * Settlers for `get_identity` calls held open by `holdIdentity` — the window + * before the identity query resolves, where the signer half of the scope is + * simply not known yet. + */ +let heldIdentity = []; +let holdIdentity = false; before(() => { Object.assign(globalThis, { @@ -56,7 +66,11 @@ before(() => { dom.window.__TAURI_INTERNALS__ = { invoke: (command, args) => { if (command === "get_identity") { - return Promise.resolve({ pubkey: SELF, display_name: "Me" }); + const identity = { pubkey: SELF, display_name: "Me" }; + if (!holdIdentity) return Promise.resolve(identity); + return new Promise((resolve) => { + heldIdentity.push(() => resolve(identity)); + }); } if (command === "start_managed_agent") { startCalls.push(args); @@ -80,10 +94,14 @@ after(() => dom.window.close()); afterEach(async () => { // Tests that pin suppression leave their wake deliberately in flight, and - // node:test never finishes a file with a promise that will not settle. + // node:test never finishes a file with a promise that will not settle. The + // same goes for an identity query a test deliberately left pending. const outstanding = heldStarts; heldStarts = []; for (const held of outstanding) held.resolve(); + const outstandingIdentity = heldIdentity; + heldIdentity = []; + for (const resolveIdentity of outstandingIdentity) resolveIdentity(); await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -91,6 +109,12 @@ beforeEach(async () => { startCalls = []; heldStarts = []; holdStarts = false; + heldIdentity = []; + holdIdentity = false; + // Toasts queue on a module-level store with no mounted, so one + // test's warning would otherwise be visible to the next. + const { toast } = await import("sonner"); + toast.dismiss(); // The in-flight map is a module singleton, so a start held open by one test // would otherwise suppress the next test's. const { resetDetachedAgentStarts } = await import( @@ -124,6 +148,9 @@ beforeEach(async () => { * Renders the real hook under the real communities provider, exposing the * detached-start callback alongside `switchCommunity` so a test can move the * active community out from under an already-captured callback. + * + * `act` is returned too, so a test can drive the render that follows a + * deliberately-delayed identity resolution. */ async function renderDetachedStart() { const { default: React } = await import("react"); @@ -134,6 +161,7 @@ async function renderDetachedStart() { const { CommunitiesProvider, useCommunities } = await import( "@/features/communities/useCommunities.tsx" ); + const { useIdentityQuery } = await import("@/shared/api/hooks.ts"); const { useDetachedAgentStart } = await import("./useDetachedAgentStart.ts"); const client = new QueryClient({ @@ -150,18 +178,31 @@ async function renderDetachedStart() { ); const rendered = renderHook( () => ({ + identityPubkey: useIdentityQuery().data?.pubkey, startDetached: useDetachedAgentStart(), switchCommunity: useCommunities().switchCommunity, }), { wrapper }, ); - // Let the identity query resolve so the signer scope is populated. - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); + if (!holdIdentity) await waitForIdentity(act, rendered); return { act, rendered }; } +/** + * Flushes until the identity query has landed, rather than for a fixed number + * of ticks: a wake fired before the signer scope resolves is now refused, so + * an under-wait would surface as a phantom suppression bug. + */ +async function waitForIdentity(act, rendered) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (rendered.result.current.identityPubkey) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + assert.fail("the identity query never resolved"); +} + const AGENT_RECORD = { pubkey: AGENT, name: "fizz" }; const OTHER_AGENT_RECORD = { pubkey: OTHER_AGENT, name: "buzz" }; @@ -300,6 +341,96 @@ test("a failed wake clears the key instead of latching the agent", async () => { rendered.unmount(); }); +/** The titles of every toast raised since the last `beforeEach`. */ +async function toastTitles() { + const { toast } = await import("sonner"); + return toast.getToasts().map((entry) => String(entry.title ?? "")); +} + +const MAY_NOT_RESPOND = /your message was sent, but the agent may not respond/; + +test("a wake is refused while the identity that would scope it is unresolved", async () => { + // The window before `get_identity` lands. `expectedSignerPubkey` would be + // undefined here, and the backend reads that as "no assertion" — so the + // wake would resolve the signing identity at execution time, which is the + // cross-tenant spawn the scope exists to prevent. + holdIdentity = true; + const { act, rendered } = await renderDetachedStart(); + + let fired; + await act(async () => { + fired = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(fired, false, "a refused wake must not be counted as one"); + assert.equal(startCalls.length, 0, "an unscoped wake must not be fired"); + assert.match( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "publish-first means the message went out; the user has to be told the agent did not wake", + ); + + // The refusal covers this moment, not the session: once the query lands the + // next send fires a fully scoped wake. + for (const resolveIdentity of heldIdentity.splice(0)) resolveIdentity(); + await waitForIdentity(act, rendered); + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), true); + await settle(); + }); + + assert.equal(startCalls.length, 1); + assert.equal(startCalls[0].expectedRelayUrl, RELAY_A); + assert.equal(startCalls[0].expectedSignerPubkey, SELF); + rendered.unmount(); +}); + +test("a wake is refused when no community is active to scope it", async () => { + window.localStorage.setItem("buzz-communities", JSON.stringify([])); + window.localStorage.removeItem("buzz-active-community-id"); + const { act, rendered } = await renderDetachedStart(); + + let fired; + await act(async () => { + fired = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(fired, false); + assert.equal(startCalls.length, 0); + assert.match((await toastTitles()).join("\n"), MAY_NOT_RESPOND); + rendered.unmount(); +}); + +test("a blank relay URL is refused rather than sent as no assertion", async () => { + // `assert_expected_relay_scope` discards a whitespace-only scope exactly as + // it discards a missing one, so emptiness has to be judged on the trimmed + // form here — a blank stored URL is an unscoped wake, not a scoped one. + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "community-blank", + name: "Blank", + relayUrl: " ", + pubkey: SELF, + addedAt: "2026-01-01T00:00:00Z", + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", "community-blank"); + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), false); + await settle(); + }); + + assert.equal(startCalls.length, 0); + rendered.unmount(); +}); + test("a wake for the same agent in another community is not suppressed", async () => { holdStarts = true; const { act, rendered } = await renderDetachedStart(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index 56e902161d8..9145710e791 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -47,6 +47,18 @@ function detachedStartFailureDetail(error: unknown): string { : message; } +/** + * The one wording for "the wake did not happen". Publish-first means the + * message went out either way, so both the refused-before-firing and the + * failed-after-firing cases owe the user the same warning, differing only in + * the detail that follows it. + */ +function warnAgentMayNotRespond(agentName: string, detail: string): void { + toast.error( + `Could not start ${agentName} — your message was sent, but the agent may not respond. ${detail}`, + ); +} + /** * Fire-and-forget managed-agent start for the publish-first mention send, * bound to the tenant scope that was active when the send fired. @@ -67,6 +79,16 @@ function detachedStartFailureDetail(error: unknown): string { * that were active when the composer last rendered, which is the send the * user pressed — never a value re-read after the switch it guards against. * + * If either half of that scope is not yet known — no active community, or an + * identity query that has not resolved — the wake is refused rather than fired + * unscoped. The backend reads a missing value as "no assertion", so an + * unscoped detached start is exactly the cross-tenant spawn this hook exists + * to prevent, and its dedupe key would collapse to a relay-less one shared + * across communities. Waiting for the query instead of refusing is not an + * option: reading the scope once it resolves is a post-send read, which is the + * thing the per-render capture rules out. Refusing is visible (a toast, and no + * wake counted) and the user's next send re-fires it. + * * Returns whether this call actually fired a wake: a start already in flight * for the same agent in the same tenant is suppressed, since the wake is * per-agent rather than per-message and the first start's replay floor is @@ -81,18 +103,32 @@ export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { // through `relay_http_base_url` (trim, strip trailing slash, ws→http), and // that comparison is case-sensitive. Lowercasing here — as the shared // storage-key normalizer does — would turn a stored `wss://Relay.Example` - // into a permanent spurious mismatch that refuses every wake. - const expectedRelayUrl = activeCommunity?.relayUrl || undefined; - // The signer check is case-insensitive, so canonicalizing is free here. - const expectedSignerPubkey = identityQuery.data?.pubkey - ? normalizePubkey(identityQuery.data.pubkey) + // into a permanent spurious mismatch that refuses every wake. Emptiness is + // judged on the trimmed form because the backend does the same, and reads a + // blank scope as no assertion at all. + const expectedRelayUrl = activeCommunity?.relayUrl?.trim() + ? activeCommunity.relayUrl : undefined; + // The signer check is case-insensitive, so canonicalizing is free here. + const expectedSignerPubkey = + normalizePubkey(identityQuery.data?.pubkey ?? "") || undefined; return React.useCallback( (agent: ManagedAgent) => { + if (!expectedRelayUrl || !expectedSignerPubkey) { + // Fail closed: an unscoped start resolves the relay and the signing + // identity at execution time, so it can land on whichever tenant is + // active by then — and its dedupe key would be shared across + // communities. + warnAgentMayNotRespond( + agent.name, + "Buzz is still connecting to this community — mention the agent again in a moment.", + ); + return false; + } // No synchronisation is needed or possible: the check, the call and the // registration below sit in one synchronous block, so no other send can // interleave between "is it in the set?" and "put it in the set". - const key = `${expectedRelayUrl ?? ""}\u0000${normalizePubkey(agent.pubkey)}`; + const key = `${expectedRelayUrl}\u0000${normalizePubkey(agent.pubkey)}`; if (inFlightDetachedStarts.has(key)) { // One wake serves both messages. A local duplicate is a backend no-op // anyway, but a provider redeploy can replace a harness that had just @@ -112,11 +148,7 @@ export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { replayFloorUnix, }) .catch((error: unknown) => { - toast.error( - `Could not start ${agent.name} — your message was sent, but the agent may not respond. ${detachedStartFailureDetail( - error, - )}`, - ); + warnAgentMayNotRespond(agent.name, detachedStartFailureDetail(error)); }) .finally(() => { // Identity-guarded: `resetDetachedAgentStarts` may have cleared the From 79a5ef856c81c08d9142a7f7a95b7ebe5adb405e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 11:55:07 +1000 Subject: [PATCH 10/18] refactor(desktop): split four files back under the size ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send-path work pushed four files past the repository file-size gate. Extract one cohesive unit from each rather than raising a ceiling: - `runtime.rs` → `runtime/setup_payload.rs`: the readiness → payload JSON → `BUZZ_ACP_SETUP_PAYLOAD` write, as `apply_setup_payload_env`. - `commands/agents.rs` → `commands/agents_create_fields.rs`: the pure create-request field validators/resolvers. - `app_state.rs` → `app_state_accessors.rs`: the inherent `AppState` lock accessors, leaving the struct, builder, and identity resolution. - `useMentionSendFlow.ts` → `useEnsureAgentMentionsReady.ts`: the mentioned agent access-policy / membership / detached-wake reconciliation. Pure moves. The one behavioral difference is that the new hook depends on the stable `mutateAsync` rather than the whole mutation object, so `ensureManagedAgentMentionsReady` no longer changes identity every render. Signed-off-by: Matt Toohey --- desktop/src-tauri/src/app_state.rs | 78 +-------- desktop/src-tauri/src/app_state_accessors.rs | 87 ++++++++++ desktop/src-tauri/src/commands/agents.rs | 60 +------ .../src/commands/agents_create_fields.rs | 49 ++++++ .../src-tauri/src/managed_agents/runtime.rs | 114 +------------ .../managed_agents/runtime/setup_payload.rs | 125 ++++++++++++++ .../ui/useEnsureAgentMentionsReady.ts | 156 ++++++++++++++++++ .../messages/ui/useMentionSendFlow.ts | 111 +------------ 8 files changed, 442 insertions(+), 338 deletions(-) create mode 100644 desktop/src-tauri/src/app_state_accessors.rs create mode 100644 desktop/src-tauri/src/commands/agents_create_fields.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs create mode 100644 desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index c25dbe3a16b..f1136e88923 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -245,82 +245,8 @@ pub fn build_app_state() -> AppState { } } -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, String> { - self.huddle_state.lock().map_err(|e| e.to_string()) - } - - pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { - 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 { - 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); - } -} +#[path = "app_state_accessors.rs"] +mod accessors; /// Resolve the user's identity key from the app data directory and wire /// the resulting [`RecoveryState`] into `AppState`. diff --git a/desktop/src-tauri/src/app_state_accessors.rs b/desktop/src-tauri/src/app_state_accessors.rs new file mode 100644 index 00000000000..72744e1605e --- /dev/null +++ b/desktop/src-tauri/src/app_state_accessors.rs @@ -0,0 +1,87 @@ +//! 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, String> { + self.huddle_state.lock().map_err(|e| e.to_string()) + } + + pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { + 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 { + 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); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index aa6c1abcd7d..afccad4c4ed 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -9,13 +9,12 @@ use crate::{ bestie_assignment::{recover_pending_assignment_cleanup, with_agent_assignments_cleared}, build_managed_agent_summary, current_instance_id, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, load_teams, - managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, - sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + managed_agents_base_dir, normalize_agent_args, resolve_provider_binary, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, + validate_provider_config, BackendKind, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::relay_ws_url_with_override, util::now_iso, @@ -56,50 +55,9 @@ pub(super) fn summarize_from_disk( ) } -fn normalize_relay_mesh( - config: Option<&RelayMeshConfig>, - backend: &BackendKind, -) -> Result, String> { - let Some(config) = config else { - return Ok(None); - }; - - let model_ref = config.model_ref.trim(); - if model_ref.is_empty() { - return Err("Buzz shared compute model is required".to_string()); - } - if backend != &BackendKind::Local { - return Err("Buzz shared compute agents must use the local backend".to_string()); - } - - Ok(Some(RelayMeshConfig { - model_ref: model_ref.to_string(), - })) -} - -fn trim_to_optional_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn resolve_created_avatar_url( - requested_avatar_url: Option<&str>, - persona_avatar_url: Option, - agent_command: &str, -) -> Option { - requested_avatar_url - .and_then(trim_to_optional_string) - .or_else(|| { - persona_avatar_url - .as_deref() - .and_then(trim_to_optional_string) - }) - .or_else(|| managed_agent_avatar_url(agent_command)) -} +#[path = "agents_create_fields.rs"] +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( diff --git a/desktop/src-tauri/src/commands/agents_create_fields.rs b/desktop/src-tauri/src/commands/agents_create_fields.rs new file mode 100644 index 00000000000..16f840ba2e8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_create_fields.rs @@ -0,0 +1,49 @@ +//! Field normalization for `create_managed_agent` — the pure validators and +//! resolvers its request-to-record mapping runs before any side effect. + +use crate::managed_agents::{managed_agent_avatar_url, BackendKind, RelayMeshConfig}; + +pub(super) fn normalize_relay_mesh( + config: Option<&RelayMeshConfig>, + backend: &BackendKind, +) -> Result, String> { + let Some(config) = config else { + return Ok(None); + }; + + let model_ref = config.model_ref.trim(); + if model_ref.is_empty() { + return Err("Buzz shared compute model is required".to_string()); + } + if backend != &BackendKind::Local { + return Err("Buzz shared compute agents must use the local backend".to_string()); + } + + Ok(Some(RelayMeshConfig { + model_ref: model_ref.to_string(), + })) +} + +pub(super) fn trim_to_optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(super) fn resolve_created_avatar_url( + requested_avatar_url: Option<&str>, + persona_avatar_url: Option, + agent_command: &str, +) -> Option { + requested_avatar_url + .and_then(trim_to_optional_string) + .or_else(|| { + persona_avatar_url + .as_deref() + .and_then(trim_to_optional_string) + }) + .or_else(|| managed_agent_avatar_url(agent_command)) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3926ea9d33f..b8d586b32af 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -27,6 +27,9 @@ pub(crate) use metadata::{ runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, REPLAY_FLOOR_ENV_VAR, SESSION_TITLE_ENV_VAR, }; +mod setup_payload; +use setup_payload::apply_setup_payload_env; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -596,114 +599,9 @@ pub fn spawn_agent_child( } // ── Readiness check: set setup-payload if agent is not ready ───────────── - // - // Build the effective env, run the readiness predicate, and serialize any - // missing requirements into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp enters - // setup-listener mode when this env var is present. - // - // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS (user env cannot - // set it). We also remove it after writing user env as a parent-process guard, - // then set it only when desktop computes NotReady — desktop is the sole source. - // - // `spawned_setup_mode` is captured outside the block to stamp - // `ManagedAgentProcess` (used by `install_acp_runtime` for auto-restart). - let spawned_setup_mode; - { - use crate::managed_agents::readiness::EffectiveAgentEnv; - use crate::managed_agents::{agent_readiness, AgentReadiness, Requirement}; - - // Construct EffectiveAgentEnv from the descriptor computed above — no second - // resolver call; the descriptor's env is already the fully layered result. - let effective = EffectiveAgentEnv { - env: descriptor.env.clone(), - config_file_path: runtime_meta.and_then(|r| r.config_file_path), - effective_command: descriptor.command.clone(), - }; - // Compute the optional payload before touching the command. - let setup_payload_json = - if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { - let reqs: Vec = requirements - .into_iter() - .map(|r| match r { - Requirement::NormalizedField { field } => serde_json::json!({ - "surface": "normalized_field", - "field": field, - }), - Requirement::EnvKey { key } => serde_json::json!({ - "surface": "env_key", - "key": key, - }), - Requirement::CliLogin { - probe_args, - setup_copy, - availability, - } => serde_json::json!({ - "surface": "cli_login", - "probe_args": probe_args, - "setup_copy": setup_copy, - "availability": availability, - }), - Requirement::CliConfigInvalid { - probe_args, - setup_copy, - diagnostic, - } => serde_json::json!({ - "surface": "cli_config_invalid", - "probe_args": probe_args, - "setup_copy": setup_copy, - "diagnostic": diagnostic, - }), - Requirement::GitBash => serde_json::json!({ - "surface": "git_bash", - }), - Requirement::MissingBinary { command } => serde_json::json!({ - "surface": "missing_binary", - "command": command, - }), - }) - .collect(); - let payload = serde_json::json!({ - "agent_name": record.name, - "agent_pubkey": record.pubkey, - "requirements": reqs, - }); - match serde_json::to_string(&payload) { - Ok(json) => Some(json), - Err(e) => { - eprintln!( - "buzz-desktop: failed to serialize setup payload for {}: {e}", - record.name - ); - None - } - } - } else { - None - }; - - spawned_setup_mode = setup_payload_json.is_some(); - - // Strip the key from the process-spawned command on every path. - // Two independent guards protect the invariant: - // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so - // merged_user_env() can never write it via saved/persona env. - // 2. This env_remove() clears any ambient parent-process value - // inherited by std::process::Command before we conditionally - // set the desktop-computed trusted value below. - // Note: merged_user_env() is written further below in this function; - // ordering relative to that call is NOT what makes this safe — the - // reserved-key strip (guard 1) handles user env regardless of order. - command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); - - // Set the payload only when desktop computed NotReady. - if let Some(json) = setup_payload_json { - command.env("BUZZ_ACP_SETUP_PAYLOAD", json); - eprintln!( - "buzz-desktop: agent {} not ready — spawning in setup-listener mode", - record.name - ); - } - } + // `spawned_setup_mode` is stamped on `ManagedAgentProcess` below. + let spawned_setup_mode = + apply_setup_payload_env(&mut command, record, &descriptor, runtime_meta); // Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness // DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated // BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s). diff --git a/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs new file mode 100644 index 00000000000..6e3456c0795 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/setup_payload.rs @@ -0,0 +1,125 @@ +//! Setup-listener payload for a spawn whose agent is not ready to run. +//! +//! The desktop is the sole readiness source; buzz-acp only transports the +//! payload. Kept beside the spawn rather than inside it so the readiness → +//! JSON → env write path reads as one unit. + +use crate::managed_agents::readiness::{EffectiveAgentEnv, EffectiveHarnessDescriptor}; +use crate::managed_agents::{ + agent_readiness, AgentReadiness, KnownAcpRuntime, ManagedAgentRecord, Requirement, +}; + +/// Build the effective env the agent would have at start-time, run the +/// readiness predicate, and if anything is missing, serialize the payload into +/// `BUZZ_ACP_SETUP_PAYLOAD`. buzz-acp detects this env var on startup and +/// enters the minimal setup-listener mode instead of the agent pool. +/// +/// Returns whether the payload was set — stamped on `ManagedAgentProcess` and +/// used by `install_acp_runtime` to target only stuck agents for auto-restart. +/// +/// SECURITY: `BUZZ_ACP_SETUP_PAYLOAD` is in `RESERVED_ENV_KEYS` so user env +/// cannot set it, but we also explicitly remove it after writing user env to +/// guard against the parent-process environment. We then set it only when +/// desktop has computed `NotReady`. +/// +/// The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: +/// `{ "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] }` +pub(super) fn apply_setup_payload_env( + command: &mut std::process::Command, + record: &ManagedAgentRecord, + descriptor: &EffectiveHarnessDescriptor, + runtime_meta: Option<&'static KnownAcpRuntime>, +) -> bool { + // Construct EffectiveAgentEnv from the descriptor the caller resolved — no + // second resolver call; the descriptor's env is already the fully layered + // result. + let effective = EffectiveAgentEnv { + env: descriptor.env.clone(), + config_file_path: runtime_meta.and_then(|r| r.config_file_path), + effective_command: descriptor.command.clone(), + }; + // Compute the optional payload before touching the command. + let setup_payload_json = + if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { + let reqs: Vec = requirements + .into_iter() + .map(|r| match r { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + availability, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + "availability": availability, + }), + Requirement::CliConfigInvalid { + probe_args, + setup_copy, + diagnostic, + } => serde_json::json!({ + "surface": "cli_config_invalid", + "probe_args": probe_args, + "setup_copy": setup_copy, + "diagnostic": diagnostic, + }), + Requirement::GitBash => serde_json::json!({ + "surface": "git_bash", + }), + Requirement::MissingBinary { command } => serde_json::json!({ + "surface": "missing_binary", + "command": command, + }), + }) + .collect(); + let payload = serde_json::json!({ + "agent_name": record.name, + "agent_pubkey": record.pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!( + "buzz-desktop: failed to serialize setup payload for {}: {e}", + record.name + ); + None + } + } + } else { + None + }; + + // Strip the key from the process-spawned command on every path. + // Two independent guards protect the invariant: + // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so + // merged_user_env() can never write it via saved/persona env. + // 2. This env_remove() clears any ambient parent-process value + // inherited by std::process::Command before we conditionally + // set the desktop-computed trusted value below. + // Note: merged_user_env() is written later in the caller; ordering + // relative to that call is NOT what makes this safe — the reserved-key + // strip (guard 1) handles user env regardless of order. + command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); + + // Set the payload only when desktop computed NotReady. + let Some(json) = setup_payload_json else { + return false; + }; + command.env("BUZZ_ACP_SETUP_PAYLOAD", json); + eprintln!( + "buzz-desktop: agent {} not ready — spawning in setup-listener mode", + record.name + ); + true +} diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts new file mode 100644 index 00000000000..940d1bbb23b --- /dev/null +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts @@ -0,0 +1,156 @@ +import * as React from "react"; +import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"; +import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + getErrorMessage, + isManagedAgentRunning, + isProviderBackedAgent, + uniqueNormalizedPubkeys, +} from "./useMentionSendFlow.helpers"; + +/** What the send path learned while making the mentioned agents ready. */ +export type EnsureAgentMentionsReadyResult = { + /** + * Agent wakes this call actually fired. A wake is detached, so a send that + * fired one should still read as fast as one that did not; a wake already in + * flight is suppressed and does not count. + */ + detachedStarts: number; + errors: string[]; + pubkeys: string[]; + /** + * Whether an awaited relay write ran, which tells the publish boundary that + * something separated it from the pre-side-effect authorization pass. + */ + wroteRelayState: boolean; +}; + +export type EnsureAgentMentionsReady = ( + mentionPubkeys: string[], + capturedChannelId: string, + preparedParticipantPubkeys?: string[], + preparedManagedAgents?: ManagedAgent[], +) => Promise; + +type AttachAgentToChannel = (input: { + channelId: string; + agent: ManagedAgent; + role: "bot"; + detachedStart: (agent: ManagedAgent) => void; +}) => Promise; + +type UseEnsureAgentMentionsReadyOptions = { + attachAgentToChannel: AttachAgentToChannel; + getManagedAgentsByPubkey: () => Promise>; + getPersonas: () => Promise; + memberPubkeys: ReadonlySet; + startAgentDetached: (agent: ManagedAgent) => boolean; +}; + +/** + * Reconcile every mentioned managed agent into a state where it will see the + * message about to be published: access policy applied, channel membership + * written, and — for an agent that is not already up — a detached wake fired. + * + * The membership write is awaited because the harness only subscribes to + * channels it belongs to; only the start itself is detached. + */ +export function useEnsureAgentMentionsReady({ + attachAgentToChannel, + getManagedAgentsByPubkey, + getPersonas, + memberPubkeys, + startAgentDetached, +}: UseEnsureAgentMentionsReadyOptions): EnsureAgentMentionsReady { + return React.useCallback( + async ( + mentionPubkeys: string[], + capturedChannelId: string, + preparedParticipantPubkeys: string[] = [], + preparedManagedAgents: ManagedAgent[] = [], + ) => { + if (!capturedChannelId || mentionPubkeys.length === 0) { + return { + detachedStarts: 0, + errors: [] as string[], + pubkeys: [] as string[], + wroteRelayState: false, + }; + } + const [managedAgentsByPubkey, personas] = await Promise.all([ + getManagedAgentsByPubkey(), + getPersonas(), + ]); + for (const agent of preparedManagedAgents) { + managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); + } + const existingMembers = new Set([...memberPubkeys].map(normalizePubkey)); + const participants = new Set([ + ...existingMembers, + ...preparedParticipantPubkeys.map(normalizePubkey), + ]); + const errors: string[] = []; + const pubkeys: string[] = []; + let wroteRelayState = false; + let detachedStarts = 0; + const countDetachedStart = (agent: ManagedAgent) => { + if (startAgentDetached(agent)) detachedStarts += 1; + }; + for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { + const agent = managedAgentsByPubkey.get(pubkey); + if (!agent) continue; + try { + const { agent: readyAgent, wrote } = existingMembers.has(pubkey) + ? { agent, wrote: false } + : await applyReusableAgentAccessPolicy( + agent, + {}, + personas.find((persona) => persona.id === agent.personaId), + ); + if (wrote) { + // The access-policy reconciliation hit the relay; a matching + // policy reports `wrote: false` and stays on the fast path. + wroteRelayState = true; + } + if (participants.has(pubkey)) { + if ( + (isProviderBackedAgent(readyAgent) && + readyAgent.status !== "deployed") || + (!isProviderBackedAgent(readyAgent) && + !isManagedAgentRunning(readyAgent)) + ) { + countDetachedStart(readyAgent); + } + } else { + await attachAgentToChannel({ + channelId: capturedChannelId, + agent: readyAgent, + role: "bot", + detachedStart: countDetachedStart, + }); + wroteRelayState = true; + } + pubkeys.push(pubkey); + } catch (error) { + errors.push( + `${agent.name}: ${getErrorMessage(error, "Could not prepare agent.")}`, + ); + } + } + return { + detachedStarts, + errors, + pubkeys: uniqueNormalizedPubkeys(pubkeys), + wroteRelayState, + }; + }, + [ + attachAgentToChannel, + getManagedAgentsByPubkey, + getPersonas, + memberPubkeys, + startAgentDetached, + ], + ); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 3c1a8ed952b..e13ea3fc301 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -9,7 +9,6 @@ import { usePersonasQuery, useProvisionChannelManagedAgentMutation, } from "@/features/agents/hooks"; -import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; @@ -25,6 +24,7 @@ import { } from "@/features/messages/lib/imetaMediaMarkdown"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { useDetachedAgentStart } from "./useDetachedAgentStart"; +import { useEnsureAgentMentionsReady } from "./useEnsureAgentMentionsReady"; import { createSendPerfTimer } from "./sendPerfLog"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; @@ -33,8 +33,6 @@ import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { formatMessageSendError, getErrorMessage, - isManagedAgentRunning, - isProviderBackedAgent, mergeMentionRecipients, MENTION_REFERENCE_TAG, mergeOutgoingTagsWithReferenceMentions, @@ -135,106 +133,13 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); - const ensureManagedAgentMentionsReady = React.useCallback( - async ( - mentionPubkeys: string[], - capturedChannelId: string, - preparedParticipantPubkeys: string[] = [], - preparedManagedAgents: ManagedAgent[] = [], - ) => { - if (!capturedChannelId || mentionPubkeys.length === 0) { - return { - detachedStarts: 0, - errors: [] as string[], - pubkeys: [] as string[], - wroteRelayState: false, - }; - } - const [managedAgentsByPubkey, personas] = await Promise.all([ - getManagedAgentsByPubkey(), - getPersonas(), - ]); - for (const agent of preparedManagedAgents) { - managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); - } - const existingMembers = new Set( - [...mentions.memberPubkeys].map(normalizePubkey), - ); - const participants = new Set([ - ...existingMembers, - ...preparedParticipantPubkeys.map(normalizePubkey), - ]); - const errors: string[] = []; - const pubkeys: string[] = []; - // Reported to the caller so the publish boundary knows whether awaited - // relay writes separated it from the pre-side-effect authorization pass. - let wroteRelayState = false; - // Reported for the send-perf summary: an agent wake is detached, so a - // send that fired one should still read as fast as one that did not. - // Only real fires count — a wake already in flight is suppressed. - let detachedStarts = 0; - const countDetachedStart = (agent: ManagedAgent) => { - if (startAgentDetached(agent)) detachedStarts += 1; - }; - for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { - const agent = managedAgentsByPubkey.get(pubkey); - if (!agent) continue; - try { - const { agent: readyAgent, wrote } = existingMembers.has(pubkey) - ? { agent, wrote: false } - : await applyReusableAgentAccessPolicy( - agent, - {}, - personas.find((persona) => persona.id === agent.personaId), - ); - if (wrote) { - // The access-policy reconciliation hit the relay; a matching - // policy reports `wrote: false` and stays on the fast path. - wroteRelayState = true; - } - if (participants.has(pubkey)) { - if ( - (isProviderBackedAgent(readyAgent) && - readyAgent.status !== "deployed") || - (!isProviderBackedAgent(readyAgent) && - !isManagedAgentRunning(readyAgent)) - ) { - countDetachedStart(readyAgent); - } - } else { - // The membership write must land before the publish (the harness - // only subscribes to channels it's a member of); only the start - // itself is detached. - await attachAgentMutation.mutateAsync({ - channelId: capturedChannelId, - agent: readyAgent, - role: "bot", - detachedStart: countDetachedStart, - }); - wroteRelayState = true; - } - pubkeys.push(pubkey); - } catch (error) { - errors.push( - `${agent.name}: ${getErrorMessage(error, "Could not prepare agent.")}`, - ); - } - } - return { - detachedStarts, - errors, - pubkeys: uniqueNormalizedPubkeys(pubkeys), - wroteRelayState, - }; - }, - [ - attachAgentMutation, - getManagedAgentsByPubkey, - getPersonas, - mentions.memberPubkeys, - startAgentDetached, - ], - ); + const ensureManagedAgentMentionsReady = useEnsureAgentMentionsReady({ + attachAgentToChannel: attachAgentMutation.mutateAsync, + getManagedAgentsByPubkey, + getPersonas, + memberPubkeys: mentions.memberPubkeys, + startAgentDetached, + }); const createMentionedPersonaAgents = React.useCallback( async (trimmed: string, capturedChannelId: string) => { const personaMentions = mentions.extractMentionPersonas(trimmed); From 0b61b0680c54ed8879fa2a6bd2f93d506214d979 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 12:28:20 +1000 Subject: [PATCH 11/18] revert(desktop): remove the send-perf instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send-perf probes added in adc7330 existed to attribute the post-publish-first spinner latency, and that analysis is done — the run log confirmed the shipped fixes and named the residual cost. Remove the instrumentation so the send path carries no per-send probe; every behavior it measured (publish-first wake, replay floor, revalidation dedupe, NIP-11 self cache) stays. - Frontend: delete `sendPerfLog.ts` and its six tests. `completeSend` no longer wraps its awaited steps or records facts; the publish-boundary revalidation returns to the direct three-condition ternary it wrapped (same conditions, same order). The perf-only `detachedStarts` counter leaves `EnsureAgentMentionsReadyResult` — `startAgentDetached` is passed straight through again. Its boolean "did this fire" return stays: the dedupe and fail-closed unit tests pin it; only the doc wording that justified it via the send-perf summary is gone. - Backend: delete `send_perf.rs` (the `Phase` stopwatch, its two tests, and the `log_send_perf` mirror command) and strip the phase timers and log lines from the relay-directory rebuild (`query_all_relay_pages` returns just the events again), the NIP-11 `self` cache hit/miss path, `send_channel_message`'s build/submit split, the shared submit's rate-limiter/HTTP split, and `start_managed_agent` entry. - E2E: drop the `log_send_perf` bridge mock and the one-summary-per-send assertion from the fast-path mentions spec; its revalidation-count assertions remain and still pin the restored ternary. Verified: cargo fmt --check, clippy --all-targets -D warnings, and cargo test --lib on desktop/src-tauri (3054 passed — down exactly the two removed Phase tests), desktop tsc --noEmit, biome check over src + tests (exit 0), desktop unit tests (5850 passed, 0 failed — down exactly the six removed sendPerfLog tests), and the modified mentions spec green against a fresh pnpm build:e2e bundle. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../agent_discovery/relay_directory.rs | 40 +------ desktop/src-tauri/src/commands/agents.rs | 12 -- .../src/commands/identity_archive.rs | 6 - desktop/src-tauri/src/commands/messages.rs | 14 --- desktop/src-tauri/src/lib.rs | 2 - desktop/src-tauri/src/relay/submit.rs | 14 --- desktop/src-tauri/src/send_perf.rs | 77 ------------- .../features/messages/ui/sendPerfLog.test.mjs | 103 ------------------ .../src/features/messages/ui/sendPerfLog.ts | 94 ---------------- .../ui/useDetachedAgentStart.test.mjs | 2 +- .../messages/ui/useDetachedAgentStart.ts | 7 +- .../ui/useEnsureAgentMentionsReady.ts | 16 +-- .../messages/ui/useMentionSendFlow.ts | 100 +++++------------ desktop/src/testing/e2eBridge.ts | 5 - desktop/tests/e2e/mentions.spec.ts | 6 - 15 files changed, 40 insertions(+), 458 deletions(-) delete mode 100644 desktop/src-tauri/src/send_perf.rs delete mode 100644 desktop/src/features/messages/ui/sendPerfLog.test.mjs delete mode 100644 desktop/src/features/messages/ui/sendPerfLog.ts diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index 57f05acb4c3..eac91f5b71d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -93,26 +93,21 @@ pub(super) fn advance_relay_cursor(filter: &mut serde_json::Value, page: &[nostr filter["before_id"] = serde_json::json!(last.id.to_hex()); } -/// Returns the events plus the number of relay pages it took to read them — -/// membership paging is sequential, so the page count is the multiplier on this -/// phase's contribution to send latency. async fn query_all_relay_pages( state: &AppState, mut filter: serde_json::Value, -) -> Result<(Vec, usize), String> { +) -> Result, String> { filter["limit"] = serde_json::json!(RELAY_DIRECTORY_PAGE_SIZE); let mut events = Vec::new(); - let mut pages = 0; loop { let page = query_relay(state, &[filter.clone()]).await?; - pages += 1; let done = page.len() < RELAY_DIRECTORY_PAGE_SIZE; if !done { advance_relay_cursor(&mut filter, &page); } events.extend(page); if done { - return Ok((events, pages)); + return Ok(events); } } } @@ -134,15 +129,10 @@ async fn list_relay_agents_for_selection( requested_pubkeys: Option<&std::collections::HashSet>, channel_id: Option<&str>, ) -> Result, String> { - // Every agent-mention send runs this rebuild at least once, and its phases - // are sequential, so each one is directly on the send's critical path. - let rebuild = crate::send_perf::Phase::start(); let viewer_pubkey = current_user_pubkey(state)?; - let relay_self = crate::send_perf::Phase::start(); let relay_pubkey = identity_archive::fetch_relay_self(state) .await? .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; - let relay_self_ms = relay_self.ms(); // Owned identities are relay state, even when this Desktop has never run // them or they have not joined a channel yet. Owner-authored coordinates @@ -155,7 +145,7 @@ async fn list_relay_agents_for_selection( if let Some(requested_pubkeys) = requested_pubkeys { owned_filter["#d"] = serde_json::json!(requested_pubkeys); } - let (owned_events, _owned_pages) = query_all_relay_pages(state, owned_filter) + let owned_events = query_all_relay_pages(state, owned_filter) .await .map_err(|error| format!("relay owned-agent query failed: {error}"))?; let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); @@ -171,11 +161,9 @@ async fn list_relay_agents_for_selection( if let Some(channel_id) = channel_id { membership_filter["#d"] = serde_json::json!([channel_id]); } - let membership = crate::send_perf::Phase::start(); - let (membership_events, membership_pages) = query_all_relay_pages(state, membership_filter) + let membership_events = query_all_relay_pages(state, membership_filter) .await .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; - let membership_ms = membership.ms(); let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( &membership_events, &relay_pubkey, @@ -184,21 +172,6 @@ async fn list_relay_agents_for_selection( if let Some(requested_pubkeys) = requested_pubkeys { member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); } - // `requested=all` is the autocomplete-facing full rebuild; a number is the - // send path revalidating exactly the mentions it is about to publish. - let requested = - requested_pubkeys.map_or_else(|| "all".to_string(), |pubkeys| pubkeys.len().to_string()); - let log_rebuild = |candidates: usize, batches_ms: f64, policy_ms: f64| { - crate::send_perf::log( - "relay_directory", - &format!( - "requested={requested} candidates={candidates} membership_pages={membership_pages} \ - relay_self_ms={relay_self_ms:.1} membership_ms={membership_ms:.1} \ - batches_ms={batches_ms:.1} policy_ms={policy_ms:.1} total_ms={:.1}", - rebuild.ms() - ), - ); - }; let candidate_pubkeys: Vec = member_agent_channel_ids .keys() .cloned() @@ -207,7 +180,6 @@ async fn list_relay_agents_for_selection( .into_iter() .collect(); if candidate_pubkeys.is_empty() { - log_rebuild(0, 0.0, 0.0); return Ok(Vec::new()); } @@ -217,7 +189,6 @@ async fn list_relay_agents_for_selection( // phases, so its runtime-directory and owner-profile phases below stay // within the ceiling even though `try_join!` runs them concurrently. let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); - let batches = crate::send_perf::Phase::start(); let (directory_events, profile_events) = tokio::try_join!( query_filter_batches( state, @@ -232,7 +203,6 @@ async fn list_relay_agents_for_selection( "relay agent owner-profile query failed", ), )?; - let batches_ms = batches.ms(); // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current @@ -240,7 +210,6 @@ async fn list_relay_agents_for_selection( // the authentic policy out of a bounded result page. let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); - let policy = crate::send_perf::Phase::start(); let managed_agent_events = query_filter_batches( state, &semaphore, @@ -248,7 +217,6 @@ async fn list_relay_agents_for_selection( "relay agent managed-policy query failed", ) .await?; - log_rebuild(candidate_pubkeys.len(), batches_ms, policy.ms()); let mut agents = nostr_convert::relay_agents_from_directory_events( &directory_events, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index afccad4c4ed..0ad7fd321c5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -827,18 +827,6 @@ pub async fn start_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { - // Publish-first sends detach this start, so where this line lands relative - // to `send_channel_message` in the stream is the proof the wake really is - // off the critical path; `replay_floor` is what puts the already-published - // mention inside the spawned harness's first subscription window. - crate::send_perf::log( - "start_managed_agent", - &format!( - "pubkey={} replay_floor={}", - pubkey.chars().take(8).collect::(), - replay_floor_unix.map_or_else(|| "none".to_string(), |floor| floor.to_string()) - ), - ); // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index d2bc0d2c6e9..bf66b761d32 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -365,13 +365,9 @@ pub(crate) async fn fetch_relay_self_at( relay_url: &str, ) -> Result, String> { if let Some(cached) = cached_relay_self(state, relay_url) { - crate::send_perf::log("relay_self", "cache=hit"); return Ok(Some(cached)); } - // A miss fronts an agent revalidation pass with an uncached HTTP GET — the - // exact send-path cost the cache exists to remove, so log either outcome. - let fetch = crate::send_perf::Phase::start(); let http_url = relay_http_base_url(relay_url); let response = state .http_client @@ -380,8 +376,6 @@ pub(crate) async fn fetch_relay_self_at( .send() .await .map_err(|e| classify_request_error(&e))?; - let fetch_ms = fetch.ms(); - crate::send_perf::log("relay_self", &format!("cache=miss fetch_ms={fetch_ms:.1}")); if !response.status().is_success() { return Ok(None); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index bd45061aac6..1e221b6bd18 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -457,10 +457,6 @@ pub async fn send_channel_message( let mut resolved_root: Option = None; - // Replies (`is_reply=true` below) resolve their thread root against the - // relay before the event can be built, so a "slow publish" may really be an - // extra read rather than the write. - let build = crate::send_perf::Phase::start(); let builder = match kind_num { buzz_core_pkg::kind::KIND_FORUM_POST => events::build_forum_post( channel_uuid, @@ -527,18 +523,8 @@ pub async fn send_channel_message( // Submit through the base resolved (and scope-checked) above and the // identity snapshotted (and signer-checked) above — a re-resolve or key // re-read here would reopen the mid-command switch window. - let build_ms = build.ms(); - let submit = crate::send_perf::Phase::start(); let (result, created_at) = submit_event_at_created_at(builder, &state, &relay_base, &signing_keys).await?; - crate::send_perf::log( - "send_channel_message", - &format!( - "kind={kind_num} is_reply={} build_ms={build_ms:.1} submit_ms={:.1}", - parent_event_id.is_some(), - submit.ms() - ), - ); let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bf1977c56ea..12082a2a82e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -41,7 +41,6 @@ mod relay; mod relay_admission; mod reset; mod secret_store; -mod send_perf; mod shutdown; mod team_catalog; mod templates; @@ -522,7 +521,6 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - send_perf::log_send_perf, terminal_runtime::terminal_attach, terminal_runtime::terminal_detach, terminal_runtime::terminal_close, diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 7737a1f6e27..b6a5703fd96 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -22,18 +22,12 @@ pub async fn submit_signed_event_at_with_keys( if event.pubkey != keys.public_key() { return Err("signed event does not match the publishing identity".to_string()); } - // The client-side admission gate can park a publish for seconds after a - // relay 429, and nothing else surfaces that wait — split it from the HTTP - // round trip so a slow send can be attributed to one or the other. - let gate = crate::send_perf::Phase::start(); crate::relay_admission::wait_for_rate_limit().await; - let rate_limit_wait_ms = gate.ms(); let url = format!("{}/events", api_base_url.trim_end_matches('/')); let body_bytes = event.as_json().into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let http = crate::send_perf::Phase::start(); let response = state .http_client .post(&url) @@ -43,14 +37,6 @@ pub async fn submit_signed_event_at_with_keys( .send() .await .map_err(|e| classify_request_error(&e))?; - crate::send_perf::log( - "submit_event", - &format!( - "kind={} rate_limit_wait_ms={rate_limit_wait_ms:.1} http_ms={:.1}", - event.kind.as_u16(), - http.ms() - ), - ); if !response.status().is_success() { return Err(relay_error_message(response).await); diff --git a/desktop/src-tauri/src/send_perf.rs b/desktop/src-tauri/src/send_perf.rs deleted file mode 100644 index 8482d03f248..00000000000 --- a/desktop/src-tauri/src/send_perf.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Timing probe for the message-send path. -//! -//! The send spinner spans every awaited step between the composer clearing and -//! the relay accepting the event, and until now none of those steps logged -//! anything on either side of the bridge — so "the send still feels slow" could -//! only be answered by guessing. These lines name the phase that dominated. -//! -//! The desktop binary installs no `tracing` subscriber, so output goes straight -//! to stderr behind the repo's `buzz-desktop:` prefix; `tracing::info!` here -//! would be silent. Lines are emitted once per send (never per keystroke), so -//! the probe stays on by default without tripping the render-perf measurement -//! guidance in AGENTS.md. -//! -//! [`log_send_perf`] is the frontend's mirror into this same stream. WKWebView -//! drops `console` output produced before a Web Inspector attaches, so the -//! composer's `[send-perf]` summary would otherwise be invisible in a terminal -//! running `just dev` — and impossible to read in order against the backend -//! phases that explain it. - -use std::time::Instant; - -/// Emit one line: `buzz-desktop: send-perf: `. -/// -/// `fields` is free-form `key=value` text; keeping the prefix and scope fixed -/// is what makes a `just dev` terminal greppable down to a single send. -pub fn log(scope: &str, fields: &str) { - eprintln!("buzz-desktop: send-perf: {scope} {fields}"); -} - -/// Millisecond stopwatch for one phase of the send path. -pub struct Phase(Instant); - -impl Phase { - pub fn start() -> Self { - Self(Instant::now()) - } - - /// Elapsed milliseconds to a tenth — finer than the differences being - /// chased, and stable enough to compare two runs by eye. - pub fn ms(&self) -> f64 { - (self.0.elapsed().as_secs_f64() * 10_000.0).round() / 10.0 - } -} - -/// Write the frontend's `[send-perf]` summary into the backend stderr stream. -/// -/// Called fire-and-forget from the composer; `payload` is the already-encoded -/// JSON summary, reproduced verbatim so the two halves of one send read as one -/// record. Async so the write never lands on the UI thread. -#[tauri::command] -pub async fn log_send_perf(label: String, payload: String) { - log(&label, &payload); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn phase_reports_elapsed_milliseconds_at_tenth_resolution() { - let phase = Phase::start(); - let ms = phase.ms(); - - assert!(ms >= 0.0, "elapsed must not be negative: {ms}"); - // A tenth-resolution value survives a round-trip through tenths. - assert_eq!(ms, (ms * 10.0).round() / 10.0); - } - - #[test] - fn phase_is_monotonic_across_reads() { - let phase = Phase::start(); - let first = phase.ms(); - let second = phase.ms(); - - assert!(second >= first, "{second} < {first}"); - } -} diff --git a/desktop/src/features/messages/ui/sendPerfLog.test.mjs b/desktop/src/features/messages/ui/sendPerfLog.test.mjs deleted file mode 100644 index 48ac98e1afc..00000000000 --- a/desktop/src/features/messages/ui/sendPerfLog.test.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { createSendPerfTimer } from "./sendPerfLog.ts"; - -function captureSink() { - const emitted = []; - return { - emitted, - sink: (label, payload) => emitted.push({ label, payload }), - }; -} - -test("a step's resolved value passes straight through", async () => { - const { emitted, sink } = captureSink(); - const timer = createSendPerfTimer("completeSend", {}, sink); - - const value = await timer.step("revalidate1", async () => ["abc"]); - timer.finish(); - - assert.deepEqual(value, ["abc"]); - assert.equal(emitted.length, 1); - assert.equal(emitted[0].label, "completeSend"); - assert.equal(typeof emitted[0].payload.steps.revalidate1, "number"); -}); - -test("a throwing step still records its time and rethrows", async () => { - const { emitted, sink } = captureSink(); - const timer = createSendPerfTimer("completeSend", {}, sink); - - await assert.rejects( - timer.step("publish", async () => { - throw new Error("relay rejected event"); - }), - /relay rejected event/, - ); - timer.finish(); - - // A failed send is exactly when the timing matters, so the step must appear. - assert.equal(typeof emitted[0].payload.steps.publish, "number"); -}); - -test("repeating a step name accumulates rather than overwrites", async () => { - const { emitted, sink } = captureSink(); - const timer = createSendPerfTimer("completeSend", {}, sink); - - await timer.step("revalidate", async () => null); - await timer.step("revalidate", async () => null); - timer.finish(); - - assert.equal(Object.keys(emitted[0].payload.steps).length, 1); - assert.ok(emitted[0].payload.steps.revalidate >= 0); -}); - -test("facts merge across construction, note, and finish", async () => { - const { emitted, sink } = captureSink(); - const timer = createSendPerfTimer( - "completeSend", - { channelType: "dm" }, - sink, - ); - - timer.note({ revalidateTrigger: null, detachedStarts: 0 }); - timer.note({ revalidateTrigger: "relaySideEffects" }); - timer.finish({ detachedStarts: 1 }); - - const { payload } = emitted[0]; - assert.equal(payload.channelType, "dm"); - assert.equal(payload.revalidateTrigger, "relaySideEffects"); - assert.equal(payload.detachedStarts, 1); -}); - -test("finish is idempotent so a finally block cannot double-report", () => { - const { emitted, sink } = captureSink(); - const timer = createSendPerfTimer("completeSend", {}, sink); - - timer.finish(); - timer.finish({ detachedStarts: 9 }); - - assert.equal(emitted.length, 1); - assert.equal(emitted[0].payload.detachedStarts, undefined); -}); - -test("the total spans the whole timer, not just its steps", async () => { - const { emitted, sink } = captureSink(); - const timer = createSendPerfTimer("completeSend", {}, sink); - - await timer.step("revalidate1", async () => null); - await timer.step("publish", async () => null); - timer.finish(); - - const { payload } = emitted[0]; - const stepTotal = Object.values(payload.steps).reduce( - (sum, ms) => sum + ms, - 0, - ); - // Sequential steps, so the total covers them both — allow a tenth of slack - // for the rounding each recorded value already went through. - assert.ok( - payload.totalMs + 0.2 >= stepTotal, - `totalMs ${payload.totalMs} < steps ${stepTotal}`, - ); -}); diff --git a/desktop/src/features/messages/ui/sendPerfLog.ts b/desktop/src/features/messages/ui/sendPerfLog.ts deleted file mode 100644 index 5b6b0e8032c..00000000000 --- a/desktop/src/features/messages/ui/sendPerfLog.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * One-summary-per-send timing probe for the composer's send path. - * - * The send spinner spans every awaited step between the composer clearing and - * the relay accepting the event, and none of those steps logged anything — so - * "the send still feels slow" could only be answered by guessing which step - * dominated. `createSendPerfTimer` wraps each awaited step and emits a single - * `[send-perf]` line naming the winner, alongside the flags that decide which - * steps ran at all (deferred upload, link previews, relay side effects). - * - * The summary is mirrored into the backend's stderr stream through the - * `log_send_perf` command: WKWebView drops `console` output produced before a - * Web Inspector attaches, and the Rust half of the same send already logs its - * phases there. A terminal running `just dev` then shows both halves in order - * with no inspector attached — which also makes a missing `[send-perf]` line - * proof that the running app predates this build. - * - * These fire once per send click, never per keystroke, so they stay on by - * default without disturbing the render-perf measurement guidance in AGENTS.md. - */ - -import { invokeTauri } from "@/shared/api/tauri"; - -/** Non-timing values recorded with a send: counts, flags, the branch taken. */ -export type SendPerfFacts = Record; - -export type SendPerfPayload = SendPerfFacts & { - totalMs: number; - /** Awaited step name to milliseconds, in the order the steps first ran. */ - steps: Record; -}; - -/** Destination for a finished summary. Overridden in tests. */ -export type SendPerfSink = (label: string, payload: SendPerfPayload) => void; - -export type SendPerfTimer = { - /** Time one awaited step. Its resolved value — or thrown error — passes through. */ - step(name: string, run: () => Promise): Promise; - /** Record a fact about this send. Later notes win on a repeated key. */ - note(facts: SendPerfFacts): void; - /** Emit the summary. Repeat calls are ignored, so `finally` blocks are safe. */ - finish(facts?: SendPerfFacts): void; -}; - -function now(): number { - return typeof performance === "undefined" ? Date.now() : performance.now(); -} - -/** Tenths of a millisecond — finer than the differences being chased. */ -function roundMs(elapsed: number): number { - return Math.round(elapsed * 10) / 10; -} - -export const defaultSendPerfSink: SendPerfSink = (label, payload) => { - console.info(`[send-perf] ${label}`, payload); - void invokeTauri("log_send_perf", { - label, - payload: JSON.stringify(payload), - }).catch(() => { - // A probe must never be able to fail — or even warn about — a send. - }); -}; - -export function createSendPerfTimer( - label: string, - facts: SendPerfFacts = {}, - sink: SendPerfSink = defaultSendPerfSink, -): SendPerfTimer { - const startedAt = now(); - const steps: Record = {}; - const recorded: SendPerfFacts = { ...facts }; - let finished = false; - return { - async step(name, run) { - const stepStartedAt = now(); - try { - return await run(); - } finally { - // Accumulate rather than overwrite: a step that runs twice in one send - // should read as its total contribution, not just the last attempt. - steps[name] = roundMs((steps[name] ?? 0) + (now() - stepStartedAt)); - } - }, - note(next) { - Object.assign(recorded, next); - }, - finish(next) { - if (finished) return; - finished = true; - Object.assign(recorded, next); - sink(label, { ...recorded, totalMs: roundMs(now() - startedAt), steps }); - }, - }; -} diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index fac5d6ed4ac..dfa00cee3d3 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -274,7 +274,7 @@ test("a second wake for the same agent is suppressed while the first is in fligh assert.equal( second, false, - "the suppressed call must report that it fired nothing, so the send-perf summary does not count a wake that never happened", + "the suppressed call must report that it fired nothing", ); rendered.unmount(); }); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index 9145710e791..2a53ff4635a 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -86,14 +86,13 @@ function warnAgentMayNotRespond(agentName: string, detail: string): void { * to prevent, and its dedupe key would collapse to a relay-less one shared * across communities. Waiting for the query instead of refusing is not an * option: reading the scope once it resolves is a post-send read, which is the - * thing the per-render capture rules out. Refusing is visible (a toast, and no - * wake counted) and the user's next send re-fires it. + * thing the per-render capture rules out. Refusing is visible (a toast, and a + * `false` return) and the user's next send re-fires it. * * Returns whether this call actually fired a wake: a start already in flight * for the same agent in the same tenant is suppressed, since the wake is * per-agent rather than per-message and the first start's replay floor is - * earlier than the second message. Callers use the result to report only real - * fires (the send-perf summary counts them). + * earlier than the second message. */ export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { const startAgentMutateAsync = useStartManagedAgentMutation().mutateAsync; diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts index 940d1bbb23b..8247c9e8c95 100644 --- a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts @@ -11,12 +11,6 @@ import { /** What the send path learned while making the mentioned agents ready. */ export type EnsureAgentMentionsReadyResult = { - /** - * Agent wakes this call actually fired. A wake is detached, so a send that - * fired one should still read as fast as one that did not; a wake already in - * flight is suppressed and does not count. - */ - detachedStarts: number; errors: string[]; pubkeys: string[]; /** @@ -72,7 +66,6 @@ export function useEnsureAgentMentionsReady({ ) => { if (!capturedChannelId || mentionPubkeys.length === 0) { return { - detachedStarts: 0, errors: [] as string[], pubkeys: [] as string[], wroteRelayState: false, @@ -93,10 +86,6 @@ export function useEnsureAgentMentionsReady({ const errors: string[] = []; const pubkeys: string[] = []; let wroteRelayState = false; - let detachedStarts = 0; - const countDetachedStart = (agent: ManagedAgent) => { - if (startAgentDetached(agent)) detachedStarts += 1; - }; for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); if (!agent) continue; @@ -120,14 +109,14 @@ export function useEnsureAgentMentionsReady({ (!isProviderBackedAgent(readyAgent) && !isManagedAgentRunning(readyAgent)) ) { - countDetachedStart(readyAgent); + startAgentDetached(readyAgent); } } else { await attachAgentToChannel({ channelId: capturedChannelId, agent: readyAgent, role: "bot", - detachedStart: countDetachedStart, + detachedStart: startAgentDetached, }); wroteRelayState = true; } @@ -139,7 +128,6 @@ export function useEnsureAgentMentionsReady({ } } return { - detachedStarts, errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), wroteRelayState, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index e13ea3fc301..abb5a69a01a 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -25,7 +25,6 @@ import { import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { useDetachedAgentStart } from "./useDetachedAgentStart"; import { useEnsureAgentMentionsReady } from "./useEnsureAgentMentionsReady"; -import { createSendPerfTimer } from "./sendPerfLog"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; @@ -269,16 +268,6 @@ export function useMentionSendFlow({ if (isSendCancelled()) return draft.preparedLinkPreviews?.release(); isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); - // `completeSend` is the whole spinner window: it starts with the composer - // clearing and ends after the relay accepts the event. - const perf = createSendPerfTimer("completeSend", { - channelType, - mentions: mentionPubkeys.length, - addressedAgents: draft.addressedAgentPubkeys.length, - isReply: draft.capturedThreadContext != null, - hasAttachments: draft.queuedAttachments.length > 0, - hasLinkPreviews: draft.preparedLinkPreviews != null, - }); const preparedUpload = draft.queuedAttachments.length > 0 ? prepareBackgroundMediaUpload(draft.queuedAttachments) @@ -377,9 +366,7 @@ export function useMentionSendFlow({ let uploadStarted = false; try { const admittedMentionPubkeys = uniqueNormalizedPubkeys( - await perf.step("revalidate1", () => - mentions.revalidateMentionPubkeys(mentionPubkeys), - ), + await mentions.revalidateMentionPubkeys(mentionPubkeys), ); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) return persistPreflightDraft(); @@ -389,10 +376,7 @@ export function useMentionSendFlow({ (pubkey) => admittedMentionPubkeySet.has(pubkey), ), ); - const managedAgentsByPubkey = await perf.step( - "managedAgentsLookup", - getManagedAgentsByPubkey, - ); + const managedAgentsByPubkey = await getManagedAgentsByPubkey(); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); @@ -421,9 +405,7 @@ export function useMentionSendFlow({ // between — a revocation can land during those waits (#5681). let relaySideEffectsRan = false; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { - sendChannelId = await perf.step("prepareSendChannel", () => - onPrepareSendChannel(preparedAgentPubkeys), - ); + sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); relaySideEffectsRan = true; if (isSendCancelled()) return restoreComposerAfterFailure(); if (!sendChannelId) { @@ -434,20 +416,14 @@ export function useMentionSendFlow({ return; } } - const agentReadiness = await perf.step("ensureAgentsReady", () => - ensureManagedAgentMentionsReady( - managedMentionPubkeys.filter( - (pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)), - ), - sendChannelId ?? "", - onPrepareSendChannel ? preparedAgentPubkeys : [], - [...managedAgentsByPubkey.values()], + const agentReadiness = await ensureManagedAgentMentionsReady( + managedMentionPubkeys.filter( + (pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)), ), + sendChannelId ?? "", + onPrepareSendChannel ? preparedAgentPubkeys : [], + [...managedAgentsByPubkey.values()], ); - perf.note({ - wroteRelayState: agentReadiness.wroteRelayState, - detachedStarts: agentReadiness.detachedStarts, - }); if (agentReadiness.wroteRelayState) { relaySideEffectsRan = true; } @@ -469,17 +445,12 @@ export function useMentionSendFlow({ } if (preparedAgentPubkeys.length > 0 && sendChannelId) { try { - const huddleSync = await perf.step("huddleSync", () => - invokeTauri<{ - matched_active_huddle: boolean; - added: string[]; - }>("sync_agents_to_active_huddle", { - channelId: sendChannelId, - agentPubkeys: preparedAgentPubkeys, - }), - ); - perf.note({ - matchedActiveHuddle: huddleSync.matched_active_huddle, + const huddleSync = await invokeTauri<{ + matched_active_huddle: boolean; + added: string[]; + }>("sync_agents_to_active_huddle", { + channelId: sendChannelId, + agentPubkeys: preparedAgentPubkeys, }); if (huddleSync.matched_active_huddle) { // A matched huddle means the sync did relay work (membership @@ -516,8 +487,10 @@ export function useMentionSendFlow({ ), ]), ); - const finalOutgoingTags = await perf.step("resolvePreviewTags", () => - resolvePreviewTags(draft, mediaTags, outgoingTags), + const finalOutgoingTags = await resolvePreviewTags( + draft, + mediaTags, + outgoingTags, ); if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; @@ -529,19 +502,10 @@ export function useMentionSendFlow({ // enrollment) — since authorization could have been revoked during // the gap. On the remaining immediate path a second pass would repeat // the same relay round-trips with the same inputs. - const revalidateTrigger = preparedUpload - ? "upload" - : draft.preparedLinkPreviews - ? "linkPreviews" - : relaySideEffectsRan - ? "relaySideEffects" - : null; - perf.note({ revalidateTrigger }); - const revalidatedMentionPubkeys = revalidateTrigger - ? await perf.step("revalidate2", () => - mentions.revalidateMentionPubkeys(mentionPubkeys), - ) - : admittedMentionPubkeys; + const revalidatedMentionPubkeys = + preparedUpload || draft.preparedLinkPreviews || relaySideEffectsRan + ? await mentions.revalidateMentionPubkeys(mentionPubkeys) + : admittedMentionPubkeys; if (signal?.aborted || isSendCancelled()) return; const finalTagsWithAgentAddress = [ ...finalOutgoingTags, @@ -550,15 +514,13 @@ export function useMentionSendFlow({ revalidatedMentionPubkeys, ), ]; - await perf.step("publish", () => - send( - finalContent, - revalidatedMentionPubkeys, - finalTagsWithAgentAddress, - sendChannelId, - draft.capturedThreadContext, - draft.preparedLinkPreviews != null, - ), + await send( + finalContent, + revalidatedMentionPubkeys, + finalTagsWithAgentAddress, + sendChannelId, + draft.capturedThreadContext, + draft.preparedLinkPreviews != null, ); if (signal?.aborted || isSendCancelled()) return; const sentMentionPubkeys = new Set( @@ -637,7 +599,6 @@ export function useMentionSendFlow({ restoreComposerAfterFailure(); throw error; } finally { - perf.finish(); if (draft.preparedLinkPreviews) { activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); } @@ -650,7 +611,6 @@ export function useMentionSendFlow({ } }, [ - channelType, clearComposer, contentRef, drafts, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3a72d84bf1d..63ae9f7df83 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11956,11 +11956,6 @@ export function maybeInstallE2eTauriMocks() { await emitMockHuddleState(); return structuredClone(settings); } - // Fire-and-forget stderr mirror of the composer's `[send-perf]` summary. - // The real command only writes a log line, so the mock is a no-op — it - // exists so send specs do not trip the unsupported-command guard below. - case "log_send_perf": - return null; case "sync_agents_to_active_huddle": { const request = payload as { channelId?: string; diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c4c8edf4628..fc6213a87ed 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1929,12 +1929,6 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), ); - // The send-perf probe mirrors exactly one summary per send. Zero would mean - // the instrumentation went silent; two would mean a step was double-counted - // or `completeSend`'s `finally` reported more than once. - expect(commandCount(commands, "log_send_perf")).toBe( - commandCount(baselineCommands, "log_send_perf") + 1, - ); }); test("managed agents keep their p tag when relay discovery fails before send", async ({ From a94a6c9d746c0292a873126549f68f768ea26fdb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 15:35:35 +1000 Subject: [PATCH 12/18] fix(desktop): queue detached agent wakes until the publish succeeds PR #7154 review point 1 (P1): the publish-first detached wake fired during send preparation, so a fast start rejection could toast "Could not start {agent} - your message was sent..." before the relay had accepted anything, and when the publish then failed the composer silently restored with that false claim as the only messaging on screen. The fail-closed scope refusal toasted the same wording synchronously, deterministically pre-publish. Any abort after the wake (cancel, readiness error, huddle failure, publish rejection, dismissed non-member prompt) also stranded a started harness for a message that never landed. Implement the reviewer's fix (option A of the analysis): wakes queue while the send prepares and flush fire-and-forget only after `await send(...)` resolves. - useEnsureAgentMentionsReady no longer takes startAgentDetached; it returns `agentsToWake` - the member fast path enqueues directly, the attach path hands the collector to attachManagedAgentToChannel as `detachedStart` (whose signature is unchanged). - createMentionedPersonaAgents collects the same way and carries its entries on the pending draft, which also moves persona wakes behind the non-member prompt: a dismissed prompt now drops them instead of waking an agent for a message the user chose not to send. - completeSend merges the draft and readiness queues (first entry per agent wins - it carries the earliest floor) and finishSend flushes them immediately after the publish resolves, before the post-send cancellation check, so a cancellation racing a successful publish cannot drop the wake for a message that did land. The spinner gains no awaited work; every abort path simply drops the queue. - Each wake's replay floor is captured at enqueue time, not flush time: the flush now runs post-publish, so a flush-time stamp could exceed the message's created_at and push the harness's startup watermark past the very message the floor exists to cover (buzz-acp clamps the floor to [now-15min, now]). useDetachedAgentStart takes the queued floor as an optional parameter and keeps fire-time capture for callers without one. The toast wording is untouched - "your message was sent" is now structurally true whenever it can appear. Tests, each confirmed red on the pre-fix code: - Three useEnsureAgentMentionsReady unit tests pin the queue contract: a stopped member agent lands in agentsToWake with an enqueue-time floor and no backend call; running/deployed agents are not queued; the attach seam queues instead of firing. - Two useDetachedAgentStart unit tests pin that an explicit floor reaches the start payload verbatim and that the no-argument form still captures fire time. - New deterministic E2E spec: with sendMessageErrors and startManagedAgentErrors both armed, a failed publish restores the draft with zero start_managed_agent calls, no timeline row, and no "your message was sent" toast (pre-fix: one start plus the false toast, no timing games needed). - The in-channel publish-first spec now also pins sign_event strictly before start_managed_agent. The repository file-size ratchet stays green: useMentionSendFlow.ts lands at 975 lines after the queue plumbing. Verified: desktop tsc --noEmit, biome check over src + tests (exit 0), desktop unit tests (5855 passed, 0 failed), the full mentions (86) and channels (89) Playwright smoke suites against a pnpm build:e2e bundle, and a 3x stress rerun of the new spec. The existing publish-first, dedupe, fail-closed-scope, and start-failure-toast specs all stay green. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/src/features/agents/channelAgents.ts | 9 +- .../ui/useDetachedAgentStart.test.mjs | 36 ++++ .../messages/ui/useDetachedAgentStart.ts | 33 +++- .../ui/useEnsureAgentMentionsReady.test.mjs | 178 ++++++++++++++++++ .../ui/useEnsureAgentMentionsReady.ts | 27 ++- .../messages/ui/useMentionSendFlow.helpers.ts | 51 +++++ .../messages/ui/useMentionSendFlow.ts | 42 ++++- desktop/tests/e2e/mentions.spec.ts | 67 +++++++ 8 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 40c714791df..24ace21b520 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -39,10 +39,11 @@ export type AttachManagedAgentToChannelInput = { /** * When set, a needed start/deploy is handed to this callback instead of * being awaited: the attach resolves as soon as the membership write lands - * and the callback owns the fire-and-forget start, including surfacing its - * failure. The message-send path uses this so a publish never blocks on an - * agent start — the spawned harness replays the already-published message - * via its startup replay floor. + * and the callback owns the start, including surfacing its failure. The + * message-send path passes a queue collector here — the wake it records is + * flushed fire-and-forget only after the relay accepts the publish, with a + * replay floor stamped at queue time, so the spawned harness replays the + * published message and an aborted send leaves no orphan wake. */ detachedStart?: (agent: ManagedAgent) => void; }; diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index dfa00cee3d3..a952aa3d1d5 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -229,6 +229,42 @@ test("a detached start carries the active community and identity as its scope", rendered.unmount(); }); +test("an explicit replay floor reaches the start payload verbatim", async () => { + // The send path stamps the floor when it queues the wake (pre-publish, so + // the floor can never exceed the published message's created_at) and only + // flushes the wake after the relay accepts the publish — a flush-time + // stamp could push the harness's startup watermark past that message. + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD, 1_234_567); + await settle(); + }); + + assert.equal(startCalls.length, 1); + assert.equal(startCalls[0].replayFloorUnix, 1_234_567); + rendered.unmount(); +}); + +test("a wake with no queued floor captures fire time", async () => { + const { act, rendered } = await renderDetachedStart(); + const before = Math.floor(Date.now() / 1000); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + const after = Math.floor(Date.now() / 1000); + assert.equal(startCalls.length, 1); + assert.ok( + startCalls[0].replayFloorUnix >= before && + startCalls[0].replayFloorUnix <= after, + "callers with no queued floor keep the fire-time capture", + ); + rendered.unmount(); +}); + test("a start captured before a community switch keeps the pre-switch scope", async () => { const { act, rendered } = await renderDetachedStart(); // What a send in flight holds: the callback from the render that fired it. diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index 2a53ff4635a..ddc1e888ced 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -48,10 +48,11 @@ function detachedStartFailureDetail(error: unknown): string { } /** - * The one wording for "the wake did not happen". Publish-first means the - * message went out either way, so both the refused-before-firing and the - * failed-after-firing cases owe the user the same warning, differing only in - * the detail that follows it. + * The one wording for "the wake did not happen". The send path flushes its + * queued wakes only after the relay accepts the publish, so whenever this + * toast can appear the message really was sent — both the refused-before- + * firing and the failed-after-firing cases owe the user the same warning, + * differing only in the detail that follows it. */ function warnAgentMayNotRespond(agentName: string, detail: string): void { toast.error( @@ -93,8 +94,19 @@ function warnAgentMayNotRespond(agentName: string, detail: string): void { * for the same agent in the same tenant is suppressed, since the wake is * per-agent rather than per-message and the first start's replay floor is * earlier than the second message. + * + * `replayFloorUnix` lets a caller pass a floor captured earlier than this + * call — the send path queues its wakes during preparation and flushes them + * only after the publish succeeds, so the floor must be the enqueue-time + * capture (≤ the message's `created_at` by construction), not flush time, + * which could exceed it and push the harness's startup watermark past the + * very message the floor exists to cover. Callers with no queued floor omit + * it and get fire time. */ -export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { +export function useDetachedAgentStart(): ( + agent: ManagedAgent, + replayFloorUnix?: number, +) => boolean { const startAgentMutateAsync = useStartManagedAgentMutation().mutateAsync; const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); @@ -112,7 +124,7 @@ export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { const expectedSignerPubkey = normalizePubkey(identityQuery.data?.pubkey ?? "") || undefined; return React.useCallback( - (agent: ManagedAgent) => { + (agent: ManagedAgent, replayFloorUnix?: number) => { if (!expectedRelayUrl || !expectedSignerPubkey) { // Fail closed: an unscoped start resolves the relay and the signing // identity at execution time, so it can land on whichever tenant is @@ -137,14 +149,15 @@ export function useDetachedAgentStart(): (agent: ManagedAgent) => boolean { } // Publish-first: the send no longer waits for the agent start. The // replay floor tells the spawned harness to replay at least back to - // this moment, so the about-to-publish message is inside its first - // subscription window however long the spawn takes. - const replayFloorUnix = Math.floor(Date.now() / 1000); + // the message that wanted this wake — the enqueue-time capture when the + // send path queued it, or this moment for a caller with no queue — so + // that message is inside the harness's first subscription window + // however long the spawn takes. const started = startAgentMutateAsync({ pubkey: agent.pubkey, expectedRelayUrl, expectedSignerPubkey, - replayFloorUnix, + replayFloorUnix: replayFloorUnix ?? Math.floor(Date.now() / 1000), }) .catch((error: unknown) => { warnAgentMayNotRespond(agent.name, detachedStartFailureDetail(error)); diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs new file mode 100644 index 00000000000..4646bff0fd4 --- /dev/null +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.test.mjs @@ -0,0 +1,178 @@ +/** + * The mentioned-agent readiness pass queues detached wakes instead of firing + * them (PR #7154 review point 1). A wake fired during send preparation runs + * before the relay has accepted the publish, so a fast start rejection could + * toast "your message was sent" while the send outcome was unknown — and any + * abort after the wake stranded a started harness for a message that never + * landed. These tests pin the queue contract: nothing starts during the + * pass, every mentioned agent that is not up lands in `agentsToWake`, and + * each entry's replay floor is stamped at enqueue time. + */ + +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const MEMBER_AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); +const CHANNEL_ID = "11111111-2222-3333-4444-555555555555"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** Every backend command reached during a test — the pass must reach none. */ +let tauriInvocations = []; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + localStorage: dom.window.localStorage, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (command) => { + tauriInvocations.push(command); + return Promise.reject(new Error(`unexpected Tauri command: ${command}`)); + }, + transformCallback: () => 1, + }; + globalThis.__TAURI_INTERNALS__ = dom.window.__TAURI_INTERNALS__; +}); + +after(() => dom.window.close()); + +beforeEach(() => { + tauriInvocations = []; +}); + +function managedAgent(overrides = {}) { + return { + pubkey: MEMBER_AGENT, + name: "fizz", + personaId: null, + status: "stopped", + backend: { type: "local" }, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +/** Renders the real hook with injected seams; `overrides` vary per test. */ +async function renderEnsureReady(overrides = {}) { + const { renderHook } = await import("@testing-library/react"); + const { useEnsureAgentMentionsReady } = await import( + "./useEnsureAgentMentionsReady.ts" + ); + const options = { + attachAgentToChannel: async () => { + throw new Error("attach must not run for an existing member"); + }, + getManagedAgentsByPubkey: async () => new Map(), + getPersonas: async () => [], + memberPubkeys: new Set(), + ...overrides, + }; + return renderHook(() => useEnsureAgentMentionsReady(options)); +} + +test("a stopped member agent is queued for a post-publish wake, never fired", async () => { + const agent = managedAgent(); + const rendered = await renderEnsureReady({ + getManagedAgentsByPubkey: async () => new Map([[MEMBER_AGENT, agent]]), + memberPubkeys: new Set([MEMBER_AGENT]), + }); + + const floorLowerBound = Math.floor(Date.now() / 1000); + const result = await rendered.result.current([MEMBER_AGENT], CHANNEL_ID); + const floorUpperBound = Math.floor(Date.now() / 1000); + + assert.deepEqual(result.errors, []); + assert.deepEqual(result.pubkeys, [MEMBER_AGENT]); + assert.equal(result.agentsToWake.length, 1); + assert.equal(result.agentsToWake[0].agent, agent); + // Stamped at enqueue time — before the publish — so the floor can never + // exceed the published message's created_at, however long the flush waits. + assert.ok( + result.agentsToWake[0].replayFloorUnix >= floorLowerBound && + result.agentsToWake[0].replayFloorUnix <= floorUpperBound, + "the replay floor must be the enqueue-time capture", + ); + assert.deepEqual( + tauriInvocations, + [], + "the readiness pass must not start the agent (or touch the backend)", + ); + rendered.unmount(); +}); + +test("only agents that are not up are queued", async () => { + const runningLocal = managedAgent({ status: "running" }); + const undeployedProvider = managedAgent({ + pubkey: OTHER_AGENT, + name: "portal", + status: "not_deployed", + backend: { type: "provider", id: "portal", config: {} }, + }); + const rendered = await renderEnsureReady({ + getManagedAgentsByPubkey: async () => + new Map([ + [MEMBER_AGENT, runningLocal], + [OTHER_AGENT, undeployedProvider], + ]), + memberPubkeys: new Set([MEMBER_AGENT, OTHER_AGENT]), + }); + + const result = await rendered.result.current( + [MEMBER_AGENT, OTHER_AGENT], + CHANNEL_ID, + ); + + assert.deepEqual(result.errors, []); + assert.deepEqual( + result.agentsToWake.map((wake) => wake.agent.pubkey), + [OTHER_AGENT], + "a running agent needs no wake; a not-deployed provider agent does", + ); + rendered.unmount(); +}); + +test("a non-member agent's wake queues through the attach seam instead of firing", async () => { + const agent = managedAgent(); + const attachCalls = []; + const rendered = await renderEnsureReady({ + attachAgentToChannel: async (input) => { + attachCalls.push(input); + // The production attach invokes detachedStart for a not-up agent once + // its membership write lands; the collector must queue, not start. + input.detachedStart(input.agent); + return {}; + }, + getManagedAgentsByPubkey: async () => new Map([[MEMBER_AGENT, agent]]), + }); + + const result = await rendered.result.current([MEMBER_AGENT], CHANNEL_ID); + + assert.equal(attachCalls.length, 1); + assert.equal(attachCalls[0].channelId, CHANNEL_ID); + assert.equal( + result.wroteRelayState, + true, + "the awaited membership write still marks the publish-boundary pass", + ); + assert.deepEqual( + result.agentsToWake.map((wake) => wake.agent.pubkey), + [MEMBER_AGENT], + ); + assert.ok(result.agentsToWake[0].replayFloorUnix > 0); + assert.deepEqual( + tauriInvocations, + [], + "no start may fire while the send is still preparing", + ); + rendered.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts index 8247c9e8c95..4e69c69d9fd 100644 --- a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts @@ -3,9 +3,11 @@ import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents" import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { + enqueueAgentWake, getErrorMessage, isManagedAgentRunning, isProviderBackedAgent, + type QueuedAgentWake, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; @@ -18,6 +20,14 @@ export type EnsureAgentMentionsReadyResult = { * something separated it from the pre-side-effect authorization pass. */ wroteRelayState: boolean; + /** + * Detached wakes this pass queued instead of firing. The caller flushes + * them only after the relay accepts the publish, so a wake — and its + * failure toast claiming "your message was sent" — can never precede the + * publish outcome, and an aborted send simply drops the queue. Each entry's + * replay floor was stamped at enqueue time (see `QueuedAgentWake`). + */ + agentsToWake: QueuedAgentWake[]; }; export type EnsureAgentMentionsReady = ( @@ -39,23 +49,23 @@ type UseEnsureAgentMentionsReadyOptions = { getManagedAgentsByPubkey: () => Promise>; getPersonas: () => Promise; memberPubkeys: ReadonlySet; - startAgentDetached: (agent: ManagedAgent) => boolean; }; /** * Reconcile every mentioned managed agent into a state where it will see the * message about to be published: access policy applied, channel membership - * written, and — for an agent that is not already up — a detached wake fired. + * written, and — for an agent that is not already up — a detached wake + * queued on the result for the caller to flush once the publish succeeds. * * The membership write is awaited because the harness only subscribes to - * channels it belongs to; only the start itself is detached. + * channels it belongs to; only the start itself is detached, and it is + * queued rather than fired so it cannot outrun the publish it exists for. */ export function useEnsureAgentMentionsReady({ attachAgentToChannel, getManagedAgentsByPubkey, getPersonas, memberPubkeys, - startAgentDetached, }: UseEnsureAgentMentionsReadyOptions): EnsureAgentMentionsReady { return React.useCallback( async ( @@ -69,6 +79,7 @@ export function useEnsureAgentMentionsReady({ errors: [] as string[], pubkeys: [] as string[], wroteRelayState: false, + agentsToWake: [] as QueuedAgentWake[], }; } const [managedAgentsByPubkey, personas] = await Promise.all([ @@ -86,6 +97,7 @@ export function useEnsureAgentMentionsReady({ const errors: string[] = []; const pubkeys: string[] = []; let wroteRelayState = false; + const agentsToWake: QueuedAgentWake[] = []; for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); if (!agent) continue; @@ -109,14 +121,15 @@ export function useEnsureAgentMentionsReady({ (!isProviderBackedAgent(readyAgent) && !isManagedAgentRunning(readyAgent)) ) { - startAgentDetached(readyAgent); + enqueueAgentWake(agentsToWake, readyAgent); } } else { await attachAgentToChannel({ channelId: capturedChannelId, agent: readyAgent, role: "bot", - detachedStart: startAgentDetached, + detachedStart: (agentToWake) => + enqueueAgentWake(agentsToWake, agentToWake), }); wroteRelayState = true; } @@ -131,6 +144,7 @@ export function useEnsureAgentMentionsReady({ errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), wroteRelayState, + agentsToWake, }; }, [ @@ -138,7 +152,6 @@ export function useEnsureAgentMentionsReady({ getManagedAgentsByPubkey, getPersonas, memberPubkeys, - startAgentDetached, ], ); } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index f0c71bd393e..e4c4f0d806e 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -11,6 +11,51 @@ import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; export { MENTION_REFERENCE_TAG }; +/** + * A detached managed-agent wake queued while the send path prepared a + * message. Queued wakes are flushed fire-and-forget only after the relay + * accepts the publish: firing earlier lets a fast start failure toast "your + * message was sent" before the publish outcome is known, and every abort + * path (cancel, readiness error, prompt dismissal, publish rejection) would + * strand a wake for a message that never landed. + */ +export type QueuedAgentWake = { + agent: ManagedAgent; + /** + * Unix seconds captured at enqueue time — before the publish — so the + * floor can never exceed the published message's `created_at`. Stamping at + * flush time instead could push the spawned harness's startup watermark + * past the very message the floor exists to cover: a background upload + * makes the enqueue-to-flush gap arbitrarily long. + */ + replayFloorUnix: number; +}; + +/** Queue a wake for `agent`, stamping its replay floor now (enqueue time). */ +export function enqueueAgentWake( + queue: QueuedAgentWake[], + agent: ManagedAgent, +): void { + queue.push({ agent, replayFloorUnix: Math.floor(Date.now() / 1000) }); +} + +/** + * Collapse queued wakes to one per agent, keeping the first: the earliest + * enqueue carries the earliest replay floor, and the floor is a lower bound, + * so the first wake covers every later mention in the same send. + */ +export function dedupeQueuedAgentWakes( + wakes: readonly QueuedAgentWake[], +): QueuedAgentWake[] { + const seen = new Set(); + return wakes.filter((wake) => { + const pubkey = normalizePubkey(wake.agent.pubkey); + if (seen.has(pubkey)) return false; + seen.add(pubkey); + return true; + }); +} + export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; inlineAgentMentionPubkeys: string[]; @@ -25,6 +70,12 @@ export type PendingNonMemberMentionSend = { outgoingTags?: string[][]; preparedLinkPreviews?: PreparedBackgroundLinkPreviews | null; preparedManagedAgents?: ManagedAgent[]; + /** + * Wakes queued while creating mentioned persona agents, carried on the + * draft so they survive the non-member prompt and flush with the readiness + * pass's queue after the publish succeeds — a dismissed prompt drops them. + */ + queuedAgentWakes?: QueuedAgentWake[]; readyAgentPubkeys?: string[]; savedContent: string; savedImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index abb5a69a01a..48d181a1651 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -30,12 +30,15 @@ import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { + dedupeQueuedAgentWakes, + enqueueAgentWake, formatMessageSendError, getErrorMessage, mergeMentionRecipients, MENTION_REFERENCE_TAG, mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, + type QueuedAgentWake, type SendMessageWithMentionFlowInput, resolvePreviewTags, uniqueNormalizedPubkeys, @@ -99,7 +102,11 @@ export function useMentionSendFlow({ const personasQuery = usePersonasQuery(); // Detached (publish-first) agent wake, bound to the community and identity // active at this render so a start that outlives a community switch fails - // closed instead of spawning against the new tenant. + // closed instead of spawning against the new tenant. The send path never + // calls it while preparing a message: wakes are queued during preparation + // and flushed through this callback only after the relay accepts the + // publish, so a start failure can never toast "your message was sent" + // before the publish outcome is known. const startAgentDetached = useDetachedAgentStart(); const getManagedAgentsByPubkey = React.useCallback(async () => { const agents = @@ -137,7 +144,6 @@ export function useMentionSendFlow({ getManagedAgentsByPubkey, getPersonas, memberPubkeys: mentions.memberPubkeys, - startAgentDetached, }); const createMentionedPersonaAgents = React.useCallback( async (trimmed: string, capturedChannelId: string) => { @@ -147,6 +153,7 @@ export function useMentionSendFlow({ errors: [] as string[], agents: [] as ManagedAgent[], pubkeys: [] as string[], + agentsToWake: [] as QueuedAgentWake[], }; } const runtimes = await getAvailableRuntimes(); @@ -154,6 +161,10 @@ export function useMentionSendFlow({ const errors: string[] = []; const agents: ManagedAgent[] = []; const pubkeys: string[] = []; + // Queued, not fired: the wakes ride the pending draft and flush only + // after the publish succeeds, so a persona created for a send the + // non-member prompt later cancels never wakes at all. + const agentsToWake: QueuedAgentWake[] = []; const seenPersonaIds = new Set(); const shouldProvisionForDm = channelType === "dm" && Boolean(onPrepareSendChannel); @@ -184,7 +195,8 @@ export function useMentionSendFlow({ model: persona.model ?? undefined, role: "bot", ensureRunning: true, - detachedStart: startAgentDetached, + detachedStart: (agentToWake) => + enqueueAgentWake(agentsToWake, agentToWake), }; const result = shouldProvisionForDm ? await provisionPersonaAgentMutation.mutateAsync(input) @@ -208,6 +220,7 @@ export function useMentionSendFlow({ agents, errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), + agentsToWake, }; }, [ @@ -218,7 +231,6 @@ export function useMentionSendFlow({ mentions.registerMentionPubkey, onPrepareSendChannel, provisionPersonaAgentMutation, - startAgentDetached, ], ); const clearComposer = React.useCallback(() => { @@ -427,6 +439,17 @@ export function useMentionSendFlow({ if (agentReadiness.wroteRelayState) { relaySideEffectsRan = true; } + // Every wake this send queued: persona creates carried on the draft + // (enqueued before the non-member prompt could defer us here), then + // the readiness pass's. Flushed only after the relay accepts the + // publish — every abort path between here and there just drops them, + // so no wake (or "your message was sent" failure toast) can exist for + // a message that never landed. First entry wins the dedupe because it + // carries the earliest replay floor, and the floor is a lower bound. + const agentsToWake = dedupeQueuedAgentWakes([ + ...(draft.queuedAgentWakes ?? []), + ...agentReadiness.agentsToWake, + ]); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); @@ -522,6 +545,15 @@ export function useMentionSendFlow({ draft.capturedThreadContext, draft.preparedLinkPreviews != null, ); + // The relay accepted the publish: flush the queued wakes now, + // before the post-send cancellation check — a cancellation racing + // a successful publish must not drop the wake for a message that + // did land. Fire-and-forget: the send awaits nothing here, and + // each wake carries its enqueue-time replay floor so the spawned + // harness replays back past this message however late the flush. + for (const wake of agentsToWake) { + startAgentDetached(wake.agent, wake.replayFloorUnix); + } if (signal?.aborted || isSendCancelled()) return; const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), @@ -625,6 +657,7 @@ export function useMentionSendFlow({ onSendRef, richText.setContent, setContent, + startAgentDetached, setPendingImeta, restoreQueuedAttachments, setSpoileredAttachmentUrls, @@ -757,6 +790,7 @@ export function useMentionSendFlow({ outgoingTags, preparedLinkPreviews, preparedManagedAgents: personaMentionResult.agents, + queuedAgentWakes: personaMentionResult.agentsToWake, readyAgentPubkeys: channelType === "dm" && onPrepareSendChannel ? [] diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index fc6213a87ed..cca410bc894 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2740,6 +2740,16 @@ test("mentioning an in-channel stopped managed agent publishes first and starts expectedRelayUrl: activeRelayUrl, expectedSignerPubkey: MOCK_VIEWER_PUBKEY, }); + // The wake is queued during send preparation and flushed only after the + // relay accepts the publish, so the sign always precedes the start — a + // wake can never exist (nor its failure toast "your message was sent" + // appear) for a message whose publish outcome is still unknown. + const commandsAfterSend = (await readCommandLog(page)).slice( + baselineCommands.length, + ); + expect(commandsAfterSend.indexOf("sign_event")).toBeLessThan( + commandsAfterSend.indexOf("start_managed_agent"), + ); const mentionChip = page .getByTestId("message-row") @@ -2856,6 +2866,63 @@ test("a detached agent start failure surfaces as a toast after the message sends await expect(input).not.toContainText("can you help"); }); +test("a failed publish drops the queued agent wake and never claims the message was sent", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Reject the publish itself. The wake is queued behind it, so a publish + // that never lands must fire no wake at all — before this ordering, the + // wake fired during send preparation, rejected fast (the injected start + // error below), and toasted "your message was sent" while the publish + // went on to fail with no corrective message. + sendMessageErrors: ["Mock relay rejected the event."], + // Armed so that IF a wake still fired it would reject immediately and + // raise the false-success toast whose absence this spec pins. + startManagedAgentErrors: ["Mock agent startup failed."], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Hey @fizz"); + await expect(autocomplete(page).getByText("fizz")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" do X"); + + const baselineStartCount = commandCount( + await readCommandLog(page), + "start_managed_agent", + ); + await page.getByTestId("send-message").click(); + + // Deterministic completion signal: the failed send restores the draft into + // the composer. On the pre-fix ordering the wake had already fired — and + // toasted — before this point, so the assertions below need no timing games. + await expect(input).toContainText("do X"); + + // The message never landed: the optimistic row was rolled back... + await expect( + page.getByTestId("message-row").filter({ hasText: "do X" }), + ).toHaveCount(0); + // ...so the queued wake was dropped rather than flushed... + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount, + ); + // ...and nothing on screen claims the message was sent. + await expect( + page.getByText("your message was sent", { exact: false }), + ).toHaveCount(0); +}); + test("a detached start fired before a community switch fails closed instead of waking the agent in the new tenant", async ({ page, }) => { From 7e835d2e7b4877153a4bb8a7a3087fd399802e5b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 16:01:54 +1000 Subject: [PATCH 13/18] fix(desktop): fence the detached-wake failure toast to its firing community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7154 review point 2: mounts outside the community remount boundary, and the detached wake's catch toasted its captured agent name and error detail unconditionally — so a start that settled after a community switch rendered community A's failure over community B's UI. The wake itself already fails closed at the backend; what leaked was warning delivery: a toast in B naming an agent that does not exist in B, carrying an uncontextualized backend error, reads as a bug in B. Implement the reviewer's fix as designed in the review analysis — a module-level scope mirror compared at toast time: - New detachedToastScope module mirrors the on-screen tenant scope outside React: set to {relayUrl, signerPubkey} by useCommunityInit when a community apply completes, cleared in resetCommunityState() per the module-singleton rule. - The catch in useDetachedAgentStart delivers the failure toast only when the scope captured at fire time matches the mirror — the relay URL verbatim past a trim and the signer case-insensitively, the backend's own comparison semantics. Mismatch or no mirror suppresses with a console.warn for diagnosability rather than rewording: any wording still names another community's agent over B's UI. - Scope comparison, deliberately not a reset generation: an A→B→A round-trip restores A's mirror, so a slow start fired in A still warns once the user is back in A — exactly where "mention the agent again" is actionable. A generation check would silently drop it. - The synchronous unscoped-start refusal toast stays unfenced by design: it fires at send time in the firing community. Tests: - Four unit tests drive the mirror through its module seam (standing in for useCommunityInit, its only production writer): on-scope delivery as the over-suppression control, suppression after a switch plus the console.warn, suppression mid-switch on a cleared mirror, and A→B→A re-delivery. Both suppression tests are red without the fence. - The old stale-toast E2E spec — which mutated localStorage without a real switch and positively pinned the stale toast, the outcome the fence now forbids — is replaced by a real rail-switch regression: two seeded communities, a genuine community-rail-button click driving the provider → remount → resetCommunityState path, the held start refused by the mock's scope check, and the toast asserted absent in B via non-retrying count snapshots (a retrying toHaveCount(0) waits out sonner's auto-dismiss and passes against the very toast it forbids — the first draft did exactly that and stayed green on the unfenced build). It keeps the published-message and scoped-invoke-payload assertions the unit tests cannot pin, and is red without the fence at the stale-toast snapshot. The existing no-switch start-failure spec is the E2E positive control — it now also proves the mirror is set on community apply. - The mock bridge pushes a start_managed_agent:settled marker into the command log on resolve and reject (a distinct string, so exact-match commandCount("start_managed_agent") is untouched), giving the spec a deterministic wait for the delayed rejection before its negative assertions. Verified: desktop tsc --noEmit, biome check over src + tests (exit 0, remaining diagnostics pre-exist in unrelated files), desktop unit tests (5859 passed, 0 failed), the full mentions (86) and community-rail (25) Playwright smoke suites against a pnpm build:e2e bundle, a 3x rerun of the new spec, and the repository file-size gate. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../features/communities/useCommunityInit.ts | 11 ++ .../messages/lib/detachedToastScope.ts | 60 +++++++ .../ui/useDetachedAgentStart.test.mjs | 152 +++++++++++++++++- .../messages/ui/useDetachedAgentStart.ts | 18 +++ desktop/src/testing/e2eBridge.ts | 10 +- desktop/tests/e2e/mentions.spec.ts | 150 +++++++++++------ 6 files changed, 349 insertions(+), 52 deletions(-) create mode 100644 desktop/src/features/messages/lib/detachedToastScope.ts diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index f2aa7013e3f..ee3363dbed2 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -25,6 +25,10 @@ import { resetAudioMediaLoadScheduler } from "@/features/messages/lib/audioMedia import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; import { resetPersistentAgentAudienceStore } from "@/features/messages/lib/persistentAgentAudience"; +import { + resetDetachedToastScope, + setDetachedToastScope, +} from "@/features/messages/lib/detachedToastScope"; import { resetDetachedAgentStarts } from "@/features/messages/ui/useDetachedAgentStart"; import { resetActiveAgentTurnsStore, @@ -82,6 +86,7 @@ async function resetCommunityState({ resetLinkPreviewPreparations(); resetPersistentAgentAudienceStore(); resetDetachedAgentStarts(); + resetDetachedToastScope(); clearSearchHitEventCache(); clearMarkdownNodeCache(); resetMessageLinkMetadataCache(); @@ -346,6 +351,12 @@ export function useCommunityInit( // trip). This runs after applyCommunity succeeds and before the app // renders so components see the restored timers on first render. restoreActiveAgentTurnsForCommunity(activeCommunity.id); + // From here this community's UI is what renders, so warnings from + // detached agent wakes captured under this scope may deliver again. + setDetachedToastScope({ + relayUrl: activeCommunity.relayUrl, + signerPubkey: identityPubkey, + }); // Prime the ref so the NEXT switch saves this community's state. prevCommunityIdRef.current = activeCommunity.id; setResult({ diff --git a/desktop/src/features/messages/lib/detachedToastScope.ts b/desktop/src/features/messages/lib/detachedToastScope.ts new file mode 100644 index 00000000000..45b34eef021 --- /dev/null +++ b/desktop/src/features/messages/lib/detachedToastScope.ts @@ -0,0 +1,60 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * The tenant scope whose UI is currently on screen, mirrored at module level + * so code running outside React — the detached agent wake's `.catch` — can + * ask "does my captured scope still describe what the user is looking at?". + * + * `` mounts outside the community remount boundary, so a toast + * fired by a promise that outlived a community switch renders over the *new* + * community's UI. The wake itself already fails closed at the backend; this + * mirror fences only warning delivery, so community A's agent name and error + * detail never surface while community B is on screen. + * + * Set by `useCommunityInit` when a community apply completes; cleared in + * `resetCommunityState()` like every community-scoped module singleton. The + * mirror is compared, not counted: an A→B→A round-trip restores A's scope, so + * a slow start fired in A may still warn once the user is back in A — which + * is exactly where "mention the agent again" is actionable. A reset + * generation could not distinguish that from a one-way switch. + */ +type DetachedToastScope = { + relayUrl: string; + /** Null when the apply completed without a resolved identity. */ + signerPubkey: string | null; +}; + +let activeScope: DetachedToastScope | null = null; + +/** Records the scope the just-applied community renders under. */ +export function setDetachedToastScope(scope: DetachedToastScope): void { + activeScope = scope; +} + +/** + * Clears the mirror. Registered in `resetCommunityState`: between a switch + * and the next apply there is no on-screen scope, and delivery fails closed. + */ +export function resetDetachedToastScope(): void { + activeScope = null; +} + +/** + * Whether a scope captured at fire time still matches the on-screen one. + * Same comparison semantics as the backend's scope assertion: the relay URL + * verbatim past a trim (its check is case-sensitive), the signer + * case-insensitively. No mirror — mid-switch, or before the first apply — + * means no match. + */ +export function matchesDetachedToastScope( + relayUrl: string, + signerPubkey: string, +): boolean { + if (activeScope === null || activeScope.signerPubkey === null) { + return false; + } + return ( + activeScope.relayUrl.trim() === relayUrl.trim() && + normalizePubkey(activeScope.signerPubkey) === normalizePubkey(signerPubkey) + ); +} diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index a952aa3d1d5..cd381bc17a9 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -17,8 +17,16 @@ * 3. A scope that is not yet known is not a scope: the backend reads a missing * relay or signer as "no assertion", so the wake is refused rather than * fired unscoped. + * 4. The failure warning is fenced to the scope it was fired under: the + * `` outlives the community remount boundary, so an unfenced + * toast from a start that settled after a switch would name community A's + * agent over community B's UI. Delivery compares the captured scope against + * the on-screen mirror (`detachedToastScope`) at toast time — not a reset + * generation, so an A→B→A round-trip warns again once A is back on screen. * - * These tests drive the real hook against the real CommunitiesProvider. + * These tests drive the real hook against the real CommunitiesProvider; the + * scope mirror is driven directly through its module seam, standing in for + * `useCommunityInit` (its only production writer), which is not mounted here. */ import assert from "node:assert/strict"; @@ -121,6 +129,14 @@ beforeEach(async () => { "./useDetachedAgentStart.ts" ); resetDetachedAgentStarts(); + // The toast-scope mirror is a module singleton too. Point it at community A + // — what `useCommunityInit` does when A's apply completes — so failure + // warnings deliver by default, as in the running app. + const { resetDetachedToastScope, setDetachedToastScope } = await import( + "@/features/messages/lib/detachedToastScope.ts" + ); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_A, signerPubkey: SELF }); window.localStorage.clear(); window.localStorage.setItem( "buzz-communities", @@ -467,6 +483,140 @@ test("a blank relay URL is refused rather than sent as no assertion", async () = rendered.unmount(); }); +/** The scope-mirror module, driven directly in place of `useCommunityInit`. */ +async function toastScopeModule() { + return import("@/features/messages/lib/detachedToastScope.ts"); +} + +test("a start failure warns while the community it was fired in is on screen", async () => { + // The positive control for the fence: with the mirror still pointing at the + // scope the wake captured (beforeEach models A's completed apply), the + // failure toast must deliver — a fence that suppresses everything would + // silently drop the only signal that the agent never woke. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.match( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "an on-scope failure must keep warning the user", + ); + rendered.unmount(); +}); + +test("a start failure fired in one community stays silent while another is on screen", async (t) => { + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + // What a real switch does to the mirror: `resetCommunityState` clears it, + // B's completed apply repoints it. The rejection then lands with B's UI on + // screen — where a toast naming A's agent would read as a bug in B. + const { resetDetachedToastScope, setDetachedToastScope } = + await toastScopeModule(); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_B, signerPubkey: SELF }); + + const warn = t.mock.method(console, "warn", () => {}); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.doesNotMatch( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "community A's failure must not toast over community B", + ); + assert.ok( + warn.mock.calls.some((call) => + String(call.arguments[0]).includes( + "suppressed a start-failure warning for fizz", + ), + ), + "suppression must stay diagnosable in the console", + ); + rendered.unmount(); +}); + +test("a start failure that settles mid-switch stays silent", async (t) => { + // The window between `resetCommunityState` and the next apply: no scope is + // on screen at all, so delivery fails closed exactly like a mismatch. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + const { resetDetachedToastScope } = await toastScopeModule(); + resetDetachedToastScope(); + + const warn = t.mock.method(console, "warn", () => {}); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.doesNotMatch((await toastTitles()).join("\n"), MAY_NOT_RESPOND); + assert.ok( + warn.mock.calls.some((call) => + String(call.arguments[0]).includes( + "suppressed a start-failure warning for fizz", + ), + ), + ); + rendered.unmount(); +}); + +test("an A→B→A round-trip keeps the warning deliverable back in A", async () => { + // The fence compares scopes at toast time — deliberately not "has a reset + // happened since capture". A generation check would get this wrong: the + // user is back in A when the slow start settles, the warning concerns the + // community on screen, and re-mentioning the agent is actionable right + // there. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + const { resetDetachedToastScope, setDetachedToastScope } = + await toastScopeModule(); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_B, signerPubkey: SELF }); + resetDetachedToastScope(); + setDetachedToastScope({ relayUrl: RELAY_A, signerPubkey: SELF }); + + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + + assert.match( + (await toastTitles()).join("\n"), + MAY_NOT_RESPOND, + "back in A, the warning is on-scope again and must show", + ); + rendered.unmount(); +}); + test("a wake for the same agent in another community is not suppressed", async () => { holdStarts = true; const { act, rendered } = await renderDetachedStart(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index ddc1e888ced..e9060826244 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useStartManagedAgentMutation } from "@/features/agents/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; +import { matchesDetachedToastScope } from "@/features/messages/lib/detachedToastScope"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -160,6 +161,23 @@ export function useDetachedAgentStart(): ( replayFloorUnix: replayFloorUnix ?? Math.floor(Date.now() / 1000), }) .catch((error: unknown) => { + // This settles arbitrarily long after the send, and `` + // mounts outside the community remount boundary — so an unfenced + // warning would render this community's agent name and error detail + // over whichever community is on screen by then. Deliver only while + // the scope captured above is the one being looked at; on an A→B→A + // round-trip the mirror matches again and the warning lands exactly + // where re-mentioning the agent is possible. Suppression is logged, + // not reworded: any wording still names another community's agent. + if ( + !matchesDetachedToastScope(expectedRelayUrl, expectedSignerPubkey) + ) { + console.warn( + `[useDetachedAgentStart] suppressed a start-failure warning for ${agent.name}: the community it was fired in is no longer on screen`, + error, + ); + return; + } warnAgentMayNotRespond(agent.name, detachedStartFailureDetail(error)); }) .finally(() => { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 63ae9f7df83..91dbf8a0db4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13741,10 +13741,18 @@ export function maybeInstallE2eTauriMocks() { activeConfig, ); case "start_managed_agent": + // The settled marker (distinct from the invocation entry, so exact- + // match commandCount("start_managed_agent") is unaffected) lets a + // spec wait deterministically for a delayed start to resolve or + // reject — required to assert the *absence* of the failure toast, + // which is only falsifiable once the rejection is known to have + // landed. return handleStartManagedAgent( payload as Parameters[0], activeConfig, - ); + ).finally(() => { + window.__BUZZ_E2E_COMMANDS__?.push("start_managed_agent:settled"); + }); case "stop_managed_agent": return handleStopManagedAgent( payload as Parameters[0], diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index cca410bc894..7a57efcbc57 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2923,22 +2923,54 @@ test("a failed publish drops the queued agent wake and never claims the message ).toHaveCount(0); }); -test("a detached start fired before a community switch fails closed instead of waking the agent in the new tenant", async ({ +test("a detached start fired before a real community switch fails closed and keeps its warning out of the new community", async ({ page, }) => { - await installMockBridge(page, { - managedAgents: [ - { - pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, - name: "fizz", - status: "stopped", - channelNames: ["general"], - }, - ], - // Holds the start open long enough for the community to move under it — - // the window the detached (publish-first) wake opened. - startManagedAgentDelayMs: 2_000, - }); + // Two seeded communities and a real rail-button switch: the click drives + // the actual provider → remount → resetCommunityState path (which clears + // and repoints the toast-scope mirror), and persists the active community + // id the mock's scope check reads — so the held start is refused exactly + // as the real backend would refuse it. The predecessor of this spec moved + // localStorage directly, which exercised the fail-closed refusal but never + // the switch itself, and pinned the stale toast's *presence* — the outcome + // the delivery fence now forbids. + const COMMUNITY_A = { + id: "ws-a", + name: "Alpha", + relayUrl: "ws://localhost:3000", + addedAt: "2026-01-01T00:00:00.000Z", + }; + const COMMUNITY_B = { + id: "ws-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", + addedAt: "2026-01-02T00:00:00.000Z", + }; + await installMockBridge( + page, + { + managedAgents: [ + { + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "fizz", + status: "stopped", + channelNames: ["general"], + }, + ], + // Holds the start open long enough for the real switch below to + // complete under it — the window the detached (publish-first) wake + // opened. + startManagedAgentDelayMs: 3_000, + }, + { skipCommunitySeed: true }, + ); + await page.addInitScript( + ({ list, active }) => { + window.localStorage.setItem("buzz-communities", JSON.stringify(list)); + window.localStorage.setItem("buzz-active-community-id", active); + }, + { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, + ); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -2949,54 +2981,72 @@ test("a detached start fired before a community switch fails closed instead of w await input.press("Enter"); await page.keyboard.type(" can you help?"); + const baselineCommands = await readCommandLog(page); const baselineStartCount = commandCount( - await readCommandLog(page), + baselineCommands, "start_managed_agent", ); + const baselineSettledCount = commandCount( + baselineCommands, + "start_managed_agent:settled", + ); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; await page.getByTestId("send-message").click(); + + // The message published in A — only the wake is at stake from here on. + const mentionChip = page + .getByTestId("message-row") + .last() + .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); + await expect(mentionChip).toBeVisible(); await expect .poll(async () => commandCount(await readCommandLog(page), "start_managed_agent"), ) .toBeGreaterThan(baselineStartCount); - - // Move the active community while the start is still held. The stored - // community is what the mock start reads for its scope check (as the - // backend reads the live workspace relay), so writing it directly models - // the switch without tearing down the mounted app the toast renders in. - await page.evaluate(() => { - const communities = JSON.parse( - window.localStorage.getItem("buzz-communities") ?? "[]", - ) as { id: string; relayUrl: string }[]; - communities.push({ - id: "other-community", - name: "Other", - relayUrl: "wss://other-tenant.example", - pubkey: "deadbeef".repeat(8), - addedAt: new Date().toISOString(), - } as (typeof communities)[number]); - window.localStorage.setItem( - "buzz-communities", - JSON.stringify(communities), - ); - window.localStorage.setItem("buzz-active-community-id", "other-community"); + // The wake carries A's scope, so the backend fails it closed once B is + // active — the unit tests can't pin the real invoke payload. + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect(startCall?.payload).toMatchObject({ + expectedRelayUrl: COMMUNITY_A.relayUrl, + expectedSignerPubkey: MOCK_VIEWER_PUBKEY, }); - // The message published regardless — only the wake is refused, and the - // toast says so rather than repeating the backend's "not sent". - const mentionChip = page - .getByTestId("message-row") - .last() - .locator("[data-mention].agent-mention-highlight", { hasText: "fizz" }); - await expect(mentionChip).toBeVisible(); + // The real switch, while the start is still held. + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); await expect( - page.getByText( - "You switched community or identity before it could start.", - { - exact: false, - }, - ), - ).toBeVisible({ timeout: 10_000 }); + page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), + ).toHaveAttribute("aria-current", "true"); + + // Wait for the held start to actually settle (the scope refusal fires + // after the injected delay), then give the rejection a beat to reach the + // hook's catch. Only past this point is the negative assertion below + // falsifiable: pre-fence, the stale toast appeared at settlement and stayed + // on screen for seconds. + await expect + .poll( + async () => + commandCount(await readCommandLog(page), "start_managed_agent:settled"), + { timeout: 10_000 }, + ) + .toBeGreaterThan(baselineSettledCount); + await page.waitForTimeout(500); + + // B is on screen and community A's failure never toasts over it. The + // suppression is logged to the console instead; an A→B→A round-trip would + // re-arm delivery (pinned at the unit level). These counts are immediate + // snapshots, not retrying toHaveCount(0) assertions — a retry would simply + // wait out the toast's auto-dismiss and pass against the very toast it + // forbids. + await expect(page.getByTestId("channel-general")).toBeVisible(); + expect( + await page.getByText("Could not start fizz", { exact: false }).count(), + ).toBe(0); + expect( + await page.getByText("your message was sent", { exact: false }).count(), + ).toBe(0); }); test("mentioning an in-channel provider managed agent publishes first and deploys it detached", async ({ From e7796d99b3c6d723a2993c67a4c18fce9d1380ba Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 16:30:00 +1000 Subject: [PATCH 14/18] fix(desktop): retain in-flight detached agent starts across community switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7154 review point 4 (P2): resetCommunityState cleared the in-flight detached-start map on every community switch, and the justification — "those starts belong to the community being left and are about to fail closed at the backend's scope assertion anyway" — is wrong for a round trip. The scope assertion is a current-state check, so a deploy still held from community A becomes valid again the moment A is re-applied. Nothing settles the held start across the switch (the mutation lives on the QueryClient above the remount boundary; apply_workspace aborts no in-flight deploys), the record still reads not_deployed for the whole window, and the map entry was the only duplicate guard on this path — so an A→B→A round trip plus a second mention deployed a provider agent twice, handing the provider the second message's replay floor (past the first message: the exact failure class this branch exists to prevent). Local spawns were never exposed; the backend coalesces those under the process lock. Implement the reviewer's fix (option (a) of the analysis): retain the entries across switches. - resetCommunityState no longer clears the map; an explicit exemption comment at the call site (the canonical singleton-reset inventory) records why, so a future contributor does not "fix" the omission. Retention is safe by construction: the key IS the tenant scope (relay URL + pubkey), so a retained A entry can never suppress or affect a wake in B, and the identity-guarded finally delete self-cleans at settlement, which the deploy op's timeout bounds. The backend's own provider_deploy_locks map is likewise never cleared on workspace apply. - resetDetachedAgentStarts stays exported as a test-only isolation seam and its doc now says so; the map header and finally-guard comments that encoded the refuted "fail closed anyway" rationale are rewritten to record the retention contract instead. - Deliberately NOT scope-filtered clearing ("only entries for the community being left"): the A-scoped entry is exactly the one that must survive the A→B switch, so that variant recreates the bug. - The detachedToastScope mirror reset stays: on-screen warning delivery is genuinely per-community state. Out of scope, unchanged: backend deploy-epoch coalescing (option (b)) remains the deferred durable fix for the wake paths that do not funnel through useDetachedAgentStart (Agents-panel Start, restore, inbound-persona deploys, reconcile_on_workspace_apply). Residual accepted: on a same-relay identity switch, a retained old-identity entry suppresses the new identity's wake until the old start fails closed and settles — the same accepted within-community residual, self-resolving on the user's next send. Tests: - New E2E regression, the load-bearing one — it drives the real rail-switch → remount → resetCommunityState path that did the clearing: a not_deployed provider agent's deploy is held open via startManagedAgentDelayMs; the mention publishes and deploys once, scoped to A; a real A→B→A rail round-trip runs under the hold; a second mention back in A publishes while start_managed_agent stays at exactly 1; the held deploy is then settled on demand and rejects, its failure warning delivers back in A, and a third mention re-fires the wake (2 calls) — retention ends at settlement rather than latching the agent. - New mock-bridge seam for that spec: __BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__() settles starts held behind startManagedAgentDelayMs on demand, following the bridge's defer/release idiom — the hold must deterministically outlast the round-trip AND settle before the retry leg. The first settlement is armed to reject via startManagedAgentErrors because a successful mock settle writes `deployed` into the record, which would hide the retry leg behind the status check rather than the map — the seam under test. - Two unit tests pin the complementary hook contracts in the existing real-CommunitiesProvider harness: an A→B→A switch hands back a callback whose key matches the retained entry, so the wake stays suppressed; and a rejection across the round-trip re-permits. The existing cross-community test keeps pinning that a B wake during the interlude is unaffected. Verified: desktop tsc --noEmit, biome check over the touched files, desktop unit tests (5856 passed; the 5 failures are the pre-existing inboxReopenNavigation/useRetainedProjectGitViews baseline, present on origin/main), the full mentions (87) and community-rail (25) Playwright smoke suites against a fresh pnpm build:e2e bundle, a 3x stress rerun of the new spec, and the repository file-size gate. The new E2E spec was confirmed red on the pre-fix build — 2 deploys vs 1 at the second-send snapshot — with the map clearing temporarily restored. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../features/communities/useCommunityInit.ts | 9 +- .../ui/useDetachedAgentStart.test.mjs | 76 +++++++++ .../messages/ui/useDetachedAgentStart.ts | 27 ++- desktop/src/testing/e2eBridge.ts | 34 +++- desktop/tests/e2e/mentions.spec.ts | 158 ++++++++++++++++++ 5 files changed, 292 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index ee3363dbed2..ef734dc5b6a 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -29,7 +29,6 @@ import { resetDetachedToastScope, setDetachedToastScope, } from "@/features/messages/lib/detachedToastScope"; -import { resetDetachedAgentStarts } from "@/features/messages/ui/useDetachedAgentStart"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -85,7 +84,13 @@ async function resetCommunityState({ resetBackgroundMediaUploads(); resetLinkPreviewPreparations(); resetPersistentAgentAudienceStore(); - resetDetachedAgentStarts(); + // Intentionally NOT reset: the in-flight detached agent-start map + // (`useDetachedAgentStart`). Its entries are keyed by tenant scope (relay + // URL + pubkey), so they cannot leak into the new community, and they + // self-clean when the start settles. Clearing them here is what permitted + // the A→B→A duplicate provider deploy: the backend's scope assertion is a + // current-state check, so a start held across a round-trip is valid again + // once A is re-applied — the map entry is its only duplicate guard. resetDetachedToastScope(); clearSearchHitEventCache(); clearMarkdownNodeCache(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index cd381bc17a9..bc56f31eb4f 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -23,6 +23,12 @@ * agent over community B's UI. Delivery compares the captured scope against * the on-screen mirror (`detachedToastScope`) at toast time — not a reset * generation, so an A→B→A round-trip warns again once A is back on screen. + * 5. The in-flight map outlives a community switch on purpose: the backend's + * scope assertion is a current-state check, so an A→B→A round-trip + * re-validates a still-held start — the map entry is its only duplicate + * guard, and it is tenant-keyed, so retention cannot affect the community + * being entered. (`resetCommunityState` no longer clears it; that seam is + * pinned E2E.) * * These tests drive the real hook against the real CommunitiesProvider; the * scope mirror is driven directly through its module seam, standing in for @@ -370,6 +376,76 @@ test("a wake fires again once the previous one has settled", async () => { rendered.unmount(); }); +test("an A→B→A community round-trip does not reopen the in-flight window", async () => { + // The production seam — `resetCommunityState` deliberately not clearing the + // map on switch — is pinned E2E, where the real switch path runs. This pins + // the hook contract that makes retention sufficient: the round-trip hands + // back a callback carrying A's scope again, its key matches the retained + // entry, and the wake stays suppressed while the first start (whose scope + // is valid again now that A is re-applied) is still deploying. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-a"); + }); + + let refire; + await act(async () => { + refire = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(refire, false, "the retained entry must still suppress"); + assert.equal( + startCalls.length, + 1, + "a round-trip must not duplicate a deploy the first start is still performing", + ); + rendered.unmount(); +}); + +test("a wake re-fires once the start held across a round-trip has rejected", async () => { + // Retention ends at settlement, not at some later reset: the `finally` + // self-cleans, so a failed start never latches the agent — the user's next + // send after the failure toast gets a real wake. + holdStarts = true; + const { act, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-b"); + }); + await act(async () => { + rendered.result.current.switchCommunity("community-a"); + }); + await act(async () => { + heldStarts[0].reject(); + await settle(); + }); + await act(async () => { + assert.equal(rendered.result.current.startDetached(AGENT_RECORD), true); + await settle(); + }); + + assert.equal( + startCalls.length, + 2, + "settlement must end the suppression even across a round-trip", + ); + rendered.unmount(); +}); + test("a failed wake clears the key instead of latching the agent", async () => { holdStarts = true; const { act, rendered } = await renderDetachedStart(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index e9060826244..f88b8f34e32 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -23,13 +23,23 @@ import { getErrorMessage } from "./useMentionSendFlow.helpers"; * Keyed by relay as well as pubkey to mirror the backend's own runtime pair * key, so a start in one community can never suppress one in another — the * same tenant boundary `expectedRelayUrl` enforces below. + * + * Entries deliberately survive community switches (`resetCommunityState` does + * NOT clear this map). A held start is not invalidated by leaving its + * community: the backend's scope assertion is a current-state check, so a + * start fired in A is valid again the moment A is re-applied — an A→B→A + * round-trip that cleared the map would let a second send duplicate a + * provider deploy the first start is still performing (and hand the harness + * the second message's replay floor, past the first message). The key is the + * tenant scope, so a retained entry cannot affect another community, and the + * `finally` below self-cleans on settlement, which the deploy op bounds. */ const inFlightDetachedStarts = new Map>(); /** - * Drops every tracked in-flight start. Registered in `resetCommunityState`: - * these starts belong to the community being left and are about to fail closed - * at the backend's scope assertion anyway. + * Drops every tracked in-flight start. Test-only isolation seam — one test's + * held start must not suppress the next test's. Production deliberately never + * calls this: see the map's doc for why entries survive community switches. */ export function resetDetachedAgentStarts(): void { inFlightDetachedStarts.clear(); @@ -181,11 +191,12 @@ export function useDetachedAgentStart(): ( warnAgentMayNotRespond(agent.name, detachedStartFailureDetail(error)); }) .finally(() => { - // Identity-guarded: `resetDetachedAgentStarts` may have cleared the - // map and a later start re-registered this key while this one was in - // flight (an A→B→A community switch), and an unguarded delete would - // drop that newer entry. Clearing in `finally` rather than on success - // is what keeps a failed start from latching the agent permanently. + // Identity-guarded: only test isolation clears this map now + // (production retains entries across community switches), but if a + // reset-and-re-register did interleave while this start was in + // flight, an unguarded delete would drop the newer entry. Clearing + // in `finally` rather than on success is what keeps a failed start + // from latching the agent permanently. if (inFlightDetachedStarts.get(key) === started) { inFlightDetachedStarts.delete(key); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 91dbf8a0db4..0e1095db74b 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -374,7 +374,8 @@ type E2eConfig = { openDmDelayMs?: number; sendMessageDelayMs?: number; /** Delay (ms) for `start_managed_agent` so e2e tests can switch the - * community mid-startup and observe the fail-closed scope check. */ + * community mid-startup and observe the fail-closed scope check. + * Releasable early via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`. */ startManagedAgentDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ mediaProxyInitiallyUnavailable?: boolean; @@ -1513,6 +1514,11 @@ declare global { /** Count of `get_event` invocations for the current defer-target ID since * the last time `__BUZZ_E2E_DEFER_GET_EVENT__` was set. */ __BUZZ_E2E_GET_EVENT_CALL_COUNT__?: number; + /** Release every `start_managed_agent` currently held behind + * `startManagedAgentDelayMs`, letting it settle (scope checks, then any + * armed `startManagedAgentErrors` rejection) without waiting out the + * delay. Returns the number of holds flushed. */ + __BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number; /** Hold the next channel read until released. */ __BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void; /** Disarm the latch and release the held channel read, if any. */ @@ -1645,6 +1651,11 @@ let deferredGetEventQueue: DeferredGetEvent[] = []; let deferredLinkPreviewMetadataQueue: Array<() => void> = []; let deferredLinkPreviewUploadQueue: Array<() => void> = []; let deferredThreadRepliesQueue: Array<() => void> = []; +// Starts currently held behind `startManagedAgentDelayMs`, releasable early +// via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`: a spec that holds a +// start across a community round-trip needs the hold long enough to be +// deterministic AND a way to settle it on demand afterwards. +let heldManagedAgentStartReleases: Array<() => void> = []; let cancelledMediaUploadIds = new Set(); let cancelledMediaFetchIds = new Set(); let mockMediaFetchControllers = new Map(); @@ -9603,7 +9614,20 @@ async function handleStartManagedAgent( ): Promise { const delayMs = config?.mock?.startManagedAgentDelayMs ?? 0; if (delayMs > 0) { - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + await new Promise((resolve) => { + let settled = false; + const release = () => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + heldManagedAgentStartReleases = heldManagedAgentStartReleases.filter( + (held) => held !== release, + ); + resolve(); + }; + const timer = window.setTimeout(release, delayMs); + heldManagedAgentStartReleases.push(release); + }); } // After the injected delay, like the real command's checks before any // spawn/deploy side effect: a caller-captured tenant scope and signer @@ -11527,6 +11551,12 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_EVENT_CALL_COUNT__ = 0; window.__BUZZ_E2E_DEFER_GET_EVENT__ = null; deferredGetEventQueue = []; + heldManagedAgentStartReleases = []; + window.__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__ = () => { + const held = heldManagedAgentStartReleases.splice(0); + for (const release of held) release(); + return held.length; + }; deferNextChannelsRead = false; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 7a57efcbc57..1b12f46fb35 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -3049,6 +3049,164 @@ test("a detached start fired before a real community switch fails closed and kee ).toBe(0); }); +test("a deploy held across an A→B→A community round-trip is not fired twice", async ({ + page, +}) => { + // The in-flight detached-start map used to be cleared by every community + // switch, and the backend's scope assertion is a current-state check — so a + // deploy still held from community A became valid again the moment A was + // re-applied, and a second mention back in A deployed the agent a second + // time (carrying the second message's replay floor, past the first + // message). The entries are tenant-keyed and self-cleaning, so they now + // survive the switch. This drives the real rail-switch path (provider → + // remount → resetCommunityState) that did the clearing; the map contract + // itself is pinned at the unit level. + const COMMUNITY_A = { + id: "ws-a", + name: "Alpha", + relayUrl: "ws://localhost:3000", + addedAt: "2026-01-01T00:00:00.000Z", + }; + const COMMUNITY_B = { + id: "ws-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", + addedAt: "2026-01-02T00:00:00.000Z", + }; + await installMockBridge( + page, + { + managedAgents: [ + { + pubkey: OUT_OF_CHANNEL_PROVIDER_AGENT_PUBKEY, + name: "portal", + status: "not_deployed", + channelNames: ["general"], + backend: { + type: "provider", + id: "portal", + config: { region: "test" }, + }, + }, + ], + // Far longer than the round-trip below ever takes, so the first deploy + // is deterministically still in flight when the second send fires; + // settled on demand via the release seam for the retry leg. + startManagedAgentDelayMs: 45_000, + // Arms the first settlement to reject. A successful mock settle writes + // `deployed` into the record, and the third send below would then skip + // the wake on status alone — the retry leg has to prove the *map entry* + // self-cleaned, so the record must still read `not_deployed`. + startManagedAgentErrors: ["Mock provider deploy failed."], + }, + { skipCommunitySeed: true }, + ); + await page.addInitScript( + ({ list, active }) => { + window.localStorage.setItem("buzz-communities", JSON.stringify(list)); + window.localStorage.setItem("buzz-active-community-id", active); + }, + { list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id }, + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const sendMention = async (text: string) => { + const input = page.getByTestId("message-input"); + await input.fill("Hey @portal"); + await expect(autocomplete(page).getByText("portal")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(` ${text}`); + // Enter rather than the send button: the third send happens while the + // first deploy's failure toast is on screen, and the toast overlay + // intercepts pointer events aimed at the composer's corner. + await input.press("Enter"); + await expect( + page.getByTestId("message-row").filter({ hasText: text }), + ).toBeVisible(); + }; + + const baselineCommands = await readCommandLog(page); + const baselineStartCount = commandCount( + baselineCommands, + "start_managed_agent", + ); + const baselineSettledCount = commandCount( + baselineCommands, + "start_managed_agent:settled", + ); + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; + + await sendMention("do X"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount + 1); + const startCall = (await readCommandPayloadLog(page)) + .slice(baselinePayloadCount) + .find((entry) => entry.command === "start_managed_agent"); + expect(startCall?.payload).toMatchObject({ + expectedRelayUrl: COMMUNITY_A.relayUrl, + expectedSignerPubkey: MOCK_VIEWER_PUBKEY, + }); + + // The round trip, while the deploy is still held. + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), + ).toHaveAttribute("aria-current", "true"); + await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click(); + await expect( + page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`), + ).toHaveAttribute("aria-current", "true"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Back in A, the held deploy's scope is valid again and the record still + // reads `not_deployed`, so this send queues a wake — the retained map entry + // is the only thing standing between it and a duplicate deploy. A + // suppressed wake makes no call, so give the post-publish flush a moment + // before snapshotting: pre-fix the duplicate invoke landed well inside it. + await sendMention("also Y"); + await page.waitForTimeout(500); + expect(commandCount(await readCommandLog(page), "start_managed_agent")).toBe( + baselineStartCount + 1, + ); + + // Settle the held deploy on demand (the armed rejection). Retention must + // end at settlement rather than latching the agent for the session. + const released = await page.evaluate( + () => + ( + window as { + __BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number; + } + ).__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?.() ?? 0, + ); + expect(released).toBe(1); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent:settled"), + ) + .toBe(baselineSettledCount + 1); + // The failure settled with A on screen, so its warning delivers here — the + // round-trip kept it out of B without dropping it. + await expect( + page.getByText("Could not start portal", { exact: false }), + ).toBeVisible(); + + // The map entry self-cleaned at settlement, so a third mention of the + // still-undeployed agent re-fires the wake. + await sendMention("try again"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount + 2); +}); + test("mentioning an in-channel provider managed agent publishes first and deploys it detached", async ({ page, }) => { From 6c68acde85f4f8e26852ad50e029a78f3e94f5e8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 20:30:58 +1000 Subject: [PATCH 15/18] fix(desktop): key in-flight detached starts by the signer too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7154 review round 2, point 2 (P2): the in-flight detached-start map was keyed `(relay URL, agent pubkey)`, but the scope a wake actually asserts is relay *and* signer — `start_managed_agent` runs both `assert_expected_relay_scope` and `assert_expected_signer` before any spawn or deploy, and the provider path re-asserts both against the payload rebuilt after the deploy lock. So a start held under one signing identity suppressed another identity's wake for the same agent on the same relay: the renderer coalescing across a boundary the backend deliberately distinguishes. The omission came from mirroring the wrong key. The map's doc justified the pair as matching the backend's `ManagedAgentRuntimeKey` — and it did, that key is `(pubkey, relay_url)` with no signer. But that tracks *runtimes*; this map tracks scoped start *operations*, and an operation's identity is the full scope it asserts. The doc is rewritten so the next reader does not restore the runtime-key rationale. Reachability is narrow but real: the signing key is one global per install, so two signers only arrive sequentially, via a mid-session import — chiefly the membership-denied onboarding overlay, which writes the new identity straight into the live query cache (`queryClient.setQueryData(["identity"], …)`) while the app underneath, including this module-level map, keeps running. Correcting our own record from b4e87be, which accepted this as "the retained old-identity entry suppresses the new identity's wake until the old start fails closed and settles": that holds only if the old start had not yet passed the scope checks. If it was already mid-spawn or mid-deploy it does *not* fail closed — the scope was valid when checked, the deploy runs to completion under the stale owner, and every send in that window is dropped. Adding the signer eliminates the class rather than shrinking the residual. - The key is now `(relay URL, expected signer, agent pubkey)`. Relay stays verbatim (its backend comparison is case-sensitive past the scheme); the signer is already canonicalized at capture, matching `assert_expected_signer`'s trim + case-insensitive compare, so two casings of one identity cannot split the key. `undefined` can never reach it — the fail-closed refusal returns before key construction when either scope half is missing. - Everything downstream falls out: the identity-guarded `finally` delete closes over the same key string, so settlement frees only its own signer's entry. - Nothing existing changes. Same-signer dedupe is untouched, and the A→B→A community retention is untouched — the identity is global, so a community round-trip hands back a callback carrying the same signer, which still matches the retained entry. - `resetCommunityState`'s exemption comment (the canonical singleton-reset inventory) now names the three-part key. Coverage at the unit seam — the existing harness drives the real hook under the real `CommunitiesProvider` and a real `QueryClient`, with the identity moved by the same `setQueryData(["identity"], …)` write the membership-denied import uses, plus a mutable `get_identity` mock so a refetch stays consistent: - A wake under a newly imported identity is not suppressed by one held under the old: two calls, same agent and same relay, differing only in `expectedSignerPubkey`. - Settling one signer's start leaves the other signer's suppression intact — per-signer settlement independence. - The existing same-signer suppression test is the unmodified control, as are the cross-community and A→B→A retention tests. Both new tests were confirmed red with the signer removed from the key, with the other 18 in the file green. No E2E: unlike b4e87be's retention fix, the seam here is entirely inside the hook, and every E2E spec runs under the mock bridge's single constant identity. Verified: desktop tsc --noEmit, biome check over the touched files, desktop unit tests (5863 passed, 0 failed), and the mentions Playwright smoke suite against a fresh pnpm build:e2e bundle (86 passed, 1 failed — the persona-mention spec flaking under load at the composer-chip step, before any start assertion; green 3x in isolation). The repository file-size gate stays green. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .../features/communities/useCommunityInit.ts | 13 +- .../ui/useDetachedAgentStart.test.mjs | 142 ++++++++++++++++-- .../messages/ui/useDetachedAgentStart.ts | 30 +++- 3 files changed, 162 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index ef734dc5b6a..4a1ddf9d0b8 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -85,12 +85,13 @@ async function resetCommunityState({ resetLinkPreviewPreparations(); resetPersistentAgentAudienceStore(); // Intentionally NOT reset: the in-flight detached agent-start map - // (`useDetachedAgentStart`). Its entries are keyed by tenant scope (relay - // URL + pubkey), so they cannot leak into the new community, and they - // self-clean when the start settles. Clearing them here is what permitted - // the A→B→A duplicate provider deploy: the backend's scope assertion is a - // current-state check, so a start held across a round-trip is valid again - // once A is re-applied — the map entry is its only duplicate guard. + // (`useDetachedAgentStart`). Its entries are keyed by the scope each start + // asserts (relay URL + signer + agent pubkey), so they cannot leak into the + // new community, and they self-clean when the start settles. Clearing them + // here is what permitted the A→B→A duplicate provider deploy: the backend's + // scope assertion is a current-state check, so a start held across a + // round-trip is valid again once A is re-applied — the map entry is its only + // duplicate guard. resetDetachedToastScope(); clearSearchHitEventCache(); clearMarkdownNodeCache(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index bc56f31eb4f..32408c65f08 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -29,6 +29,11 @@ * guard, and it is tenant-keyed, so retention cannot affect the community * being entered. (`resetCommunityState` no longer clears it; that seam is * pinned E2E.) + * 6. That dedupe key is the *whole* scope the wake asserts — relay and signer, + * not the relay alone. The backend distinguishes signers before it spawns + * or deploys, so the renderer must not coalesce across them: a start held + * under the identity in force before a mid-session key import cannot stand + * in for the imported identity's wake. * * These tests drive the real hook against the real CommunitiesProvider; the * scope mirror is driven directly through its module seam, standing in for @@ -41,6 +46,9 @@ import { after, afterEach, before, beforeEach, test } from "node:test"; import { JSDOM } from "jsdom"; const SELF = "1".repeat(64); +// The key a mid-session import puts in force — the signing identity is one +// global per install, so two signers only ever arrive back to back. +const IMPORTED_SELF = "2".repeat(64); const AGENT = "a".repeat(64); const OTHER_AGENT = "b".repeat(64); // Mixed case on purpose: the backend's scope comparison is case-sensitive @@ -68,6 +76,12 @@ let holdStarts = false; */ let heldIdentity = []; let holdIdentity = false; +/** + * The identity `get_identity` currently reports. Mutable so a test can move it + * the way a mid-session key import does, keeping a refetch consistent with the + * cache write that drove the switch. + */ +let currentIdentityPubkey = SELF; before(() => { Object.assign(globalThis, { @@ -80,7 +94,7 @@ before(() => { dom.window.__TAURI_INTERNALS__ = { invoke: (command, args) => { if (command === "get_identity") { - const identity = { pubkey: SELF, display_name: "Me" }; + const identity = { pubkey: currentIdentityPubkey, display_name: "Me" }; if (!holdIdentity) return Promise.resolve(identity); return new Promise((resolve) => { heldIdentity.push(() => resolve(identity)); @@ -125,6 +139,7 @@ beforeEach(async () => { holdStarts = false; heldIdentity = []; holdIdentity = false; + currentIdentityPubkey = SELF; // Toasts queue on a module-level store with no mounted, so one // test's warning would otherwise be visible to the next. const { toast } = await import("sonner"); @@ -172,7 +187,8 @@ beforeEach(async () => { * active community out from under an already-captured callback. * * `act` is returned too, so a test can drive the render that follows a - * deliberately-delayed identity resolution. + * deliberately-delayed identity resolution, and the `QueryClient` so a test can + * move the signing identity the way a mid-session import does. */ async function renderDetachedStart() { const { default: React } = await import("react"); @@ -207,22 +223,43 @@ async function renderDetachedStart() { { wrapper }, ); if (!holdIdentity) await waitForIdentity(act, rendered); - return { act, rendered }; + return { act, client, rendered }; } /** - * Flushes until the identity query has landed, rather than for a fixed number - * of ticks: a wake fired before the signer scope resolves is now refused, so - * an under-wait would surface as a phantom suppression bug. + * Flushes until the identity query has landed (on `expected`, when given), + * rather than for a fixed number of ticks: a wake fired before the signer + * scope resolves is now refused, so an under-wait would surface as a phantom + * suppression bug. */ -async function waitForIdentity(act, rendered) { +async function waitForIdentity(act, rendered, expected) { for (let attempt = 0; attempt < 20; attempt += 1) { - if (rendered.result.current.identityPubkey) return; + const current = rendered.result.current.identityPubkey; + if (expected ? current === expected : Boolean(current)) return; await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } - assert.fail("the identity query never resolved"); + assert.fail( + expected + ? `the identity query never reported ${expected}` + : "the identity query never resolved", + ); +} + +/** + * Puts a different signing key in force mid-session, through the exact seam + * production uses: the membership-denied onboarding overlay imports an nsec and + * writes the new identity straight into the live identity query cache + * (`CommunityOnboardingFlow`), while the app underneath — module singletons, + * in-flight mutations, this map — keeps running. + */ +async function importIdentity(act, rendered, client, pubkey) { + currentIdentityPubkey = pubkey; + await act(async () => { + client.setQueryData(["identity"], { pubkey, display_name: "Me" }); + }); + await waitForIdentity(act, rendered, pubkey); } const AGENT_RECORD = { pubkey: AGENT, name: "fizz" }; @@ -716,3 +753,90 @@ test("a wake for the same agent in another community is not suppressed", async ( ); rendered.unmount(); }); + +test("a wake under a newly imported identity is not suppressed by one held under the old", async () => { + // The signer half of the same boundary. `start_managed_agent` asserts the + // expected signer before it spawns or deploys — and a provider deploy + // re-asserts it against the payload rebuilt after the deploy lock — so a + // start held under the previous identity is not the wake this one is owed. + // Suppressing it would drop every send for the length of a cold spawn or + // first deploy, and leave the agent deployed under the stale owner. + holdStarts = true; + const { act, client, rendered } = await renderDetachedStart(); + + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + await importIdentity(act, rendered, client, IMPORTED_SELF); + + let refire; + await act(async () => { + refire = rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + + assert.equal(refire, true, "the identity now in force is owed its own wake"); + assert.deepEqual( + startCalls.map((call) => call.expectedSignerPubkey), + [SELF, IMPORTED_SELF], + "the key carries the signer, so one identity's in-flight wake never suppresses another's", + ); + // Same agent on the same relay: the signer is the only thing separating the + // two operations, which is exactly what the pre-fix key could not see. + assert.deepEqual( + startCalls.map((call) => call.pubkey), + [AGENT, AGENT], + ); + assert.deepEqual( + startCalls.map((call) => call.expectedRelayUrl), + [RELAY_A, RELAY_A], + ); + rendered.unmount(); +}); + +test("settling one signer's start leaves the other signer's suppression intact", async () => { + // Per-signer settlement independence: the `finally` delete closes over the + // key it registered, so freeing the imported identity's entry must not lift + // the suppression the still-held old-identity start is providing (nor the + // reverse). + holdStarts = true; + const { act, client, rendered } = await renderDetachedStart(); + // What a send fired before the import holds: the callback from the render + // that fired it, still carrying the old signer. + const startAsOldIdentity = rendered.result.current.startDetached; + + await act(async () => { + startAsOldIdentity(AGENT_RECORD); + await settle(); + }); + await importIdentity(act, rendered, client, IMPORTED_SELF); + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD); + await settle(); + }); + assert.equal(startCalls.length, 2, "both signers have a start in flight"); + + await act(async () => { + heldStarts[1].resolve(); + await settle(); + }); + + let refireImported; + let refireOld; + await act(async () => { + refireImported = rendered.result.current.startDetached(AGENT_RECORD); + refireOld = startAsOldIdentity(AGENT_RECORD); + await settle(); + }); + + assert.equal(refireImported, true, "the settled signer's key is free again"); + assert.equal( + refireOld, + false, + "the still-held signer's entry must survive another signer's settlement", + ); + assert.equal(startCalls.length, 3); + assert.equal(startCalls[2].expectedSignerPubkey, IMPORTED_SELF); + rendered.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index f88b8f34e32..0303e98e193 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -9,7 +9,8 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { getErrorMessage } from "./useMentionSendFlow.helpers"; /** - * Detached starts still in flight, keyed by `(scoped relay URL, pubkey)`. + * Detached starts still in flight, keyed by the full tenant scope the wake + * asserts: `(scoped relay URL, expected signer, agent pubkey)`. * * Awaiting the start used to make a duplicate unreachable: `isPending` was a * hard early return in the composer's send handler, so no second send could @@ -20,9 +21,16 @@ import { getErrorMessage } from "./useMentionSendFlow.helpers"; * collapsing include cross-composer ones (channel composer, thread panel, * `NewMessageScreen` each hold their own `useMentionSendFlow`). * - * Keyed by relay as well as pubkey to mirror the backend's own runtime pair - * key, so a start in one community can never suppress one in another — the - * same tenant boundary `expectedRelayUrl` enforces below. + * The key is the asserted scope, not the backend's `ManagedAgentRuntimeKey` + * (which is `(pubkey, relay_url)` — it tracks *runtimes*, while this map tracks + * scoped start *operations*). `start_managed_agent` asserts relay **and** + * signer before it spawns or deploys, so coalescing on the relay alone would + * let a start held under one signing identity suppress another identity's wake + * for the same agent on the same relay — a mid-session key import (the + * membership-denied overlay writes a new identity straight into the live query + * cache) inside a deploy window is enough to reach it. Both halves are in the + * key, so a wake is only ever suppressed by one asserting exactly the same + * scope. * * Entries deliberately survive community switches (`resetCommunityState` does * NOT clear this map). A held start is not invalidated by leaving its @@ -102,9 +110,9 @@ function warnAgentMayNotRespond(agentName: string, detail: string): void { * `false` return) and the user's next send re-fires it. * * Returns whether this call actually fired a wake: a start already in flight - * for the same agent in the same tenant is suppressed, since the wake is - * per-agent rather than per-message and the first start's replay floor is - * earlier than the second message. + * for the same agent under the same asserted scope — relay *and* signer — is + * suppressed, since the wake is per-agent rather than per-message and the + * first start's replay floor is earlier than the second message. * * `replayFloorUnix` lets a caller pass a floor captured earlier than this * call — the send path queues its wakes during preparation and flushes them @@ -150,7 +158,13 @@ export function useDetachedAgentStart(): ( // No synchronisation is needed or possible: the check, the call and the // registration below sit in one synchronous block, so no other send can // interleave between "is it in the set?" and "put it in the set". - const key = `${expectedRelayUrl}\u0000${normalizePubkey(agent.pubkey)}`; + // + // Relay verbatim, because its backend comparison is case-sensitive; + // signer and agent normalized. `expectedSignerPubkey` is already + // canonicalized at capture, matching `assert_expected_signer`'s + // case-insensitive compare, so two casings of one identity cannot split + // the key. + const key = `${expectedRelayUrl}\u0000${expectedSignerPubkey}\u0000${normalizePubkey(agent.pubkey)}`; if (inFlightDetachedStarts.has(key)) { // One wake serves both messages. A local duplicate is a backend no-op // anyway, but a provider redeploy can replace a harness that had just From f67439c6ef85894eb9dc3f554a51ddb909a8d445 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 2 Sep 2026 14:51:15 +1000 Subject: [PATCH 16/18] fix(desktop): bound how stale the publish-boundary mention admission can be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7154 review round 2, point 1 (P2): the publish-boundary revalidation skips when `preparedUpload`, `draft.preparedLinkPreviews`, and `relaySideEffectsRan` are all false, so the pre-side-effect authorization pass stands as the publish answer across whatever separated the two. Those three are an *enumeration* of the awaited steps known to reach the relay, and enumerations rot: a contributor adding an awaited step later must remember to set the boolean, and forgetting fails silent and unsafe — a stale admission publishes and nothing goes red. This branch already hardened one instance of this exact fragility class in cbd43fa, making the access-policy `wrote` signal load-bearing by construction rather than by convention. Measure the gap instead of enumerating its causes: - New `shouldRevalidateMentionsAtPublish` in `useMentionSendFlow.helpers` takes the three named triggers plus `msSinceAdmission` and adds a fourth clause: revalidate when at least `MENTION_ADMISSION_MAX_AGE_MS` (200) elapsed since the admission pass resolved. `completeSend` stamps `performance.now()` at admission and passes the delta at the boundary — monotonic, so a wall-clock step backwards cannot suppress the check. - The named triggers stay: they catch sub-threshold relay writes, which the clock alone would miss. - Cost on the fast path is nil. The measured immediate send is ~5 ms of local IPC between admission and publish (managed-agents cache read, the member short-circuit in readiness, and the no-active-huddle sync, which returns before touching the relay), so the bound never fires and the send keeps its single pass. What it converts is the unenumerated case: a future awaited step, a cold cache, or contention degrades to one extra ~570 ms pass instead of a silently stale publish. Conceding one tail the review write-up did not name and we had not either: `sync_agents_to_active_huddle` takes `AGENT_SYNC_LOCK` *before* its phase check, so a concurrent huddle-enrolling send can hold the "no active huddle" IPC — the leg that reports no relay work — open for hundreds of ms. Rare, but it is a real case where that step is not instant, and it is precisely what a clock catches and an enumeration cannot. Two corrections to the review's narrative, neither changing the fix. The "matching access policy leaves the flag false" combination is unreachable: `applyReusableAgentAccessPolicy` only runs for a non-member (`useEnsureAgentMentionsReady.ts:105-111`), and a non-member that skips the `wrote: true` attach must be in `participants` via `preparedParticipantPubkeys`, which is non-empty only under DM expansion — and that branch already set `relaySideEffectsRan` before readiness ran. And unconditional revalidation was not taken because it re-adds ~570 ms to every send to shrink an uncaught window that is ~600 ms on this branch *and* on `origin/main` (the admission pass's own three sequential relay queries leave its answer ~380 ms stale at resolution; the publish flight adds ~215 ms). Moving admission to the publish boundary was not taken because `admittedMentionPubkeys` gates every relay side effect that must precede the publish — DM participant set, access-policy/attach, the wake queue, huddle enrollment — so a late-only pass would write membership and queue agent wakes for unadmitted agents, the #5681 violation this ordering exists to prevent. Tests: - The requested causal regression, red-on-revert verified: a member relay agent on `general` (readiness short-circuits — no policy read, no attach, no wake), no huddle, no attachments. A new releasable bridge knob `syncAgentsToActiveHuddleDelayMs` holds the huddle-sync IPC open; the spec waits for +1 revalidation, injects the revocation, then releases via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()` — injection before release makes the ordering deterministic by construction rather than by timing. It asserts the message publishes, quinn's p tag is stripped, revalidation is +2, and `add_channel_members`, `attach_managed_agent`, `update_managed_agent` and `start_managed_agent` are all unchanged, which is what proves the leg wrote nothing and so fired no named trigger. Neutering the elapsed clause makes it red (p tag present). - The untouched fast-path spec at `mentions.spec.ts:1793` stays at +1 as the over-trigger control: the bound must not fire on unheld sends, or the latency win this branch exists for is gone. - Three unit tests pin the predicate directly: the fast path reuses the admitted set, the bound fires at exactly `MENTION_ADMISSION_MAX_AGE_MS` and not one ms below, and each named trigger still fires on its own inside the bound. The repository file-size gate stays green. Verified: desktop tsc --noEmit, biome check over the touched files (exit 0), desktop unit tests (5909 passed, 0 failed), and the full mentions (89) and channels (88) Playwright smoke suites against a fresh pnpm build:e2e bundle, plus a 3x stress rerun of the new spec. Unrelated, recorded so it is not mistaken for fallout: the `huddle-transcription.spec.ts` "assigns distinct agent voices" spec fails on this branch — it also fails 3/3 with this change stashed and on a clean tree, so it predates this commit. Diagnosing it needs a stable preview server: a stale or dying server on port 4173 produces both phantom failures and phantom passes here. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Matt Toohey --- .../ui/useMentionSendFlow.helpers.test.mjs | 47 ++++++++ .../messages/ui/useMentionSendFlow.helpers.ts | 43 +++++++ .../messages/ui/useMentionSendFlow.ts | 24 ++-- desktop/src/testing/e2eBridge.ts | 38 ++++++ desktop/tests/e2e/mentions.spec.ts | 109 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 4 + 6 files changed, 257 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs index bc11d795126..8c298cc26c4 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs @@ -4,9 +4,18 @@ import test from "node:test"; import { formatMessageSendError, getErrorMessage, + MENTION_ADMISSION_MAX_AGE_MS, mergeMentionRecipients, + shouldRevalidateMentionsAtPublish, } from "./useMentionSendFlow.helpers.ts"; +const fastPathSend = { + hasDeferredUpload: false, + hasDeferredLinkPreviews: false, + relaySideEffectsRan: false, + msSinceAdmission: 3, +}; + test("formatMessageSendError preserves the publication failure", () => { assert.equal( formatMessageSendError(new Error("relay rejected voice note")), @@ -29,6 +38,44 @@ test("getErrorMessage preserves Tauri string errors", () => { assert.equal(getErrorMessage({}, "Unknown error"), "Unknown error"); }); +test("the immediate fast path reuses the admitted mention set", () => { + assert.equal(shouldRevalidateMentionsAtPublish(fastPathSend), false); +}); + +test("a long gap between admission and publish forces a fresh pass", () => { + // The staleness bound, not any named trigger: none of the enumerated + // relay-touching steps ran, yet the two passes are far enough apart that + // the earlier admission can no longer be assumed current. + assert.equal( + shouldRevalidateMentionsAtPublish({ + ...fastPathSend, + msSinceAdmission: MENTION_ADMISSION_MAX_AGE_MS, + }), + true, + ); + assert.equal( + shouldRevalidateMentionsAtPublish({ + ...fastPathSend, + msSinceAdmission: MENTION_ADMISSION_MAX_AGE_MS - 1, + }), + false, + ); +}); + +test("each relay-touching trigger forces a fresh pass on its own", () => { + for (const trigger of [ + "hasDeferredUpload", + "hasDeferredLinkPreviews", + "relaySideEffectsRan", + ]) { + assert.equal( + shouldRevalidateMentionsAtPublish({ ...fastPathSend, [trigger]: true }), + true, + `${trigger} must revalidate even inside the staleness bound`, + ); + } +}); + test("address-locked agents join explicit mentions without duplicating recipients", () => { const explicit = ["A".repeat(64), "b".repeat(64)]; const locked = ["a".repeat(64), "C".repeat(64)]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index e4c4f0d806e..e92adbd51ee 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -130,6 +130,49 @@ export function mergeOutgoingTagsWithReferenceMentions( ]; } +/** + * How long the pre-side-effect mention-authorization pass may stand in as the + * publish-boundary answer before the publish re-runs it. + * + * The named triggers in {@link shouldRevalidateMentionsAtPublish} enumerate + * the awaited steps known to reach the relay, and an enumeration rots: a step + * added later that suspends the send for a round-trip would silently publish + * a stale admission, and nothing would fail. Measuring the gap fails the + * other way — an unenumerated suspension, a cold cache, a contended backend + * lock, or an event-loop stall costs one extra pass instead of a stale + * publish. The measured fast path is single-digit milliseconds of local IPC + * with no relay traffic possible, so this bound never fires there. + */ +export const MENTION_ADMISSION_MAX_AGE_MS = 200; + +/** + * Whether the publish must re-run mention authorization rather than reuse the + * pre-side-effect pass (#5681): either an awaited step known to reach the + * relay ran in between, or the two are far enough apart that the earlier + * answer can no longer be assumed current. + * + * `msSinceAdmission` must come from a monotonic clock (`performance.now()`), + * not `Date.now()` — a wall-clock step backwards would suppress the check. + */ +export function shouldRevalidateMentionsAtPublish({ + hasDeferredUpload, + hasDeferredLinkPreviews, + relaySideEffectsRan, + msSinceAdmission, +}: { + hasDeferredUpload: boolean; + hasDeferredLinkPreviews: boolean; + relaySideEffectsRan: boolean; + msSinceAdmission: number; +}) { + return ( + hasDeferredUpload || + hasDeferredLinkPreviews || + relaySideEffectsRan || + msSinceAdmission >= MENTION_ADMISSION_MAX_AGE_MS + ); +} + export function getErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message; if (typeof error === "string" && error.trim()) return error; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 48d181a1651..45ddb08c5f9 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -41,6 +41,7 @@ import { type QueuedAgentWake, type SendMessageWithMentionFlowInput, resolvePreviewTags, + shouldRevalidateMentionsAtPublish, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; @@ -380,6 +381,8 @@ export function useMentionSendFlow({ const admittedMentionPubkeys = uniqueNormalizedPubkeys( await mentions.revalidateMentionPubkeys(mentionPubkeys), ); + // Monotonic stamp for the publish-boundary staleness bound below. + const admittedAtMs = performance.now(); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) return persistPreflightDraft(); const admittedMentionPubkeySet = new Set(admittedMentionPubkeys); @@ -520,15 +523,20 @@ export function useMentionSendFlow({ // Mention authorization was already established by the pre-side-effect // pass above. Re-validate at the publish boundary only when something // separated that pass from this publish — a deferred wait (background - // media upload, link-preview settlement) or an awaited relay side + // media upload, link-preview settlement), an awaited relay side // effect (DM expansion, membership/access-policy writes, huddle - // enrollment) — since authorization could have been revoked during - // the gap. On the remaining immediate path a second pass would repeat - // the same relay round-trips with the same inputs. - const revalidatedMentionPubkeys = - preparedUpload || draft.preparedLinkPreviews || relaySideEffectsRan - ? await mentions.revalidateMentionPubkeys(mentionPubkeys) - : admittedMentionPubkeys; + // enrollment), or simply enough elapsed time that the earlier answer + // is no longer current — since authorization could have been revoked + // during the gap. On the remaining immediate path a second pass would + // repeat the same relay round-trips with the same inputs. + const revalidatedMentionPubkeys = shouldRevalidateMentionsAtPublish({ + hasDeferredUpload: preparedUpload != null, + hasDeferredLinkPreviews: draft.preparedLinkPreviews != null, + relaySideEffectsRan, + msSinceAdmission: performance.now() - admittedAtMs, + }) + ? await mentions.revalidateMentionPubkeys(mentionPubkeys) + : admittedMentionPubkeys; if (signal?.aborted || isSendCancelled()) return; const finalTagsWithAgentAddress = [ ...finalOutgoingTags, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0e1095db74b..7236ac5e820 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -316,6 +316,11 @@ type E2eConfig = { addAgentToHuddleError?: string; /** Delay an invocation-time huddle snapshot to exercise hydration ordering. */ huddleStateReadDelayMs?: number; + /** Delay (ms) for `sync_agents_to_active_huddle` so e2e tests can hold the + * send path open across a leg that writes nothing to the relay (no active + * huddle) and revoke a mention mid-hold. + * Releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. */ + syncAgentsToActiveHuddleDelayMs?: number; /** Delay companion creation to expose the newly-started huddle handoff state. */ openHuddleWindowDelayMs?: number; /** Delay the native start result after membership arrives in the channel list. */ @@ -1519,6 +1524,10 @@ declare global { * armed `startManagedAgentErrors` rejection) without waiting out the * delay. Returns the number of holds flushed. */ __BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__?: () => number; + /** Release every `sync_agents_to_active_huddle` currently held behind + * `syncAgentsToActiveHuddleDelayMs`, letting it resolve without waiting + * out the delay. Returns the number of holds flushed. */ + __BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?: () => number; /** Hold the next channel read until released. */ __BUZZ_E2E_DEFER_NEXT_CHANNELS_READ__?: () => void; /** Disarm the latch and release the held channel read, if any. */ @@ -1656,6 +1665,11 @@ let deferredThreadRepliesQueue: Array<() => void> = []; // start across a community round-trip needs the hold long enough to be // deterministic AND a way to settle it on demand afterwards. let heldManagedAgentStartReleases: Array<() => void> = []; +// Huddle agent syncs currently held behind `syncAgentsToActiveHuddleDelayMs`, +// releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. The hold +// must outlast the mid-send mutation a spec injects, then settle on demand +// rather than on a timer, so the publish never races the injection. +let heldHuddleAgentSyncReleases: Array<() => void> = []; let cancelledMediaUploadIds = new Set(); let cancelledMediaFetchIds = new Set(); let mockMediaFetchControllers = new Map(); @@ -11557,6 +11571,12 @@ export function maybeInstallE2eTauriMocks() { for (const release of held) release(); return held.length; }; + heldHuddleAgentSyncReleases = []; + window.__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__ = () => { + const held = heldHuddleAgentSyncReleases.splice(0); + for (const release of held) release(); + return held.length; + }; deferNextChannelsRead = false; deferredChannelsReadResolve = null; window.__BUZZ_E2E_CHANNELS_READ_PENDING__ = 0; @@ -11991,6 +12011,24 @@ export function maybeInstallE2eTauriMocks() { channelId?: string; agentPubkeys?: string[]; }; + const syncDelayMs = + activeConfig?.mock?.syncAgentsToActiveHuddleDelayMs ?? 0; + if (syncDelayMs > 0) { + await new Promise((resolve) => { + let settled = false; + const release = () => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + heldHuddleAgentSyncReleases = heldHuddleAgentSyncReleases.filter( + (held) => held !== release, + ); + resolve(); + }; + const timer = window.setTimeout(release, syncDelayMs); + heldHuddleAgentSyncReleases.push(release); + }); + } if ( !mockHuddle || !request.channelId || diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 1b12f46fb35..86d2db9ffc5 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2321,6 +2321,115 @@ test("sends that enroll agents into an active huddle revalidate at the publish b ); }); +test("a send held open by a no-write step still revalidates at the publish boundary", async ({ + page, +}) => { + // The three named triggers enumerate the awaited steps known to reach the + // relay; the staleness bound covers the ones they cannot name. Here the only + // thing separating the authorization pass from the publish is the huddle + // sync — which with no active huddle writes nothing and returns + // `matched_active_huddle: false`, so no named trigger fires — held open long + // enough for a revocation to land. The p tag must still be stripped. + await installMockBridge(page, { + // Released on demand below; long enough that it is never waited out. + syncAgentsToActiveHuddleDelayMs: 45_000, + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + // Already a member, so readiness short-circuits: no access-policy read, no + // membership write, no wake. + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn hello"); + + const baselineCommands = await readCommandLog(page); + await page.getByTestId("send-message").click(); + + // The pre-side-effect pass has admitted quinn; the huddle sync now holds the + // publish open. Revoke before releasing, so the ordering is deterministic by + // construction rather than by timing. + await expect + .poll(async () => + commandCount(await readCommandLog(page), "revalidate_relay_agents"), + ) + .toBe(commandCount(baselineCommands, "revalidate_relay_agents") + 1); + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); + + // The send is parked on the held sync, so this only widens a gap that is + // already open — it pins the admission-to-publish distance past the + // staleness bound on any machine. + await page.waitForTimeout(400); + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__?.() ?? 0, + ), + ) + .toBeGreaterThan(0); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toBeNull(); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe( + commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1, + ); + // The second pass was owed to elapsed time alone: nothing on this leg wrote + // relay state, which is what makes it invisible to the named triggers. + for (const command of [ + "add_channel_members", + "attach_managed_agent", + "update_managed_agent", + "start_managed_agent", + ]) { + expect(commandCount(commands, command)).toBe( + commandCount(baselineCommands, command), + ); + } +}); + test("selected relay agents are invited as bots before sending", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 97c7096d5a5..c3f4ed69f4c 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -229,6 +229,10 @@ type MockBridgeOptions = { }; /** Delay an invocation-time huddle snapshot to exercise hydration ordering. */ huddleStateReadDelayMs?: number; + /** Delay (ms) for `sync_agents_to_active_huddle` so e2e tests can hold the + * send path open across a leg that writes nothing to the relay. + * Releasable early via `__BUZZ_E2E_RELEASE_HUDDLE_AGENT_SYNCS__()`. */ + syncAgentsToActiveHuddleDelayMs?: number; /** Delay companion creation to expose the newly-started huddle handoff state. */ openHuddleWindowDelayMs?: number; /** Delay the native start result after membership arrives in the channel list. */ From 2f1dad3251a077830995c0d116a946f6cb3dcb69 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 2 Sep 2026 16:38:03 +1000 Subject: [PATCH 17/18] fix(desktop): always revalidate mention authorization at the publish boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7154 review round 3 (P2): the elapsed-time staleness bound shipped in c2c3aac deliberately admitted a <=200 ms window in which the publish reused the pre-side-effect mention admission, and the reviewer's shipped-bundle probe (the checked-in huddle-hold spec with its 400 ms post-revocation wait turned down to zero) proved a revocation landing inside that window published the stale p tag. That was the bound working as designed — "<=200 ms of admitted staleness is acceptable" is a policy call, and no explicit product/security sign-off exists for it. Third round on the same point; concede in full and remove the policy question rather than re-arguing it. The publish boundary now re-runs mention revalidation unconditionally, and the entire conditional apparatus is deleted: the shouldRevalidateMentionsAtPublish predicate, the MENTION_ADMISSION_MAX_AGE_MS threshold, the monotonic admittedAtMs stamp, the relaySideEffectsRan trigger tracking (its DM-expansion and readiness assignments and the matched_active_huddle branch — the huddle sync invoke and its error handling stay), and the three predicate unit tests. The resulting contract fits in one sentence — the pass immediately before signing/publish is always fresh — and cannot rot the way a trigger enumeration or a clock policy can. The early admission pass stays: it gates every relay side effect that must precede the publish (DM expansion, access-policy/attach, wake queueing, huddle enrollment), the #5681 ordering a late-only pass would violate. Sends with no agent mentions are unaffected: revalidateAgentMentionPubkeys returns before any relay traffic when the requested set contains no agents. Agent-mention sends pay one extra targeted pass (~570 ms today); the follow-up commit joins the membership read into the backend's batch fan-out to claw most of that back. A second pass shifts rather than shrinks the ~600 ms revocation blind window (membership-read staleness plus publish flight) — relay-side NIP-29 scoping remains the enforcement boundary — but removing the conditional removes the accepted-staleness policy and the enumeration-rot surface with it. useEnsureAgentMentionsReady keeps its wroteRelayState signal and applyReusableAgentAccessPolicy keeps the {agent, wrote} contract — both stay truthful and unit-pinned; the doc comment now records that nothing consumes the flag to decide anything. E2E, per the review: - The huddle-hold staleness spec becomes exactly the reviewer's probe: the 400 ms post-revocation wait is deleted, so the revocation is released with zero further hold (revoke-before-release keeps the ordering deterministic by construction). Red-on-revert verified: with the pre-fix useMentionSendFlow restored against this spec, quinn's pubkey stays in the outgoing mentions and the revalidation count stays at +1. - The two fast-path specs pinning revalidate_relay_agents at +1 now pin +2: the publish-boundary pass is unconditional, including on the fastest member-agent path. - The deferred-upload (+2), attach-path (mid-hold +1, final +2), active-huddle (+2), and DM-expansion (channels.spec.ts, 2) pins are unchanged and still meaningful; their comments no longer describe a conditional reuse. Verified: desktop tsc --noEmit, biome check over the touched files (exit 0), desktop unit tests (5906 passed, 0 failed — down exactly the three deleted predicate tests), the full mentions (88) and channels (89) Playwright smoke suites against a fresh pnpm build:e2e bundle, a 3x stress rerun of the zero-wait staleness spec, and the red-on-revert check above. Two infra notes from the runs: a self-hosted or Playwright-managed preview server on 4173 is killed mid-run by the sandboxed shell — both mass-failure runs (79 and 86 connection-refused failures) reran green unsandboxed with zero test changes. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../ui/useEnsureAgentMentionsReady.ts | 8 ++-- .../ui/useMentionSendFlow.helpers.test.mjs | 47 ------------------ .../messages/ui/useMentionSendFlow.helpers.ts | 43 ----------------- .../messages/ui/useMentionSendFlow.ts | 47 +++--------------- desktop/tests/e2e/mentions.spec.ts | 48 +++++++++---------- 5 files changed, 36 insertions(+), 157 deletions(-) diff --git a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts index 4e69c69d9fd..4c3485275ed 100644 --- a/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts +++ b/desktop/src/features/messages/ui/useEnsureAgentMentionsReady.ts @@ -16,8 +16,10 @@ export type EnsureAgentMentionsReadyResult = { errors: string[]; pubkeys: string[]; /** - * Whether an awaited relay write ran, which tells the publish boundary that - * something separated it from the pre-side-effect authorization pass. + * Whether an awaited relay write ran. Informational only: the publish + * boundary revalidates mention authorization unconditionally, so nothing + * consumes this to decide anything — it stays because the signal is + * truthful by construction and unit-pinned. */ wroteRelayState: boolean; /** @@ -111,7 +113,7 @@ export function useEnsureAgentMentionsReady({ ); if (wrote) { // The access-policy reconciliation hit the relay; a matching - // policy reports `wrote: false` and stays on the fast path. + // policy reports `wrote: false`. wroteRelayState = true; } if (participants.has(pubkey)) { diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs index 8c298cc26c4..bc11d795126 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs @@ -4,18 +4,9 @@ import test from "node:test"; import { formatMessageSendError, getErrorMessage, - MENTION_ADMISSION_MAX_AGE_MS, mergeMentionRecipients, - shouldRevalidateMentionsAtPublish, } from "./useMentionSendFlow.helpers.ts"; -const fastPathSend = { - hasDeferredUpload: false, - hasDeferredLinkPreviews: false, - relaySideEffectsRan: false, - msSinceAdmission: 3, -}; - test("formatMessageSendError preserves the publication failure", () => { assert.equal( formatMessageSendError(new Error("relay rejected voice note")), @@ -38,44 +29,6 @@ test("getErrorMessage preserves Tauri string errors", () => { assert.equal(getErrorMessage({}, "Unknown error"), "Unknown error"); }); -test("the immediate fast path reuses the admitted mention set", () => { - assert.equal(shouldRevalidateMentionsAtPublish(fastPathSend), false); -}); - -test("a long gap between admission and publish forces a fresh pass", () => { - // The staleness bound, not any named trigger: none of the enumerated - // relay-touching steps ran, yet the two passes are far enough apart that - // the earlier admission can no longer be assumed current. - assert.equal( - shouldRevalidateMentionsAtPublish({ - ...fastPathSend, - msSinceAdmission: MENTION_ADMISSION_MAX_AGE_MS, - }), - true, - ); - assert.equal( - shouldRevalidateMentionsAtPublish({ - ...fastPathSend, - msSinceAdmission: MENTION_ADMISSION_MAX_AGE_MS - 1, - }), - false, - ); -}); - -test("each relay-touching trigger forces a fresh pass on its own", () => { - for (const trigger of [ - "hasDeferredUpload", - "hasDeferredLinkPreviews", - "relaySideEffectsRan", - ]) { - assert.equal( - shouldRevalidateMentionsAtPublish({ ...fastPathSend, [trigger]: true }), - true, - `${trigger} must revalidate even inside the staleness bound`, - ); - } -}); - test("address-locked agents join explicit mentions without duplicating recipients", () => { const explicit = ["A".repeat(64), "b".repeat(64)]; const locked = ["a".repeat(64), "C".repeat(64)]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index e92adbd51ee..e4c4f0d806e 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -130,49 +130,6 @@ export function mergeOutgoingTagsWithReferenceMentions( ]; } -/** - * How long the pre-side-effect mention-authorization pass may stand in as the - * publish-boundary answer before the publish re-runs it. - * - * The named triggers in {@link shouldRevalidateMentionsAtPublish} enumerate - * the awaited steps known to reach the relay, and an enumeration rots: a step - * added later that suspends the send for a round-trip would silently publish - * a stale admission, and nothing would fail. Measuring the gap fails the - * other way — an unenumerated suspension, a cold cache, a contended backend - * lock, or an event-loop stall costs one extra pass instead of a stale - * publish. The measured fast path is single-digit milliseconds of local IPC - * with no relay traffic possible, so this bound never fires there. - */ -export const MENTION_ADMISSION_MAX_AGE_MS = 200; - -/** - * Whether the publish must re-run mention authorization rather than reuse the - * pre-side-effect pass (#5681): either an awaited step known to reach the - * relay ran in between, or the two are far enough apart that the earlier - * answer can no longer be assumed current. - * - * `msSinceAdmission` must come from a monotonic clock (`performance.now()`), - * not `Date.now()` — a wall-clock step backwards would suppress the check. - */ -export function shouldRevalidateMentionsAtPublish({ - hasDeferredUpload, - hasDeferredLinkPreviews, - relaySideEffectsRan, - msSinceAdmission, -}: { - hasDeferredUpload: boolean; - hasDeferredLinkPreviews: boolean; - relaySideEffectsRan: boolean; - msSinceAdmission: number; -}) { - return ( - hasDeferredUpload || - hasDeferredLinkPreviews || - relaySideEffectsRan || - msSinceAdmission >= MENTION_ADMISSION_MAX_AGE_MS - ); -} - export function getErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message; if (typeof error === "string" && error.trim()) return error; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 45ddb08c5f9..a8de65a75ca 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -41,7 +41,6 @@ import { type QueuedAgentWake, type SendMessageWithMentionFlowInput, resolvePreviewTags, - shouldRevalidateMentionsAtPublish, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; @@ -381,8 +380,6 @@ export function useMentionSendFlow({ const admittedMentionPubkeys = uniqueNormalizedPubkeys( await mentions.revalidateMentionPubkeys(mentionPubkeys), ); - // Monotonic stamp for the publish-boundary staleness bound below. - const admittedAtMs = performance.now(); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) return persistPreflightDraft(); const admittedMentionPubkeySet = new Set(admittedMentionPubkeys); @@ -413,15 +410,8 @@ export function useMentionSendFlow({ ...agentMentionPubkeys, ]); let sendChannelId = draft.capturedChannelId; - // The pre-side-effect authorization pass above only stands at the - // publish boundary while nothing separates the two. Track whether any - // awaited relay round-trip (DM-expansion channel resolution, agent - // membership/access-policy writes, huddle enrollment) actually ran in - // between — a revocation can land during those waits (#5681). - let relaySideEffectsRan = false; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); - relaySideEffectsRan = true; if (isSendCancelled()) return restoreComposerAfterFailure(); if (!sendChannelId) { return restoreComposerAfterFailure(); @@ -439,9 +429,6 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); - if (agentReadiness.wroteRelayState) { - relaySideEffectsRan = true; - } // Every wake this send queued: persona creates carried on the draft // (enqueued before the non-member prompt could defer us here), then // the readiness pass's. Flushed only after the relay accepts the @@ -471,19 +458,10 @@ export function useMentionSendFlow({ } if (preparedAgentPubkeys.length > 0 && sendChannelId) { try { - const huddleSync = await invokeTauri<{ - matched_active_huddle: boolean; - added: string[]; - }>("sync_agents_to_active_huddle", { + await invokeTauri("sync_agents_to_active_huddle", { channelId: sendChannelId, agentPubkeys: preparedAgentPubkeys, }); - if (huddleSync.matched_active_huddle) { - // A matched huddle means the sync did relay work (membership - // read at minimum, enrollment writes for missing agents); no - // active huddle returns before touching the relay. - relaySideEffectsRan = true; - } if (isSendCancelled()) return restoreComposerAfterFailure(); } catch (error) { if (isSendCancelled()) return restoreComposerAfterFailure(); @@ -520,23 +498,12 @@ export function useMentionSendFlow({ ); if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; - // Mention authorization was already established by the pre-side-effect - // pass above. Re-validate at the publish boundary only when something - // separated that pass from this publish — a deferred wait (background - // media upload, link-preview settlement), an awaited relay side - // effect (DM expansion, membership/access-policy writes, huddle - // enrollment), or simply enough elapsed time that the earlier answer - // is no longer current — since authorization could have been revoked - // during the gap. On the remaining immediate path a second pass would - // repeat the same relay round-trips with the same inputs. - const revalidatedMentionPubkeys = shouldRevalidateMentionsAtPublish({ - hasDeferredUpload: preparedUpload != null, - hasDeferredLinkPreviews: draft.preparedLinkPreviews != null, - relaySideEffectsRan, - msSinceAdmission: performance.now() - admittedAtMs, - }) - ? await mentions.revalidateMentionPubkeys(mentionPubkeys) - : admittedMentionPubkeys; + // The pass immediately before signing/publish is always fresh: + // mention authorization is re-validated here unconditionally, + // whatever did or did not separate it from the admission pass + // above (#5681). + const revalidatedMentionPubkeys = + await mentions.revalidateMentionPubkeys(mentionPubkeys); if (signal?.aborted || isSendCancelled()) return; const finalTagsWithAgentAddress = [ ...finalOutgoingTags, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 86d2db9ffc5..d7097e9e54c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1920,11 +1920,11 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); - // Exactly one targeted revalidation: the pre-side-effect pass doubles as the - // publish-boundary check on the fast path — no deferred upload/preview and - // no relay-writing side effect (member agent, no DM expansion, no huddle). + // Two targeted revalidations: the pre-side-effect admission pass, then the + // publish-boundary pass, which is unconditional — even this fast path (member + // agent, no deferred upload/preview, no DM expansion, no huddle) re-runs it. expect(commandCount(commands, "revalidate_relay_agents")).toBe( - commandCount(baselineCommands, "revalidate_relay_agents") + 1, + commandCount(baselineCommands, "revalidate_relay_agents") + 2, ); expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), @@ -2032,8 +2032,9 @@ test("targeted revocation before send causes no agent side effects", async ({ .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); + // Admission pass plus the unconditional publish-boundary pass. expect(commandCount(commands, "revalidate_relay_agents")).toBe( - commandCount(baselineCommands, "revalidate_relay_agents") + 1, + commandCount(baselineCommands, "revalidate_relay_agents") + 2, ); expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), @@ -2053,10 +2054,9 @@ test("targeted revocation before send causes no agent side effects", async ({ test("deferred-upload sends revalidate agent authorization at the publish boundary", async ({ page, }) => { - // The immediate send path reuses the pre-side-effect authorization pass, but - // a background media upload can hold the publish open for arbitrarily long — + // A background media upload can hold the publish open for arbitrarily long — // authorization revoked during that window must still strip the p tag. This - // pins the second, publish-boundary revalidation on the deferred path. + // pins the publish-boundary revalidation on the deferred path. await installMockBridge(page, { deferredComposerUploads: true, uploadDelayMs: 1_500, @@ -2249,8 +2249,8 @@ test("sends that attach a mentioned agent revalidate at the publish boundary", a expect(commandCount(commands, "revalidate_relay_agents")).toBe( commandCount(baselineCommands, "revalidate_relay_agents") + 2, ); - // The policy already matched, so the second pass was owed to the membership - // write alone. + // The policy already matched: the attach's membership write was the only + // relay round-trip holding the publish open for the revocation to land in. expect(commandCount(commands, "update_managed_agent")).toBe( commandCount(baselineCommands, "update_managed_agent"), ); @@ -2260,8 +2260,8 @@ test("sends that enroll agents into an active huddle revalidate at the publish b page, }) => { // With a huddle live on the channel, the awaited huddle enrollment is a - // relay round-trip between the authorization pass and the publish, so the - // publish boundary re-validates instead of reusing the earlier pass. + // relay round-trip between the authorization pass and the publish; the + // publish boundary re-validates rather than trusting the earlier pass. await installMockBridge(page, { huddle: { parentChannelId: GENERAL_CHANNEL_ID, @@ -2324,12 +2324,13 @@ test("sends that enroll agents into an active huddle revalidate at the publish b test("a send held open by a no-write step still revalidates at the publish boundary", async ({ page, }) => { - // The three named triggers enumerate the awaited steps known to reach the - // relay; the staleness bound covers the ones they cannot name. Here the only - // thing separating the authorization pass from the publish is the huddle - // sync — which with no active huddle writes nothing and returns - // `matched_active_huddle: false`, so no named trigger fires — held open long - // enough for a revocation to land. The p tag must still be stripped. + // The publish boundary revalidates unconditionally, however brief the gap: + // here the only thing separating the authorization pass from the publish is + // the huddle sync — which with no active huddle writes nothing to the relay + // — and the revocation is released with zero further hold. A revocation + // landing in any admission-to-publish gap must strip the p tag; this is the + // reviewer's sub-threshold probe of the since-removed elapsed-time bound, + // which deliberately accepted this very staleness. await installMockBridge(page, { // Released on demand below; long enough that it is never waited out. syncAgentsToActiveHuddleDelayMs: 45_000, @@ -2390,10 +2391,9 @@ test("a send held open by a no-write step still revalidates at the publish bound window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; }, ALLOWLIST_RELAY_AGENT_PUBKEY); - // The send is parked on the held sync, so this only widens a gap that is - // already open — it pins the admission-to-publish distance past the - // staleness bound on any machine. - await page.waitForTimeout(400); + // Release immediately — no post-revocation hold. Any conditional reuse of + // the admission pass (a trigger enumeration, an elapsed-time bound) would + // publish quinn's stale p tag here. await expect .poll(() => page.evaluate( @@ -2416,8 +2416,8 @@ test("a send held open by a no-write step still revalidates at the publish bound expect(commandCount(commands, "sync_agents_to_active_huddle")).toBe( commandCount(baselineCommands, "sync_agents_to_active_huddle") + 1, ); - // The second pass was owed to elapsed time alone: nothing on this leg wrote - // relay state, which is what makes it invisible to the named triggers. + // Nothing on this leg wrote relay state — the second pass exists only + // because the publish boundary is unconditional. for (const command of [ "add_channel_members", "attach_managed_agent", From 9dfc72895cfcdd7d0e2d2a5386171ad4091bed41 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 2 Sep 2026 16:56:11 +1000 Subject: [PATCH 18/18] perf(desktop): join the membership read into the targeted directory fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the previous commit: making the publish-boundary mention revalidation unconditional re-added one targeted list_relay_agents_for_selection pass (~570 ms — three sequential relay stages) to every agent-mention send. Claw most of that back inside the pass itself. On the targeted path (requested_pubkeys = Some, the send-path revalidate_relay_agents route), the candidates are already named by the caller, so the channel-membership read no longer gates the batch fan-out: it now runs inside the existing tokio::try_join! alongside the runtime-directory and owner-profile batches — two round-trip stages instead of three, ~380 ms per pass. The managed-policy stage stays behind the profiles because it needs the verified owners resolved from them. The full-rebuild path (None) keeps its sequential shape: membership *discovers* its candidates there, so nothing can be issued before it resolves. Authorization semantics are unchanged. The membership retain on the final result (and the channel_ids assignment that follows it) still drops every requested pubkey membership does not admit; the only behavior delta is that directory/profile/policy reads may now issue for requested pubkeys membership would have excluded — bounded by the user-typed mention set, and invisible in the returned result. The membership pager runs outside the per-rebuild semaphore (it is a single sequential request stream), so the targeted path's in-flight ceiling is the batch cap plus one; the semaphore comment now records that. Combined with the previous commit this puts the fast-path spinner at roughly two passes of two stages (~985 ms vs 794 ms with the deleted staleness bound, vs ~1.7 s pre-branch) and shrinks the genuine revocation blind window from ~600 ms to ~405 ms — the publish-boundary pass's membership read now resolves ~190 ms closer to the publish — tighter than origin/main's two-pass baseline ever was. No new tests: the pure filter builders keep their existing unit tests, the ignored real-relay tests cover the full-rebuild path, and the send- path E2E specs (revocation stripping the p tag through revalidate_relay_agents, fast-path +2 pins) exercise the targeted route end to end through the mock bridge, which is indifferent to this internal sequencing. Verified: cargo fmt --check, cargo clippy --all-targets -D warnings, and cargo test --lib (3068 passed, 0 failed) on desktop/src-tauri, plus the repository file-size gate. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../agent_discovery/relay_directory.rs | 157 ++++++++++++------ 1 file changed, 109 insertions(+), 48 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index eac91f5b71d..8f7493e1e8b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -145,14 +145,16 @@ async fn list_relay_agents_for_selection( if let Some(requested_pubkeys) = requested_pubkeys { owned_filter["#d"] = serde_json::json!(requested_pubkeys); } - let owned_events = query_all_relay_pages(state, owned_filter) - .await - .map_err(|error| format!("relay owned-agent query failed: {error}"))?; - let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let owned_query = async { + query_all_relay_pages(state, owned_filter) + .await + .map_err(|error| format!("relay owned-agent query failed: {error}")) + }; - // Membership remains authoritative and visible only to this viewer. - // Known owned identities can have any membership role; other candidates - // must still have explicit bot-role evidence. + // Membership remains the authoritative and bounded authorization scope, + // visible only to this viewer. Known owned identities can have any + // membership role; other candidates must still have explicit bot-role + // evidence. let mut membership_filter = serde_json::json!({ "kinds": [39002], "authors": [&relay_pubkey], @@ -161,48 +163,107 @@ async fn list_relay_agents_for_selection( if let Some(channel_id) = channel_id { membership_filter["#d"] = serde_json::json!([channel_id]); } - let membership_events = query_all_relay_pages(state, membership_filter) - .await - .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; - let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( - &membership_events, - &relay_pubkey, - &owned_candidates, - ); - if let Some(requested_pubkeys) = requested_pubkeys { - member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); - } - let candidate_pubkeys: Vec = member_agent_channel_ids - .keys() - .cloned() - .chain(owned_candidates) - .collect::>() - .into_iter() - .collect(); - if candidate_pubkeys.is_empty() { - return Ok(Vec::new()); - } - - let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); - let profile_filters = exact_author_filters(&candidate_pubkeys, 0); - // One semaphore per rebuild caps `/query` requests across this rebuild's - // phases, so its runtime-directory and owner-profile phases below stay - // within the ceiling even though `try_join!` runs them concurrently. + let membership_query = async { + query_all_relay_pages(state, membership_filter) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}")) + }; + // One semaphore per rebuild caps batched `/query` requests across this + // rebuild's phases, so its runtime-directory and owner-profile phases stay + // within the ceiling even though `try_join!` runs them concurrently. The + // owned-agent and membership pagers are single sequential request streams + // and run outside the semaphore, so the targeted path's ceiling is the + // batches plus two. let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); - let (directory_events, profile_events) = tokio::try_join!( - query_filter_batches( - state, - &semaphore, - &directory_filters, - "relay agent runtime-directory query failed", - ), - query_filter_batches( - state, - &semaphore, - &profile_filters, - "relay agent owner-profile query failed", - ), - )?; + let (member_agent_channel_ids, candidate_pubkeys, directory_events, profile_events) = + if let Some(requested_pubkeys) = requested_pubkeys { + // Targeted path: the caller already names the candidates, so + // neither the owned-agent read nor the membership read gates the + // directory/profile fan-out — they all join it, one round-trip + // stage instead of three. The owned read is `#d`-scoped to the + // requested keys, so it can only ever name candidates already in + // this set. Directory, profile, and (below) policy reads may now + // issue for requested pubkeys membership excludes — bounded by the + // user-typed mention set — but the membership/owner retain on the + // final result still drops them, so what is returned is identical. + let candidate_pubkeys: Vec = requested_pubkeys.iter().cloned().collect(); + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + let (owned_events, membership_events, directory_events, profile_events) = tokio::try_join!( + owned_query, + membership_query, + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); + member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); + ( + member_agent_channel_ids, + candidate_pubkeys, + directory_events, + profile_events, + ) + } else { + // Full rebuild: the owned-agent and membership reads *discover* the + // candidates, so both must resolve before the batch filters can be + // built. Sequential shape retained — this is the autocomplete path, + // not the send path. + let owned_events = owned_query.await?; + let membership_events = membership_query.await?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + let member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); + let candidate_pubkeys: Vec = member_agent_channel_ids + .keys() + .cloned() + .chain(owned_candidates) + .collect::>() + .into_iter() + .collect(); + if candidate_pubkeys.is_empty() { + return Ok(Vec::new()); + } + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + let (directory_events, profile_events) = tokio::try_join!( + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; + ( + member_agent_channel_ids, + candidate_pubkeys, + directory_events, + profile_events, + ) + }; // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current