diff --git a/changelog.d/fixes/10136-combo-scoped-session-stickiness.md b/changelog.d/fixes/10136-combo-scoped-session-stickiness.md new file mode 100644 index 0000000000..6cab4a2a7b --- /dev/null +++ b/changelog.d/fixes/10136-combo-scoped-session-stickiness.md @@ -0,0 +1 @@ +- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 21c0ee8a19..afd4ca63ad 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -2644,7 +2644,8 @@ async function handleRoundRobinCombo({ filteredTargets, // #7270: normalize both wire shapes (.messages / Responses-API .input) so RR // stickiness engages on the /v1/responses surface, not just Chat Completions. - normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }) + normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }), + combo.name ); const rrAffinity = applyPromptCacheAffinity( filteredTargets, diff --git a/open-sse/services/combo/sessionStickiness.ts b/open-sse/services/combo/sessionStickiness.ts index 228ef02c58..6f51f69df0 100644 --- a/open-sse/services/combo/sessionStickiness.ts +++ b/open-sse/services/combo/sessionStickiness.ts @@ -8,7 +8,8 @@ * * Design * ────── - * • Hash key: SHA-256 of the FIRST user message → first 16 hex chars. + * • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity + * at production call sites → first 16 hex chars. * Using only the first message gives a stable key that does not change as * the conversation grows, yet still identifies the conversation reliably. * • Headroom gate: before reusing the sticky connection we re-check that its @@ -317,6 +318,23 @@ export function deriveMessageHash( return createHash("sha256").update(text).digest("hex").slice(0, 16); } +/** + * Keep one conversation's prompt-cache affinity local to the Combo that learned + * it. Without this namespace, two different Combos receiving the same first + * user message share a binding and can silently reorder each other's targets. + * The unscoped form remains available for direct callers and backwards-compatible + * unit seams; production dispatchers always provide their Combo name. + */ +function scopeMessageHash(messageHash: string, namespace?: string): string { + if (!namespace) return messageHash; + return createHash("sha256") + .update(namespace) + .update("\0") + .update(messageHash) + .digest("hex") + .slice(0, 16); +} + /** Evict expired entries and enforce the hard cap. */ function evict(): void { const now = Date.now(); @@ -424,19 +442,22 @@ export interface ApplyStickinessResult { * * @param orderedTargets Targets already ordered by the combo strategy. * @param messages Request body.messages. + * @param namespace Combo identity that owns this sticky binding. * @returns Result with (possibly reordered) targets. */ export async function applySessionStickiness( orderedTargets: ResolvedComboTarget[], - messages: Array<{ role?: string; content?: unknown }> | null | undefined + messages: Array<{ role?: string; content?: unknown }> | null | undefined, + namespace?: string ): Promise { const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false }; try { if (orderedTargets.length <= 1) return noOp; - const messageHash = deriveMessageHash(messages); - if (!messageHash) return noOp; + const rawMessageHash = deriveMessageHash(messages); + if (!rawMessageHash) return noOp; + const messageHash = scopeMessageHash(rawMessageHash, namespace); const existing = stickyMap.get(messageHash); if (!existing) return { targets: orderedTargets, messageHash, stuck: false }; diff --git a/open-sse/services/combo/targetResolution.ts b/open-sse/services/combo/targetResolution.ts index 6c4c162a97..d5af5a6e01 100644 --- a/open-sse/services/combo/targetResolution.ts +++ b/open-sse/services/combo/targetResolution.ts @@ -498,7 +498,8 @@ async function applyContinuityFilters( initialOrderedTargets, // #7270: normalize both wire shapes (.messages / Responses-API .input) so the // stickiness key is derivable on the /v1/responses surface, not just Chat Completions. - normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }) + normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }), + combo.name ); let orderedTargets = sticky.targets; if (!cacheStrategyAffinityApplied) { diff --git a/tests/unit/combo-session-stickiness.test.ts b/tests/unit/combo-session-stickiness.test.ts index 39f486836f..2d4d93e14d 100644 --- a/tests/unit/combo-session-stickiness.test.ts +++ b/tests/unit/combo-session-stickiness.test.ts @@ -214,6 +214,36 @@ test("different message hashes can map to different connections", async () => { assert.equal(r2.targets[0].connectionId, "conn-Y"); }); +test("identical first messages do not leak sticky targets across combos", async () => { + injectSat({ util5h: 0.0, util7d: 0.0 }); + const messages = [{ role: "user", content: "Shared control prompt" }]; + const targets = [ + makeTarget("conn-primary"), + makeTarget("conn-intermediate"), + makeTarget("conn-last"), + ]; + + const firstCombo = await applySessionStickiness(targets, messages, "combo-with-last-success"); + assert.ok(firstCombo.messageHash); + recordStickyBinding(firstCombo.messageHash, "conn-last"); + + const repeatedFirstCombo = await applySessionStickiness( + targets, + messages, + "combo-with-last-success" + ); + assert.equal(repeatedFirstCombo.stuck, true); + assert.equal(repeatedFirstCombo.targets[0].connectionId, "conn-last"); + + const secondCombo = await applySessionStickiness(targets, messages, "fresh-priority-combo"); + assert.equal(secondCombo.stuck, false); + assert.deepEqual( + secondCombo.targets.map((target) => target.connectionId), + ["conn-primary", "conn-intermediate", "conn-last"], + "a binding learned by another combo must not reorder this combo's configured priority" + ); +}); + test("saturation fetch error → fail-open (original order, no crash)", async () => { __setStickinessHeadroomFetcherForTests(async (_id: string) => { throw new Error("network failure");