Files
PaperClipAI/server
Nicky LeachandPaperclip 33eb68b3ae fix(server): end the duplex route on the pre-bind bounds when frames arrive before the bind (#11860)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The host and a plugin worker talk over a duplex channel route with
bounds on buffered frames and total bytes
> - A worker can batch its data and exit frames with the open reply, so
those frames arrive before the route binds and before a listener
attaches
> - Two of the route bounds did not hold on that pre-bind path: a shared
limit let the pre-open hold swallow an over-limit frame before the
buffered-frame bound could end the route, and the route end discarded
chunks a later listener still needed
> - This pull request gives the pre-open hold its own ceiling above the
buffered bound, and keeps the buffered chunks across a route end
> - The benefit is a duplex route that enforces its bounds and preserves
valid data, even when a worker batches frames ahead of the bind

## Linked Issues or Issue Description

No existing GitHub issue covers this. Filing it directly here, following
the bug report template.

**What happened?**

Two duplex channel route bounds in
`server/src/services/plugin-worker-manager.ts` did not hold when the
data and exit frames arrived in the open-reply read batch, before the
route bound:

- The pre-open hold and the pre-bind buffered-frame bound shared one
limit. When a caller lowered the buffered bound, the hold dropped the
overflow frame as a protocol error before the buffered bound could end
the route, so the route never ended.
- The route end discarded the buffered chunks. A frame can end the route
during the replay, before a listener attaches, and the chunks the host
accepted before that frame are valid data.

**Expected behavior**

The pre-open hold uses its own ceiling, above the buffered bound, so the
replay after the bind lets the buffered bound end the route. A route end
keeps the buffered chunks so a listener that attaches after the end
still drains them.

**Steps to reproduce**

1. Open a duplex channel where the worker batches several data frames
with the open reply.
2. Lower `maxPreBindBufferedFrames` below the batch size.
3. Observe the route fails to end on the buffered-frame bound, or a
listener that attaches after an end-during-replay never receives the
chunks buffered before that end.

**Paperclip version or commit**

`933749e01f74e82ce5d315c071be534d04e01158`

**Deployment mode**

Local dev (`pnpm dev`) and server unit tests.

**Agent adapter(s) involved**

None — this is host/plugin-worker transport infrastructure, not
adapter-specific.

**Database mode**

Not database-related.

**Access context**

Any board or agent path that runs a plugin worker over a duplex channel
route.

## What Changed

- Give the pre-open frame hold its own ceiling
(`MAX_DUPLEX_CHANNEL_PRE_OPEN_HOLD_FRAMES`), separate from the pre-bind
buffered-frame bound, so lowering the buffered bound still ends the
route instead of being pre-empted by the hold.
- Keep the buffered chunks on a route end instead of discarding them, so
a listener that attaches after an end-during-replay still drains the
data the host already accepted.
- Add two regression tests that batch frames with the open reply, so
both bounds run through the pre-bind path deterministically.

## Verification

- `cd server && npx vitest run
src/__tests__/plugin-worker-manager-duplex.test.ts` — 24/24 tests pass,
including the two new regression cases.

## Risks

Low risk. This only changes bound bookkeeping on an internal transport
path (frame hold ceiling and end-time buffer retention); it does not
change the wire protocol or any public API. The new ceiling is a
constant above the existing buffered bound, so pre-open holds are still
capped.

## Model Used

Claude, Sonnet 5 (claude-sonnet-5); assisted with repository-grounded
diff review and drafted this PR description from the commit and code
history. No functional code in this PR was authored by Claude — the fix
itself is Priya Raman's, preserved with original authorship intact.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-21 10:09:30 -07:00
..