Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion services/runner/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
computeCredentialEpoch,
configFingerprint,
credentialEpochMismatch,
carriesMinimalHistory,
mountCredentialsExpired,
expectedNextHistoryFingerprint,
historyFingerprint,
Expand Down Expand Up @@ -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";

Expand Down
10 changes: 10 additions & 0 deletions services/runner/tests/unit/session-keepalive-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down
Loading