From 66e70b6926115ab5a80fc7cdc064604fceb0fc73 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 24 Jul 2026 23:49:56 +0200 Subject: [PATCH] fix(runner): keep the warm session when the client sends a minimal history The keep-alive check fingerprints the prior conversation the client sent and compares it against what the previous turn stored. A last-message-only client sends no prior conversation, so the fingerprint is of an empty array and can never match. Every conversation was evicted to cold on every turn after the first. Measured on a three-turn run, turn 2 went from 1570ms to 4623ms while the suite still reported a pass, because a cold turn is still faster than the first. Skip the history comparison when the request carries only its own fresh user turn. The session id already binds the request to the conversation; a minimal-history client simply no longer asserts it, so there is nothing to compare. Requests that do send a history are still checked exactly as before, including the approval-resume path, which always sends the full history. Claude-Session: https://claude.ai/code/session_01KM69J7uHafgciiN5zfG7qR --- services/runner/src/server.ts | 9 ++++++++- .../tests/unit/session-keepalive-dispatch.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 44208229f5..b7e7ed7e26 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -49,6 +49,7 @@ import { computeCredentialEpoch, configFingerprint, credentialEpochMismatch, + carriesMinimalHistory, mountCredentialsExpired, expectedNextHistoryFingerprint, historyFingerprint, @@ -595,9 +596,15 @@ export async function runWithKeepalive( existing.credentialEpoch, incomingEpoch, ); + // A last-message-only client sends no prior conversation, so there is nothing to compare: + // `priorConversation` is empty and the fingerprint can never match what the last turn stored. + // Comparing anyway evicts the warm session on every turn of every conversation. The session + // id already binds the request to this conversation; the client simply no longer asserts it. + const clientAssertsHistory = !carriesMinimalHistory(request); let mismatch: string | undefined; if (cfgFp !== existing.configFingerprint) mismatch = "config"; - else if (priorFp !== existing.historyFingerprint) mismatch = "history"; + else if (clientAssertsHistory && priorFp !== existing.historyFingerprint) + mismatch = "history"; else if (credMismatch) mismatch = credMismatch; else if (!tailIsFreshUserMessage(request)) mismatch = "tail"; diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index 04129c0bc4..c77f1ae55a 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -476,6 +476,16 @@ describe("runWithKeepalive: validation mismatches degrade to cold", () => { assert.equal(calls.acquire, 2); }); + it("a last-message-only turn 2 keeps the warm session (no history to compare)", async () => { + // The flag-on frontend sends ONLY the trailing user message, so `priorConversation` is + // empty and its fingerprint can never match what turn 1 stored. Comparing anyway evicted + // every conversation to cold on every turn. + const minimal = turn2("s1", { messages: [{ role: "user", content: "more" }] }); + const { calls, env1 } = await parkThen(minimal); + assert.equal(env1.destroyed, 0, "the warm env must survive a minimal-history turn"); + assert.equal(calls.acquire, 1, "no cold re-acquire"); + }); + it("credential-epoch expiry evicts to cold", async () => { // The parked mount expiry is in the past, so the next turn's epoch check fails. const { calls, env1 } = await parkThen(turn2(), {