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
6 changes: 5 additions & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2898,7 +2898,11 @@ async fn tokio_main() -> Result<()> {
// Fire-and-forget: on rare fast-failure paths the
// guard's cleanup may race with this add, leaving a
// cosmetic stale πŸ‘€. Acceptable β€” see ReactionGuard docs.
if accepted {
// Only post the πŸ‘€ "seen" reaction for kind-9 chat
// messages. Reacting to reactions (7), deletions (5),
// presence, etc. makes co-located agents echo πŸ‘€
// (and its cleanup deletions) in an infinite loop.
if accepted && event_for_steer.kind.as_u16() == 9 {
let rc = ctx.rest_client.clone();
let eid = event_id_hex.clone();
tokio::spawn(async move {
Expand Down
86 changes: 85 additions & 1 deletion crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1558,7 +1558,13 @@ pub async fn run_prompt_task(
// See `ReactionGuard` docs for ordering guarantees and known edge cases.
let reaction_ids: Vec<String> = batch
.as_ref()
.map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect())
.map(|b| {
b.events
.iter()
.filter(|be| be.event.kind.as_u16() == 9)
.map(|be| be.event.id.to_hex())
.collect()
})
.unwrap_or_default();
let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone());

Expand Down Expand Up @@ -4548,6 +4554,84 @@ mod tests {
assert_eq!(composed, "hello channel");
}

// ── reaction_ids: kind-9 gate (reaction-echo regression pin) ────────────
//
// Background: a 2026-08-15 incident in #ci-cd produced ~50 events/sec of
// kind-5 (deletions) + kind-7 (πŸ’¬ reactions) caused by co-located agents
// reacting to each other's reactions. The `reaction_ids` vec built inside
// `run_prompt_task` feeds both `react_working` (adds πŸ’¬) and
// `clear_reactions` (removes πŸ‘€+πŸ’¬). If the vec includes non-kind-9
// events, those events get cosmetic reactions whose subsequent cleanup
// deletions become new batch events β€” closing the loop. The fix at
// pool.rs:1559 filters `reaction_ids` to kind-9 only; this test pins
// that filter behavior so it cannot regress silently.

fn make_batch_event(kind: u16, content: &str) -> crate::queue::BatchEvent {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::Custom(kind), content)
.sign_with_keys(&keys)
.expect("sign");
crate::queue::BatchEvent {
event,
prompt_tag: format!("kind-{kind}"),
received_at: std::time::Instant::now(),
}
}

#[test]
fn reaction_ids_filters_to_kind_nine_only() {
let kind9_note = make_batch_event(9, "hello channel");
let kind7_reaction = make_batch_event(7, "+");
let kind5_deletion = make_batch_event(5, "");

let kind9_id_hex = kind9_note.event.id.to_hex();
let kind7_id_hex = kind7_reaction.event.id.to_hex();
let kind5_id_hex = kind5_deletion.event.id.to_hex();

let batch = FlushBatch {
channel_id: Uuid::new_v4(),
events: vec![kind7_reaction, kind9_note, kind5_deletion],
cancelled_events: vec![],
cancel_reason: None,
};

// Mirror the construction in run_prompt_task: filter batch.events
// down to kind-9 only, then collect their hex ids.
let reaction_ids: Vec<String> = batch
.events
.iter()
.filter(|be| be.event.kind.as_u16() == 9)
.map(|be| be.event.id.to_hex())
.collect();

assert_eq!(reaction_ids, vec![kind9_id_hex.clone()]);
assert!(!reaction_ids.contains(&kind7_id_hex));
assert!(!reaction_ids.contains(&kind5_id_hex));
}

#[test]
fn reaction_ids_is_empty_when_batch_has_no_kind_nine_events() {
let batch = FlushBatch {
channel_id: Uuid::new_v4(),
events: vec![make_batch_event(7, "+"), make_batch_event(5, "")],
cancelled_events: vec![],
cancel_reason: None,
};

let reaction_ids: Vec<String> = batch
.events
.iter()
.filter(|be| be.event.kind.as_u16() == 9)
.map(|be| be.event.id.to_hex())
.collect();

assert!(
reaction_ids.is_empty(),
"ReactionGuard::new drops the rest_client when ids are empty, so a \
non-kind-9-only batch must produce no reaction ids at all"
);
}

// ── prepend_standing_for_legacy ───────────────────────────────────────────

fn full_standing() -> crate::queue::StandingContext<'static> {
Expand Down
3 changes: 3 additions & 0 deletions deploy/compose/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ services:
BUZZ_GIT_REPO_PATH: /data/git
BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-false}
BUZZ_GIT_CONFORMANCE_PROBE: ${BUZZ_GIT_CONFORMANCE_PROBE:-true}
BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN: "60000"
BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN: "600"
BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN: "60000"
ports:
- "${BUZZ_HTTP_PORT:-3000}:3000"
volumes:
Expand Down