From 24dd7aec90c00fb1096f47c383b254ce9e83e076 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:01:45 -0500 Subject: [PATCH] fix: prefer freshest duplicate store matches --- src/gateway/session-utils.test.ts | 51 +++++++++++++++++++++++++++++++ src/gateway/session-utils.ts | 40 +++++++++++++----------- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 9745abf26b3..bd20aa7ebac 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -375,6 +375,57 @@ describe("gateway session utils", () => { } }); + test("loadSessionEntry prefers the freshest duplicate row across discovered stores", async () => { + clearConfigCache(); + try { + await withStateDirEnv("session-utils-load-entry-cross-store-", async ({ stateDir }) => { + const canonicalSessionsDir = path.join(stateDir, "agents", "main", "sessions"); + fs.mkdirSync(canonicalSessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(canonicalSessionsDir, "sessions.json"), + JSON.stringify( + { + "agent:main:main": { sessionId: "sess-canonical-stale", updatedAt: 10 }, + "agent:main:MAIN": { sessionId: "sess-canonical-fresh", updatedAt: 1000 }, + }, + null, + 2, + ), + "utf8", + ); + + const discoveredSessionsDir = path.join(stateDir, "agents", "main ", "sessions"); + fs.mkdirSync(discoveredSessionsDir, { recursive: true }); + fs.writeFileSync( + path.join(discoveredSessionsDir, "sessions.json"), + JSON.stringify( + { + "agent:main:main": { sessionId: "sess-discovered-mid", updatedAt: 500 }, + }, + null, + 2, + ), + "utf8", + ); + + await writeConfigFile({ + session: { + mainKey: "main", + store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { list: [{ id: "main", default: true }] }, + }); + clearConfigCache(); + + const loaded = loadSessionEntry("agent:main:main"); + + expect(loaded.entry?.sessionId).toBe("sess-canonical-fresh"); + }); + } finally { + clearConfigCache(); + } + }); + test("pruneLegacyStoreKeys removes alias and case-variant ghost keys", () => { const store: Record = { "agent:ops:work": { sessionId: "canonical", updatedAt: 3 }, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index ae09e8ce24f..e23b1feeca5 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -399,29 +399,33 @@ export function resolveFreshestSessionEntryFromStoreKeys( return resolveFreshestSessionStoreMatchFromStoreKeys(store, storeKeys)?.entry; } -/** - * Find a session entry by exact or case-insensitive key match. - * Returns both the entry and the actual store key it was found under, - * so callers can clean up legacy mixed-case keys when they differ from canonicalKey. - */ -function findStoreMatch( +function findFreshestStoreMatch( store: Record, ...candidates: string[] ): { entry: SessionEntry; key: string } | undefined { - // Exact match first. + const matches = new Map(); for (const candidate of candidates) { - if (candidate && store[candidate]) { - return { entry: store[candidate], key: candidate }; + const trimmed = candidate.trim(); + if (!trimmed) { + continue; + } + const exact = store[trimmed]; + if (exact) { + matches.set(trimmed, { entry: exact, key: trimmed }); + } + for (const key of findStoreKeysIgnoreCase(store, trimmed)) { + const entry = store[key]; + if (entry) { + matches.set(key, { entry, key }); + } } } - // Case-insensitive scan for ALL candidates. - const loweredSet = new Set(candidates.filter(Boolean).map((c) => c.toLowerCase())); - for (const key of Object.keys(store)) { - if (loweredSet.has(key.toLowerCase())) { - return { entry: store[key], key }; - } + if (matches.size === 0) { + return undefined; } - return undefined; + return [...matches.values()].toSorted( + (a, b) => (b.entry.updatedAt ?? 0) - (a.entry.updatedAt ?? 0), + )[0]; } /** @@ -782,7 +786,7 @@ function resolveGatewaySessionStoreLookup(params: { }; let selectedStorePath = fallback.storePath; let selectedStore = params.initialStore ?? loadSessionStore(fallback.storePath); - let selectedMatch = findStoreMatch(selectedStore, ...scanTargets); + let selectedMatch = findFreshestStoreMatch(selectedStore, ...scanTargets); let selectedUpdatedAt = selectedMatch?.entry.updatedAt ?? Number.NEGATIVE_INFINITY; for (let index = 1; index < candidates.length; index += 1) { @@ -791,7 +795,7 @@ function resolveGatewaySessionStoreLookup(params: { continue; } const store = loadSessionStore(candidate.storePath); - const match = findStoreMatch(store, ...scanTargets); + const match = findFreshestStoreMatch(store, ...scanTargets); if (!match) { continue; }