Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod pool_lifecycle;
mod queue;
mod relay;
mod setup_mode;
mod silent_reply;
mod usage;

pub use usage::TurnUsage;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(_)
) {
Expand Down
6 changes: 4 additions & 2 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) => {
Expand Down
24 changes: 24 additions & 0 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ pub struct EventQueue {
in_flight_deadlines: HashMap<Uuid, Instant>,
/// Number of events in each in-flight batch (for expiry logging).
in_flight_batch_sizes: HashMap<Uuid, usize>,
/// 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<Uuid, u64>,
retry_after: HashMap<Uuid, Instant>,
/// Per-channel retry attempt counter for exponential backoff / dead-lettering.
retry_counts: HashMap<Uuid, u32>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions crates/buzz-acp/src/silent_reply.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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));
}
}
10 changes: 6 additions & 4 deletions deploy/charts/buzz/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions deploy/charts/buzz/tests/render_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion deploy/charts/buzz/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []

Expand Down
32 changes: 32 additions & 0 deletions desktop/scripts/fix-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down