Skip to content

[WRONG BRANCH] chore(release): promote 2.55.0-preview.20260914 to preview - #4617

Merged
lidge-jun merged 58 commits into
previewfrom
codex/260914-preview-2550
Sep 14, 2026
Merged

[WRONG BRANCH] chore(release): promote 2.55.0-preview.20260914 to preview#4617
lidge-jun merged 58 commits into
previewfrom
codex/260914-preview-2550

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Promotes the dev product snapshot 62f02223a0 to the preview train as 2.55.0-preview.20260914.

The 2.55.0 line carries the #4546 cost-guard work: one send budget per logical request with a shared final-recovery reserve (#4609), zero-is-zero refusals that return a typed local error rather than a synthetic 502, compact and the Kiro inner retries admitted against that budget (#4611), a finite send ceiling per root workflow with an interactive reserve a fan-out cannot take (#4612, #4614), and a healthy detour promoted on transient-hold expiry instead of released cold, with Retry-After honoured as a lower bound on the transient path (#4616).

The previous preview tip 2.54.0-preview.20260914 is already tagged, released and on npm, and is outranked by v2.54.0, so it could not be re-released even as a dry run. This is a new candidate rather than a re-cut.

Scope this preview does NOT claim

The PRD's RG2 set is not complete. Still open: the durable cross-restart reservation ledger, V2 child first placement, the minimum quota/cache domain contract, the transient half-open probe lease, combo hops on the shared budget, Cursor's inner retries, and the sends-per-logical-request surfacing. This preview is for validating the send and workflow ceilings that did land; it is not a claim that #4546 is fully resolved.

Verification

dev tip CI at 9f39e75d4f completed success with no failing jobs. Local suite NOT RUN by policy. The preview-branch push CI on the merged SHA is the release gate and is checked separately before any dispatch.

Checklist

  • Targets preview as a release promotion
  • Version-only change on top of a reviewed dev snapshot
  • Local CI — NOT RUN by policy

lidge-jun and others added 30 commits September 14, 2026 03:20
Owner-authorized maintainer integration into dev. Final documentation head 7b4d6ab has successful required checks; expensive product jobs were conditionally skipped for this documentation-only change. Review corrections distinguish issue 4429 from 4519 and archive the completed unit. Integrated implementation evidence remains dev run 34760250023 at cb2e15b.
…4545)

Refuses an in-place restart when the CLI version differs from the running proxy, so a newer CLI no longer hands restart to an older server that then respawns its own binary and reports success. Adds the unknown-health-version regression alongside the placeholder case.

Carries #4529 by Voyagerroc-Lab.

Verification: local product suite, typecheck, build and install NOT RUN. Hosted Cross-platform CI run 34775280313 succeeded at a523f0f. Merged through maintainer self-integration per MAINTAINERS.md.

Co-authored-by: Voyagerroc-Lab <328063293+Voyagerroc-Lab@users.noreply.github.com>
Co-authored-by: Voyagerroc-Code <325343927+Voyagerroc-Code@users.noreply.github.com>
…end (#4548)

sidecarSettingsForBridge read the model out of the global config.webSearchSidecar
block without checking which backend that block was configured for, and
src/server/responses/core.ts hands the block over whole. A global
{backend: "openai", model: "gpt-5.6-luna"} therefore reached runAnthropicWebSearch
whenever a provider set webSearchBridge.backend to "anthropic", and Anthropic
rejects the model, so the bridge search failed. Same shape for xai and gemini.

The global model now applies only when resolveSidecarBackend(sidecar.backend)
equals the bridge backend; otherwise the bridge runs that backend's own default.
An unset global backend still resolves to "openai", so an unset-backend model
reaches an openai bridge and no other. Only the model is gated: reasoning is a
generic effort level, and xSearch is xai-only with no per-backend default and no
webSearchBridge equivalent, so gating it would make an openai sidecar plus an xai
bridge plus x_search inexpressible.

No credential crosses a backend, before or after this change.
resolvePassthroughWebSearchBridgeAuth switches on the bridge backend and consults
only that backend's credential locator. This is a model and settings defect.

resolveSidecarBackend and WebSearchBackendId move from src/web-search/index.ts to
src/web-search/sidecar-providers.ts, which exists precisely so the bridge can
resolve a backend without value-importing the barrel; index.ts re-exports both, so
every existing consumer is unchanged.
…4547)

parseCatalogBuffer had arms for ClientModelConfig fields 1, 4, 18 and 22
and no default, so field 5 (supports_images) was dropped by omission.
Carry it on ModelCatalogEntry as an optional boolean: a present true
asserts text+image support, a present false asserts text-only, and an
omitted field stays unknown. It deliberately does not copy the disabled
pattern, which defaults to false — collapsing unknown into text-only was
the #1796 regression, and antigravity-models.ts already implements the
same tri-state for its discovered catalog.

