fix: prefer freshest duplicate store matches

This commit is contained in:
Tak Hoffman
2026-03-26 10:01:45 -05:00
parent 5f9f08394a
commit 24dd7aec90
2 changed files with 73 additions and 18 deletions

View File

@@ -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<string, unknown> = {
"agent:ops:work": { sessionId: "canonical", updatedAt: 3 },

View File

@@ -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<string, SessionEntry>,
...candidates: string[]
): { entry: SessionEntry; key: string } | undefined {
// Exact match first.
const matches = new Map<string, { entry: SessionEntry; key: string }>();
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;
}