paperclipai/paperclip · error
DUPLEX_CHANNEL_OPEN_FAILED
DUPLEX_CHANNEL_OPEN_FAILED
Error message
DUPLEX_CHANNEL_OPEN_FAILED
What it means
Thrown by the plugin worker manager when a duplex channel open cannot produce a usable session: after the open RPC completes, the reply must carry a bindable worker session id and the route must still be in the opening state (readBindableWorkerSessionId at server/src/services/plugin-worker-manager.ts:1848). A malformed reply, a duplicate or late open reply, or a route that already left opening fails closed with DUPLEX_CHANNEL_OPEN_FAILED; the same constant is also used to wrap non-Error throwables from the send/open path. Every failure path terminalizes the route exactly once.
Solutions
- Retry the open — the failed route is terminalized, so a fresh open starts from a clean state (close any prior session first to avoid ROUTE_BUSY).
- Check plugin worker logs for the open handler: confirm it replies exactly once with the session id in the expected field.
- Align plugin and server versions so the duplex open reply schema matches readBindableWorkerSessionId's expectations.
- If the worker is unresponsive, restart the plugin worker before retrying.
Example fix
// before
const session = await worker.openDuplexChannel(input); // DUPLEX_CHANNEL_OPEN_FAILED
// after (retry once after the failed route is terminalized)
let session;
try {
session = await worker.openDuplexChannel(input);
} catch (err) {
if (err instanceof Error && err.message === "DUPLEX_CHANNEL_OPEN_FAILED") {
session = await worker.openDuplexChannel(input); // route was terminalized; safe to retry
} else throw err;
} Defensive patterns
Strategy: retry
Try / catch
try { return await worker.openDuplexChannel(input); } catch (err) { if ((err as Error).message === "DUPLEX_CHANNEL_OPEN_FAILED") { await backoff(250); return await worker.openDuplexChannel(input); /* failed route was terminalized; retry is safe */ } throw err; } Prevention
- Keep plugin and server protocol versions aligned so open replies always carry the session id.
- Reply exactly once from custom duplex open handlers — late or duplicate replies never bind.
- If opens fail repeatedly, restart the plugin worker before retrying.
When it happens
Trigger: openDuplexChannel where the worker's open reply lacks/misses the session id field, replies twice (late duplicate never binds), the route timed out or was terminalized between send and reply, or the underlying RPC threw a non-Error value. Also fires when the open send itself fails with a non-Error throwable (Errors propagate unchanged).
Common situations: Plugin protocol version mismatch (worker writes a different reply shape); worker under load replying after the route lifetime expired; bugs in a custom plugin's duplex open handler; serialization losing fields across the worker boundary.
Related errors
- Plugin worker " " is not running for the duplex channel.
- DUPLEX_CHANNEL_ROUTE_BUSY
- ACPX_SESSION_ENSURE_NON_ERROR
- Completed warm transition template conflicts with its exact…
- Daytona duplex channel: no cached sandbox resolves the…
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-08-21).
Data as JSON: /api/errors/bd355c13c6a2a3b1.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/services/plugin-worker-manager.ts:1982
// The process-scoped aggregate route-slot controller. The manager injects it, so
// the ceiling counts every duplex route across every worker in the process, not
// one agent's setting. When it is absent, the worker admits every open, so the
// handle runs unbounded in isolation (a unit test constructs it this way).
const duplexRouteSlots = options.duplexRouteSlots ?? null;
// The routes that currently hold one aggregate slot. The host releases a slot one
// time per route, so a double terminalize never releases two slots.
const duplexRouteSlotHolders = new Set<DuplexChannelRoute>();
// The terminalized routes that still hold buffered bytes for a late listener
// drain. A terminalized route leaves the opening and live maps, so this registry
// keeps the worker-exit sweep able to clear its still-buffered representations.
const terminalDuplexRoutes = new Set<DuplexChannelRoute>();
// Try to reserve one aggregate route slot for a route. Return true when the
// route holds a slot after the call. When no controller is present, the route
// always holds a slot.
function acquireDuplexRouteSlot(route: DuplexChannelRoute): boolean {
if (!duplexRouteSlots) return true;
if (!duplexRouteSlots.tryAcquire()) return false;
duplexRouteSlotHolders.add(route);
return true;
}
// Release the aggregate route slot a route holds, one time. A route that never
// held a slot releases nothing.
function releaseDuplexRouteSlot(route: DuplexChannelRoute): void {
if (!duplexRouteSlotHolders.delete(route)) return;
duplexRouteSlots?.release();
}
// Retire the worker at once. The host kills the process, so every live route,
// every reserved route, and every tombstone drops together. The host uses this
// as the fail-closed response to an ownership violation and to a tombstone-set
// overflow. The host never logs the raw frame content on a violation.
function retireDuplexWorkerOnViolation(reason: string): void {View on GitHub (pinned to 01ad858492)