From 1118913ee14dc4ed6f3c05cfd673be379950fc0b Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 14:21:35 +0300 Subject: [PATCH 1/3] fix(chart): default to post-auto-migrate buzz:main image Signed-off-by: Taksh Co-authored-by: Cursor --- deploy/charts/buzz/Chart.yaml | 10 ++++++---- deploy/charts/buzz/tests/render_test.yaml | 5 +++++ deploy/charts/buzz/values.yaml | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 956e085749..11378f5f63 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,8 +7,10 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.6 -appVersion: "0.1.0" +version: 0.1.7 +# Post-auto-migrate image (see #988). Empty values.image.tag falls back here — +# keep this on a tag that understands BUZZ_AUTO_MIGRATE (compose uses `:main`). +appVersion: "main" home: https://github.com/block/buzz sources: - https://github.com/block/buzz @@ -23,8 +25,8 @@ maintainers: url: https://github.com/block annotations: artifacthub.io/changes: | - - kind: added - description: Optional READ_DATABASE_URL env (secretKeyRef) enabling relay read-replica routing; absent key preserves prior behavior. + - kind: fixed + description: Default appVersion tracks ghcr.io/block/buzz:main so BUZZ_AUTO_MIGRATE works out of the box (#2473). artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 6313fd5958..ef63a84c35 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -36,6 +36,11 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml + # Empty image.tag falls back to Chart.appVersion — must be post-auto-migrate (#2473). + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/block/buzz:main + template: templates/deployment.yaml - it: lets an explicit value disable huddle audio in a single-replica render set: diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 180afc6746..fde8a96a9d 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -24,7 +24,9 @@ quickstart: false # ── Image ──────────────────────────────────────────────────────────────────── image: repository: ghcr.io/block/buzz - tag: "" # empty → .Chart.AppVersion + # Empty → Chart.appVersion (must be a post-auto-migrate image; see #2473). + # Pin to sha-<7> or a release tag for production. + tag: "" pullPolicy: IfNotPresent pullSecrets: [] From fc7bba93ad3c53292c9f91fe4239ec93dd3d30da Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 14:21:35 +0300 Subject: [PATCH 2/3] fix(desktop): disable WebKitGTK dmabuf renderer in AppImage Signed-off-by: Taksh Co-authored-by: Cursor --- desktop/scripts/fix-appimage.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/desktop/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh index 45f537bb72..447a02f355 100755 --- a/desktop/scripts/fix-appimage.sh +++ b/desktop/scripts/fix-appimage.sh @@ -114,6 +114,38 @@ echo "==> Symlinking system GStreamer plugin directory" rm -rf "$LIBDIR/gstreamer-1.0" ln -s "/usr/lib/$MULTIARCH/gstreamer-1.0" "$LIBDIR/gstreamer-1.0" +# WebKitGTK's dmabuf renderer cores on Intel Mesa + rootless XWayland +# (KDE Plasma Wayland, etc.). Setting this is a no-op where dmabuf already +# works and is required for a usable window on the failing path (#2338). +echo "==> Disabling WebKitGTK dmabuf renderer for AppImage launches" +HOOK_DIR="$WORKDIR/squashfs-root/apprun-hooks" +mkdir -p "$HOOK_DIR" +cat > "$HOOK_DIR/99-buzz-webkit-dmabuf.sh" <<'EOF' +# Allow operators to override (e.g. WEBKIT_DISABLE_DMABUF_RENDERER=0) if needed. +export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}" +EOF +# linuxdeploy AppRun sources apprun-hooks/*.sh; if this layout ever skips that, +# inject the export near the top of AppRun so the env still applies. +if [[ -f "$WORKDIR/squashfs-root/AppRun" ]] \ + && ! grep -q 'WEBKIT_DISABLE_DMABUF_RENDERER' "$WORKDIR/squashfs-root/AppRun"; then + if ! grep -q 'apprun-hooks' "$WORKDIR/squashfs-root/AppRun"; then + # Insert after the shebang (or at line 1 if shebang-less). + tmp_apprun="$(mktemp)" + { + if head -n1 "$WORKDIR/squashfs-root/AppRun" | grep -q '^#!'; then + head -n1 "$WORKDIR/squashfs-root/AppRun" + echo 'export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"' + tail -n +2 "$WORKDIR/squashfs-root/AppRun" + else + echo 'export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"' + cat "$WORKDIR/squashfs-root/AppRun" + fi + } > "$tmp_apprun" + mv "$tmp_apprun" "$WORKDIR/squashfs-root/AppRun" + chmod +x "$WORKDIR/squashfs-root/AppRun" + fi +fi + echo "==> Repacking AppImage" # Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE); # without it appimagetool downloads the runtime from its mutable `continuous` From 9331d35b3b9e957ed118abc26b10162c7f24d8bd Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 14:21:35 +0300 Subject: [PATCH 3/3] fix(acp): notice when a mention turn Ok's with no channel reply Signed-off-by: Taksh Co-authored-by: Cursor --- crates/buzz-acp/src/lib.rs | 19 +++- crates/buzz-acp/src/pool.rs | 6 +- crates/buzz-acp/src/queue.rs | 24 +++++ crates/buzz-acp/src/silent_reply.rs | 131 ++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-acp/src/silent_reply.rs diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 862732f478..20d34d149d 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -10,6 +10,7 @@ mod pool_lifecycle; mod queue; mod relay; mod setup_mode; +mod silent_reply; mod usage; pub use usage::TurnUsage; @@ -1977,6 +1978,9 @@ async fn tokio_main() -> Result<()> { } if config.ignore_self && buzz_event.event.pubkey.to_hex() == pubkey_hex { + if silent_reply::is_agent_reply_kind(kind_u32) { + queue.note_self_publish(buzz_event.channel_id); + } tracing::debug!(channel_id = %buzz_event.channel_id, "dropping self-authored event"); continue; } @@ -2984,7 +2988,20 @@ fn handle_prompt_result( // Don't requeue batches for channels the agent was removed from — // those events are stale and should be silently dropped. if !removed_channels.contains(&batch.channel_id) { - if matches!( + if matches!(result.outcome, PromptOutcome::Ok(_)) { + // Ok turns keep the batch only for silent-reply detection — + // never requeue a successful turn (#2459). + let publishes = queue.take_self_publishes(batch.channel_id); + if let Some(content) = + silent_reply::silent_reply_loss_notice(&result.outcome, Some(&batch), publishes) + { + tracing::warn!( + channel_id = %batch.channel_id, + "mention turn completed Ok with no agent channel publish — posting failure notice" + ); + spawn_failure_notice(rest_client, &batch, content); + } + } else if matches!( result.outcome, PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) ) { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index fb08096792..99d9d2e4a5 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1933,7 +1933,8 @@ pub async fn run_prompt_task( agent, source, PromptOutcome::Ok(StopReason::EndTurn), - None, // turn succeeded — batch was processed, no requeue + // Keep batch for silent-reply detection; Ok must not requeue. + batch, ); return; } @@ -1996,7 +1997,8 @@ pub async fn run_prompt_task( agent, source, PromptOutcome::Ok(stop_reason), - None, + // Keep batch for silent-reply detection; Ok must not requeue. + batch, ); } Err(AcpError::AgentExited) => { diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..a18f34cc8b 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -141,6 +141,11 @@ pub struct EventQueue { in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). in_flight_batch_sizes: HashMap, + /// Agent-authored stream messages observed on the wire while a channel + /// turn is in flight (counted at the `ignore_self` drop site). Used to + /// detect silent reply loss when ACP reports Ok but `buzz messages send` + /// never landed (#2459). + in_flight_self_publishes: HashMap, retry_after: HashMap, /// Per-channel retry attempt counter for exponential backoff / dead-lettering. retry_counts: HashMap, @@ -182,6 +187,7 @@ impl EventQueue { in_flight_channels: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), + in_flight_self_publishes: HashMap::new(), retry_after: HashMap::new(), retry_counts: HashMap::new(), dedup_mode, @@ -278,6 +284,7 @@ impl EventQueue { ); self.in_flight_channels.remove(&id); self.in_flight_deadlines.remove(&id); + self.in_flight_self_publishes.remove(&id); // Recover any withheld goose-native steer events for the expired // channel back to the queue front so normal dispatch delivers // them. Unlike the in-flight batch above (already delivered to a @@ -393,6 +400,7 @@ impl EventQueue { self.in_flight_channels.remove(&channel_id); self.in_flight_deadlines.remove(&channel_id); self.in_flight_batch_sizes.remove(&channel_id); + self.in_flight_self_publishes.remove(&channel_id); let now = Instant::now(); match self.retry_after.get(&channel_id) { // Active throttle → channel was requeued; keep retry_counts intact. @@ -574,6 +582,7 @@ impl EventQueue { ); self.in_flight_channels.remove(&id); self.in_flight_deadlines.remove(&id); + self.in_flight_self_publishes.remove(&id); // Symmetric with the flush_next expiry block: recover withheld // goose-native steer events for the expired channel so they are // not permanently orphaned in the side table. @@ -646,6 +655,21 @@ impl EventQueue { self.in_flight_channels.contains(&channel_id) } + /// Record a self-authored stream message seen on the wire for an in-flight + /// channel turn. No-op when the channel is not in flight. + pub fn note_self_publish(&mut self, channel_id: Uuid) { + if self.in_flight_channels.contains(&channel_id) { + *self.in_flight_self_publishes.entry(channel_id).or_insert(0) += 1; + } + } + + /// Take and clear the self-publish count for `channel_id` (0 if none). + pub fn take_self_publishes(&mut self, channel_id: Uuid) -> u64 { + self.in_flight_self_publishes + .remove(&channel_id) + .unwrap_or(0) + } + // ── Goose-native steer withhold (side table) ────────────────────────── // // While a goose-native `_goose/unstable/session/steer` write is in flight diff --git a/crates/buzz-acp/src/silent_reply.rs b/crates/buzz-acp/src/silent_reply.rs new file mode 100644 index 0000000000..aae5bbab86 --- /dev/null +++ b/crates/buzz-acp/src/silent_reply.rs @@ -0,0 +1,131 @@ +//! Detect turns that finished `Ok` without publishing a channel reply (#2459). +//! +//! Agents reply out-of-band via `buzz messages send`. When that write fails, +//! ACP still reports `end_turn` / `Ok` and the human sees silence. The harness +//! counts agent-authored stream messages observed on the wire during the turn +//! (self-events that `ignore_self` would otherwise drop) and posts a failure +//! notice when a mention-triggered turn ends with zero publishes. + +use buzz_core::kind::{KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2}; + +use crate::pool::PromptOutcome; +use crate::queue::FlushBatch; + +/// True when this batch was admitted as an `@mention` of a stream message — +/// the path that is expected to produce a visible channel reply. +pub(crate) fn batch_expects_channel_reply(batch: &FlushBatch) -> bool { + batch.events.iter().any(|be| { + be.prompt_tag == "@mention" + && matches!( + be.event.kind.as_u16() as u32, + KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2 + ) + }) +} + +/// Kind filter used when counting self-authored publishes during a turn. +pub(crate) fn is_agent_reply_kind(kind: u32) -> bool { + matches!(kind, KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2) +} + +/// Return a user-visible notice when an Ok turn looks like a silently lost reply. +pub(crate) fn silent_reply_loss_notice( + outcome: &PromptOutcome, + batch: Option<&FlushBatch>, + agent_message_publishes: u64, +) -> Option { + if !matches!(outcome, PromptOutcome::Ok(_)) { + return None; + } + let batch = batch?; + if !batch_expects_channel_reply(batch) { + return None; + } + if agent_message_publishes > 0 { + return None; + } + Some( + "⚠️ I finished the turn but couldn't publish a reply to the channel. Please re-send if it's still needed." + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::{AcpError, StopReason}; + use crate::queue::BatchEvent; + use nostr::{EventBuilder, Keys, Kind}; + use std::time::Instant; + use uuid::Uuid; + + fn mention_batch() -> FlushBatch { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "hi @agent") + .tag(nostr::Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()) + .sign_with_keys(&keys) + .unwrap(); + FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![BatchEvent { + event, + prompt_tag: "@mention".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + } + } + + fn all_mode_batch() -> FlushBatch { + let mut batch = mention_batch(); + batch.events[0].prompt_tag = "all".into(); + batch + } + + #[test] + fn notice_fires_for_mention_ok_with_zero_publishes() { + let batch = mention_batch(); + let notice = + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), Some(&batch), 0); + assert!(notice.unwrap().contains("couldn't publish")); + } + + #[test] + fn notice_skips_when_agent_published() { + let batch = mention_batch(); + assert!( + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), Some(&batch), 1,) + .is_none() + ); + } + + #[test] + fn notice_skips_non_mention_batches() { + let batch = all_mode_batch(); + assert!( + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), Some(&batch), 0,) + .is_none() + ); + } + + #[test] + fn notice_skips_errors_and_missing_batch() { + assert!(silent_reply_loss_notice( + &PromptOutcome::Error(AcpError::Protocol("x".into())), + Some(&mention_batch()), + 0, + ) + .is_none()); + assert!( + silent_reply_loss_notice(&PromptOutcome::Ok(StopReason::EndTurn), None, 0,).is_none() + ); + } + + #[test] + fn reply_kind_filter_excludes_reactions() { + assert!(is_agent_reply_kind(KIND_STREAM_MESSAGE)); + assert!(is_agent_reply_kind(KIND_STREAM_MESSAGE_V2)); + assert!(!is_agent_reply_kind(7)); + } +}