Repository navigation
Stop the Claude Code bridge from silently dropping requests - #859
Merged
Merged
Conversation
Every daemon->bridge request that failed to decode was dropped without a reply, so the caller learned nothing until its 30s JSON-RPC timeout fired. Reported in #853 as "every turn times out (turn/start)". The drop had a mechanism, not just a missing error path. Both directions number their outgoing requests with a plain counter from 1 (runtime.ts and bridge.ts), and `decodeBridgeJsonRpcResponse` accepted an inbound *request* as a success response: the schemas are non-strict, and `result: z.unknown()` also matches a missing key. `handleLine` checks the response branch first, so a request whose id collided with an outstanding tool call or permission request settled that pending promise with garbage and returned before ever reaching the request decoder. Discriminate on `method` instead, which fixes the pi and acp bridges too since they share the decoder and the pattern. Then make the remaining decode failures loud: `decodeClaudeCodeJsonRpcRequest` returns a discriminated result so `handleLine` can answer -32602 with the zod issue summary, or -32601 for an unknown method, and log to stderr (which the runtime already captures into provider diagnostics). Known methods are derived from the schema union so the list cannot drift. Separately, let the workspace sandbox degrade instead of aborting the session. The approval-preset migration in #797 rewrote `readonly` defaults to `accept-edits`, which enables the Claude SDK OS sandbox; the SDK defaults `failIfUnavailable` to true, so hosts without bubblewrap (headless Linux) lost the whole session. `autoAllowBashIfSandboxed` only auto-approves while the sandbox is active, so degrading costs the session its auto-allow and falls back to bb's own canUseTool gating rather than running unsandboxed. No HOST_DAEMON_PROTOCOL_VERSION bump: the bridge ships inside the daemon bundle and nothing on the server/daemon wire changed. Co-Authored-By: Claude <noreply@anthropic.com>
ymichael
added a commit
that referenced
this pull request
Aug 17, 2026
The kit (@bb/provider-bridge-protocol/conformance) drives any bridge black-box over a lines-in/messages-out transport: JSON-RPC hygiene, the initialize handshake, then a shared session lifecycle with grammar checks (turn settlement, item-opens-before-delta, release-not-interrupted, id uniqueness across resume). Transport-agnostic so in-process handleLine bridges and spawned binaries both work; no test-framework dependency. Calibration against the UNMODIFIED acp bridge pins the phase-2a work list and surfaced a real finding: #859 gave the acp bridge request-vs-response discrimination but reply-never-drop was never implemented there - handleParsedMessage silently returns for unknown or schema-invalid requests, so the hygiene rules fail today. Aliveness-dependent probes report skipped (not false failures) on bridges that drop unknown methods. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ymichael
added a commit
that referenced
this pull request
Aug 17, 2026
Third conformant bridge, same four moves (templates cab42e59a, 7c44fda2a, df8ede082). translate-message.ts and the adapter's translation closures move into event-translation.ts behind createClaudeEventTranslator (byte-identity verified, deviations enumerated); interactions.ts and session-params.ts extracted and shared by both dialects; the adapter shrinks 1373 -> 413 lines. Hygiene needed only constant/error-detail gaps - this bridge is where #853/#859 were fixed, and no test pinned a drop. Handshake facts with evidence: sessionRestore true (SDK resume), fork "checkpoint" (forkSession upToMessageId - also evidence for the plan's fork/rewind merge candidate), manualCompaction FALSE (finding: claude has no compact wire method; /compact rides prompt text), approvalRequestPolicy "provider" (canUseTool pre-filters, #1236 - the runtime skips reclassification via the handshake getter). Canonical surface: per-session entropy translators, identity-before- events, canonical options feeding the same applyLiveSessionSettings machinery (providerOptions omission = keep current), release with no fabricated interruption and NO background-task settlement (session stays resumable, the opaqueTaskIds rule), interrupt keeps full settlement, session/replaced wired at both rebuild sites with settlement strictly preceding it, and approval/AskUserQuestion/ExitPlanMode all as canonical interaction/request round trips. Conformance 11/11. Suites 969 default + 252 isolated (966+251 baseline all unchanged); daemon bundle builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the silent-drop failure mode reported in #853, plus the 0.33.0 change behind that report's first error.
The silent drop had a mechanism
@amadad correctly identified that
handleLinedrops any request that fails schema validation (if (!request) return), turning every mismatch into an undebuggable 30s timeout. But that alone does not explain a well-formedturn/startvanishing. This does:Both directions number their outgoing requests with a plain integer counter from 1 —
runtime.ts:230(nextRequestId = 1) andbridge.ts:298(toolCallRequestIdCounter = 0, then+= 1per forwarded tool call and per permission request). They share one id space on a bidirectional channel.Meanwhile
decodeBridgeJsonRpcResponseaccepted an inbound request as a success response: the response schemas are non-strictz.objects, so the extramethod/paramsare allowed, andresult: z.unknown()also matches a missing key. SincehandleLinechecks the response branch first, a daemon request whose id collided with an outstanding tool call or permission request settled that pending promise with garbage and returned before reaching the request decoder — no reply, no log, caller times out 30s later.That matches the reported forensics exactly: bytes consumed off the socketpair (
Recv-Q/Send-Q0), bridge idle inepoll_wait, handler never invoked.Fixed by discriminating on
method, which is what separates the two per the JSON-RPC spec and what the daemon side already does (parseJsonRpcLinechecks!parsedMethod). This also fixes the pi and acp bridges, which share the decoder and the same counter pattern.Remaining decode failures are now loud
Both of @amadad's suggestions, implemented:
decodeClaudeCodeJsonRpcRequestreturns a discriminated result instead ofnull, sohandleLineanswers -32602 carrying the zod issue summary (e.g.params.workflowsEnabled: expected boolean, received undefined) and logs to stderr, which the runtime already captures into provider diagnostics.Sandbox degrades instead of aborting
The approval-preset migration in #797 rewrote
readonlyandworkspace-writedefaults toaccept-edits(0078_permission_modes.sql), andaccept-editsenables the Claude SDK OS sandbox — which 0.0.32'sreadonlymapping did not. The SDK defaultsfailIfUnavailabletotrue, so an install that previously defaulted toreadonlylost every session on a host withoutbubblewrap. That is the "sandbox deps missing" error in the report.Now
failIfUnavailable: false.autoAllowBashIfSandboxedonly auto-approves while the sandbox is actually active, so degrading costs the session its auto-allow and falls back to bb's owncanUseToolgating rather than running unsandboxed.Worth deciding separately whether the headless Linux install should declare
bubblewrap/socatas dependencies, since a degraded sandbox is a quieter posture than the operator may expect.Two things I did not change
inputGroupsgroup. The bridge requires.min(1)per group and the adapter maps groups throughstripClaudePlanCommandInput, so I expected a/plan-only group to empty and take the turn down. It cannot:removeCommandMentionsFromPromptInputis a.mapthat rewrites entry text and never drops entries. I implemented the fix, found the premise wrong, and reverted it. If such a group ever arrives from upstream, it is now a-32602naminginputGroups.callId: call-${requestId}, which is carried in tool-call payloads, for no correctness gain.Testing
turbo run test typecheck lint --filter=@bb/agent-runtime— 798 tests, clean. Each new test was verified to fail against the unfixed code:turn/startwhose id collides with a pendingAskUserQuestionis still dispatched, and the pending request is not settled by itturn/startgets-32602naming the offending field; an unknown method gets-32601decodeClaudeCodeJsonRpcRequestreportsunknown_methodandinvalid_paramsdistinctlyNo
HOST_DAEMON_PROTOCOL_VERSIONbump: the bridge ships inside the daemon bundle, and nothing on the server/daemon wire changed.I cannot prove from here that the id collision is @amadad's exact trigger — that needs a turn with a tool call or permission request outstanding when the daemon sends its next request. It is the only mechanism I found that reproduces their forensics, and the
-32602path is what will pin it definitively on their machine: the next failure names a field instead of timing out.🤖 Generated with Claude Code