From 534c2bb648d7dac3feb90549718d9b32ad7b18bb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 20 Sep 2026 15:27:02 +0900 Subject: [PATCH] fix(cursor): separate conversation identity from pool affinity --- src/codex/lineage.ts | 8 ++++++++ src/server/responses/request-prepare.ts | 3 ++- structure/providers/cursor.md | 5 +++-- tests/codex-integration/codex-lineage-placement.test.ts | 9 +++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/codex/lineage.ts b/src/codex/lineage.ts index 4788efbe274..e1af42fca96 100644 --- a/src/codex/lineage.ts +++ b/src/codex/lineage.ts @@ -157,6 +157,14 @@ export function codexConversationKeyFor(familyId: string, threadId: string): str .digest("base64url")}`; } +/** A per-thread identity for consumers whose state must not be shared by a cache cohort. */ +export function codexThreadConversationKey(headers: Headers): string | undefined { + const sessionId = boundedLineageComponent(headers.get("session-id")); + const threadId = boundedLineageComponent(headers.get("thread-id")); + if (sessionId === undefined || threadId === undefined) return undefined; + return codexConversationKeyFor(sessionId, threadId); +} + /** * The cohort anchor's key: the same string on both sides of the derivation. * diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 3c35bd3a806..6041094960d 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -38,6 +38,7 @@ import { hasCallerCodexBearer, requestOwnedMainPinState, } from "../../codex/auth-context"; +import { codexThreadConversationKey } from "../../codex/lineage"; import { copyPreviousResponseReplayProvenance, expandPreviousResponseInput, @@ -245,7 +246,7 @@ export async function prepareResponsesRequest( // parent all present the same parent id. `codexConversationIdentity` already reads this header // for the same reason, and a surface that must tell siblings apart needs it too (#5033). const inboundOwnThreadId = req.headers.get("thread-id")?.trim() || undefined; - const cursorClientThreadId = codexPoolAffinityKey(req.headers); + const cursorClientThreadId = codexThreadConversationKey(req.headers); const originalBody = body; if (options.comboReplaySnapshot) { copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index c8392c48606..bebecf23ba2 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -89,8 +89,9 @@ a process-local store and reuses that snapshot on the next validated linear cont rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed checkpoint plus only the uncovered suffix. A request without checkpointRef may use the prefix index only when a remembered Cursor conversation or stable client thread owns the resolved conversation id. -The stable owner may be the Codex parent-thread header or the existing bounded process-local HMAC of -the complete Desktop session-id/thread-id pair. The request must also have a covered message prefix +The stable owner may be the Codex parent-thread header or the bounded process-local HMAC of the +complete Desktop session-id/thread-id pair, never its session-wide pool-affinity cohort. The request +must also have a covered message prefix and system/developer digest that match exactly one snapshot for that same conversation. Headerless requests without a stable owner full-replay. Isolated helper/shadow turns never join the parent or sibling conversation. An explicit missing checkpointRef full-replays. Compaction, account or model mismatch, missing refs, decode failures, and diff --git a/tests/codex-integration/codex-lineage-placement.test.ts b/tests/codex-integration/codex-lineage-placement.test.ts index 496c49e2929..19b5515cb98 100644 --- a/tests/codex-integration/codex-lineage-placement.test.ts +++ b/tests/codex-integration/codex-lineage-placement.test.ts @@ -34,6 +34,7 @@ import { codexLineageScopeKey, codexLineageWorkflowLane, codexThreadLineageLookup, + codexThreadConversationKey, recordCodexThreadLineage, } from "../../src/codex/lineage"; import { clearPoolRotationState } from "../../src/codex/pool-rotation"; @@ -570,6 +571,14 @@ describe("cohort pool affinity (#4780)", () => { expect(bareChild.conversationKey).not.toBe(root.conversationKey); }); + test("thread conversation identity stays distinct inside a shared cache cohort", () => { + const first = rootHeaders(); + const second = new Headers({ "session-id": "sess", "thread-id": "sibling" }); + + expect(codexPoolAffinityKey(first)).toBe(codexPoolAffinityKey(second)); + expect(codexThreadConversationKey(first)).not.toBe(codexThreadConversationKey(second)); + }); + test("which requests bind at all is unchanged", () => { // Deliberately untouched by #4780: only the VALUE of the key moves, never the set of // requests that produce one. A bare thread-id still has no family anchor.