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

  1. 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).
  2. Check plugin worker logs for the open handler: confirm it replies exactly once with the session id in the expected field.
  3. Align plugin and server versions so the duplex open reply schema matches readBindableWorkerSessionId's expectations.
  4. 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

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


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)