From 17b3d3fe99507292016670d3b8cb85bdc4ca7f7a Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:39:43 +0900 Subject: [PATCH 1/5] fix(claude): preserve Go affinity through final combo dispatch Carry #4050 at e5c2411f7b35c6265aacce19f66f13eace544579. Local suites NOT RUN; final hosted CI pending. Co-authored-by: David Wang <72378768+david-wang-0@users.noreply.github.com> Co-authored-by: GPT-6 Astra Co-authored-by: Claude Fable 5.1 --- .../src/content/docs/guides/providers.md | 12 +- src/server/claude-messages.ts | 40 ++++-- src/server/responses/core.ts | 7 +- structure/adapters/registry.md | 3 + structure/catalog.md | 3 + structure/clients/claude-desktop.md | 3 + structure/data-planes/images.md | 3 + structure/data-planes/inbound-compat.md | 10 ++ structure/gui-and-management-api.md | 3 + structure/ops/service-and-sidecars.md | 3 + structure/providers/xai-grok.md | 3 + structure/runtime.md | 3 + structure/subagents.md | 3 + structure/transports/inventory.md | 3 + structure/transports/responses.md | 3 + structure/transports/streaming-health.md | 3 + .../opencode-go-session-header.test.ts | 124 +++++++++++++++++- 17 files changed, 211 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 693c3565f5..a093f22dc6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -476,8 +476,16 @@ inbound value is treated as client input and hashed into Go affinity; the internal bridge carries the original value, so native Chat, bridged Chat, and Responses derive the same result. Explicit provider-config session headers are operator overrides and are sent unchanged. Clients must keep the -identifier stable within a conversation and distinct across conversations; requests -without a session identifier cannot receive automatic session affinity. +identifier stable within a conversation and distinct across conversations. A request +without any session identifier is not given an inferred cross-request identity; it is +instead sent under a session allocated for that request alone, isolated from every +other request (see the provider reference for how that value is carried). +For Claude Messages, configured OpenCode Go session headers remain authoritative. +Otherwise, valid explicit session or thread headers take precedence, and valid +conversation identity in `metadata.user_id` supplies the fallback. This fallback is +applied to the final Go destination, including random combo selections and fallback +attempts, rather than the preliminary route. Shared system-prompt cache keys do +not identify conversations, and Go-specific identity is not sent to non-Go targets. Generated Pi provider configurations enable `compat.sendSessionAffinityHeaders` so Pi sends its per-session identity to the proxy. Existing manually managed Pi configurations can set this option on their `opencodex` provider as well. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index ba4d4999d3..40e0a3fbc6 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -38,6 +38,7 @@ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-dec import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; import { conversationIdFromClaudeMetadata, + getOrAllocateRequestSessionLane, linkRequestSessionLane, normalizeLogConversationId, sessionLaneIdFromRequest, @@ -637,6 +638,12 @@ export async function handleClaudeMessages( } } +/** + * Translate a Claude Messages request, route it through the Responses pipeline, + * and translate the reply back. Runs under a translator budget owned by the + * caller; Go session affinity is derived here and handed to the final Go + * transport out of band rather than through replay headers. + */ async function handleClaudeMessagesWithBudget( req: Request, config: OcxConfig, @@ -867,27 +874,31 @@ async function handleClaudeMessagesWithBudget( }; } } - if (opencodeGoRoute) { - const session = req.headers.get("x-opencode-session"); - if (session) headers.set("x-opencode-session", session); - } - const hasExplicitGoSession = opencodeGoRoute - && (sessionLaneIdFromRequest(headers) !== undefined - || normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined); - const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession + // Carry Go identity out of band: a combo's preflight target may differ from its + // actual dispatch/fallback target. Never add Go-only identity to replay headers. + const metadataGoLane = cacheKeySource === "metadata" + && typeof internalBody.prompt_cache_key === "string" && isRec(anthropicBody) - && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined; - // Go can also use the Responses adapter; its eligibility gate must win on both wires. - if (opencodeGoRoute ? synthesizeGoSession : nativeRoute) { + && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined + ? normalizeLogConversationId(uuidFromHex(internalBody.prompt_cache_key)) + : undefined; + // Without any valid conversation identity, fall back to the request-scoped lane + // allocated on the admitted client request (#4172): stable across retries and + // route reconstruction, distinct per request, and never derived from a shared + // system-prompt cache key or from a later synthesized native session_id header. + const claudeGoSessionLane = sessionLaneIdFromRequest(headers) + ?? normalizeLogConversationId(req.headers.get("x-opencode-session")) + ?? metadataGoLane + ?? getOrAllocateRequestSessionLane(req); + if (nativeRoute && !opencodeGoRoute) { // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex // clients always send their session uuid; devlog 090 follow-up: body-level // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends // the header, so synthesize a stable per-session uuid from the same cache key. - // Routed Go requests need this lane too for their x-opencode-session affinity — - // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback + // Use ONLY a real per-session key (metadata.user_id). The system-hash fallback // key is shared across Desktop conversations, and a shared session_id's backend // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. - if (cacheKeySource === "metadata" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") { + if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); } } @@ -934,6 +945,7 @@ async function handleClaudeMessagesWithBudget( // Without this the replay would look native and a Responses-scoped wire default // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", + claudeGoAffinity: { sessionLane: claudeGoSessionLane }, stripClaudeMainAuthForNoncanonicalForward: true, ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}), // Claude's internal stored-main enrichment is not an original caller credential. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a10d97d282..2d5618f82b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1682,6 +1682,8 @@ export interface ConsumedComboFailure { export interface HandleResponsesOptions { + /** Internal Claude replay identity; consumed only by the final canonical Go transport. */ + claudeGoAffinity?: { sessionLane?: string }; /** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */ codexAuthPolicy?: CodexAuthPolicyConfig; turnAdmissionLease?: AdmissionLease; @@ -2485,6 +2487,7 @@ async function applyFinalRouteRequestNormalization(args: { logCtx: RequestLogContext; inboundWire: InboundWire; inboundTransport?: "websocket"; + claudeGoAffinity?: HandleResponsesOptions["claudeGoAffinity"]; }): Promise { const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args; const effortSelector = prepareEffortNormalization(parsed, route); @@ -2512,7 +2515,8 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). - route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); + route.provider = resolveOpenCodeGoTransport(route.provider, + args.claudeGoAffinity ? args.claudeGoAffinity.sessionLane : getOrAllocateRequestSessionLane(req)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; @@ -3934,6 +3938,7 @@ async function handleResponsesInner( logCtx, inboundWire, inboundTransport: options.inboundTransport, + claudeGoAffinity: options.claudeGoAffinity, }); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index b0fab66633..a4dc21adbf 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -63,3 +63,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/catalog.md b/structure/catalog.md index fbb356e2d3..0ba4acca3e 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -275,3 +275,6 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 36511b6f00..1957d058db 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -82,3 +82,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index 01d0cd4b0f..25646c7de4 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -76,3 +76,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 45fe1c11e7..2045ce64f9 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -96,3 +96,13 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +## Claude affinity at final Go dispatch + +`src/server/claude-messages.ts` carries validated conversation affinity privately through +Responses options. Configured Go headers win; otherwise explicit session/thread identity, +then an explicit Go header, then valid Claude metadata, then the original request-scoped +allocation supplies the lane. `src/server/responses/core.ts` applies it only at the final +canonical Go transport, including combo selection and failover. No Go-only replay header +reaches non-Go destinations. Shared system cache keys never become conversation identity. +The request-scoped fallback is stable across retries and distinct across client requests. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index a38b7970f0..a3c61e1f7d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -534,3 +534,6 @@ advances the observation clock, so a retained older row cannot defer evaluation ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 21bafe5b3b..39dc9a82da 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -139,3 +139,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5c497d4084..5b149ac6a2 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -62,3 +62,6 @@ Account-scoped OAuth quota remains display evidence for provider-level Combo sel The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/runtime.md b/structure/runtime.md index 4763842283..5abf80774d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -216,3 +216,6 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/subagents.md b/structure/subagents.md index 251d3ead81..f190aab084 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -211,3 +211,6 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. + +Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index f0348c1cdd..b2fc3b3fae 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -67,3 +67,6 @@ Quota publication distinguishes display reports from explicitly supplied inferen The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5624a2e04e..2321d78dd6 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -520,3 +520,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ae20c7c7ea..68093843ea 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -196,3 +196,6 @@ claims stored main, after terminal vision, routed vision and search exclusions. The management quota DTO keeps Combo editing aligned with scoped inference evidence; see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routing-quota). + +Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) +privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index 9b326e294c..be63d32d9d 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { providerConfigSeed } from "../../src/providers/derive"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { getProviderRegistryEntry } from "../../src/providers/registry"; @@ -122,6 +123,127 @@ describe("OpenCode Go session affinity (#3344)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); + for (const model of [CHAT_MODEL, MUSE_MODEL]) { + for (const preliminaryAdapter of ["openai-chat", "openai-responses"] as const) { + for (const strategy of ["random", "failover"] as const) { + for (const identity of [ + { name: "metadata", headers: {}, metadata: "user_test_account__session_conversation-a", expected: "ocx_a89540229ef781fd5f7adf92a711b436" }, + { name: "explicit Go header", headers: { [SESSION_HEADER]: "client-session-a" }, metadata: "other-session", expected: "ocx_516d593899f34b7baca2db37c7b0c8c5" }, + { name: "explicit lane", headers: { session_id: "native-client-session", [SESSION_HEADER]: "client-session-a" }, metadata: "other-session", expected: "ocx_a197dbb87311c29a5fbe51140e3845ce" }, + { name: "operator override", headers: {}, metadata: "user_test_account__session_conversation-a", operator: true, expected: "operator-session" }, + { name: "invalid explicit lane", headers: { session_id: "invalid\tidentity", [SESSION_HEADER]: "invalid\tidentity" }, metadata: "user_test_account__session_conversation-a", expected: "ocx_a89540229ef781fd5f7adf92a711b436" }, + { name: "invalid metadata", headers: {}, metadata: "invalid\u0000identity", expected: "isolated" }, + { name: "shared system only", headers: {}, metadata: undefined, expected: "isolated" }, + ]) { + test(`Claude ${strategy} ${preliminaryAdapter} to Go uses ${identity.name} on ${model}`, async () => { + // Without valid identity the final Go destination still receives a + // request-scoped lane (#4172): well-formed, never the shared-system or + // metadata-derived value, and distinct across independent requests. + const observed: string[] = []; + for (const round of [1, 2]) { + clearComboSelectionState(); + clearComboTargetCooldowns(); + const requests: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, headers: new Headers(init?.headers) }); + if (url.startsWith("https://other.example")) { + return Response.json({ error: { message: "model retired", code: "model_not_found" } }, { status: 404 }); + } + return upstreamResponse(url, true); + }) as typeof fetch; + const config = { + providers: { + other: { adapter: preliminaryAdapter, authMode: "key", baseUrl: "https://other.example/v1", apiKey: "test-key", models: ["other"] }, + "renamed-go": opencodeGo(identity.operator ? { headers: { "X-OpenCode-Session": "operator-session" } } : {}), + }, + combos: { affinity: { strategy, targets: [ + { provider: "other", model: "other" }, { provider: "renamed-go", model }, + ] } }, + } as unknown as OcxConfig; + const entropy = spyOn(Math, "random").mockReturnValue(0.9); + // Preliminary route checks the first target; dispatch independently picks Go. + entropy.mockReturnValueOnce(0); + try { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json", ...identity.headers } as Record, + body: JSON.stringify({ model: "combo/affinity", max_tokens: 64, stream: false, + messages: [{ role: "user", content: "ping" }], + system: "Shared system prompt is not a session.", + metadata: { user_id: identity.metadata } }), + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(requests.at(-1)?.url).toStartWith("https://opencode.ai/zen/go/v1/"); + const lane = requests.at(-1)?.headers.get(SESSION_HEADER); + if (identity.expected === "isolated") { + expect(lane).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(lane).not.toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + observed.push(lane!); + } else { + expect(lane).toBe(identity.expected); + } + if (strategy === "failover") { + expect(requests).toHaveLength(2); + expect(requests[0]?.headers.has(SESSION_HEADER)).toBe(false); + } else { + expect(requests).toHaveLength(1); + } + } finally { + entropy.mockRestore(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + } + if (identity.expected !== "isolated" && round === 1) break; + } + if (identity.expected === "isolated") { + expect(observed).toHaveLength(2); + expect(observed[0]).not.toBe(observed[1]); + } + }); + } + } + } + + test(`Claude random Go preflight does not leak affinity to a final non-Go Responses target (${model})`, async () => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + const requests: Headers[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://other.example/v1/responses"); + requests.push(new Headers(init?.headers)); + return upstreamResponse(String(input)); + }) as typeof fetch; + const config = { + providers: { + other: { adapter: "openai-responses", authMode: "key", baseUrl: "https://other.example/v1", apiKey: "test-key", models: ["other"] }, + "renamed-go": opencodeGo(), + }, + combos: { affinity: { strategy: "random", targets: [ + { provider: "renamed-go", model }, { provider: "other", model: "other" }, + ] } }, + } as unknown as OcxConfig; + const entropy = spyOn(Math, "random").mockReturnValue(0.9).mockReturnValueOnce(0); + try { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }, + body: JSON.stringify({ model: "combo/affinity", max_tokens: 64, stream: false, + messages: [{ role: "user", content: "ping" }], + metadata: { user_id: "user_test_account__session_conversation-a" } }), + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(requests).toHaveLength(1); + expect(requests[0]?.has(SESSION_HEADER)).toBe(false); + expect(requests[0]?.has("session_id")).toBe(false); + } finally { + entropy.mockRestore(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + } + }); + } + test("Claude metadata gives stable Go affinity across turns and distinct conversations", async () => { const input = { claude: true, model: CHAT_MODEL, metadataUserId: "user_test_account__session_conversation-a" }; const first = await captureRequest(input); From d608d7fb0a5b6cc4ed6d300ebab34e39e97e63f7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:54:36 +0900 Subject: [PATCH 2/5] fix(claude): project native affinity at final canonical attempts Address #4340 reverse Go-to-ChatGPT selection review. Keep synthesized identity in attempt-local header copies, preserve explicit session/thread identity and retry projection, and leave policy replay headers unchanged. Add real-handler reverse selection and policy-hop regression coverage. Local suites NOT RUN. --- .../260912_cache_lane/025_affinity_native.md | 15 ++ scripts/test-layout/layout.json | 1 + src/server/claude-messages.ts | 20 +-- src/server/responses/core.ts | 17 +- structure/data-planes/inbound-compat.md | 7 + .../claude-native-affinity.test.ts | 150 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 7 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 devlog/_plan/260912_cache_lane/025_affinity_native.md create mode 100644 tests/claude-integration/claude-native-affinity.test.ts diff --git a/devlog/_plan/260912_cache_lane/025_affinity_native.md b/devlog/_plan/260912_cache_lane/025_affinity_native.md new file mode 100644 index 0000000000..84c7927fe9 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/025_affinity_native.md @@ -0,0 +1,15 @@ +# Final native affinity after preliminary Go route + +Previous D: prefix implemented; confirmed P2 on #4340 requires correction before integration. Source https://github.com/lidge-jun/opencodex/pull/4340#discussion_r3995130580. Class C3 transport identity; same authorized runtime/no-local-suites/no-merge scope. This extends the existing affinity PR, not a new independent feature. + +MODIFY src/server/claude-messages.ts: remove preliminary `if (nativeRoute && !opencodeGoRoute)` session_id synthesis. Retain validated metadata UUID privately as new HandleResponsesOptions.claudeNativeSessionId, alongside claudeGoAffinity. Do not derive from system fallback. Explicit session_id is forwarded as before and wins. + +MODIFY src/server/responses/core.ts: add optional `claudeNativeSessionId?: string` to internal options. Create a private `withClaudeNativeSession(headers, provider, sessionId)` helper that returns headers unchanged unless canonical OpenAI, private value present, and no explicit session_id/session-id/thread-id header. Then clone Headers and set only the cloned session_id. Apply to both finalAuth.headers and finalAuth.callerAuthHeaders after final auth resolution; alternate-account retries already consume callerAuthHeaders. Reapply to selectedForwardHeaders after a native credential refresh, whose replay result rebuilds from req. Never mutate req.headers. Policy/combo replay sees original headers and carries only the private option. Explicit underscore, hyphenated session and thread-only identity all prevent metadata synthesis. No public serialization: creation Claude handler -> recursive option spreads -> attempt-local auth/header copies -> canonical adapter. + +A audit corrections: reject request-header mutation because policy fallback reuses the same request. Reject caller JWT fixture because Claude drops caller auth. Use isolated stored main under an actual admitted turn; no ambient credentials. + +MODIFY tests/providers/opencode-go-session-header.test.ts: real handler random/failover Go preflight -> canonical ChatGPT fixture, valid metadata yields expected UUID, explicit native header wins, no metadata/shared-system cannot synthesize. Mock outbound fetch; isolate OPENCODEX_HOME and CODEX_HOME, store synthetic main JWT/account and use tryAdmitTurn lease with real handler logIds so existing claimed-main enrichment is reached. Add canonical failure then noncanonical policy fallback control with original request.headers unchanged; existing runPolicyFallbackHops fixture may be used to inspect header-copy boundary. Retain final non-Go no-header controls. Hosted CI only; local product checks NOT RUN. Assert actual session_id and prompt_cache_key at outbound boundary, not source text. + +MODIFY structure/data-planes/inbound-compat.md final affinity contract to describe private native lane at final canonical destination; mapped links already exist. Preserve source authors. C source audit + diff check, then exact final-tip hosted run tracked in verification cycle. D records missed earlier review scenario and repair head. + +Test placement amendment: NEW tests/claude-integration/claude-native-affinity.test.ts and both layout mappings instead of enlarging the existing 600-line Go suite. Same real-handler matrix plus policy wrapper with real core and controlled trace. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7241f26266..8472806df6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -317,6 +317,7 @@ "claude-messages-endpoint.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", + "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 40e0a3fbc6..7fed3fb693 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -30,7 +30,6 @@ import { import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; -import { registryEntryForProviderDestination } from "../providers/registry"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; @@ -798,19 +797,13 @@ async function handleClaudeMessagesWithBudget( // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. - let nativeRoute = false; - let opencodeGoRoute = false; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); - // Match the fixed key-auth destination before per-model wire overrides, including - // renamed Go providers without treating custom or lookalike URLs as Go. - opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go"; // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); logCtx.routeDecision = route.routeDecision; if (route.provider.adapter === "openai-responses") { - nativeRoute = true; delete internalBody.max_output_tokens; delete internalBody.temperature; delete internalBody.top_p; @@ -890,18 +883,6 @@ async function handleClaudeMessagesWithBudget( ?? normalizeLogConversationId(req.headers.get("x-opencode-session")) ?? metadataGoLane ?? getOrAllocateRequestSessionLane(req); - if (nativeRoute && !opencodeGoRoute) { - // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex - // clients always send their session uuid; devlog 090 follow-up: body-level - // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends - // the header, so synthesize a stable per-session uuid from the same cache key. - // Use ONLY a real per-session key (metadata.user_id). The system-hash fallback - // key is shared across Desktop conversations, and a shared session_id's backend - // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. - if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { - headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); - } - } let internalReq: Request; try { // The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch. @@ -946,6 +927,7 @@ async function handleClaudeMessagesWithBudget( // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", claudeGoAffinity: { sessionLane: claudeGoSessionLane }, + claudeNativeSessionId: metadataGoLane, stripClaudeMainAuthForNoncanonicalForward: true, ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}), // Claude's internal stored-main enrichment is not an original caller credential. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2d5618f82b..e427895a73 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1684,6 +1684,8 @@ export interface ConsumedComboFailure { export interface HandleResponsesOptions { /** Internal Claude replay identity; consumed only by the final canonical Go transport. */ claudeGoAffinity?: { sessionLane?: string }; + /** Validated Claude metadata identity; projected only into final canonical attempt headers. */ + claudeNativeSessionId?: string; /** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */ codexAuthPolicy?: CodexAuthPolicyConfig; turnAdmissionLease?: AdmissionLease; @@ -2053,6 +2055,15 @@ function canPassThroughEncryptedV2AgentTask( ).adapter === "openai-responses"; } +/** Keep synthesized Claude identity out of request headers reused by policy/combo fallback. */ +function withClaudeNativeSession(headers: Headers, provider: OcxProviderConfig, sessionId?: string): Headers { + if (!sessionId || !isCanonicalOpenAiForwardProvider(provider) + || headers.has("session_id") || headers.has("session-id") || headers.has("thread-id")) return headers; + const forwarded = new Headers(headers); + forwarded.set("session_id", sessionId); + return forwarded; +} + type ResponsesAuthResolution = | { ok: true; authCtx: CodexAuthContext; headers: Headers; callerAuthHeaders: Headers; substituteMainCredential: boolean } | { ok: false; response: Response }; @@ -4000,8 +4011,8 @@ async function handleResponsesInner( const finalAuth = await resolveResponsesCodexAuth(req, config, route, options, credentialDomainWasRewritten); if (!finalAuth.ok) return finalAuth.response; authCtx = finalAuth.authCtx; - selectedForwardHeaders = finalAuth.headers; - callerAuthHeaders = finalAuth.callerAuthHeaders; + selectedForwardHeaders = withClaudeNativeSession(finalAuth.headers, route.provider, options.claudeNativeSessionId); + callerAuthHeaders = withClaudeNativeSession(finalAuth.callerAuthHeaders, route.provider, options.claudeNativeSessionId); substituteMainCredential = finalAuth.substituteMainCredential; } @@ -5384,7 +5395,7 @@ async function handleResponsesInner( } authCtx = replay.authCtx; route.provider = replay.provider; - selectedForwardHeaders = replay.headers; + selectedForwardHeaders = withClaudeNativeSession(replay.headers, replay.provider, options.claudeNativeSessionId); const replayAdapter = resolveSelectionAdapter( resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire), config.cacheRetention, diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2045ce64f9..f49aef115e 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -106,3 +106,10 @@ allocation supplies the lane. `src/server/responses/core.ts` applies it only at canonical Go transport, including combo selection and failover. No Go-only replay header reaches non-Go destinations. Shared system cache keys never become conversation identity. The request-scoped fallback is stable across retries and distinct across client requests. + +Claude metadata also supplies a private native-session value. Only the final canonical ChatGPT +attempt receives it, in copied forwarding headers; an explicit underscore session, hyphenated +session or thread header suppresses synthesis. Refresh and alternate-account retries retain +that value. Original request headers stay unchanged so policy fallback cannot promote a generated +native identifier into a noncanonical replay. Go preliminary selection does not suppress the +final native affinity, and shared-system keys do not provide either conversation value. diff --git a/tests/claude-integration/claude-native-affinity.test.ts b/tests/claude-integration/claude-native-affinity.test.ts new file mode 100644 index 0000000000..d3cb071e5b --- /dev/null +++ b/tests/claude-integration/claude-native-affinity.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "../../src/server/responses/policy-fallback"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const metadata = "user_test_account__session_conversation-native"; +// Independent SHA-256/UUID fixture vectors; no production helper builds the oracle. +const key = "9745d86cd579894abd0ef69a5214cf96"; +const expectedSession = "9745d86c-d579-494a-8d0e-f69a5214cf96"; +let isolated: IsolatedCodexHome; +let home: string; +let previousHome: string | undefined; +let token: string; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-native-affinity-")); + process.env.OPENCODEX_HOME = home; + isolated = installIsolatedCodexHome("ocx-native-affinity-codex-"); + token = fakeChatGptJwt({ exp: Math.floor(Date.now() / 1000) + 86400, chatgpt_account_id: "fixture-native-main" }); + writeFileSync(join(isolated.path, "auth.json"), JSON.stringify({ tokens: { access_token: token, account_id: "fixture-native-main" } })); + clearComboSelectionState(); + clearComboTargetCooldowns(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + clearComboSelectionState(); + clearComboTargetCooldowns(); + isolated.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function config(): OcxConfig { + return { openaiProviderTierVersion: 2, providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex", models: ["gpt-5.6-luna"] }, + go: { ...providerConfigSeed(getProviderRegistryEntry("opencode-go")!), apiKey: "test-go-key" }, + other: { adapter: "openai-responses", authMode: "key", baseUrl: "https://affinity.example/v1", apiKey: "test-other-key", models: ["m"] }, + } } as OcxConfig; +} +function completed(): Response { + return Response.json({ id: "resp_affinity", object: "response", status: "completed", output: [], + usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } }); +} + +describe("Claude final canonical native affinity after a Go preliminary pick", () => { + for (const strategy of ["random", "failover"] as const) { + for (const explicit of [undefined, "session_id", "session-id", "thread-id"] as const) { + test(`${strategy} preserves ${explicit ?? "metadata native identity"}`, async () => { + const cfg = config(); + cfg.combos = { reverse: { strategy, targets: [ + { provider: "go", model: "glm-5.2" }, { provider: "openai", model: "gpt-5.6-luna" }, + ] } }; + const seen: Array<{ url: string; headers: Headers; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + seen.push({ url, headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + return url.startsWith("https://opencode.ai/") + ? Response.json({ error: { message: "model retired", code: "model_not_found" } }, { status: 404 }) : completed(); + }) as typeof fetch; + const entropy = spyOn(Math, "random").mockReturnValue(0.9).mockReturnValueOnce(0); + const lease = tryAdmitTurn(); + expect(lease).not.toBeNull(); + try { + const req = new Request("http://localhost/v1/messages", { method: "POST", + headers: { "content-type": "application/json", ...(explicit ? { [explicit]: "caller-conversation" } : {}) }, + body: JSON.stringify({ model: "combo/reverse", max_tokens: 32, stream: false, + metadata: { user_id: metadata }, messages: [{ role: "user", content: "ping" }] }) }); + const response = await handleClaudeMessages(req, cfg, { model: "", provider: "" }, + { requestId: `affinity-${strategy}-${explicit ?? "metadata"}`, start: Date.now(), turnAdmissionLease: lease! }); + await response.text(); + expect(response.status).toBe(200); + const wire = seen.at(-1)!; + expect(wire.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(wire.headers.get("session_id")).toBe(explicit ? explicit === "session_id" ? "caller-conversation" : null : expectedSession); + if (explicit) expect(wire.headers.get(explicit)).toBe("caller-conversation"); + expect(wire.headers.has("x-opencode-session")).toBe(false); + expect(wire.body.prompt_cache_key).toBe(key); + expect(req.headers.get("session_id")).toBe(explicit === "session_id" ? "caller-conversation" : null); + } finally { entropy.mockRestore(); lease?.release(); } + }); + } + } + + test("shared-system cache key never becomes native session identity", async () => { + let captured: Headers | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + captured = new Headers(init?.headers); return completed(); + }) as typeof fetch; + const lease = tryAdmitTurn(); + try { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ + model: "openai/gpt-5.6-luna", system: "Shared prefix", messages: [{ role: "user", content: "ping" }], max_tokens: 32, + }), + }), config(), { model: "", provider: "" }, { requestId: "shared-prefix", start: Date.now(), turnAdmissionLease: lease! }); + await response.text(); + expect(response.status).toBe(200); + expect(captured?.has("session_id")).toBe(false); + } finally { lease?.release(); } + }); + + test("native failure leaves policy-hop request headers free of synthesized identity", async () => { + const cfg = config(); + const trace = { version: 1, decisionId: "native-hop", createdAt: Date.now(), requestedModel: "openai/gpt-5.6-luna", + routeKind: "policy", profile: { id: "native-hop", revision: "1" }, requirements: [], + candidates: [ + { provider: "openai", model: "gpt-5.6-luna", eligible: true, exclusions: [], score: { total: 2 } }, + { provider: "other", model: "m", eligible: true, exclusions: [], score: { total: 1 } }, + ], selected: { candidateIndex: 0, provider: "openai", model: "gpt-5.6-luna", reason: "fixture" }, + } as unknown as Parameters[0]; + const requests: Request[] = []; + const wires: Headers[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + wires.push(new Headers(init?.headers)); + return String(input).startsWith("https://chatgpt.com/") + ? Response.json({ error: { message: "model retired", code: "model_not_found" } }, { status: 404 }) : completed(); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}`, "chatgpt-account-id": "fixture-native-main" }, + body: JSON.stringify({ model: "openai/gpt-5.6-luna", input: "ping", stream: false }) }); + const runCore: NonNullable[4]>["runCore"] = async (request, current, log, options) => { + requests.push(request); + const response = await handleResponses(request, current, log, options); + if (requests.length === 1) log.routeDecision = trace; + return response; + }; + const response = await handleResponsesWithPolicyFallback(req, cfg, { model: "", provider: "" }, + { claudeNativeSessionId: expectedSession }, { runCore }); + await response.text(); + expect(response.status).toBe(200); + expect(requests).toHaveLength(2); + expect(wires[0]?.get("session_id")).toBe(expectedSession); + expect(wires.at(-1)?.has("session_id")).toBe(false); + expect(requests.every(request => !request.headers.has("session_id"))).toBe(true); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 62724ffed2..9177ea21c9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -152,6 +152,7 @@ "claude-messages-endpoint.test.ts": "claude-integration", "claude-model-info.test.ts": "claude-integration", "claude-models-discovery.test.ts": "claude-integration", + "claude-native-affinity.test.ts": "claude-integration", "claude-native-passthrough.test.ts": "claude-integration", "claude-outbound.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", From de2042628d80f78df3e0d90da5f28166aa7aaf79 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 13:55:38 +0900 Subject: [PATCH 3/5] fix(claude): retain native UUID separately from Go lane digest --- devlog/_plan/260912_cache_lane/025_affinity_native.md | 2 ++ src/server/claude-messages.ts | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260912_cache_lane/025_affinity_native.md b/devlog/_plan/260912_cache_lane/025_affinity_native.md index 84c7927fe9..0d22be757a 100644 --- a/devlog/_plan/260912_cache_lane/025_affinity_native.md +++ b/devlog/_plan/260912_cache_lane/025_affinity_native.md @@ -13,3 +13,5 @@ MODIFY tests/providers/opencode-go-session-header.test.ts: real handler random/f MODIFY structure/data-planes/inbound-compat.md final affinity contract to describe private native lane at final canonical destination; mapped links already exist. Preserve source authors. C source audit + diff check, then exact final-tip hosted run tracked in verification cycle. D records missed earlier review scenario and repair head. Test placement amendment: NEW tests/claude-integration/claude-native-affinity.test.ts and both layout mappings instead of enlarging the existing 600-line Go suite. Same real-handler matrix plus policy wrapper with real core and controlled trace. + +C review correction: normalizeLogConversationId hashes its input, so native projection retains raw validated UUID separately; only metadataGoLane uses normalized hash. Preserve fixed historical UUID oracle, no cache-identity migration. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 7fed3fb693..83b6eb5264 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -869,12 +869,13 @@ async function handleClaudeMessagesWithBudget( } // Carry Go identity out of band: a combo's preflight target may differ from its // actual dispatch/fallback target. Never add Go-only identity to replay headers. - const metadataGoLane = cacheKeySource === "metadata" + const claudeNativeSessionId = cacheKeySource === "metadata" && typeof internalBody.prompt_cache_key === "string" && isRec(anthropicBody) && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined - ? normalizeLogConversationId(uuidFromHex(internalBody.prompt_cache_key)) + ? uuidFromHex(internalBody.prompt_cache_key) : undefined; + const metadataGoLane = normalizeLogConversationId(claudeNativeSessionId); // Without any valid conversation identity, fall back to the request-scoped lane // allocated on the admitted client request (#4172): stable across retries and // route reconstruction, distinct per request, and never derived from a shared @@ -927,7 +928,7 @@ async function handleClaudeMessagesWithBudget( // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", claudeGoAffinity: { sessionLane: claudeGoSessionLane }, - claudeNativeSessionId: metadataGoLane, + claudeNativeSessionId, stripClaudeMainAuthForNoncanonicalForward: true, ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}), // Claude's internal stored-main enrichment is not an original caller credential. From 4f1b6b4cb392dfaafce91c1b2cb0128004e88623 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:02:08 +0900 Subject: [PATCH 4/5] docs: record repaired affinity current-dev adaptation --- devlog/_plan/260912_cache_lane/026_affinity_adapt.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 devlog/_plan/260912_cache_lane/026_affinity_adapt.md diff --git a/devlog/_plan/260912_cache_lane/026_affinity_adapt.md b/devlog/_plan/260912_cache_lane/026_affinity_adapt.md new file mode 100644 index 0000000000..ad0eb164a8 --- /dev/null +++ b/devlog/_plan/260912_cache_lane/026_affinity_adapt.md @@ -0,0 +1,7 @@ +# Repaired affinity current-dev adaptation + +Previous D: prefix adaptation completed. Live #4340 now CONFLICTING with current dev. Class C2 same owned-branch adaptation, no local suites/build/typecheck/install, no merge. Rebase own three commits after30d5016a onto5042a376. Preserve exact original affinity runtime patch and native-repair delta at37a4e6b65, all credits. No changes to other lane branches. + +MODIFY conflict resolutions in13 mapped structure docs: union complete new-base helper contracts with original Go affinity links/section. Runtime.md also preserves newer continuation paragraph. src/server/responses/core.ts and layout files auto-merge, independently compare patch additions/deletions to old range. Later native-repair append may conflict at inbound-compat tail; preserve both current-base/Go/native paragraphs exactly. Add this026 checkpoint only. + +C compares old30d5016a..37a4e6b65 to new5042a376..newhead, exact runtime/tests range-diff and doc-union source audit. Push no-verify with exact old-head force lease, then new-tip hosted CI; parent owns merge. From d354924f0af38a48f5768fca0cd09c5145bdb4bd Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 14:10:40 +0900 Subject: [PATCH 5/5] docs: record affinity serial integration slot --- devlog/_plan/260912_cache_lane/027_affinity_slot.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 devlog/_plan/260912_cache_lane/027_affinity_slot.md diff --git a/devlog/_plan/260912_cache_lane/027_affinity_slot.md b/devlog/_plan/260912_cache_lane/027_affinity_slot.md new file mode 100644 index 0000000000..c5f76acecf --- /dev/null +++ b/devlog/_plan/260912_cache_lane/027_affinity_slot.md @@ -0,0 +1,3 @@ +# Serial affinity integration slot + +Parent pinned dev10c73569e9141f61c363b5fb61963d5c27e174d9 after4342 and reserved affinity-first integration. Previous D Hermes contract complete, live acceptance open. Rebase only own four commits after5042a376 onto parent-pinned10c73569; old headf58cb87b1c. Same026 audited append-union mechanism and no local suite/build/typecheck/install/merge. Keep all current-base source/docs and preserve own authored runtime/test bytes, credits and025/026 records. Conflict resolution scope is mapped structure docs; stop to audit unexpected runtime conflicts. Range-diff confirms source/tests unchanged; independent reviewer checks exact resulting doc union and head. Lease push pinned to oldf58cb, parent merges next. Prefix stays untouched until parent gives next base.