The header schema comment gains the #5 row and the #18 row it never
listed, and its verification claim now says which fields came from the
bundled extension.js, which from a live catalog dump, and which from the
public WindsurfAPI documentation. The owning structure doc records the
catalog pre-flight contract. Propagation of the flag to the client
catalog is a separate change.
…nts (#4543)

* fix(cursor): refund spare envelope bytes to clipped invocation arguments

The 2 KiB per-call cap on the arguments named inside a replayed tool-result
envelope is charged while the envelope is still being built, so it cost a call
2 KiB whether or not anything else wanted those bytes. In a small replay nearly
the whole 192-root / 512 KiB envelope went unused and the cap still bit: a
4,693-byte successful write_file lost its tail inside a 6,011-byte replay, and
because the result text does not repeat the argument, the model could no longer
see what it had just written.

Add a second pass after the root set is assembled and before it is stored. It
spends only leftover aggregate bytes, newest tool result first, skips a root
whose own output was already elided, and never drops, shrinks or reorders a
retained root. The cap itself is unchanged and still decides admission on its
2 KiB prefix, so a 600 KiB argument stays clipped rather than evicting the
output it describes.

The gate is echoToolResultInRoot, not externalModel: native composer-2.5 echoes
results into roots without being an external wire model, so the narrower gate
would have left the one native model with clipped invocation lines capped for no
reason. The widening uses the callback form of String.prototype.replace, because
serialized arguments routinely contain $&, $' and $1, which the string form
would expand into the surrounding match.

Closes #4516

* fix(cursor): accept readonly rawMessages in the restoration pass

request.rawMessages is readonly OcxMessage[]; the new second pass declared a
mutable OcxMessage[] parameter, which strict typecheck rejects (TS4104). The
pass only reads the array, so widen the parameter instead of copying.

* fix(cursor): keep a collapsed root's run note through a rebuild

An adversarial counter-read of the restoration pass found the real defect one
layer down. pushDeduped builds the collapsed root's wire payload from the marked
text but stored the UNMARKED text in the candidate's `text` field, so every
consumer that rebuilds a root from `text` silently deleted the "produced N times
in a row" note: truncateToolResultBlob already did, and the new invocation
restoration did too. That note is the repetition breaker's per-entry half, so
losing it re-primes the self-reinforcing loop the breaker exists to end.

Store the marked text, which makes `text` a true mirror of the stored payload
for the first time, and fixes the truncation path by the same change.

Also anchor the restoration's search on the preceding newline. toolResultToText
always emits the invocation after the [tool_result], call_id: and name: lines, so
the real line is never first; name: renders the result's tool name, which nothing
sanitizes, so an unanchored search could be satisfied by a crafted tool name and
rewrite that header instead of the invocation.

The regression test fails with the pushDeduped change reverted and passes with it.

* test(cursor): record why the 600 KiB cap tests are not refund tests

The refund leaves those two fixtures alone because restoring a 600 KiB argument
costs more than the whole envelope, so cost > spare is always true there. That is
a size-dependent skip, not a rule that the line stays clipped: an argument over
the cap but well under the envelope is restored by design. Anyone shrinking those
fixtures to speed them up would silently convert them from tests of the cap into
tests of the refund, which is the one reading that would make them vacuous.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
)

Three leftovers from scoping the history preflight, found by re-reading the
owned docs against the shipped behaviour.

`codex-home.md` still said a detected migration always restores all three
preimages before refusing. That is now direction- and reason-dependent: on apply
the paginated refusal retires the relabel unit and the write stands, because it
is permanent and compensating it produced a home with no OpenCodex models at
all. Every other reason there still compensates, and restore and removal
compensate on all of them, because retiring a provider definition its thread
rows still name would orphan them.

The unattended-sync test stubbed the injector returning a paginated refusal,
which it can no longer produce, so the test guarded an unreachable shape while
still passing. It now stubs an operational reason, which is still a hard refusal.

