Skip to content
Open
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
57 changes: 55 additions & 2 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,11 +540,29 @@ impl EventQueue {
/// the generic queue — they are stored separately and merged by
/// `flush_next()`. No retry throttle, no backoff.
pub fn requeue_as_cancelled(&mut self, batch: FlushBatch, reason: CancelReason) {
let entry = self.cancelled_batches.entry(batch.channel_id).or_default();
let channel_id = batch.channel_id;
let entry = self.cancelled_batches.entry(channel_id).or_default();
// Preserve any already-cancelled events from a prior cancel (double-cancel).
entry.extend(batch.cancelled_events);
entry.extend(batch.events);
self.cancel_reasons.insert(batch.channel_id, reason);
// Enforce the same per-channel cap as the main queue. This side-map is
// only cleared when a turn *completes* (or the channel drains), so a
// sustained mid-turn interrupt/steer flood — every turn cancelled
// before it finishes — would otherwise grow both this map and the
// prompt `format_prompt` renders from it (see `cancelled_events`)
// without bound. Trim oldest (front) so the most-recent "what you were
// working on" events survive, mirroring the queue's overflow handling.
if entry.len() > MAX_PENDING_PER_CHANNEL {
let overflow = entry.len() - MAX_PENDING_PER_CHANNEL;
entry.drain(0..overflow);
tracing::warn!(
channel_id = %channel_id,
limit = MAX_PENDING_PER_CHANNEL,
dropped = overflow,
"cancelled-batch overflow — dropped oldest cancelled events to enforce cap"
);
}
self.cancel_reasons.insert(channel_id, reason);
}

/// Returns `true` if any channel has pending events that are not in-flight
Expand Down Expand Up @@ -3862,6 +3880,41 @@ mod tests {
);
}

#[test]
fn test_sustained_cancellation_caps_cancelled_batches() {
// A mid-turn interrupt/steer flood cancels every turn before it
// completes. Each cancel carries the accumulated cancelled events
// forward, so without a cap `cancelled_batches[ch]` (and the prompt
// rendered from it) would grow one event per cancel, without bound.
let mut q = EventQueue::new(DedupMode::Queue);
let ch = Uuid::new_v4();

let iterations = MAX_PENDING_PER_CHANNEL + 50;
for i in 0..iterations {
q.push(make_queued(ch, &format!("evt-{i}")));
let batch = q.flush_next().expect("a queued event should flush");
// Cancel the in-flight turn, then release the channel so the next
// flush can run — the real Interrupt/Steer path.
q.requeue_as_cancelled(batch, CancelReason::Interrupt);
q.mark_complete(ch);
}

let accumulated = q.cancelled_batches.get(&ch).map(Vec::len).unwrap_or(0);
assert!(
accumulated <= MAX_PENDING_PER_CHANNEL,
"cancelled_batches grew past the cap: {accumulated} > {MAX_PENDING_PER_CHANNEL}",
);

// The cap trims the OLDEST events, so the most-recent one survives.
let newest = format!("evt-{}", iterations - 1);
assert!(
q.cancelled_batches
.get(&ch)
.is_some_and(|evts| evts.iter().any(|be| be.event.content == newest)),
"the most-recent cancelled event must be retained",
);
}

#[test]
fn test_reply_instruction_present_for_channel_thread_reply() {
let ch = Uuid::new_v4();
Expand Down