fix(openai): reuse connections that free up while parallel agents wait, so prompts stay cached - #185
Merged
Merged
Conversation
…t, so prompts stay cached When many agents share one session, clodex spaces out the new connections it opens to OpenAI. A turn held in that queue was sized up against the connections that existed when it arrived, so when another turn finished and freed one up while it waited, it opened a second connection anyway instead of picking that one up. Duplicates crowd out other conversations' connections, and every one evicted costs that conversation a full resend of its context on its next turn: a cache miss, a bigger prompt, and one more connection. A turn that was queued now looks again before it opens anything, and takes over a connection whose conversation it continues exactly. That is the same exact-history check used on arrival, so a freed connection whose conversation does not line up is still never continued, and a turn that was let through on arrival behaves exactly as before. Having opened nothing, it also hands back the new-connection allowance it was charged, so the next turn is not delayed for a connection that was never opened. This is an uncommon case, not a common one: it needs a same-conversation predecessor to finish inside the few seconds this turn spends queued. The reachability measurement is in the pull request. Refs #173
bman654
changed the base branch from
fix/throttle-retry-after-header
to
main
September 5, 2026 14:35
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.
When you run a lot of agents at once on a ChatGPT/Codex plan, clodex spaces out how fast it opens
new connections to OpenAI. A turn that gets held in that queue used to decide what it needed the
moment it arrived, and then stick to that decision. So if another turn finished while it was
waiting — freeing up a connection that was already holding exactly the conversation it was about to
continue — it opened a second connection anyway. Now it takes over the freed one instead. That
saves a connection, keeps the prompt cached rather than resending it, and stops one conversation's
duplicates from pushing another conversation's connection out. This is an edge case, not
something you hit routinely (what is and is not known about that is below), and nothing else
about pacing changes: a turn that
already has a connection to reuse when it arrives is still never delayed.
Closes #173.
The user-visible failure
Under parallel fan-out on one Claude session, clodex opened a new WebSocket connection for a turn
that could have continued a chain head sitting idle in the same partition. Each duplicate head
consumes one of the 8 nursery slots, so it evicts another conversation's reusable head; that
conversation then resends its full context on its next turn — a cache miss, a larger prompt — and
opens another connection, which is more pacing, which widens the window in which this happens
again.
Root cause
#172 introduced an
await(pacing admission) between the head scan and connection creation. Afterthat wait it re-read the partition and demoted itself to
parallel_isolatedif a sibling had gonein flight meanwhile. It did not handle the other direction: a sibling that completed during
the wait leaves an idle, reusable head, and nothing re-ran
continuationMatchafter the wait.Why a match is possible at all — the non-obvious part
A continuation needs the head's stored
requestInput ++ expectedAssistantto be a strict prefix ofthe waiting request's input. That reads as though the waiter must already contain the head's
output, which two independent agents never do — and on that reading the whole scenario is
unreachable. It is wrong because
expectedAssistantcan be empty:continuationMatch's guardis
!entry.expectedAssistant, and[]passes it. A response that completes with no outputitems stores a prefix equal to its own input alone, so any same-partition request that merely
extends that input matches it having copied nothing from it. Two subagents fanned out from one
Claude session open with byte-identical inputs, which is exactly that condition. This shape is
staged directly by a test (below), and the test's premise is checked: give the predecessor output
and the same test opens a third socket instead.
Production reachability — what is and is not known
The necessary conditions are enumerable; the frequency is not known, and the available
diagnostics cannot establish it. All four must hold at once:
parallel_isolatedsocket is discarded and leaves nothing;matchingCandidateCount == 0).Streaming all 17 local ledger files (178,298 head decisions, 63,508 primary creations) found no
qualifying opportunity. That is the honest limit of what the corpus supports, because the same
corpus contains zero
ws_new_connection_pacedevents and zero decisions carryingpacingWaitedMs— it records no queued requests at all. With no paced requests in it there is nodenominator, so it cannot be turned into a rate; an earlier draft of this description quoted "about
1 in 20,500 paced requests" and that figure is withdrawn as unsupported.
Nor is that zero evidence that pacing does not engage.
ws_new_connection_pacedis emittedonly on a nonzero wait, a refusal or an abort, so its absence cannot distinguish "the pacer never
delayed anything" from "the pacer was not there". Replaying the real connection-creation timestamps
from those files through the shipped 60/min, burst-10 bucket predicts 0 waits in the post-release
files but 1,686 waits and 1,590 refusals in the 2026-08-27 file.
What can be said plainly: this is an edge taken when it appears, not something users hit routinely,
and the change is judged on being safe and free rather than on frequency. The log analysis above
was run by the review panel against local ledgers, not re-derived by the author of this change.
Unreachable entirely with
CLODEX_WS_MAX_NEW_CONNECTIONS_PER_MIN=0.The change
src/oauth/responses-websocket.ts(surgical; the file is under the repo's "do not restructure"directive):
scanForHeads()— same expressions, sameordering, same tie-breaks — and the continuation-application block into
continueOnHead(), whichreturns its decision so the arrival if/else chain still assigns
decisionon every path. Bothhoists are pure moves; the pre-existing 121 tests pass against either version.
adopts the head and leaves exactly the state an arrival-time match would have left: the promotion
and eviction side effects,
persistentrestored (so a transport-retry replacement is stillreusable), the scan results replacing the arrival ones so the ledger is coherent, and
promptChangesre-derived against the head actually being continued rather than whichever idlebranch the arrival scan picked as a diagnostic stand-in.
queuedflag, notwaitedMs— the latter is a difference oftwo clock reads, so a clock stepped backwards mid-wait reports 0 for a request that really did
queue, and gating on the number would skip the re-scan precisely there.
admission.release?.()). It opened no connection, soholding the token would delay the next request for an upgrade that never happened — the same rule
a cancellation already followed. Measured before this: a real-pacer probe showed the next
connection waiting an unnecessary ~1s.
!selected, which is a no-op on the path feat(openai): reduce the risk of rate-limit errors when many agents run at once #172 shipped.degraded for this turn" — is deferred rather than raised, and dropped if the request then
continues a freed head. Nothing was degraded, and a scary notice on a perfectly cached turn is how
the one warning whose value depends on being believed gets trained away. Deferring the whole
warning rather than emitting and retracting it also means a dropped warning costs none of the
per-signature budget the next genuine occurrence needs. It is dropped only on that path: a
refusal, an abort or any non-rematch outcome still raises it exactly as before.
pacingRescanOutcome(continuation|parallel_isolated|no_change),recorded only when the re-scan ran, and
suppressedMismatchWarningswhen a warning was dropped;the drop is also traced, so it is never invisible.
src/oauth/ws-upgrade-pacer.ts:UpgradeAdmissiongains optionalqueuedandrelease.releaseis one-shot — a second call would mint a token the bucket never charged, which is the one way a
refund can raise the sustained rate instead of correcting it.
Atomicity
Nothing between the re-scan and
dispatchContextawaits — the nursery eviction, the diagnosticemit and
new ReadableStream({ start })are all synchronous, andstartruns synchronously in theconstructor — so selection and the
inFlightclaim happen in one synchronous run. Two queuedrequests released together cannot both adopt one head. (Verified by the review panel with an
inserted-yield mutation, not only by argument.)
What I deliberately left out
parallel_isolateddemotion when thepartition simply goes quiet during the wait. Such a request already has
persistent === falseand, absent a re-match, keeps it, so it opens an isolated socket that retains no head at all.
Isolated sockets are 35-38% of decisions in busy diagnostics files — a larger share of the
same harm than the slice this PR closes. Leaving it is a deliberate scope choice, not an
oversight; re-persisting such a request is a behaviour change to feat(openai): reduce the risk of rate-limit errors when many agents run at once #172's demotion and deserves its
own issue, tests and reachability analysis.
can only be freed by an upstream completion, which arrives on a socket event — a macrotask.
ws_new_connection_paced(it still records nothing on a zero wait), and no wideningof
continuationMatch. The safety property is entirely delegated to the unchanged matcher.Discriminating tests and the mutations that prove them
Full-file runs throughout, never
-t. Snapshots restored withcp, nevergit checkout. Allhead/chain state is staged through production producers (
response.output_item.done/response.completed), never planted in the request input; every clock is injected, so no testdepends on
Date.now()ordering.length 2 → 1, i.e. a shallow matcher continued a chain that was not ours. The shallow negative below stays GREEN under that same mutation, which is why this test existsnew_partition_head, still persistent), then overtakenlength 1 → 2Prompt caching is degradedlength 1 → 2(the duplicate)expectedAssistantshape is what carries itcontinuationMatchto accept anythinglength 2 → 1, i.e. it continued a chain whose lineage did not matchwaitedMs > 0persistentrestorelength 2 → 3parallel_isolated(existing test, extended with the new ledger field)CLODEX_WS_MAX_NEW_CONNECTIONS_PER_MIN=0, production shared pacer)queuedeven when the clock steps backwardsqueuedfrom elapsed timeThe token-release chain is proven in two halves: the pacer unit tests prove the bucket honours a
release on both the immediate and the queued path and refunds only the fraction it debited; the
transport tests prove the call site releases exactly once on a rematch and never when it actually
opens a connection.
Runtime evidence and environment
export CLODEX_HOME=$(mktemp -d) CLAUDE_CODE_ENTRYPOINT=cli && pnpm typecheck && pnpm test && pnpm build— typecheck clean, 2331 tests / 107 files passed, build success. Node v24.14.1(matches
.nvmrcand CI), macOS 15 (darwin 25.6.0), pnpm 10.34.5.The suite was run both with and without the ambient
CLODEX_WS_MAX_CONNECTIONS=64/CLODEX_WS_MAX_NURSERY_CONNECTIONS=24that this machine exports, green in both, so no assertionhere depends on the local caps. No outbound HTTP(S) proxy variables were set;
wsis mocked and thedynamic import is warmed before any concurrent phase, so no test opens a real socket.
What I could NOT verify
behavioural evidence is the fake-socket suite. No
clodex claude -psmoke test was run on thisbranch — the change is confined to the OAuth WebSocket transport, but the Anthropic passthrough
and translated legs are untested here and that is a known gap, not a silent one.
retained prefix follow from the mechanism, but no before/after cache-hit measurement was taken.
real-pacer probe; this branch pins the call and the bucket behaviour separately rather than
re-measuring that latency.
here — and, as stated above, it establishes the absence of an observed opportunity, not a rate.
reasoning-gap canary shares the same deferral seam but is not separately staged in a rematch.
startbasis of the atomicity argument, are reasoning from the specification and the event-loop model —
the panel's inserted-yield mutation corroborates the atomicity conclusion, but neither is a
direct instrumented measurement of production.
Failure and rollback
Every new path is additive and fails closed onto existing behaviour. If the re-scan finds nothing,
the request proceeds exactly as #172 left it. The one timing change on an unaffected path is that a
gap warning is now raised just after the pacing admission rather than just before it — the same
synchronous run of the same request, and it is still raised on the refusal and abort exits. If a pacer implementation omits
queuedorrelease(both optional), the code falls back to the elapsed time and simply keeps the token — i.e. today's
behaviour. Reverting the commit restores #172 exactly; no persisted state, config or on-disk format
is touched, so there is no upgrade or migration concern.