{"record":{"id":"bd355c13c6a2a3b1","repo":"paperclipai/paperclip","slug":"duplex-channel-open-failed","errorCode":"DUPLEX_CHANNEL_OPEN_FAILED","errorMessage":"DUPLEX_CHANNEL_OPEN_FAILED","messagePattern":"DUPLEX_CHANNEL_OPEN_FAILED","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/plugin-worker-manager.ts","lineNumber":1848,"sourceCode":"          environmentId: input.environmentId,\n          providerLeaseId: input.providerLeaseId,\n          command: input.command,\n        },\n        duplexChannelOpenTimeoutMs,\n      );\n    } catch (err) {\n      // A send failure, an RPC rejection, or an open timeout. Terminalize the\n      // route exactly once and fail closed.\n      await terminalizeDuplexChannelRoute(route);\n      throw err instanceof Error ? err : new Error(DUPLEX_CHANNEL_OPEN_FAILED);\n    }\n\n    const workerSessionId = readBindableWorkerSessionId(route, openResult);\n    if (!workerSessionId) {\n      // A malformed reply, or a route that already left `opening`. A late or a\n      // duplicate reply never binds, revives, or reopens a route.\n      await terminalizeDuplexChannelRoute(route);\n      throw new Error(DUPLEX_CHANNEL_OPEN_FAILED);\n    }\n    // Bind the worker session identifier one time and move the route to `open`.\n    route.workerSessionId = workerSessionId;\n    route.state = \"open\";\n\n    // Replay any data or exit frame that arrived in the open-reply read batch,\n    // before the route bound. The route is `open` now, so each replayed frame\n    // passes through the normal per-frame bounds and the session match.\n    drainPreOpenDuplexChannelNotifications(route);\n\n    // Start the route lifetime timer now the route is open. The route ends when\n    // the timer expires. Every terminal path and the worker-exit path clears the\n    // timer. Unreference the timer so it never blocks the host process shutdown.\n    // A replayed frame can end the route during the drain above, so start the\n    // timer only while the route is still open.\n    if (route.state === \"open\") {\n      route.lifetimeTimer = setTimeout(() => {\n        void terminalizeDuplexChannelRoute(route);","sourceCodeStart":1830,"sourceCodeEnd":1866,"githubUrl":"https://github.com/paperclipai/paperclip/blob/a7e689b3c35347b529cb9f54c9b9a8575a3dcab6/server/src/services/plugin-worker-manager.ts#L1830-L1866","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before\nconst session = await worker.openDuplexChannel(input); // DUPLEX_CHANNEL_OPEN_FAILED\n\n// after (retry once after the failed route is terminalized)\nlet session;\ntry {\n  session = await worker.openDuplexChannel(input);\n} catch (err) {\n  if (err instanceof Error && err.message === \"DUPLEX_CHANNEL_OPEN_FAILED\") {\n    session = await worker.openDuplexChannel(input); // route was terminalized; safe to retry\n  } else throw err;\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"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; }","preventionTips":["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."],"tags":["duplex-channel","plugin-worker","handshake","fail-closed","retry"],"backgroundTag":"channel-open-failed","analyzedSha":"a7e689b3c35347b529cb9f54c9b9a8575a3dcab6","analyzedAt":"2026-08-21T17:58:32.592Z","schemaVersion":2},"datasetVersion":"2026-08-22T09:17:25.309Z"}