Skip to content

fix(acp): re-subscribe channels the relay layer has dropped - #5014

Open
joe-rodgers wants to merge 1 commit into
block:mainfrom
joe-rodgers:fix/acp-subscribe-confirmation
Open

fix(acp): re-subscribe channels the relay layer has dropped#5014
joe-rodgers wants to merge 1 commit into
block:mainfrom
joe-rodgers:fix/acp-subscribe-confirmation

Conversation

@joe-rodgers

Copy link
Copy Markdown

Problem

subscribed_channel_ids in the harness main loop records the intent to enqueue a subscribe command, not a confirmed subscription: subscribe_channel / subscribe_channel_from only queue a REQ on the background task's command channel and return Ok.

The relay layer can then permanently drop a channel behind the harness's back — drop_channel_on_access_denied (member evicted / open→private flip) and the two "missing filter — dropping" paths in drain_rate_limited_pending / drain_resubscribe_retry. All three clear active_subscriptions / active_filters, so reconnect never restores the channel either.

Nothing told lib.rs. The if subscribed_channel_ids.contains(&ch) guard on the member-added path then made every subsequent membership notification for that channel a silent no-op, and the channel stayed dead until the process restarted — with no log line saying so.

Fix

The background task records each permanent drop into a DroppedChannels set shared with HarnessRelay. The main loop drains it via take_dropped_channels() before the "already subscribed" guard runs and clears those ids, so the next member-added notification re-subscribes instead of short-circuiting. A fresh Subscribe command clears the drop mark so a rebuilt subscription is not torn down again. Every drop now logs at warn.

Testing

Relay-side: drop is reported, connection-level denials are not, re-subscribe clears the mark, missing-filter paths report. Harness-side: a dropped channel takes the subscribe path on the next membership notification, unrelated channels untouched, repeatable.

cargo test -p buzz-acp --release: 675 passed, 0 failed. cargo fmt --check clean.

Context

Found on a long-running self-hosted fleet where individual agents would go quiet in one channel while still answering in others, with nothing in the logs to distinguish it from an idle agent.

`subscribed_channel_ids` in the harness main loop records the intent to
enqueue a subscribe command, not a confirmed subscription:
`subscribe_channel` / `subscribe_channel_from` only queue a REQ on the
background task's command channel and return Ok.

The relay layer can then permanently drop a channel behind the harness's
back — `drop_channel_on_access_denied` (member evicted / open→private
flip) and the two "missing filter — dropping" paths in
`drain_rate_limited_pending` / `drain_resubscribe_retry`. All three clear
`active_subscriptions` / `active_filters`, so reconnect never restores
the channel either.

Nothing told lib.rs. The `if subscribed_channel_ids.contains(&ch)` guard
on the member-added path then made every subsequent membership
notification for that channel a silent no-op, and the channel stayed
dead until the process restarted, with no log line saying so.

Fix: the background task records each permanent drop into a
`DroppedChannels` set shared with `HarnessRelay`. The main loop drains it
via `take_dropped_channels()` before the "already subscribed" guard runs
and clears those ids, so the next member-added notification re-subscribes
instead of short-circuiting. A fresh Subscribe command clears the drop
mark so a rebuilt subscription is not torn down again. Every drop now
logs at warn.

Tests: relay-side (drop is reported, connection-level denials are not,
re-subscribe clears the mark, missing-filter paths report) and
harness-side (a dropped channel takes the subscribe path on the next
membership notification; unrelated channels untouched; repeatable).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KzSArAnn5xntFSNurJDH3
@joe-rodgers
joe-rodgers requested a review from a team as a code owner August 6, 2026 04:37
loganj added a commit that referenced this pull request Sep 8, 2026
🤖
## Summary

When a Buzz agent falls behind on incoming messages, its connection can
make the backlog worse while trying to recover. The connection buffers
messages from the relay server until the agent is ready to process them;
if that buffer overflows, recovery previously requested history for
**every subscribed channel** and paused socket reads while sending those
requests. That adds traffic to an already overloaded connection. This
change requests history only for affected subscriptions, once the code
consuming those messages has room, with at least five seconds between
attempts.

The recovery path now:

- Combines repeated losses into one pending recovery per affected
subscription, keeping the oldest dropped timestamp so replay starts
early enough.
- Waits until at least half the consumer queue is free and the relay's
existing rate-limit delay has expired. The queue wakes recovery when
space becomes available; recovery does not periodically sample capacity
or hold queue space away from live messages.
- Attempts one subscription at a time, choosing the least recently
attempted so a busy channel cannot crowd out other channels or
membership notifications. The five-second delay starts when an attempt
finishes, including a failed write; failed writes leave recovery
pending.

Recovery is paced by available capacity, not by how often messages are
lost. This is not a larger buffer or a cutoff that abandons recovery.
Subscription identifiers, message filters, replay timestamp overlap and
duplicate filtering are unchanged; no downstream agent changes are
required.

This targets a reproducible overload **amplifier**, not every cause of
overload or every catch-up limitation. The initial live overload's cause
has not been established. Recovery remains best effort: a successful
request write is not proof of delivery, and existing history/retention
limits, bounded duplicate tracking and replay limitations still apply.
There is no exactly-once or complete catch-up guarantee. A stalled write
can still pause socket reads for the existing ten-second timeout; the
pacing bound does not cover initial subscriptions, reconnects or other
retry paths.

### Related issue

Closest related: #5014 (channel re-subscription); also #6661 (membership
reconciliation) and #6090 (relay backpressure gap signaling). This
addresses local overflow recovery scheduling, not those separate
mechanisms.

### Testing

Recorded offline comparisons against the previous behavior, with the
final implementation at `8000636f3073167c5a5107bb179c7d91160f1729`:

| Same fixture: 18 subscriptions, three overload rounds | Before | After
|
| --- | --- | --- |
| Recovery history requests | 108 | 3 |
| Ping-response delay | About 4.6 seconds | Below the measurement's 1 ms
resolution |

A separate bounded-history fixture delivered all 320 events plus
subsequent live traffic in **both** versions. Regression coverage
exercises the real socket-handling task, including intermittent consumer
capacity, fairness, failed writes and cancellation of capacity waits
before live delivery. These are synthetic results, not production
throughput measurements or evidence of a deployed cure.

The full local `RUST_TEST_THREADS=4 just ci` run passed on September 4,
2026. Earlier unsuccessful local runs remain part of the validation
history. The [recorded validation evidence and separate desktop
follow-up](#7325 (comment))
preserve the original desktop mock-history scroll failure, its passing
rerun and the remaining investigation. That desktop path does not run
the agent connection code; neither this repair nor the passing rerun
fixes the observed scroll problem.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant