From b63e3df91f3ec617e619d1175b586e9e1130ca25 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 04:42:14 +0900 Subject: [PATCH 1/2] fix(server): bound logical session turn lanes --- .../260826_session_lane_bounds/010_design.md | 104 +++++++++ src/server/index.ts | 14 +- src/server/lifecycle.ts | 42 +++- src/server/request-log-conversation.ts | 7 + src/server/ws-bridge.ts | 16 +- tests/session-lane-recall-harness.test.ts | 203 ++++++++++++++++++ 6 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 devlog/_plan/260826_session_lane_bounds/010_design.md create mode 100644 tests/session-lane-recall-harness.test.ts diff --git a/devlog/_plan/260826_session_lane_bounds/010_design.md b/devlog/_plan/260826_session_lane_bounds/010_design.md new file mode 100644 index 00000000000..394b3f16056 --- /dev/null +++ b/devlog/_plan/260826_session_lane_bounds/010_design.md @@ -0,0 +1,104 @@ +# #820 session-lane bounds design + +## Current facts + +- `src/server/lifecycle.ts:32-40` owns a 256-turn global admission gate, an + `AbortController -> ActiveTurnLease` map, and the admitted-lease set. +- `src/server/lifecycle.ts:160-218` admits a lease without logical-session metadata and + lets that lease bind any number of abort controllers. +- `src/server/lifecycle.ts:205-215` releases controller mappings and the global gate only + when the lease settles. `src/server/lifecycle.ts:373-386` keeps that ownership through + stream terminal/cancel cleanup. +- `src/server/index.ts:699-716` admits HTTP turns without passing request identity, so two + overlapping recalls from the same thread are indistinguishable from independent turns. +- `src/server/request-log-conversation.ts:58-78` documents the available identity order: + parent thread, true thread/Cursor conversation, then session; it also warns that a + synthesized/shared session id must not coalesce distinct conversations. +- `devlog/_fin/260801_zero_leak_state_stores/035_plan.md:672-683` explicitly deferred #820 + scheduler/session-lane architecture. +- `src/server/lifecycle.ts` is a core-path module and therefore may not directly or + transitively import `src/lab/` (`AGENTS.md:21-52`). + +## Scope and exclusions + +This unit adds fail-fast logical-session lanes to the existing lifecycle admission +boundary and a deterministic 32/64-session recall harness. It does not add a scheduler, +same-session queue, weighted memory permits, account-load routing, relay redesign, or Lab +dependency. Missing/unsafe session identity remains an independent request lane rather +than risking false cross-session serialization. + +## Structural decision + +### Context + +The global gate bounds total active turns, but controller-keyed ownership cannot enforce +the protocol rule that one logical session has at most one active model turn. Retaining raw +session headers as map keys would also make lane metadata proportional to caller-controlled +header length. + +### Rejected alternatives + +- Key directly by `AbortController`: current behavior; it cannot recognize a second recall + for the same logical session. +- Queue a second same-session turn: issue #820 specifies zero queued turns by default and a + full scheduler was explicitly deferred. +- Store raw header/session values: this makes retained lane memory input-sized and exposes + sensitive identifiers through diagnostics. +- Derive identity inside `lifecycle.ts` from `Request`: this couples the generic lifecycle + owner to HTTP and does not cover WebSocket frame lanes cleanly. + +### Chosen move + +Add a fixed-size opaque `SessionLaneId` derived at ingress from the strongest safe request +identity. `tryAdmitTurn(laneId?)` atomically claims the lane before acquiring the existing +global permit and releases it idempotently with the lease. No identity means no shared lane. +The lane registry stores only fixed-size digests and exposes count/high-water/rejection +metrics, so retained metadata is bounded by the already-bounded active-turn population and +fixed bytes per lane. + +The HTTP listener derives the lane synchronously before handler work. WebSocket response +frames retain that fixed-size lane from their upgrade request without introducing an +`await` in server startup. The dependency direction remains `server/index -> lifecycle`; +`lifecycle` does not import request parsing or optional subsystems. + +### Consequences + +- Independent lanes remain parallel up to the existing global cap; overlapping turns on + one identified lane fail with the existing structured `server_busy` response. +- Lane identifiers are process-local opaque digests; raw session/thread values are neither + retained nor reported. +- Anonymous requests retain current independent-turn behavior because guessing identity + would be less protocol-safe than leaving them uncoordinated. +- This is admission, not scheduling: there are no waiters and no queue memory. + +## Harness contract + +The harness drives 32 sustained and 64 burst independent lane sessions through actual +lifecycle leases and canonical tool-call event/SSE translation. Each session performs a +model tool-call terminal, external tool-result recall, and a second model terminal while +barriers keep all sessions concurrent. It asserts: + +- all independent sessions are simultaneously admitted; +- a same-lane overlapping recall is rejected and never queued; +- call/item/output indexes and tool namespace/name/arguments remain session-local; +- all leases and lane bytes return to zero after each wave; +- lane high-water bytes are a fixed linear envelope at 32 and 64 sessions. + +The memory oracle uses lifecycle-owned byte accounting as the deterministic bound and also +records Bun RSS/heap/external/array-buffer deltas as observational measurements. Removing +the fixed lane bound must make the deterministic memory assertion fail; RSS alone is not a +valid mutation oracle because allocator retention is nondeterministic. + +## Verification and falsification + +```bash +bun test tests/session-lane-recall-harness.test.ts +bun x tsc --noEmit +bun test tests/core-lab-boundary.test.ts tests/*lifecycle*.test.ts \ + tests/*translator-budget*.test.ts tests/session-lane-recall-harness.test.ts +``` + +For every new behavioral test, temporarily revert the production hunk it covers, run the +focused test and record its failing tail, then restore the hunk and rerun green. The memory +test must additionally be mutated to remove/expand the fixed per-lane accounting bound and +must fail on its independent expected envelope. diff --git a/src/server/index.ts b/src/server/index.ts index f59b4d0ae37..bc9412aae07 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -109,6 +109,7 @@ import { type RequestLogContext, type RequestLogEntry, } from "./request-log"; +import { sessionLaneIdFromRequest } from "./request-log-conversation"; export { addFinalRequestLog, filterRequestLogs, @@ -701,7 +702,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server Promise, ): Promise { - const lease = tryAdmitTurn(); + const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers)); if (!lease) return serverBusyResponse(req, "active turns", policy); let response: Response; try { @@ -896,7 +897,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server(); const admittedTurns = new Set(); +const activeSessionLanes = new Set(); +let sessionLanePeak = 0; +let sessionLaneAdmitted = 0; +let sessionLaneRejected = 0; const knownTurnControllers = new WeakSet(); let turnReleaseMisses = 0; let shutdownDraining = false; @@ -149,6 +156,10 @@ export function resetLifecycleDrainStateForTests(): void { temporaryDrainOwners.clear(); nativeMainDrainOwners.clear(); nativeMainTurns.clear(); + activeSessionLanes.clear(); + sessionLanePeak = 0; + sessionLaneAdmitted = 0; + sessionLaneRejected = 0; nativeMainSelections = 0; for (const resolve of temporaryDrainWaiters) resolve(); temporaryDrainWaiters.clear(); @@ -157,10 +168,22 @@ export function resetLifecycleDrainStateForTests(): void { serverStartupReleaseFlights = new WeakMap, Promise>(); releaseServerStartupLifecycleImpl = releaseNativeMainStartupLifecycle; } -export function tryAdmitTurn(): ActiveTurnLease | null { +export function tryAdmitTurn(sessionLaneId?: string): ActiveTurnLease | null { if (isDraining()) return null; + const opaqueSessionLaneId = sessionLaneId + ? createHash("sha256").update(sessionLaneId).digest("hex").slice(0, SESSION_LANE_ID_BYTES) + : undefined; + if (opaqueSessionLaneId && (activeSessionLanes.has(opaqueSessionLaneId) || activeSessionLanes.size >= MAX_ACTIVE_SESSION_LANES)) { + sessionLaneRejected += 1; + return null; + } const gateLease = turnGate.tryAcquire(); if (!gateLease) return null; + if (opaqueSessionLaneId) { + activeSessionLanes.add(opaqueSessionLaneId); + sessionLaneAdmitted += 1; + sessionLanePeak = Math.max(sessionLanePeak, activeSessionLanes.size); + } const controllers = new Set(); let active = true; let transferred = false; @@ -211,6 +234,7 @@ export function tryAdmitTurn(): ActiveTurnLease | null { } controllers.clear(); nativeMainTurns.delete(lease); + if (opaqueSessionLaneId) activeSessionLanes.delete(opaqueSessionLaneId); gateLease.release(); }, }; @@ -261,6 +285,22 @@ export function unregisterTurn(ac: AbortController): void { } export function isDraining(): boolean { return shutdownDraining || temporaryDrainOwners.size > 0; } export function getActiveTurnCount(): number { return turnGate.metrics().active; } +export interface SessionLaneMetrics { + active: number; + peak: number; + admitted: number; + rejected: number; + retainedBytes: number; +} +export function sessionLaneMetrics(): SessionLaneMetrics { + return { + active: activeSessionLanes.size, + peak: sessionLanePeak, + admitted: sessionLaneAdmitted, + rejected: sessionLaneRejected, + retainedBytes: activeSessionLanes.size * SESSION_LANE_ID_BYTES, + }; +} export function getNativeMainProfileRequestCount(): number { return nativeMainSelections + nativeMainTurns.size; } diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 3fae35ef1a5..6ce0ac007ca 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -61,6 +61,13 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null { return headers.get("session_id") ?? headers.get("session-id"); } +/** Fixed-size logical turn lane; true thread identity wins over a fallback session header. */ +export function sessionLaneIdFromRequest(headers: Headers): string | undefined { + return normalizeLogConversationId(headers.get("x-codex-parent-thread-id")) + ?? normalizeLogConversationId(headers.get("thread-id")) + ?? normalizeLogConversationId(sessionIdHeaderFromRequest(headers)); +} + function firstSanitizedConversationId( ...values: Array ): string | undefined { diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index c593861d275..1dde3c39d60 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -34,6 +34,8 @@ export interface WsData { authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup cancel?: () => void; // cancels the in-flight stream reader/fetch turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns + /** Fixed-size logical session lane derived at the HTTP upgrade boundary. */ + sessionLaneId?: string; /** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */ kind?: "responses" | "live-sideband"; liveUpstream?: WebSocket; @@ -60,10 +62,20 @@ export interface WsData { * A test that only asserts "the socket opened" would still pass if the admission * were dropped from the payload, so the payload itself is what gets asserted. */ -export function buildResponsesWsData(headers: Headers, admission: DataPlaneAdmission, admissionLease?: AdmissionReservation>): WsData { +export function buildResponsesWsData( + headers: Headers, + admission: DataPlaneAdmission, + admissionLease?: AdmissionReservation>, + sessionLaneId?: string, +): WsData { // Auth is handshake-time only on this path: the per-frame contexts have no // request headers left to re-resolve from, so the decision rides along here. - return { headers, admission, ...(admissionLease ? { admissionLease } : {}) }; + return { + headers, + admission, + ...(admissionLease ? { admissionLease } : {}), + ...(sessionLaneId ? { sessionLaneId } : {}), + }; } export class WsSendDroppedError extends Error { diff --git a/tests/session-lane-recall-harness.test.ts b/tests/session-lane-recall-harness.test.ts new file mode 100644 index 00000000000..27eee1d8660 --- /dev/null +++ b/tests/session-lane-recall-harness.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; +import { saveConfig } from "../src/config"; +import { + MAX_ACTIVE_SESSION_LANES, + SESSION_LANE_ID_BYTES, + resetLifecycleDrainStateForTests, + sessionLaneMetrics, + tryAdmitTurn, + type ActiveTurnLease, +} from "../src/server/lifecycle"; +import { sessionLaneIdFromRequest } from "../src/server/request-log-conversation"; +import { startServer } from "../src/server"; +import type { AdapterEvent, OcxConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "key" }; + +interface ProtocolCall { + id: string; + name: string; + arguments: string; +} + +function chatSse(session: number, round: number, callCount: number): string { + const frames: string[] = []; + for (let index = 0; index < callCount; index += 1) { + frames.push(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index, + id: `call_s${session}_r${round}_t${index}`, + function: { name: `mcp__lane_${session}__tool_${index}`, arguments: `{"session":${session},` }, + }] } }] })}\n\n`); + } + for (let index = callCount - 1; index >= 0; index -= 1) { + frames.push(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ + index, + function: { arguments: `"round":${round},"tool":${index}}` }, + }] } }] })}\n\n`); + } + frames.push(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [] }, finish_reason: "tool_calls" }] })}\n\n`); + frames.push("data: [DONE]\n\n"); + return frames.join(""); +} + +async function parseCalls(session: number, round: number): Promise { + const callCount = (session % 8) + 1; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapterProduction(provider)); + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(chatSse(session, round, callCount)))) { + events.push(event); + } + const calls: ProtocolCall[] = []; + let current: ProtocolCall | undefined; + for (const event of events) { + if (event.type === "tool_call_start") { + expect(current).toBeUndefined(); + current = { id: event.id, name: event.name, arguments: "" }; + } else if (event.type === "tool_call_delta") { + expect(current).toBeDefined(); + current!.arguments += event.arguments; + } else if (event.type === "tool_call_end") { + expect(current).toBeDefined(); + calls.push(current!); + current = undefined; + } + } + expect(current).toBeUndefined(); + expect(events.at(-1)?.type).toBe("done"); + expect(calls).toHaveLength(callCount); + for (let index = 0; index < calls.length; index += 1) { + expect(calls[index]).toEqual({ + id: `call_s${session}_r${round}_t${index}`, + name: `mcp__lane_${session}__tool_${index}`, + arguments: `{"session":${session},"round":${round},"tool":${index}}`, + }); + expect(JSON.parse(calls[index].arguments)).toEqual({ session, round, tool: index }); + } + return calls; +} + +function memorySnapshot() { + const memory = process.memoryUsage(); + return { + rss: memory.rss, + heapUsed: memory.heapUsed, + external: memory.external, + arrayBuffers: memory.arrayBuffers, + }; +} + +async function runRecallWave(sessionCount: 32 | 64) { + resetLifecycleDrainStateForTests(); + const before = memorySnapshot(); + const leases: ActiveTurnLease[] = []; + for (let session = 0; session < sessionCount; session += 1) { + const lease = tryAdmitTurn(`logical-session-${session}`); + expect(lease).not.toBeNull(); + leases.push(lease!); + } + expect(sessionLaneMetrics()).toMatchObject({ + active: sessionCount, + peak: sessionCount, + retainedBytes: sessionCount * SESSION_LANE_ID_BYTES, + }); + expect(tryAdmitTurn("logical-session-0")).toBeNull(); + + const firstCalls = await Promise.all(Array.from({ length: sessionCount }, (_, session) => parseCalls(session, 1))); + for (const lease of leases) lease.release(); + expect(sessionLaneMetrics().active).toBe(0); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + + const recallLeases = Array.from({ length: sessionCount }, (_, session) => { + const lease = tryAdmitTurn(`logical-session-${session}`); + expect(lease).not.toBeNull(); + return lease!; + }); + const secondCalls = await Promise.all(Array.from({ length: sessionCount }, (_, session) => parseCalls(session, 2))); + for (const lease of recallLeases) lease.release(); + expect(sessionLaneMetrics().active).toBe(0); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + for (let session = 0; session < sessionCount; session += 1) { + expect(new Set([...firstCalls[session], ...secondCalls[session]].map(call => call.id)).size) + .toBe(firstCalls[session].length + secondCalls[session].length); + } + const after = memorySnapshot(); + const measured = { + sessions: sessionCount, + lanePeakBytes: sessionCount * SESSION_LANE_ID_BYTES, + rssDelta: after.rss - before.rss, + heapUsedDelta: after.heapUsed - before.heapUsed, + externalDelta: after.external - before.external, + arrayBuffersDelta: after.arrayBuffers - before.arrayBuffers, + }; + console.log(`[session-lane-harness] ${JSON.stringify(measured)}`); + return measured; +} + +describe("#820 concurrent tool-recall session harness", () => { + test("the HTTP boundary rejects an overlapping recall on the same logical session", async () => { + resetLifecycleDrainStateForTests(); + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-session-lane-")); + process.env.OPENCODEX_HOME = home; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "forward" }, + }, + } as OcxConfig); + const headers = new Headers({ "content-type": "application/json", session_id: "recall-session" }); + const held = tryAdmitTurn(sessionLaneIdFromRequest(headers)); + const server = startServer(0); + try { + expect(held).not.toBeNull(); + const overlapping = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers, + body: "not-json", + }); + expect(overlapping.status).toBe(503); + expect(await overlapping.json()).toMatchObject({ error: { code: "server_busy" } }); + held?.release(); + const afterRelease = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers, + body: "not-json", + }); + expect(afterRelease.status).toBe(400); + } finally { + held?.release(); + await server.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + test("32 sustained independent sessions preserve protocol isolation within the lane envelope", async () => { + const measured = await runRecallWave(32); + expect(measured.lanePeakBytes).toBe(1024); + }); + + test("64 burst independent sessions preserve protocol isolation at the lane cap", async () => { + const measured = await runRecallWave(64); + expect(MAX_ACTIVE_SESSION_LANES).toBe(64); + expect(measured.lanePeakBytes).toBe(2048); + }); + + test("the 65th identified lane is rejected without allocating lane memory", () => { + resetLifecycleDrainStateForTests(); + const leases = Array.from({ length: 64 }, (_, index) => tryAdmitTurn(`capacity-${index}`)); + expect(leases.every(Boolean)).toBe(true); + expect(tryAdmitTurn("capacity-overflow")).toBeNull(); + expect(sessionLaneMetrics()).toMatchObject({ active: 64, retainedBytes: 2048, rejected: 1 }); + for (const lease of leases) lease?.release(); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + }); +}); From 2cb088e906af7888d76f655e78afa34e8ee500d4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 04:55:21 +0900 Subject: [PATCH 2/2] fix(lanes): key a session lane on parent+thread, not the parent alone A parallel subagent fan-out is Codex's normal shape, and every child of one parent carries the SAME x-codex-parent-thread-id - that is exactly what codexPoolAffinityKey keys on so a fan-out pins to one account. A lane derived the same way inherits that coalescing, so the second and third sibling of any fan-out were rejected with 503. Reproduced before fixing: two thread_spawn requests differing only in thread-id resolved to one lane and the second tryAdmitTurn returned null. A lane wants the MOST specific identity, which is the opposite of what affinity wants. Keyed on the pair, the parent qualifies the lane rather than defining it: siblings separate, while two overlapping turns of one conversation still share a lane - the protocol rule this boundary exists to enforce. Both components are already fixed-size digests, so retained lane bytes stay bounded. Falsified: restoring the parent-first derivation fails the new test with 'Expected 3 distinct lanes, received 1'. --- src/server/request-log-conversation.ts | 22 ++++++++++++--- tests/session-lane-recall-harness.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index 6ce0ac007ca..ed1e2f33d4d 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -61,11 +61,25 @@ export function sessionIdHeaderFromRequest(headers: Headers): string | null { return headers.get("session_id") ?? headers.get("session-id"); } -/** Fixed-size logical turn lane; true thread identity wins over a fallback session header. */ +/** + * Fixed-size logical turn lane (#820). + * + * A lane must be as SPECIFIC as the identity available, which is the opposite of what + * `codexPoolAffinityKey` wants. Affinity deliberately prefers the parent thread so a whole + * subagent fan-out pins to one account; a lane keyed that way would put every parallel + * subagent of one parent into a single lane and reject all but the first with 503 — the + * fan-out is the normal case, not an abuse. + * + * So the parent is a QUALIFIER, never the lane on its own when a child thread exists: the + * pair separates siblings while still keeping one conversation's overlapping turns together. + */ export function sessionLaneIdFromRequest(headers: Headers): string | undefined { - return normalizeLogConversationId(headers.get("x-codex-parent-thread-id")) - ?? normalizeLogConversationId(headers.get("thread-id")) - ?? normalizeLogConversationId(sessionIdHeaderFromRequest(headers)); + const parent = normalizeLogConversationId(headers.get("x-codex-parent-thread-id")); + const thread = normalizeLogConversationId(headers.get("thread-id")); + const session = normalizeLogConversationId(sessionIdHeaderFromRequest(headers)); + const specific = thread ?? session; + if (parent && specific) return `${parent}\u0000${specific}`; + return specific ?? parent; } function firstSanitizedConversationId( diff --git a/tests/session-lane-recall-harness.test.ts b/tests/session-lane-recall-harness.test.ts index 27eee1d8660..a2bc5044a91 100644 --- a/tests/session-lane-recall-harness.test.ts +++ b/tests/session-lane-recall-harness.test.ts @@ -200,4 +200,38 @@ describe("#820 concurrent tool-recall session harness", () => { for (const lease of leases) lease?.release(); expect(sessionLaneMetrics().retainedBytes).toBe(0); }); + + /** + * The regression this lane derivation exists to avoid (#820). + * + * A parallel subagent fan-out is Codex's normal shape, and every child of one parent + * carries the SAME `x-codex-parent-thread-id` — that is what `codexPoolAffinityKey` + * deliberately keys on, so the whole fan-out pins to one account. A lane keyed the same + * way inherits that coalescing and rejects every sibling after the first with 503. + * + * Keyed on the pair, the parent qualifies the lane instead of defining it: siblings + * separate, while two overlapping turns of ONE conversation still share a lane, which is + * the protocol rule this admission boundary is here to enforce. + */ + test("parallel subagents of one parent take separate lanes, and one conversation still shares one", () => { + resetLifecycleDrainStateForTests(); + const parent = "parent-thread-id"; + const spawn = (threadId: string) => new Headers({ + "x-codex-parent-thread-id": parent, + "x-codex-turn-metadata": JSON.stringify({ subagent_kind: "thread_spawn" }), + "thread-id": threadId, + }); + + const siblingLanes = ["child-a", "child-b", "child-c"].map(id => sessionLaneIdFromRequest(spawn(id))); + expect(new Set(siblingLanes).size).toBe(3); + const siblingLeases = siblingLanes.map(lane => tryAdmitTurn(lane)); + expect(siblingLeases.every(Boolean)).toBe(true); + + // Same parent AND same child thread: one logical conversation, so the second overlapping + // turn is refused rather than admitted alongside the first. + expect(tryAdmitTurn(sessionLaneIdFromRequest(spawn("child-a")))).toBeNull(); + + for (const lease of siblingLeases) lease?.release(); + expect(sessionLaneMetrics().retainedBytes).toBe(0); + }); });