diff --git a/.github/workflows/_ci-desktop.yml b/.github/workflows/_ci-desktop.yml
index 108e18a215..15893a1cb4 100644
--- a/.github/workflows/_ci-desktop.yml
+++ b/.github/workflows/_ci-desktop.yml
@@ -24,7 +24,10 @@ jobs:
desktop-core:
name: Desktop Core
runs-on: ubuntu-latest
- timeout-minutes: 45
+ # The compiled-flag verification step rebuilds the workspace for each
+ # BUZZ_BUILD_* state and runs the full suite under all three compile
+ # states; the complete recipe needs ~46m, so budget 60m.
+ timeout-minutes: 60
if: github.event_name == 'push' || inputs.desktop || inputs.desktop_rust || inputs.rust
permissions:
contents: read
diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md
index 41d9a214bd..3d011eb8eb 100644
--- a/crates/buzz-acp/README.md
+++ b/crates/buzz-acp/README.md
@@ -270,6 +270,10 @@ Forum event kinds:
4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz.
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.
+ If the inbound queue overflows, the harness attempts replay for affected
+ subscriptions when capacity and relay quota permit, with at least five seconds
+ between attempts. Recovery depends on available relay history and the consumer
+ making progress; complete delivery is not guaranteed.
Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1.
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index cc2952a2f8..ab7233d493 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -4783,6 +4783,23 @@ fn handle_prompt_result(
} else {
hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)");
}
+ } else if matches!(
+ &result.outcome,
+ PromptOutcome::Error(acp::AcpError::AgentError { code: -32002, message })
+ if message.contains("model not found")
+ ) {
+ // Retrying the same missing model cannot repair its configuration.
+ tracing::warn!(
+ channel_id = %batch.channel_id,
+ events = batch.events.len(),
+ "dead-lettering batch immediately — model not found"
+ );
+ let content = "⚠️ I couldn't process the last request: the configured model \
+ wasn't found at the provider's endpoint. Open agent settings, select a \
+ different model from the dropdown, and save your changes. Restart the agent \
+ to apply the new configuration, then re-send your request."
+ .to_string();
+ spawn_failure_notice(rest_client, &batch, content);
} else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) {
// Auth errors are non-retryable: the token won't self-repair
// between retries, so requeueing only wastes attempt slots and
@@ -11012,12 +11029,25 @@ mod error_outcome_emission_tests {
);
}
- /// A non-auth application error (e.g. usage credits) must still follow the
- /// standard requeue path so today's behavior is unchanged.
#[tokio::test]
- async fn non_auth_application_error_is_requeued() {
- let keys = nostr::Keys::generate();
- let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
+ async fn model_not_found_posts_recovery_notice_without_retrying() {
+ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let rest = relay::RestClient {
+ http: reqwest::Client::new(),
+ base_url: format!("http://{}", listener.local_addr().unwrap()),
+ keys: Keys::generate(),
+ auth_tag_json: None,
+ };
+ let keys = Keys::generate();
+ let root = nostr::EventId::from_byte_array([0xaa; 32]);
+ let parent = nostr::EventId::from_byte_array([0xbb; 32]);
+ let event = EventBuilder::new(Kind::Custom(9), "test")
+ .tags([
+ nostr::Tag::parse(["e", &root.to_hex(), "", "root"]).unwrap(),
+ nostr::Tag::parse(["e", &parent.to_hex(), "", "reply"]).unwrap(),
+ ])
.sign_with_keys(&keys)
.unwrap();
let channel_id = uuid::Uuid::new_v4();
@@ -11033,10 +11063,173 @@ mod error_outcome_emission_tests {
cancel_reason: None,
};
- // Usage-credits error — AgentError but NOT an auth error.
- let usage_error = acp::AcpError::AgentError {
+ let raw_error = r#"llm model not found: (gpt-6-astra) 404 Not Found: {"error_code":"NOT_FOUND","message":"'gpt-6-astra' does not exist."}"#;
+ let model_error = AcpError::AgentError {
+ code: -32002,
+ message: raw_error.to_string(),
+ };
+ let expected_error = model_error.to_string();
+ let observer = ObserverHandle::in_process();
+
+ let agent = dummy_agent(0).await;
+ let mut pool = AgentPool::from_slots(vec![None]);
+ let task_id = pool.join_set.spawn(async {}).id();
+ pool.task_map_mut().insert(
+ task_id,
+ crate::pool::TaskMeta {
+ agent_index: 0,
+ channel_id: None,
+ scope: None,
+ turn_id: "test-turn-id".to_string(),
+ recoverable_batch: None,
+ control_tx: None,
+ steer_tx: None,
+ successful_steer_deliveries: HashSet::new(),
+ },
+ );
+ let mut queue = EventQueue::new(config::DedupMode::Queue);
+ let config = test_config();
+ let mut heartbeat_in_flight = false;
+ let removed_channels = std::collections::HashSet::new();
+ let mut crash_history = vec![SlotCircuit {
+ crash_times: Vec::new(),
+ open_until: None,
+ respawn_in_flight: false,
+ }];
+ let (respawn_tx, _respawn_rx) = mpsc::channel(8);
+ let mut respawn_tasks = tokio::task::JoinSet::new();
+ let result = PromptResult {
+ agent,
+ source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }),
+ turn_id: "test-turn-id".to_string(),
+ outcome: PromptOutcome::Error(model_error),
+ batch: Some(batch),
+ };
+ handle_prompt_result(
+ &mut pool,
+ &mut queue,
+ &config,
+ result,
+ &mut heartbeat_in_flight,
+ &removed_channels,
+ &mut crash_history,
+ &respawn_tx,
+ &mut respawn_tasks,
+ Some(observer.clone()),
+ Some(&rest),
+ );
+
+ // The batch must not be requeued: pending_channels returns 0.
+ assert_eq!(
+ queue.pending_channels(),
+ 0,
+ "model-not-found must stop immediately — batch must not be requeued"
+ );
+ assert_eq!(
+ queue.queued_event_count(channel_id),
+ 0,
+ "model-not-found must stop immediately — no events should be pending"
+ );
+
+ assert!(
+ pool.agents_mut()[0].is_some(),
+ "healthy process remains reusable"
+ );
+ assert!(respawn_tasks.is_empty());
+ let errors: Vec<_> = observer
+ .snapshot()
+ .into_iter()
+ .filter(|event| event.kind == "turn_error")
+ .collect();
+ assert_eq!(errors.len(), 1);
+ assert_eq!(errors[0].payload["code"], -32002);
+ assert_eq!(errors[0].payload["error"], expected_error);
+
+ // Capture the real signed notice sent by handle_prompt_result, without a live relay.
+ let notice: nostr::Event = tokio::time::timeout(Duration::from_secs(3), async {
+ let (socket, _) = listener.accept().await.unwrap();
+ let mut reader = BufReader::new(socket);
+ let mut line = String::new();
+ reader.read_line(&mut line).await.unwrap();
+ assert_eq!(line, "POST /events HTTP/1.1\r\n");
+ let mut content_length = None;
+ for _ in 0..64 {
+ line.clear();
+ assert_ne!(reader.read_line(&mut line).await.unwrap(), 0);
+ if line == "\r\n" {
+ break;
+ }
+ if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
+ content_length = Some(value.trim().parse::().unwrap());
+ }
+ }
+ let size = content_length.expect("request Content-Length");
+ assert!(size < 65536);
+ let mut body = vec![0; size];
+ reader.read_exact(&mut body).await.unwrap();
+ reader
+ .get_mut()
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
+ .await
+ .unwrap();
+ serde_json::from_slice(&body).unwrap()
+ })
+ .await
+ .expect("failure notice must be posted on the first failure");
+ notice.verify().unwrap();
+ assert_eq!(notice.pubkey, rest.keys.public_key());
+ assert_eq!(notice.kind, Kind::Custom(9));
+ assert_eq!(
+ notice.content,
+ "⚠️ I couldn't process the last request: the configured model wasn't found at the provider's endpoint. Open agent settings, select a different model from the dropdown, and save your changes. Restart the agent to apply the new configuration, then re-send your request."
+ );
+ let tags = serde_json::to_value(¬ice.tags).unwrap();
+ assert!(tags
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|tag| tag[0] == "h" && tag[1] == channel_id.to_string()));
+ let threading = queue::parse_thread_tags(¬ice);
+ assert_eq!(threading.root_event_id, Some(root.to_hex()));
+ assert_eq!(threading.parent_event_id, Some(parent.to_hex()));
+ }
+
+ /// A non-auth application error (e.g. usage credits) must still follow the
+ /// standard requeue path so today's behavior is unchanged.
+ #[tokio::test]
+ async fn non_auth_application_error_is_requeued() {
+ assert_application_error_is_requeued(acp::AcpError::AgentError {
code: -32000,
message: "Usage credits required for 1M context".to_string(),
+ })
+ .await;
+ }
+
+ #[tokio::test]
+ async fn non_model_resource_not_found_is_requeued() {
+ assert_application_error_is_requeued(acp::AcpError::AgentError {
+ code: -32002,
+ message: "Resource not found: session no longer exists".to_string(),
+ })
+ .await;
+ }
+
+ async fn assert_application_error_is_requeued(error: acp::AcpError) {
+ let keys = nostr::Keys::generate();
+ let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
+ .sign_with_keys(&keys)
+ .unwrap();
+ let channel_id = uuid::Uuid::new_v4();
+ let batch = FlushBatch {
+ channel_id,
+ scope: scope::SessionScope::Conversation { channel_id },
+ events: vec![BatchEvent {
+ event,
+ prompt_tag: "test".into(),
+ received_at: std::time::Instant::now(),
+ }],
+ cancelled_events: vec![],
+ cancel_reason: None,
};
let agent = dummy_agent(0).await;
@@ -11070,7 +11263,7 @@ mod error_outcome_emission_tests {
agent,
source: PromptSource::Channel(scope::SessionScope::Conversation { channel_id }),
turn_id: "test-turn-id".to_string(),
- outcome: PromptOutcome::Error(usage_error),
+ outcome: PromptOutcome::Error(error),
batch: Some(batch),
};
handle_prompt_result(
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index 23ed454fa2..a019e75834 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -1160,10 +1160,8 @@ struct BgState {
/// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since).
/// Cleared per-channel after a successful resubscribe.
channel_dropped_since: HashMap,
- /// Set by the backpressure handler when the event channel is full.
- /// The main loop checks this flag and triggers a proactive resubscribe
- /// (without waiting for a disconnect) so dropped events are replayed.
- proactive_resubscribe_needed: bool,
+ /// Rate/fairness bookkeeping only; replay cursors retain baseline semantics.
+ recovery: recovery::RecoverySchedule,
/// Unix timestamp captured just before the relay connection was established.
/// Used as the floor `since` for membership notification replay so events
/// predating this session are never re-delivered.
@@ -1237,7 +1235,7 @@ impl BgState {
membership_sub_active: false,
observer_control_sub_active: false,
channel_dropped_since: HashMap::new(),
- proactive_resubscribe_needed: false,
+ recovery: recovery::RecoverySchedule::default(),
startup_watermark: None,
subscribe_since: HashMap::new(),
rate_limit_gate: None,
@@ -1298,6 +1296,9 @@ impl BgState {
/// Prevents stale replay on re-subscribe and avoids unbounded state growth
/// for channels that are removed and never re-added.
fn clear_channel_state(&mut self, channel_id: &Uuid) {
+ self.recovery
+ .last_attempt
+ .remove(&channel_sub_id(*channel_id));
self.last_seen.remove(channel_id);
self.subscribe_since.remove(channel_id);
self.channel_dropped_since.remove(channel_id);
@@ -1826,82 +1827,6 @@ async fn run_background_task(
let mut drain_pacing_next: Option = None;
loop {
- if state.proactive_resubscribe_needed {
- state.proactive_resubscribe_needed = false;
- info!("proactive resubscribe triggered by backpressure event loss");
- // Proactive resubscribe runs on the EXISTING socket — do NOT clear the
- // rate-limit gate or pending queues.
- match resubscribe_after_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &agent_pubkey_hex,
- false, // existing socket — preserve gate state
- )
- .await
- {
- ResubscribeResult::Ok => {}
- ResubscribeResult::Shutdown => return,
- ResubscribeResult::RetryConnection => {
- warn!("proactive resubscribe had failures — triggering reconnect");
- let _ = event_tx.try_send(None);
- match try_autonomous_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &keys,
- &relay_url,
- &agent_pubkey_hex,
- &event_tx,
- &observer_control_tx,
- auth_tag.as_ref(),
- )
- .await
- {
- ReconnectOutcome::Ok => {
- if matches!(
- drain_post_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &agent_pubkey_hex
- )
- .await,
- ReconnectOutcome::Shutdown
- ) {
- return;
- }
- }
- ReconnectOutcome::Shutdown => return,
- ReconnectOutcome::Failed => {
- if matches!(
- wait_for_reconnect(
- &mut ws,
- &mut cmd_rx,
- &mut state,
- &keys,
- &relay_url,
- &agent_pubkey_hex,
- &event_tx,
- &observer_control_tx,
- true,
- auth_tag.as_ref(),
- )
- .await,
- ReconnectOutcome::Shutdown
- ) {
- return;
- }
- }
- }
- ping_sent = false;
- last_pong = Instant::now();
- connected_since = Instant::now();
- stable_logged = false;
- }
- }
- }
-
// Drain pending subs, one REQ per pacing tick within the relay's
// admission window.
let drain_window_open = drain_pacing_next.is_none_or(|t| tokio::time::Instant::now() >= t);
@@ -1982,7 +1907,11 @@ async fn run_background_task(
}
}
+ let recovery_at = recovery::ready_at(&mut state);
tokio::select! {
+ _ = recovery::ready(&event_tx, recovery_at) => {
+ recovery::recover_one(&mut ws, &mut state, &event_tx, &agent_pubkey_hex).await;
+ }
raw = ws.next() => {
// Determine if the socket is lost.
let socket_lost = match raw {
@@ -2358,12 +2287,10 @@ async fn handle_ws_message(
// replay starts early enough to re-deliver it.
state.membership_dropped_since =
Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts)));
- // Proactively trigger resubscribe without waiting for a disconnect.
- state.proactive_resubscribe_needed = true;
warn!(
channel_id = %channel_uuid,
ts,
- "membership notification dropped (backpressure) — proactive resubscribe queued"
+ "membership notification dropped (backpressure) — targeted recovery pending"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => return false,
@@ -2400,12 +2327,10 @@ async fn handle_ws_message(
.entry(channel_id)
.and_modify(|d| *d = (*d).min(ts))
.or_insert(ts);
- // Proactively trigger resubscribe without waiting for a disconnect.
- state.proactive_resubscribe_needed = true;
warn!(
channel_id = %channel_id,
ts,
- "event channel full — dropping event for channel {channel_id} — proactive resubscribe queued"
+ "event channel full — dropping event for channel {channel_id} — targeted recovery pending"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
@@ -4248,6 +4173,11 @@ async fn wait_for_any_ok(
}
}
+mod recovery;
+
+#[cfg(test)]
+mod recovery_tests;
+
#[cfg(test)]
mod tests {
use super::*;
@@ -4775,7 +4705,7 @@ mod tests {
.expect("signing should succeed")
}
- async fn test_ws_pair() -> (WsStream, WebSocketStream) {
+ pub(super) async fn test_ws_pair() -> (WsStream, WebSocketStream) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test websocket");
@@ -4792,7 +4722,7 @@ mod tests {
(client, server.await.expect("join test websocket server"))
}
- async fn next_test_frame(
+ pub(super) async fn next_test_frame(
server: &mut WebSocketStream,
) -> serde_json::Value {
let message = timeout(Duration::from_secs(1), server.next())
@@ -5035,14 +4965,14 @@ mod tests {
));
}
- fn test_channel_filter() -> ChannelFilter {
+ pub(super) fn test_channel_filter() -> ChannelFilter {
ChannelFilter {
kinds: Some(vec![9]),
require_mention: false,
}
}
- fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) {
+ pub(super) fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) {
apply_command_to_state(
state,
RelayCommand::Subscribe {
diff --git a/crates/buzz-acp/src/relay/recovery.rs b/crates/buzz-acp/src/relay/recovery.rs
new file mode 100644
index 0000000000..62da119a4b
--- /dev/null
+++ b/crates/buzz-acp/src/relay/recovery.rs
@@ -0,0 +1,132 @@
+//! Overflow recovery is an attempted replay, not an EOSE/consumer receipt.
+//! Keep the existing IDs and cursor retirement rules; bound when work is sent.
+use super::*;
+
+pub(super) const RECOVERY_INTERVAL: Duration = Duration::from_secs(5);
+
+#[derive(Default)]
+pub(super) struct RecoverySchedule {
+ next_attempt: Option,
+ pub(super) last_attempt: HashMap,
+}
+
+/// Attempt at most one affected subscription, with space for replay to arrive.
+/// Failed writes retain the loss cursor and are paced too. No EOSE is interpreted
+/// as completion: overlapping requests keep their existing stable wire IDs.
+pub(super) async fn recover_one(
+ ws: &mut WsStream,
+ state: &mut BgState,
+ event_tx: &mpsc::Sender
- {agent?.pubkey ?? "No agent selected"}
+ {agent
+ ? (agentNpub ?? UNAVAILABLE_KEY_LABEL)
+ : "No agent selected"}
- {agent ? (
-
+ {agent && agentNpub ? (
+
) : null}
diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx
index 7db7c32bfa..89588b5756 100644
--- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx
+++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx
@@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { UserSearchResult } from "@/shared/api/types";
-import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
+import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
- truncatePubkey(user.pubkey)
+ truncateNpub(user.pubkey)
);
}
diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx
index 400f93af7c..d910bfc8a6 100644
--- a/desktop/src/features/agents/ui/RespondToField.tsx
+++ b/desktop/src/features/agents/ui/RespondToField.tsx
@@ -4,7 +4,8 @@ import {
mergeAllowlist,
parsePubkeyInput,
} from "@/features/agents/lib/respondToAllowlist";
-import { truncatePubkey } from "@/shared/lib/pubkey";
+import { parsePubkeyInput as parseCanonicalPubkey } from "@/shared/lib/nostrUtils";
+import { truncateNpub } from "@/shared/lib/pubkey";
import { PubKey } from "@/shared/ui/PubKey";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
@@ -59,7 +60,7 @@ function formatSearchUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
- truncatePubkey(user.pubkey)
+ truncateNpub(user.pubkey)
);
}
@@ -69,7 +70,7 @@ function formatSearchUserSecondary(user: UserSearchResult) {
if (displayName && nip05Handle) {
return nip05Handle;
}
- return truncatePubkey(user.pubkey);
+ return truncateNpub(user.pubkey);
}
const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
@@ -278,8 +279,6 @@ export function CreateAgentRespondToField({
);
}
-const HEX_64_RE = /^[0-9a-f]{64}$/i;
-
function AllowlistPicker({
allowlist,
deferredQuery,
@@ -325,10 +324,12 @@ function AllowlistPicker({
}) {
const isPersona = variant === "persona";
- // Detect if the query is a valid hex pubkey that's not already in the list.
- const queryIsHexPubkey =
- HEX_64_RE.test(deferredQuery) &&
- !allowlist.some((p) => p.toLowerCase() === deferredQuery.toLowerCase());
+ // Detect if the query is a pubkey (npub or hex) not already in the list;
+ // direct entry offers the canonical hex for storage.
+ const queryPubkey = parseCanonicalPubkey(deferredQuery);
+ const queryIsDirectPubkey =
+ queryPubkey !== null &&
+ !allowlist.some((p) => p.toLowerCase() === queryPubkey);
return (
))}
- ) : queryIsHexPubkey ? (
+ ) : queryIsDirectPubkey ? (