From eabe70ae7fc4da3cf8cd99818962dcd771a2ad26 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 13 Oct 2026 02:32:33 +0100 Subject: [PATCH] fix(frontdoor): stop opting sessions into headroom's mid-turn queueing The hop re-set x-headroom-session-id from the launch's session id so the daemon would attribute savings to a session. Headroom also reads that header as an opt-in to mid-turn queueing: while one stream is active for a session, another streaming request carrying it is answered 202 with a headroom_queued JSON body and never reaches the upstream. A client expecting an event stream reports "Streaming response ended before any complete data was received" and retries without streaming. Roughly 40 percent of a day's messages responses through the daemon were these. The hop no longer sends headroom's own session header, and still strips any inbound copy. Headroom already prefers the x-claude-code-session-id every Claude Code request carries for attribution, so the launch's id is filled in under that header only when the client sent none. --- src/frontdoor/headroomHop.test.ts | 9 +++++++-- src/frontdoor/headroomHop.ts | 7 ++++--- src/frontdoor/route.ts | 11 ++++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/frontdoor/headroomHop.test.ts b/src/frontdoor/headroomHop.test.ts index 65b57b8a..7bf3e580 100644 --- a/src/frontdoor/headroomHop.test.ts +++ b/src/frontdoor/headroomHop.test.ts @@ -352,7 +352,7 @@ describe("the headroom hop", () => { } }); - it("re-sets the project identity and the session attribution id on the hop, replacing any inbound copy, and forwards no launcher session header beyond it", async () => { + it("re-sets the project identity on the hop, replacing any inbound copy, and never forwards headroom's own session header, which opts a session into mid-turn queueing", async () => { const headroom = await fakeHeadroom(); const door = await startDoor({ files: { [`${PROVIDERS_DIR}/codex.json`]: codexProvider } }); try { @@ -364,7 +364,12 @@ describe("the headroom hop", () => { }); const seen = headroom.seen()[0]; expect(seen?.headers["x-headroom-project-id"]).toBe("/repo"); - expect(seen?.headers["x-headroom-session-id"]).toBe("session-1"); + expect(seen?.headers["x-headroom-session-id"]).toBeUndefined(); + // The client sent no session header of its own, so the launch's id stands in for it under Claude Code's name. + expect(seen?.headers["x-claude-code-session-id"]).toBe("session-1"); + // A client that identifies itself keeps its own id. + await fetch(`${door.url}/providers/codex/v1/messages`, { dispatcher: trusting, method: "POST", headers: { "content-type": "application/json", [SESSION_HEADER]: "session-1", [HEADROOM_FLAG_HEADER]: "1", [AUTH_HEADER]: LAUNCH_TOKEN, "x-claude-code-session-id": "the-clients-own" }, body: MESSAGES_BODY }); + expect(headroom.seen()[1]?.headers["x-claude-code-session-id"]).toBe("the-clients-own"); expect(seen?.headers[IDENTITY_HEADER]).toBeUndefined(); expect(seen?.headers[SESSION_HEADER]).toBeUndefined(); } finally { diff --git a/src/frontdoor/headroomHop.ts b/src/frontdoor/headroomHop.ts index 79aa6a89..7adf3745 100644 --- a/src/frontdoor/headroomHop.ts +++ b/src/frontdoor/headroomHop.ts @@ -7,7 +7,7 @@ import { HTTP_STATUS } from "../codex/http"; import type { HeadroomSocketTarget } from "../headroom/socket"; import { forwardableHeaders } from "./connect"; import type { CredentialCustody } from "./custody"; -import { HEADROOM_BASE_URL_HEADER, HOP_ID_HEADER, HOP_SECRET_HEADER, PROJECT_ID_HEADER, PROVIDER_HEADER, SESSION_ATTRIBUTION_HEADER, parseProviderPath, type RoutedRequest, type RoutedResponse } from "./route"; +import { HEADROOM_BASE_URL_HEADER, HOP_ID_HEADER, HOP_SECRET_HEADER, CLIENT_SESSION_HEADER, PROJECT_ID_HEADER, PROVIDER_HEADER, parseProviderPath, type RoutedRequest, type RoutedResponse } from "./route"; import { lineageOf, type Lineage } from "./lineage"; import { upstreamChunks } from "./server"; @@ -82,8 +82,9 @@ export async function applyHeadroomHop(request: RoutedRequest, response: RoutedR if (request.session.projectId !== undefined) { headers[PROJECT_ID_HEADER] = request.session.projectId; } - if (request.session.sessionId !== undefined) { - headers[SESSION_ATTRIBUTION_HEADER] = request.session.sessionId; + // The client's own session header wins at headroom and is kept as it is. A client that sends none is attributed from the launch's id under that same header, never under headroom's own session header: that one opts the session into mid-turn queueing, which answers a concurrent streaming request with a 202 JSON body the client cannot parse as a stream. + if (request.session.sessionId !== undefined && !Object.keys(headers).some((name) => name.toLowerCase() === CLIENT_SESSION_HEADER)) { + headers[CLIENT_SESSION_HEADER] = request.session.sessionId; } headers[HOP_SECRET_HEADER] = deps.hopSecret; try { diff --git a/src/frontdoor/route.ts b/src/frontdoor/route.ts index 4bd49e30..d7bfa825 100644 --- a/src/frontdoor/route.ts +++ b/src/frontdoor/route.ts @@ -1,6 +1,11 @@ import type { Readable } from "node:stream"; import type { IncomingHttpHeaders } from "node:http"; +/** + * Headroom's own session header, which the door never sends and strips from every inbound request. Headroom reads it as an opt-in to mid-turn queueing: while one stream is active for a session, another streaming request carrying it is answered `202 {"event":"headroom_queued"}` without reaching the upstream, which a client expecting an event stream reports as "Streaming response ended before any complete data was received". Nothing a client sends may choose it. + */ +const HEADROOM_SESSION_HEADER = "x-headroom-session-id"; + /** * The agent-shim-internal headers the launcher injects through `ANTHROPIC_CUSTOM_HEADERS` so the front door can identify and route a request's session. None of them may ever leave the machine: the identity step strips every one before a route sees the request, so no upstream, and not even the headroom hop, learns what they say. */ @@ -13,7 +18,7 @@ export const INTERNAL_HEADER_NAMES = [ "x-agent-shim-hop", "x-agent-shim-hop-id", "x-headroom-project-id", - "x-headroom-session-id", + HEADROOM_SESSION_HEADER, "x-headroom-base-url", ] as const; @@ -35,9 +40,9 @@ export const HEADROOM_FLAG_HEADER = "x-agent-shim-headroom"; export const PROJECT_ID_HEADER = "x-headroom-project-id"; /** - * Headroom's per-session attribution id, the key its `/stats/sessions/` serves: the daemon attributes a request only when it carries this header (or the client's own `x-claude-code-session-id`, which no routed client sends). Re-set by the headroom hop from the launch's own session id, so the daemon attributes traffic the client itself never identifies; inbound copies are stripped at the identity step like every other internal header. + * The session id headroom attributes savings to and keys its per-session state by, and the one it prefers over its own header: Claude Code stamps every request with it. The hop fills it from the launch's own session id only when the client sent none, so traffic from a client that does not identify itself is still attributed, without opting the session into queueing. */ -export const SESSION_ATTRIBUTION_HEADER = "x-headroom-session-id"; +export const CLIENT_SESSION_HEADER = "x-claude-code-session-id"; /** * Headroom's per-request upstream selector, which the child used to carry and the door now owns: the door strips any inbound copy (a leftover from an older launcher's environment would otherwise redirect the daemon behind the door's back) and its hop sets it fresh when a route names an upstream.