One preflight test claimed to assert that provider definitions are preserved;
it asserts which target sets reach a paginated row. Renamed to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
…s failure (#4553)

Covers the live path that #4512 was asked for and merged without: an invalid live answer must return 502 to the client while booking the real upstream 200, and an alias-registration failure must return 503 while booking the same 200.

Follow-up to #4512 (merged as 9b2fc10).

Verification: local product suite, typecheck, build and install NOT RUN. Hosted Cross-platform CI run 34779112640 succeeded at bf29126. Merged through maintainer self-integration per MAINTAINERS.md.

Co-authored-by: maoxin1234 <275637173+maoxin1234@users.noreply.github.com>
Five regressions for the invocation refund landed in #4543.

- A just-over-cap argument (~2,117 bytes against the 2,048 cap) must come back
  byte-exact. The existing fixture is 4,600 bytes, where thousands of spare bytes
  surround the decision and an off-by-one in the cost arithmetic or in the
  newline-anchored search cannot show.
- No result may be evicted to pay for a wider invocation line, and under the
  fixture's uniform per-round costs the restored set must be the newest
  contiguous suffix. That second claim is a direction check: flipping the walk to
  oldest-first makes it a prefix and turns this red. The comment says so, and
  says plainly that contiguity is not guaranteed under mixed sizes, because the
  pass skips an unaffordable line with continue rather than break.
- A checkpoint-covered call must keep its argument tail in the replayed suffix.
  Drop knownCallsOffset from the pass's callBefore bound and only this case
  notices, since that term is identically zero on the full-replay path.
- A multi-byte argument must survive intact, with U+FFFD asserted absent so a
  failure names itself rather than only showing unequal strings.
- The outputElided skip is load bearing, and finding that out took two tries. A
  sweep of single-result fixtures said the guard was dead code — elision appeared
  to always cut the invocation line too — and an adversarial counter-read found
  the configuration that sweep could not reach. Truncation alone cannot pay for a
  restoration: it undershoots its own budget by about 28 bytes. Initiator
  recovery can. With a ~519.7 KiB system prompt the equal-share pass cuts two
  trailing results to ~2.3 KiB, losing "output:" but keeping the clipped
  invocation line, and recovery then drops the older elided sibling to fit the
  user turn; those freed bytes become spare. The test searches that ~24-byte
  window rather than pinning a literal size, because pinning one made it pass on
  a two-character call id and fail on a twelve-character one, and it fails loudly
  if the window disappears.

Each of the last three was verified by mutation. The two 600 KiB cap tests are
byte-identical. The only src change is the comment recording what the guard
actually depends on, and structure/providers/cursor.md records it too — the
earlier draft of both called the guard defensive, which was wrong.
…og (#4556)

Round 1 made the Devin catalog parser preserve ClientModelConfig field #5
as a tri-state on ModelCatalogEntry; the flag stopped at the cache. Carry
it through: fetchDevinUsableModels now votes per base across the rows that
collapse into it and returns inputModalities, and the Devin branch of
provider-fetch spreads that value before catalogHintsFromProviderConfig,
so exact modelCapabilities declarations, the legacy modelInputModalities
record and the vision-sidecar rewrite keep precedence and the live value
survives only when none of them applies.

Collapse policy, pinned with the round-1 #1796 precedent: rows that never
asserted field #5 abstain, so one unsuffixed unknown row cannot poison a
base whose effort variants were measured image-capable; unanimous measured
rows advertise ["text"] or ["text","image"]; measured disagreement
advertises nothing, because a single measured false is not outvoted by its
siblings. The accepted mismatch is documented in code: resolveWireModelUid
prefers the plain UID when the catalog lists it, so a variant-measured
image base can route a no-effort request to an unasserted plain row.

Tests: a new devin-live-models suite seeds the cache through the real
parser via a setCachedCatalogForTests seam and covers the collapse matrix
(disabled and MODEL_* rows proven non-voters by behavior) plus five
fetchProviderModels advertised-catalog cases locking the hint precedence,
including the existing sidecar path for exact text-only declarations.
Layout registries name the new file; structure/catalog.md and
structure/adapters/registry.md record the contract.
)

The adversarial counter-read of 5d95dbf returned two should-fix findings
and one nit, all folded here: the advertised-catalog tests now stub
globalThis.fetch to throw, so a seeded-cache miss fails the test instead
of dialling Cognition; the catalog.md precedence sentence is scoped to
inputModalities (live contextWindow and reasoningEfforts have their own
configured sources and the broader claim was not literally true) and moved
out of the TTL paragraph; and the registry.md collapse wording covers the
whole EFFORT_TOKENS suffix set rather than only effort variants.
Ten PRs landed on dev across two merge rounds run by four worktree lane threads,
each merged only after the check run's head_sha was verified against the PR head,
with post-merge dev runs 34778300807 and 34782580496 as the joint proof for each
round. #4522, #4530 and #4516 are closed with merge references after an independent
audit of every claim against the tree; eleven issues are deliberately left open with
their residuals named.

#4555 is green and deliberately unmerged: MAINTAINERS.md requires explicit security
review for a change that sends the serving provider's API key to an operator-named
endpoint, and the dev self-integration exception does not cover that review. An
adversarial review found a real silent regression there, which the lane fixed.

Records what the unit learned, including that a fresh lane worktree has no
node_modules so hosted CI is the only evidence that can exist, that a push already
queues CI so the explicit dispatch is a fallback, and that a cancelled dev run is a
concurrency artifact of the release train rather than a failure.
Names the two items still waiting on people: #4555 green and pending the security
review MAINTAINERS.md requires for a credential-destination change, and #4528 whose
only CI failure is a stale-base release version line rather than anything in its
diff. Records that the thread heartbeat was repointed to watch exactly those two and
made read-only by construction, after an audit caught an earlier draft instructing
it to close #4519 automatically on merge, which is not the verified-code-evidence
standard every other close in this unit met.
…4519) (#4555)

* fix(web-search): assess the bridge search endpoint as a destination (#4519)

providers.<name>.webSearchBridge.endpoint names the URL that receives that
provider's own API key as a Bearer token when the ollama bridge backend runs a
search. Two checks stood in front of it and neither was a destination assessment:
providerWebSearchBridgeConfigError did new URL plus an http/https protocol test,
and resolveOllamaWebSearchEndpoint returned the configured value whenever originOf
parsed it. Provider baseUrl has had the real assessment for a long time; the
endpoint had none, so endpoint: "http://169.254.169.254/latest/meta-data" was
accepted and the key was sent there.

Both boundaries now run the existing providerDestinationConfigError. Metadata
destinations are refused unconditionally. Loopback, localhost and private space are
refused unless the provider sets allowPrivateNetwork or its name is a registry entry
that is local by definition, which is what keeps a self-hosted Ollama working.

The plan-time check is the load-bearing one, not a second opinion. A hand-edited
config file, ocx config set and ocx config import all reach configSchema only and
never call providerWebSearchBridgeConfigError, and resolveOllamaWebSearchEndpoint is
the only reader of this field in the tree, so a value that survives file load still
cannot be spent. It refuses silently by design; config-time is where the operator is
told why.

Both checks are synchronous and literal-only and resolve no DNS, so a hostname that
resolves into metadata or private space still passes. No new classifier was written
and no DNS was added at this boundary.

* fix(web-search): tell the operator when a bridge endpoint is refused

A provider keyed under a custom name, say "my-ollama", pointing at
http://127.0.0.1:11434/api/web_search armed before the destination check and
disarms after it, because only the registry ids (ollama, vllm, lm-studio,
litellm) are local by default. Two properties combined to make that invisible:
config load never runs providerWebSearchBridgeConfigError, so the block loads
cleanly, and the plan-time refusal returns undefined by design so the key stays
unspent. The operator's web search stopped working with no signal at all.

resolveOllamaWebSearchEndpoint now warns once per provider and endpoint when the
refusal is a destination decision, naming the two remedies: set
allowPrivateNetwork, or key the provider under its registry id. The planner runs
per request, so the warning is deduplicated and the dedupe set is bounded. The
destination URL is never logged and the provider key goes through
redactSecretString, since a provider key is caller-controlled.

Adds the coverage the review named as missing: a metadata endpoint survives
validateConfigCandidate intact and is then refused at plan time, which pins the
load-path behavior the whole argument rests on rather than simulating it.
… preserve an explicit reasoning disable (#4534)

* [agent] fix: normalize inbound Chat images before route selection and preserve an explicit reasoning disable

The native Chat fast path recognized only `image_url` parts, while the translated
path also understood Pi/MCP `{type:"image", data, mimeType}` and Anthropic-shaped
`{type:"image", source}` parts. Two failures followed from that single gap: a
text-only routed model kept an image-bearing body because `isNativeChatRouteEligible`
could not see the image, and the native whitelist passthrough forwarded the foreign
part verbatim to an OpenAI-compatible upstream that does not accept it.

Recognition now lives once in `src/chat/image-parts.ts`, and
`normalizeChatImageParts` runs before `routeModel` so the diversion decision and the
forwarded wire observe the same parts. A body with no foreign image part is returned
by reference and stays byte-identical. A remote reference is recognized and
rewritten, never fetched.

Separately, the Chat inbound effort allowlist dropped `none`. That is the runtime's
disable sentinel, not an unknown value: `src/reasoning-effort.ts` maps it to omitting
the wire parameter and the Pi export maps Pi's `off` level onto it. Dropping it let a
provider default re-enable thinking the caller had turned off, which is not neutral
for Anthropic families that think by default.

Audit findings F1 and F7 (2026-09-14).

Local verification NOT RUN BY USER INSTRUCTION.

* [agent] test: place the new Chat regressions in their seeded layout domain

Hosted CI caught this: tests/test-layout-tooling.test.ts reported
"chat-inbound-reasoning-none.test.ts: seed responses != server" for both new files.
scripts/test-layout/layout.json seeds a `chat-` prefix to the `responses` domain,
which is where the sibling Chat-translation tests already live, so registering them
under `server` contradicted the seed.

Moves both files to tests/responses/ and registers them there. Import depth is
unchanged, so no import edits were needed.

Local verification NOT RUN BY USER INSTRUCTION.

* [agent] docs: correct the stale reason for dropping the opencode none variant

Independent review found this comment now states a false fact about this stack's own
change. It said the chat ingress allowlist OUTPUT_CONFIG_EFFORTS "has no none", which
stopped being true in 63fbe66 when F7 added the disable sentinel to that allowlist.

The filter itself is kept, narrowly and on a stated basis: emitting the variant would
change what this exporter writes into a user's opencode config, and whether opencode's
picker round-trips reasoningEffort "none" back to a wire this proxy reads has not been
verified. Re-enabling it is a scoped follow-up needing that check, not a side effect of
an ingress fix. MCode and ZCode filter none for their own separate reasons, which
remain accurate at their call sites.

Comment-only; no behavior change.

Local verification NOT RUN BY USER INSTRUCTION.
… and carry Chat reasoning and penalties (#4535)

Restacked onto the squashed #4534 landing; tree identical to pre-restack head e35995c.
…e and map Anthropic parallel=false (#4536)

Restacked onto the squashed #4535 landing; tree identical to pre-restack head dbb969d.
…s instead of dropping them (#4539)

Restacked onto the squashed #4536 landing; tree identical to pre-restack head c5a66bb.
…ary (#4562)

Restacked onto the squashed #4539 landing; tree identical to pre-restack head fff3aa4.
… failover (#4563)

Sanitizes Codex-forward identity metadata and preserves narrowly scoped pre-output target-local failover, so a combo that already took a 429 can try the next healthy target instead of terminating on a request-local 400.

Carries #4528 by @RHODIZSECURITY.

Verification: local product suite, typecheck, build and install NOT RUN. Hosted Cross-platform CI run 34797124354 succeeded at 2530f4c with 21 jobs and zero failures. Merged through maintainer admin on the project owner's explicit instruction.

Co-authored-by: RHODIZSECURITY <180237049+RHODIZSECURITY@users.noreply.github.com>
* fix(cli): escape catalog diagnostics before terminal output

* docs(cli): specify diagnostic escaping in translated guides

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
…4573)

Records the roadmap for two more delivery rounds against the open backlog:
per-lane issue assignment, orchestrator models, explicit per-lane write scopes,
the contributor merge queue, and the merge and closure policy the rounds follow.

The lane split was audited three times and changed each time. Two round-1 lanes
had both claimed the same Responses source file and are now one lane; the Models
dashboard and the catalog backend now have one owner each; the reasoning-ladder
file has a single owner across both rounds; and because any lane that adds a
user-facing setting has to edit the shared config schema, one lane per wave owns
that schema while the others land behavior only.

Documentation only. Nothing in the build, typecheck or test path reads from
devlog/, and this branch touches no source, test, workflow or release file.
…4565)

* fix(codex): bound completed entitlement version misses per account

* fix(codex): charge roster misses only after flight admission

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
* test(lab): verify busy-lock ownership without wall-clock timing

* test(lab): detect repeated live-owner probes without wall-clock timing

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
#4298)

* perf(bridge): zero-copy stream chunking for text and reasoning buffers

- Accumulate streaming text deltas and reasoning summaries into string[] chunks rather than immediate string concatenation.
- Postpone string realization to boundary flushes (flushText, flushSummaryReasoning, and batch assembly), eliminating frequent intermediate string allocations and V8 ConsString flattening pauses on long outputs.
- Keep tool-call arguments on string concatenation with explicit rationale for mid-stream JSON parsing.
- Add regression coverage for 1000-delta stream assembly and budget limit rejection.

* fix(bridge): ignore zero-byte string fragments in chunk accumulators

* fix(bridge): restore consistent indentation in batch accumulation path
* fix(cli): stop connect runtime discovery after a valid selection

* fix(cli): reuse status runtime selection for readiness

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
lidge-jun and others added 19 commits September 14, 2026 14:59
) (#4588)

Kiro coerced an absent cacheReadInputTokens or cacheWriteInputTokens to 0 and then recorded it as a measured value. Every other usage path omits what it has no reading for, and cacheHitRate is null when unobserved, so this was the one place a silent provider looked like a total cache miss - the exact signal needed to tell whether a routing change preserved the prompt cache. Absence is now unknown; a malformed counter is still a malformed event, and the bridge wire still emits its zero default for strict clients.
…nd serve alpha/search from a configured sidecar (#4586)

* fix(web-search): end a mixed-tool bridge leg instead of failing it, and serve alpha/search from a configured sidecar

Two web-search gaps, both reported against a deployment with no ChatGPT
forward provider.

The hosted-search bridge failed closed whenever one upstream leg carried
both an intercepted web_search call and a tool call the client has to run:
the search cell closed as failed, the client's call was dropped, and the
turn died after five reconnects. Such a leg now ends the turn on the leg.
The intercepted searches run, their hosted cells complete, the held client
calls are released with their call_id and streamed order intact, and the
leg's own terminal closes the turn. No continuation is sent upstream,
because the client's call is unanswered and the conversation owes the
client a turn rather than the gateway, and no tool output is fabricated for
a call the bridge cannot execute. A leg whose upstream terminal already
ended the turn runs no search at all and closes any cell it opened, so a
dead turn is never billed and never leaves a spinner behind.

This covers the remainder of issue 4429 on the client-facing side only. The
destination still never receives the executed search result: the caller
replays the hosted web_search_call cell, which carries the query and its
sources but no result text, so the destination's own function_call and
function_call_output pair is not reconstructed. Repairing that needs the
outbound body rewritten before the first leg is dispatched, which lives
outside this module.

POST /v1/alpha/search returned 400 whenever no ChatGPT forward candidate
existed, before considering any configured backend, so an API-key-only
deployment could not use built-in web search at all. When and only when
that candidate list is empty, an explicitly configured webSearchSidecar
backend of anthropic, xai, gemini, or exa now serves the request with that
backend's own credential and answers the shape the client reads. The
verbatim relay is untouched while a forward provider exists. An unset or
openai backend, a sidecar disabled by enabled:false, and a named backend
whose credential is missing all keep the 400 rather than borrowing another
paid backend, and a backend that fails answers with its own diagnostic
instead of asking for ChatGPT auth.

No new configuration field: the fallback reads the webSearchSidecar block
that already exists.

* fix(web-search): name the missing credential instead of asking for ChatGPT auth

A deployment that already chose a web-search backend was still told to
configure a ChatGPT forward provider when that backend's credential was
absent, which is the exact answer the feature request asked this path to
stop giving. Resolution now distinguishes a deployment that named no
backend from one whose named backend cannot authenticate: the first keeps
the ChatGPT-auth message, the second is refused with a message naming that
backend and the credential it could not find, and neither reaches another
paid backend.
… pin from overriding it (#4585)

* fix(codex): discover Codex App runtimes and defer the prompt probe's version check

The prompt probe carried a private four-path POSIX resolver. The Windows Codex
App installs codex.exe under %LOCALAPPDATA%\OpenAI\Codex\bin\<version>, which
that list can never match, so the probe reported an absent candidate on machines
where Codex was plainly installed (issue 4458).

- runtime.ts gains an "installed" source that enumerates the Windows Codex App
  bin root newest-first (deterministic name tie-break) and keeps the four POSIX
  paths the probe used to hardcode. It ranks after PATH, so PATH stays
  authoritative.
- deps.probeVersion === false selects a spawnable candidate without running
  `codex --version`. A request-path probe needs something it can spawn, not a
  version, and ~1s of blocking exec per candidate is what made the probe give up.
- That deferred selection gets its own memo and never publishes into runtime
  authority, because peekCodexRuntimeProcessCache feeds convergence and the
  bundled catalog, which would read a null version as "unknown version".
- ENOENT from the version probe is now program-not-found rather than a generic
  --version failure, so a missing program stops looking like a broken one.
- prompt-text-probe.ts asks the shared resolver, spawns through
  codexExecInvocation so a Windows .cmd launches correctly, and reports a stable
  failure kind. Process stderr is read only to classify and never returned.

Co-authored-by: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com>

* fix(codex): stop a stale auto-discovered runtime from overriding a newer one

codex-runtime.json recorded command, source and version, but not how the record
got there. resolveAndPersistCodexRuntime writes every automatically discovered
selection, so a "configured" entry proved nothing about operator intent - and
resolution then stuck to it even when a newer runtime was present. On the
reporting machine a still-runnable codex-cli 0.135.0 kept winning over the
0.153.4 the Codex App was actually running, and the catalog derived its
reasoning ladder from the older binary (issue 4204).

- PersistedCodexRuntimeState gains origin: "pinned" | "discovered".
  resolveAndPersistCodexRuntime writes "discovered"; a direct persistCodexRuntime
  call, which is how doctor --fix selects, stays "pinned". A record with no
  origin reads as discovered, because auto-discovery is what wrote every one of
  them, and reading it as a pin would leave the bug unfixed on exactly the
  installs that have it.
- An unpinned record hands over only to a strictly newer valid candidate.
  A pin is never touched, equal versions stick, and an unknown version on either
  side is not evidence of an upgrade. The handover is reported as
  supersededDiscovered, kept separate from replacedConfigured so "superseded"
  never reads as "gone".
- The no-discovery fast path still probes the bounded Codex App roots when the
  persisted record is unpinned. That path is what the catalog's bundled loader
  uses, so without it the comparison could never run where the bug actually
  bites. PATH-wide discovery stays off.
- The prompt probe's reported runtime and failure detail now go through
  displayCodexRuntimePath: the response is served over the management API and a
  Windows Codex App path contains the account name.

No config-schema field was added; codex-runtime.json is runtime-owned state.

* test(codex): keep the PATH-outranks-installed case on a colon-free PATH entry

pathCandidates splits PATH on node's delimiter, which is ":" on the POSIX
runners this suite also runs on. The Windows-style "C:\on-path" therefore split
into "C" and "\on-path", neither of which produced a candidate the fixture's
existsSync recognised, so every PATH candidate failed and the installed Codex
App runtime won — the exact opposite of what the test asserts. It failed on
macOS 1/2 and test 1/4 and passed nowhere the split occurs.

The entry is now colon-free, so the directory survives the split on every
platform and the test proves the ranking it was written for.

---------

Co-authored-by: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com>
…d agree with preview (#4546) (#4589)

Follow-up to #4580, from review of the merged commit.

P1: preview and resolve disagreed on the FIRST detour. Preview refused to pick one because pickRoundRobinAccount commits and advances the ring, so it returned null and fell through to the ordinary binding while resolve served from a fresh alternate. Subagent fallback scores the previewed account to decide whether a model is reachable, so it could retire a model over usage the request would never touch. Preview now peeks the same candidate through peekAlternateCodexAccount, which delegates for every strategy except round-robin because that is the only branch with a side effect.

P1: when no detour existed the code fell through and deleted the binding. A provider-wide 503 soft-avoids every sibling, which is precisely when the candidate list is empty, so the hold did not cover the failure it was written for. Being unable to send is not the same as forgetting which account owns the conversation: the binding now survives and the bound account is returned, on both the ordinary and model-detour lanes.

reset-first could still move a bound thread onto an account with no usage reading, because hasCodexQuotaHeadroom answers true for unknown. The quota strategy excludes those through its strictly-cooler compare; reset ordering has no such compare and now says it explicitly.
* docs(devlog): record the round 2 outcome and close the delivery unit

Three wave-A lanes landed with one config-schema owner and no collisions. Unit totals: 22 pull requests closed, 13 issues closed, three issues deliberately left open with their landed scope recorded, three follow-ups filed.

* docs(devlog): move the round 2/3 delivery unit to _fin

Both rounds delivered and the closure sweep verified against live GitHub state: 22 pull requests terminal, 13 issues closed, three issues deliberately left open with their landed scope recorded.
… turn (#4595)

* docs(devlog): plan the regression audit and the 2.55.0 release

Names the six source files three separate merges each touched in the dev delta, states that the audit reads the merged state rather than any single diff, and records the one deliberate deviation from the release script preflight along with what covers each skipped check.

* fix(web-search): do not release a withheld client call under a failed turn

A mixed bridge leg whose upstream terminal was response.failed released its withheld client-executed tool call, because the mixed-tool termination change routed both failed and incomplete terminals through the same endWithoutSearch branch and that branch flushes held calls. The failure path ten lines above documents the opposite rule: releasing a tool call the client would start executing is exactly what must not happen under a turn that is already over.

The two terminals differ. An incomplete turn is one the client can still act on, so its held call goes back; a failed turn is over. The decision now carries whether held calls may be released, true only for incomplete, and the emit path drops them otherwise. The hosted cell still closes in both cases, which is what the reordering was for.

Found by a cross-merge regression audit of the dev delta before promotion. Records the audit findings for all five contended file groups alongside the fix.

* docs(devlog): correct the release plan version-line and CI-event facts

preview and main carry two different version lines over the same product tree, because release.yml requires package.json to equal the dispatched version and a preview dispatch must be a prerelease. Only the clean-tree guard and the npm channel-forward check are script-only. And only a push-event CI run on the release branch satisfies the publish gate; a green PR run at the same SHA is refused.

* docs(devlog): write the 2.55.0 release runbook

The exact ordered sequence with the gate gating each step, including the two version lines over one product tree, the push-event CI requirement, the Service lifecycle prerequisite, and the dev move that must precede the stable publish.
…4546) (#4592)

* feat(codex): record why a live binding was kept, moved, or released (#4546)

logCtx.affinity was typed and persisted but never assigned, and routing had no reason to report, so an account move was only visible by comparing account labels across log lines. resolveCodexAccountForThreadDetailed now returns the decision and its cause, the pool auth context carries it, and the usage entry persists both move and reason.

* fix(codex): hoist the affinity decision to the pool context scope

The declaration sat inside the selection block and the spread landed on the main-pool return, so the pool context never carried it and typecheck failed. Reading resolution.affinity through an in-check keeps the fixed-account branch of the union valid.

* fix(codex): report the affinity decision on every selection path

A first placement returned through the active-account retention path, which carried no decision, so the record was missing for exactly the case that establishes a binding. All selection returns now report, and the detailed-resolver assertions move to toMatchObject because the resolution carries a field they did not previously expect.

* fix(codex): carry a release reason from the outcome path to the next resolve

A 429 clears the pin inside recordCodexUpstreamOutcome, so the request that pays for the cold prefix arrived with nothing left to explain why. The reason is now held per thread, bounded, and consumed by that thread next resolve. Two routing cases compared whole resolutions to each other and now compare the account, because a first placement and a later reuse legitimately report different decisions.

* fix(test): compare the account, not the whole resolution, for model detour independence

* fix(test): compare the account for the second model detour lane too

* fix(test): tolerate the affinity decision in the 401 replay resolution check
…d-budget owner (#4602)

* docs(devlog): plan wp4 send budget at diff level

* docs(devlog): correct the wp4 send-budget plan from the audit round

Four claims were wrong: the #2981 helper is not the opt-in part and Codex passthrough gets a fresh allowance per leg; the same-request account resend is retryCodexPoolOnAlternateAccount, not applyFailureFailover; continuation repair is already covered on the policy path while empty-completion, rebuildAndRefetch, compact and generic OAuth hops are not; and Retry-After is already shortened by local caps, so treating it as a lower bound is a behavior change. A 3-send ceiling would also break the 3+1 recovery the plan measured.

* docs(devlog): locate the send-budget owner and why the passthrough escapes it

handleResponses already owns a request-scoped transient budget and documents itself as covering recovery refetches, but the Codex passthrough legs sit in an earlier scope and pass neither attempts nor onSendsConsumed, so each takes a fresh default of 3. That is the source of the measured 4/7/12, and hoisting the owner is the smallest first step.
…oss a no-account resolve (#4598) (#4604)

The release reason re-derived a subset of the selectable guards and fell through to a quota fallback, so paused, plan-excluded, cooled-down and quota-avoided releases named a cause routing never used. It now comes from the same predicates in the same order as isCodexAccountSelectable. Separately, a no-account return carried no payload and the pending reason was consumed before selection, so a release that failed to find a replacement was never recorded; the reason is now reported on that return, handed forward, and forgotten only once reported.
…rough (#4546) (#4605)

* fix(responses): share one transient send budget with the Codex passthrough (#4546)

The budget owner was declared below the passthrough branch, so it was in the temporal dead zone for those four sends and each took the helper fresh default of 3. Hoisting it above the branch and wiring the sends makes one logical request share one transient budget across its recovery legs. The cross-account alternate is untouched because it does not go through the helper, so the 3+1 recovery shape is preserved.

* test(responses): pin the shared transient budget across a sanitized rebuild

The repeated function-output decrypt case sent 6 times (3 on the first leg, a fresh 3 on the rebuild). With the budget shared it sends 4: the rebuild draws on what is left rather than a new allowance. That count is the regression for #4546.

* test(lib): pin the passthrough legs into the shared-budget source oracle

The oracle asserted exactly three legs report into the counter. The four Codex passthrough sends now do too, and the oracle names them plus the transientRetryPolicyFor gate that would silently restore a fresh allowance.
) (#4606)

* feat(logs): surface the account decision in the route explanation (#4546)

The affinity move and its reason were persisted but never exposed, so the record only existed for someone willing to parse usage.jsonl. The route-decision endpoint behind ocx logs explain now carries them, null for rows that have no account decision.

* fix(usage): persist the affinity record the writer was already setting

appendUsageEntry builds the persisted entry from an explicit whitelist, so affinity and affinityReason were dropped on write and #4592 never reached disk. Both are now normalized against known value sets, and a reason is kept only alongside a move.
…#4546) (#4608)

The budget was a counter local to one handleResponsesInner frame, and a combo parent runs a separate child turn per target, so a three-target fan-out took three fresh allowances. It is now a holder on HandleResponsesOptions, minted at genuine ingress and inherited by children through the options spread that already carries comboAttempt and translatorBudget.
…ero (#4546) (#4609)

* fix(responses): one send budget per logical request, and zero means zero (#4546)

Refs #4546. wp4 steps 2-4 of the cost-guard roadmap.

The amplification behind #4546 was never one missing limit. Every layer that can re-send
counted its own allowance, so a per-layer 3 composed into a per-request 12. #4605 and #4608
gave the transient layers one shared counter; this gives that counter a policy.

src/lib/request-execution-budget.ts carries the guarded text-Codex profile: four model sends
per logical request, a base allowance of three shared by the initial send and same-target
retries, and ONE final-recovery reserve that an account move and a validated rebuild share
rather than taking one each. The permit is consumed immediately before the physical send, not
reconciled after the helper returns, because a counter read afterwards cannot stop two legs
that both saw the same remainder.

Zero now means zero. The Math.max(1, ...) floors in remainingTransientSendBudget and in both
retry helpers funded one more send on every recovery leg, which is most of how a bounded
per-leg allowance became an unbounded per-request count. A refused send raises the typed
SendBudgetExhaustedError, which UpstreamRetryEvidenceError no longer wraps and which
transportFailureResponse maps to request_send_budget_exhausted instead of reporting a proxy
decision as a 502 upstream fault.

Where a reusable upstream answer already exists, the refusal happens before that body is
cancelled: the native OAuth 401 replay and the same-target 429 wait now check the remainder in
their own conditions, so an exhausted request returns the real 401 or 429 with its Retry-After
rather than a synthetic 502.

Two holes that survived #4608 are closed. The adapter initial send passed the raw policy on the
argument that nothing had been spent yet, which is false for a combo child: it inherited the
parent's holder and then took a fresh full allowance anyway. And the cross-account move was
bounded by nothing per request -- excludeAccountId excludes only the account that just failed,
and the recovery loop can return after the alternate fails too, so one request could walk the
pool an account at a time.

Deliberately out of scope, recorded rather than hidden: the same-account gated-model 400 ladder
keeps its own maxRetrySends bound; compact, Kiro, Cursor and the generic OAuth hops still hold
their own allowances.

* docs(devlog): record the wp4 slice A audit counterexamples (#4546)

* fix(responses): a consumed dispatch permit refuses the next send (#4546)

Refs #4546. The single-use contract was written but not enforced: every call site discarded the boolean, so a leg that reached its thunk twice -- an adapter that calls its executor again, or a retry shape that re-enters -- got the second send for free. The return now gates the send.
…end budget (#4546) (#4611)

* fix(responses): compact and the Kiro inner retries join the request send budget (#4546)

Refs #4546. PRD R04.

Compact held its own allowance. Its normal send took a fresh transient three, the stored-pool
401 replay added one, and the 429 alternate added another -- and the guard meant to make those
last two mutually exclusive keys on kind === 'pool', so a main-pool credential left it false and
really could reach five. The recursive handoff child then forwarded the options object without a
holder and minted its own, so one logical compact could reach ten. It now draws the shared
remainder for the ladder and spends base-then-reserve for each single send, and the handoff child
inherits the holder explicitly.

Kiro was the larger multiplier. It nests a three-round throttle loop over a three-attempt reset
ladder that can itself run twice per round, so one adapter entry could be eighteen upstream
requests, and the text-fallback rebuild constructed a fresh context that dropped whatever core
passed. AdapterFetchContext now carries an optional budget, every physical send inside the reset
ladder is admitted against it, and the fallback rebuild carries it forward. The field is optional
and unlimited when absent so an adapter unit test that calls the transport context-free keeps its
own retry shape.

Deliberately still out of scope: Cursor rides IncomingMeta rather than AdapterFetchContext, the
compact routed fallback mints a fresh budget, and the generic OAuth hops keep their own
per-request failover counters.

* style(responses): align the sendBudget field with its sibling context keys (#4546)
Refs #4546. PRD R05/wp5, first slice.

The per-request budget bounds how many times one request reaches upstream. It cannot bound a
fan-out: a worker that spawns seven hundred children, each sending exactly once, never violates a
per-request cap and still spends the account. That is the second half of the incident.

src/lib/workflow-budget.ts tracks the root workflow -- the user-visible task, identified by the
parent thread header -- and gives it a finite physical-send ceiling. Every send charged to the
request budget is charged to the root as well, including the cross-account move, and a root that
has spent its ceiling is refused before dispatch with workflow_budget_exhausted rather than a
synthetic upstream error.

An exhausted root is never evicted to make room. Dropping a live entry would hand the fan-out a
fresh allowance, which is precisely the laundering the ceiling exists to stop, so eviction skips
any root with work in flight.

The ledger is process-local and in-memory. It bounds a single proxy process honestly and says
nothing about a second process sharing the same account pool; that needs a shared durable store
and is declared out of scope rather than implied. The concurrency ceiling and the interactive
reserve are implemented in the module but not yet wired, because they need a release path tied to
the turn lease.
…4546) (#4613)

Refs #4546. Forward fix for the gates failure on 1abc5cc.

HandleResponsesOptions.sendBudget is typed as the narrow TransientSendBudget holder so a caller
that predates the execution budget can still pass one. AdapterFetchContext needs the full
contract, because an adapter that retries internally has to call reserveDispatch. Passing the
narrowed value straight through failed typecheck at all three fetchResponse literals. Narrow it
once next to the other budget helpers instead of asserting at each call site; an adapter that
receives undefined keeps its own retry shape, which is the documented optional behaviour.
…) (#4614)

Refs #4546. PRD R06/wp5, second slice, completing the module landed in #4612.

A fan-out shares the conversation it serves. Without a reserve, a worker burst takes every
concurrency slot under its own root and the interactive turn that started it waits behind its own
children. runAdmittedHttpTurn now admits each turn against the root workflow as well as the
process-wide turn gate: a request that names a parent thread distinct from its own is treated as
that fan-out and may not take the reserved slots, while a top-level request is the conversation
and may.

The refusal is a local queue-capacity answer, not a synthetic upstream error, and the lease is
released on both the normal and the throwing path so a failed turn cannot leak a slot.
…our Retry-After (#4546) (#4616)

* fix(codex): promote a healthy detour instead of releasing it, and honour Retry-After (#4546)

Refs #4546. PRD R07.

When a transient hold outlived its window, routing deleted the whole affinity entry -- including
the detour account that had actually been serving the thread -- and re-picked cold. The timer
expiring restores the right to re-decide; it is not itself a recovery, and treating it as one
threw away the single piece of evidence the request had. A still-healthy detour is now promoted
to the binding instead, with the move recorded as rebound/transient_hold_expired so the reason is
visible. A detour that has itself gone unhealthy still falls through to the cold path.

Retry-After is a lower bound on the transient path. The local maximum delay bounds our own
exponential backoff and has no business shortening a wait the provider stated: sending early is a
request we already know will be refused, which is the storm the header exists to prevent. It is
opt-in per caller so the change lands on the transient path first rather than silently lengthening
every adapter's backoff, and an honoured wait is ceilinged at one minute so an hour-long
Retry-After cannot park a request.

* docs(devlog): record the R07 detour-promotion outcome (#4546)
Promotes the dev product snapshot 62f0222 to the preview train.

The 2.55.0 line carries the #4546 cost-guard work: one send budget per logical request with a
shared final-recovery reserve, zero-is-zero refusals with a typed error rather than a synthetic
502, compact and the Kiro inner retries admitted against that budget, a finite send ceiling per
root workflow with an interactive reserve a fan-out cannot take, and a healthy detour promoted on
transient-hold expiry instead of released cold.

The previous preview tip 2.54.0-preview.20260914 is already tagged and published and is outranked
by v2.54.0, so it could not be re-released; this is a new candidate rather than a re-cut.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 10:28
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T10:30:11.593026Z 704857f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot changed the title chore(release): promote 2.55.0-preview.20260914 to preview [WRONG BRANCH] chore(release): promote 2.55.0-preview.20260914 to preview Sep 14, 2026
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/260914-preview-2550

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (preview); retarget to dev. UI screenshot required.

What to do

  • Retarget this PR to dev — all contributions go to dev.
  • Add a screenshot of the UI change to the PR description.

Its title has been prefixed with [WRONG BRANCH].
Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually. The required enforce-target check will keep failing until every issue above is resolved.

@github-actions
github-actions Bot marked this pull request as draft September 14, 2026 10:28
@lidge-jun
lidge-jun marked this pull request as ready for review September 14, 2026 10:28
@lidge-jun
lidge-jun merged commit 7bdd1b2 into preview Sep 14, 2026
23 of 25 checks passed
@lidge-jun
lidge-jun deleted the codex/260914-preview-2550 branch September 14, 2026 10:29
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.

4 participants