Skip to content

Stop the Claude Code bridge from silently dropping requests - #859

Merged
SawyerHood merged 1 commit into
mainfrom
bb/fix-claude-code-turn-timeouts-thr_2b5dq232ni
Jul 23, 2026
Merged

SawyerHood merged 1 commit into
mainfrom
bb/fix-claude-code-turn-timeouts-thr_2b5dq232ni

Conversation

@SawyerHood

Copy link
Copy Markdown
Collaborator

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 handleLine drops 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-formed turn/start vanishing. This does:

Both directions number their outgoing requests with a plain integer counter from 1 — runtime.ts:230 (nextRequestId = 1) and bridge.ts:298 (toolCallRequestIdCounter = 0, then += 1 per forwarded tool call and per permission request). They share one id space on a bidirectional channel.

Meanwhile decodeBridgeJsonRpcResponse accepted an inbound request as a success response: the response schemas are non-strict z.objects, so the extra method/params are allowed, and result: z.unknown() also matches a missing key. Since handleLine checks 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-Q 0), bridge idle in epoll_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 (parseJsonRpcLine checks !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:

  • decodeClaudeCodeJsonRpcRequest returns a discriminated result instead of null, so handleLine answers -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.
  • Unknown methods get -32601. The known-method set is derived from the schema union rather than hardcoded, so it cannot drift.

Sandbox degrades instead of aborting

The approval-preset migration in #797 rewrote readonly and workspace-write defaults to accept-edits (0078_permission_modes.sql), and accept-edits enables the Claude SDK OS sandbox — which 0.0.32's readonly mapping did not. The SDK defaults failIfUnavailable to true, so an install that previously defaulted to readonly lost every session on a host without bubblewrap. That is the "sandbox deps missing" error in the report.

Now failIfUnavailable: false. autoAllowBashIfSandboxed only auto-approves while the sandbox is actually active, so degrading costs the session its auto-allow and falls back to bb's own canUseTool gating rather than running unsandboxed.

Worth deciding separately whether the headless Linux install should declare bubblewrap/socat as dependencies, since a degraded sandbox is a quieter posture than the operator may expect.

Two things I did not change

  • Empty inputGroups group. The bridge requires .min(1) per group and the adapter maps groups through stripClaudePlanCommandInput, so I expected a /plan-only group to empty and take the turn down. It cannot: removeCommandMentionsFromPromptInput is a .map that 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 -32602 naming inputGroups.
  • Renumbering the bridge's outgoing ids. Once requests and responses are discriminated correctly, colliding ids are harmless — each side matches responses against its own outgoing requests, so the directions are independent id spaces. Renumbering would have changed 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:

  • a turn/start whose id collides with a pending AskUserQuestion is still dispatched, and the pending request is not settled by it
  • a schema-invalid turn/start gets -32602 naming the offending field; an unknown method gets -32601
  • decodeClaudeCodeJsonRpcRequest reports unknown_method and invalid_params distinctly

No HOST_DAEMON_PROTOCOL_VERSION bump: 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 -32602 path is what will pin it definitively on their machine: the next failure names a field instead of timing out.

🤖 Generated with Claude Code

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>
@SawyerHood
SawyerHood merged commit 65c85c0 into main Jul 23, 2026
10 checks passed
@SawyerHood
SawyerHood deleted the bb/fix-claude-code-turn-timeouts-thr_2b5dq232ni branch July 23, 2026 04:06
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant