Skip to content

[WRONG BRANCH] release: promote dev into main for v2.32.1 - #2507

Merged
lidge-jun merged 12 commits into
mainfrom
dev
Aug 25, 2026
Merged

[WRONG BRANCH] release: promote dev into main for v2.32.1#2507
lidge-jun merged 12 commits into
mainfrom
dev

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Promote dev into main for the v2.32.1 bugfix release.

dev head bb89eafbe carries the closed v2.32.1 hotfix train: seven merged work phases plus two post-merge review closures. main (96e2f67c3, v2.32.0) is an ancestor of dev, so this promotion introduces no divergence.

Code changes since v2.32.0:

The remaining commits are devlog-only.

Verification

  • Frozen code SHA faaa78dc0; git diff --name-only faaa78dc0 bb89eafbe outside devlog/ is empty.
  • Push-event Cross-platform CI run 32793104507 on faaa78dc0: success, every job green.
  • GO/NO-GO readiness report: devlog/_plan/260824_v2_32_1_hotfix_train/900_go_nogo_readiness_report.md.
  • Typecheck exit 0, privacy scan passed, general suite 14565 pass / 0 fail at the frozen SHA.

Checklist

  • Release promotion, not a feature change
  • Exact-SHA CI evidence recorded
  • No GUI change in this promotion range

lidge-jun and others added 12 commits August 24, 2026 22:11
Opens the docs-only cycle for the next release train. The planning note this
started from targeted v2.31.1; that baseline is void because v2.32.0 shipped
from main on 2026-08-24. This unit re-derives the baseline from live git state
and plans the train as v2.32.1, bugfix-only.

The first draft got the branch relationship wrong: it read a one-way
--is-ancestor result as divergence. An independent audit re-ran both directions
and dev turns out to be an ancestor of main, 0 ahead and 27 behind, with a
one-line tree delta. wp1 is therefore a fast-forward, not a backmerge, and the
correction is recorded in the document rather than quietly fixed.

Three audit rounds moved two other things. #2427 was reordered from first to
last: changing the test runner before the runtime fixes would make every later
failure ambiguous between a real regression and parallel-execution flakiness.
And #2472's regression got its own work-phase (wp9) once the audit pointed out
the plan had made it a mandatory gate while assigning nobody to write it.

Contents: 000 baseline/scope/roadmap, 001 verbatim reviewer-lane evidence, and
one diff-level decade doc per implementation phase (010 wp1, 020 wp3/#2483,
030 wp4/#2481, 040 wp5/#2473, 050 wp6/#2477, 060 wp7/#2476, 070 wp2/#2427,
080 wp8 freeze, 090 wp9/#2472).

No code changes. No promotion, tag, or publish.
devlog: v2.32.1 hotfix train roadmap unit, and put dev on the v2.32.0 release lineage
…inking (#2483)

* fix(anthropic): classify capitalized/dotted Claude ids as adaptive thinking

claudeFamilyVersion only matched lowercase `claude-\<family>-\<major>-\<minor>`
ids. Vendor ids such as `Claude-Opus-4.8-joybuilder` failed both the
case-sensitive prefix match and the dotted minor parse (4.8 -> minor 0),
so usesAdaptiveThinking() returned false and the adapter sent the legacy
`thinking: {type: "enabled", budget_tokens}` wire shape to models that
reject it (Bedrock 400: "thinking.type.enabled is not supported for this
model. Use thinking.type.adaptive and output_config.effort").

Make the parser case-insensitive, accept `.` as a minor separator, and
lowercase the captured family before table lookup. Date-pinned ids
(claude-opus-4-20250514) and legacy families (opus <= 4.6) keep their
previous classification.

* test(anthropic): cover capitalized/dotted Claude ids in thinking wire-shape matrix

Regression for the family parser fix: Claude-Opus-4.8-joybuilder and
claude-opus-4.8-joybuilder must pick the adaptive wire shape, while
Claude-Opus-4.6-joybuilder (below the adaptive threshold) must stay on
the legacy thinking.enabled shape.

* fix(anthropic): reject a longer number, not any dot, in the family tail

The capitalization and dotted-minor repair is right, but widening the
tail from (?!\d) to (?![\d.]) to stop "claude-opus-4.20250514" also
rejected "claude-opus-4-8.1": the minor group matches "8", the tail sees
the following dot, the match is discarded, and the regex backtracks to a
major-only "4.0". That id parsed as Opus 4.8 before this PR, so it would
newly take the legacy thinking.enabled wire shape — the exact 400 this
change exists to prevent, reintroduced for a different id family.

A dotted suffix after a dashed minor is not a dotted minor. The tail's
job is to reject a longer NUMBER, which the original (?!\d) already did;
the dotted-minor support belongs entirely to the [.-] separator. Keeping
(?!\d) and adding only [.-] and /i covers every id the PR intended,
preserves every id the old regex classified correctly, and additionally
recovers "claude-opus-4.20250514" and "claude-opus-4.8.1", which the
wider tail turned into no match at all.

Tests: the adaptive matrix gains the dashed-capitalized and end-of-string
dotted cells so the capitalization and separator axes are covered
independently, plus "claude-opus-4-8.1" as the regression for the above.
The legacy matrix gains a capitalized date-pinned id, which previously
reached that branch by failing to parse rather than by parsing correctly.
The #545 explicit-disable matrix gains "Claude-Sonnet-5" — the only case
that exercises claudeFamilyVersion's second caller, where a miss is
invisible because the request simply goes out without the disable.

Verified red-then-green: with the original tail restored, only the
claude-opus-4-8.1 case fails (59 pass, 1 fail); with this correction,
60 pass, 0 fail. tsc --noEmit clean.

---------

Co-authored-by: liyongjie.103 <liyongjie.103@jd.com>
Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
…ches it (#2481)

* fix(catalog): match selectedModels the way the canonical resolver matches it

`filterCatalogVisibleModels` built the per-provider allowlist as a plain
`Set(selectedModels)` and tested it with `allow.has(m.id)` — the native model id,
exactly. The canonical resolution of the same list keys it through the slug
equivalence:

  sync.ts:819-821   new Set([...models].map(m => slugEquivalenceKey(routedSlug(provider, m))))
  sync.ts:1039      selected === undefined || selected.has(slugEquivalenceKey(slug))

so the two disagree for any provider whose native ids contain a slash:

  stored "moonshotai/kimi-k3-free"   sync accepts=true   catalog filter accepts=true
  stored "moonshotai-kimi-k3-free"   sync accepts=true   catalog filter accepts=false

The second is the Codex-facing slug `routedSlug()` produces and the picker
displays, and `ocx models remove` already accepts it (tests/cli-models.test.ts:332,
"models remove accepts raw and encoded slash selectors"). An allowlist written
from what the user sees therefore blanked the provider's catalog silently, while
`routeModel` decoded the same string back and served the model happily.

Affects providers with slash-bearing native ids: openrouter, zenmux, nvidia,
together, fireworks.

The `disabledModels` loop three lines above is already tolerant of both forms via
`slugEquals`, and slug-codec.ts:20-21 states the rule this restores: "Config
comparisons are tolerant … so legacy raw values keep working regardless of which
form was stored."

* test(catalog): pin the lossy collision and record the rejected alternative

The key comparison is right for the reported bug, but it is lossy in a
way worth writing down: "a/b" and "a-b" collapse to one equivalence key,
so a provider publishing both spellings has them selected together. That
behavior now has tests asserting what the code actually does, rather than
being left for someone to discover from a support thread.

It also has a rejected alternative recorded next to it. Resolving each
selection against the provider's current rows looks stricter and is not:
the roster is an incomplete dictionary, so when live discovery omits
"a-b" but returns "a/b", an exact "a-b" selection resolves onto "a/b" and
reproduces the same over-grant. It would additionally make fresh
filtering disagree with the equivalence relation sync.ts applies when
merging the persisted catalog — two catalog stages with different rules
is the bug class this change removes.

The real fix is one selection resolver shared by filtering, persisted
sync, CLI removal, and routing, evaluated against a complete known-id
set, with a single ambiguity policy. That is an architecture change and
does not belong in a bugfix-only release; tracked as #2491.

Tests: 242 pass across selected-models, codex-catalog, slug-codec, and
cli-models. tsc --noEmit clean.

---------

Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
* fix(codex): keep oversized Responses turns off the WS transport

The Codex backend closes the socket on any inbound message of 16 MiB or
more without sending a Responses terminal event, which reached clients as
a bare 502 upstream_server_error. Because the wrapper only fell back to
SSE when the *upgrade* failed, a thread that crossed the ceiling could
never recover: every retry resent the same oversized frame.

Measured against the live endpoint on 2026-08-23: 16,777,000 B completed,
16,777,300 B closed the socket in ~1s, reproducibly. The same body still
succeeds over HTTP SSE, so the limit belongs to this transport alone.

Size the `response.create` frame before dialing and take the SSE path when
it does not fit. Deciding before the socket opens is what keeps the resend
safe -- after open the caller already holds a streaming Response, and a
retry there could double-generate the turn.

Two supporting changes:

- Carry the WS close code and reason into the stream error. A 1009 was
  previously indistinguishable from a network drop, and nothing in
  usage.jsonl or /api/logs recorded the real cause.
- Apply the provider's `upstreamHttpVersion` pin to the SSE fallback. The
  fallback is a routine path now, and serving a turn over HTTP while
  silently dropping the operator's protocol pin is wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(codex): pin the transport boundary at the adjacent byte

The sizing helper already had unit tests, but nothing proved the real
serialized frame routes correctly one byte on each side of the limit.
That gap matters because the request body is not the frame: `stream` is
deleted and `type` is added before sending, so padding sized against the
body sits eleven bytes away from what is actually transmitted. An
off-by-one would live exactly there and pass every existing test.

These two build padding so the serialized frame is exactly limit-1 and
exactly limit, then assert the whole path: one socket and one send of the
expected byte length under, zero sockets and one SSE call at it. Flipping
the gate from >= to > fails the second one, so it catches a real
off-by-one at the transport level rather than only in the helper.

Two comment corrections while here. The close-code comment claimed the
named 1009 message makes the failure diagnosable from the logs; it does
not. The eager relay turns any stream error into a generic
`upstream_reset` synthetic terminal without feeding it back through the
inspector, so `/api/logs` retains only `streamAborted`. The message
reaches the client and stops there, and the comment now says so rather
than promising observability the code does not deliver.

The margin comment described 64 KiB as absorbing a future append. There
is no append. It is a conservative cushion, and the useful thing to
record is what it actually covers: RFC 6455 framing is 14 bytes at this
payload size — an 8-byte extended length plus a 4-byte client mask — so
even a backend counting frame headers has ~65.5 KiB of room.

Tests: 59 pass, 1 skip across ws-upstream, sse-failed-tail, and
upstream-http-version. tsc --noEmit clean.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
* fix(responses): honor namespace tool choice

* fix(responses): match the tool kind, not just the name, when arming aliases

Narrowing the alias map by tool_choice was right, but the allowed_tools
branch matched entries by name alone. Entries there are typed
{type: z.string()} by the schema, so the accepted set is open-ended, and
a selector naming a different KIND of tool contributed a function name it
has nothing to do with. An upstream answering with that wire name then
had it restored into a namespaced client call the caller never selected.

Restricting the branch to function|custom closes that, and it has to be
an allowlist rather than a denylist: enumerating kinds to reject can
never be complete when the schema accepts any string, and a kind added
next year would arrive pre-authorized.

That left a narrower version of the same mismatch. The alias identity
carried only {namespace, name}, so the declared kind was gone by the time
tool_choice was compared: a tool declared function could be selected by a
custom selector, and vice versa, both schema-valid. A wire name says
which tool, not what kind of call may carry it. The identity now keeps
the kind it was declared with, and both selector branches require it to
agree.

Tests cover fourteen non-function kinds plus an unknown future one, each
asserting the alias map stays empty and an upstream call carrying that
wire name is left unrestored with no namespace injected. Positive
controls declare and select the same kind so the filter cannot pass by
being deny-all, and two cross-kind negatives cover both directions
through both the forced and allowed_tools shapes. The default cases —
absent, auto, required — are pinned as unrestricted, since narrowing
should apply only where the caller narrowed.

Verified red-then-green: reverting only the type filter fails fifteen
cases; reverting only the kind match fails the cross-kind pair.
189 pass across namespace-tool-compat, responses-parser,
openai-responses-passthrough, and responses-opaque-blob-recovery, plus 71
across the undeclared-tool-guard and custom-tool-compat suites.
tsc --noEmit clean.

---------

Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
#2476)

* fix(responses): stop rewriting an unchanged snapshot every two seconds

`responses-state.json` is bounded at 24 MiB and rewritten whole on a fixed
2 s debounce, so under sustained traffic every cycle paid a complete
re-serialization plus an atomic replacement of a file nothing reads until
the next start.

Two narrow measures, both scoped to the write path:

- A flush that would reproduce the existing file byte-for-byte is
  skipped. A mutation does not always change what gets persisted —
  entries past the per-entry or total bound are dropped from the
  selection, and spill demotion moves bytes out of it. The comparison is
  a length plus a Bun.hash digest rather than the retained payload, which
  at the 24 MiB bound would double the snapshot's memory cost. The skip
  is conditional on the file still existing, so a snapshot deleted
  underneath the process is restored.
- The debounce scales with the size of the last snapshot written: base
  2 s below 1 MiB, linear above it, clamped at 30 s. The write rate is
  then roughly flat as the cache grows instead of growing with it.

Durability is unchanged for a graceful shutdown, which flushes; a longer
debounce only widens the window in which a hard kill loses the most
recent continuation entries, which are cache.

Journal / incremental store deliberately not attempted here.

Refs #2460

* docs(troubleshooting): say the debounce follows the last snapshot written

The section read as though the cadence tracked the pending snapshot. It
tracks the size of the last snapshot actually written, so a cache that
has only just grown still takes the short wait once. Review feedback on
#2476.

* fix(responses): verify the snapshot on disk before skipping a write

Skipping a byte-identical rewrite is the right fix for the amplification,
but the cached digest describes what this process last wrote, which is
not the same claim as what is on disk now. A second proxy sharing the
home, or anything that rewrites the file in place, leaves the digest
describing bytes that are gone.

That matters more than it sounds. Before the skip existed, every flush
rewrote the file and so repaired external damage silently. Skipping on
the digest alone turns a self-healing snapshot into a permanently corrupt
one, and nothing notices until the next restart fails to load the
continuation state. Replacing the file with different bytes of the same
length reproduces it: the digest still matches, the file still exists,
and the flush declines to repair.

The skip now verifies identity against the file itself. The cached digest
is keyed to the resolved write target, so a config-dir change or a
retargeted symlink is a miss rather than a false match, and the contents
are compared byte-for-byte before declining to write. Size is checked
first so the common mismatch costs a stat, any read failure answers "no"
and the caller rewrites, and the read only happens when the digest
already agreed. The amplification being fixed is the repeated 24 MiB
atomic replace, not the read that avoids it.

This also removes the need to trust Bun.hash for correctness. It stays a
cheap first filter, but a collision can no longer produce a false skip.

Tests: same-length external replacement must be rewritten, proven by
reverting only the disk check. The docs line claiming graceful shutdown
"always flushes" is corrected too — the flush is a disk write and can
fail like any other, and writeBoundedSnapshot swallows that into a
"failed" outcome the lifecycle warning cannot see.

124 pass across write-amplification and responses-state. tsc clean.

---------

Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
Two threads were opened on #2477 and #2476 shortly before each merged, so
neither was addressed. Both are real and both are one-line predicates.

A selector's namespace is either absent — meaning "unqualified, resolve
the bare name" — or a string naming the group. rewriteNamedSelector
treated every non-string value as absent, so {type:"function",
namespace:1, name:"safe"} took the unqualified path, resolved to a
namespace wire name, and authorized an alias the caller never qualified.
A wrong-but-valid namespace already failed closed; only malformed ones
slipped through. Present-and-invalid now returns the selector untouched.

The snapshot fast path compared content but not permissions. This file
holds persisted request and response bodies and is written owner-only,
and the unconditional rewrite used to restore that on every mutation.
Skipping on content alone let a broadened mode persist for the life of
the process — a durable privacy regression rather than a slow one. A
widened file is now treated as not matching, so the caller rewrites it
through the hardening path. POSIX-only check; Windows ACLs are
re-applied by that same write path.

The docs claim that any byte-identical flush is skipped is also
corrected: the skip needs this process to have written the same bytes to
the same target, and the file to still match.

Tests: five malformed namespace shapes plus an allowed_tools entry, with
positive controls proving neither the qualified nor the unqualified path
regressed; and a mode-broadening regression. Both mutation-proven —
reverting either predicate fails exactly its own cases.
…2501)

Refusing to rewrite a malformed selector was not enough. When its name is
already the flattened wire name — {type:"function", namespace:1,
name:"collaboration__safe"} — returning it unchanged leaves an exact
match against the alias map, so the alias arms anyway and an upstream
call is restored into the namespace. Both selector shapes reach it.

The check belongs at the authorization gate, not only in the rewriter: a
malformed namespace makes the whole selector untrustworthy regardless of
which name it carries. The unqualified shape is untouched, since an
absent namespace is a deliberate, legitimate selector.

Also states the POSIX permission precondition in the troubleshooting
page, which described only the content half of the skip rule.

Tests cover both malformed-with-wire-name shapes and keep a positive
control on the unqualified selector so the fix cannot pass by being
deny-all. 186 pass across namespace-tool-compat, responses-parser,
openai-responses-passthrough, and the write-amplification suite.
tsc --noEmit clean.
The readiness report freezes dev at faaa78d with the verdict GO, and
records the audit that got it there: the first freeze at 02c302a was
rejected, correctly, on three counts.

Two of the three unresolved review threads it found were live defects
that had been opened minutes before their PRs merged and so were never
addressed — a malformed namespace authorizing a tool alias, and the
snapshot fast path never restoring broadened permissions on a file that
holds request and response bodies. Both are fixed and both needed a
second pass, because the first fix for the namespace case was itself
incomplete against a pre-flattened wire name.

The third finding was the fairest: the full-suite gate was red and the
report argued an exception for it. Arguing an exception in the document
that reports the result is gate-weakening after the fact. The gate is now
decomposed the way CI actually partitions the suite, and passes in that
form: 14565 pass across the general batches, storage-policy green in its
own job, with the single api-usage failure proven identical on the
untouched pre-train baseline.

Also records the two units that closed without merging. #2472 is
NOT_REPRODUCED: a regression was written, passed, and then deleted once
review showed it pinned the pre-execution announcement path rather than
the post-execution loss the issue describes. #2427 is deferred on five
runs of data — four different tests flaked, each green in isolation —
because a runner that fails one run in three would make the freeze gate
itself unfalsifiable.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 25, 2026 01:36
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 25, 2026
@lidge-jun
lidge-jun merged commit d35592b into main Aug 25, 2026
27 of 30 checks passed
@github-actions github-actions Bot changed the title release: promote dev into main for v2.32.1 [WRONG BRANCH] release: promote dev into main for v2.32.1 Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (main); retarget to dev.

What to do

  • Retarget this PR to dev — all contributions go to dev.

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.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dfc481aa-9e65-43d9-813c-27cf95607903

📥 Commits

Reviewing files that changed from the base of the PR and between 96e2f67 and bb89eaf.

📒 Files selected for processing (27)
  • devlog/_plan/260824_v2_32_1_hotfix_train/000_baseline_scope_and_roadmap.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/001_reviewer_lane_evidence.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/010_wp1_dev_fastforward_to_release_lineage.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/020_wp3_pr2483_anthropic_id_classification.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/030_wp4_pr2481_selectedmodels_slug_equivalence.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/040_wp5_pr2473_oversized_ws_transport.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/050_wp6_pr2477_namespace_alias_authorization.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/060_wp7_pr2476_snapshot_write_amplification.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/070_wp2_pr2427_parallel_test_runner.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/080_wp8_freeze_verification_and_go_nogo.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/090_wp9_issue2472_mixed_sequence_regression.md
  • devlog/_plan/260824_v2_32_1_hotfix_train/900_go_nogo_readiness_report.md
  • docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md
  • src/adapters/anthropic.ts
  • src/adapters/base.ts
  • src/adapters/openai-responses.ts
  • src/codex/catalog/provider-fetch.ts
  • src/responses/namespace-tool-compat.ts
  • src/responses/state.ts
  • src/server/responses/fetch-helpers.ts
  • src/server/responses/ws-upstream.ts
  • tests/anthropic-reasoning.test.ts
  • tests/namespace-tool-compat.test.ts
  • tests/openai-responses-passthrough.test.ts
  • tests/responses-state-write-amplification.test.ts
  • tests/selected-models.test.ts
  • tests/ws-upstream.test.ts
 ___________________________________________________________________
< That's not technical debt - that's technical *predatory lending*. >
 -------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants