fix: prefer deterministic session id resume targets

This commit is contained in:
Tak Hoffman
2026-03-24 21:16:12 -05:00
parent 9c7823350b
commit df58b4f5fb
2 changed files with 61 additions and 22 deletions

View File

@@ -20,6 +20,7 @@ import {
type SessionEntry,
} from "../../config/sessions.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
import { listAgentIds } from "../agent-scope.js";
import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js";
@@ -67,28 +68,33 @@ export function resolveSessionKeyForRequest(opts: {
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
// When duplicates exist across agent stores, pick the same deterministic best match used by the
// shared gateway/session resolver helpers instead of whichever store happens to be scanned first.
if (
!explicitSessionKey &&
opts.sessionId &&
!explicitSessionKey &&
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
) {
const foundKey = Object.keys(sessionStore).find(
(key) => sessionStore[key]?.sessionId === opts.sessionId,
);
if (foundKey) {
sessionKey = foundKey;
}
}
const matches: Array<[string, SessionEntry]> = [];
const storeByKey = new Map<string, SessionKeyResolution>();
const addMatches = (
candidateStore: Record<string, SessionEntry>,
candidateStorePath: string,
): void => {
for (const [candidateKey, candidateEntry] of Object.entries(candidateStore)) {
if (candidateEntry?.sessionId !== opts.sessionId) {
continue;
}
matches.push([candidateKey, candidateEntry]);
storeByKey.set(candidateKey, {
sessionKey: candidateKey,
sessionStore: candidateStore,
storePath: candidateStorePath,
});
}
};
// When sessionId was provided but not found in the primary store, search all agent stores.
// Sessions created under a specific agent live in that agent's store file; the primary
// store (derived from the default agent) won't contain them.
// Also covers the case where --to derived a sessionKey that doesn't match the requested sessionId.
if (
opts.sessionId &&
!explicitSessionKey &&
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
) {
addMatches(sessionStore, storePath);
const allAgentIds = listAgentIds(opts.cfg);
for (const agentId of allAgentIds) {
if (agentId === storeAgentId) {
@@ -96,12 +102,16 @@ export function resolveSessionKeyForRequest(opts: {
}
const altStorePath = resolveStorePath(sessionCfg?.store, { agentId });
const altStore = loadSessionStore(altStorePath);
const foundKey = Object.keys(altStore).find(
(key) => altStore[key]?.sessionId === opts.sessionId,
);
if (foundKey) {
return { sessionKey: foundKey, sessionStore: altStore, storePath: altStorePath };
addMatches(altStore, altStorePath);
}
const preferredKey = resolvePreferredSessionKeyForSessionIdMatches(matches, opts.sessionId);
if (preferredKey) {
const preferred = storeByKey.get(preferredKey);
if (preferred) {
return preferred;
}
sessionKey = preferredKey;
}
}

View File

@@ -562,6 +562,35 @@ describe("agentCommand", () => {
});
});
it("resolves duplicate cross-agent sessionIds deterministically", async () => {
await withTempHome(async (home) => {
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
const otherStore = path.join(home, "sessions", "other", "sessions.json");
const retiredStore = path.join(home, "sessions", "retired", "sessions.json");
writeSessionStoreSeed(otherStore, {
"agent:other:main": {
sessionId: "run-dup",
updatedAt: Date.now() + 1_000,
},
});
writeSessionStoreSeed(retiredStore, {
"agent:retired:acp:run-dup": {
sessionId: "run-dup",
updatedAt: Date.now(),
},
});
const cfg = mockConfig(home, storePattern, undefined, undefined, [
{ id: "other" },
{ id: "retired", default: true },
]);
const resolution = resolveSession({ cfg, sessionId: "run-dup" });
expect(resolution.sessionKey).toBe("agent:retired:acp:run-dup");
expect(resolution.storePath).toBe(retiredStore);
});
});
it("forwards resolved outbound session context when resuming by sessionId", async () => {
await withCrossAgentResumeFixture(async ({ sessionId, sessionKey, cfg }) => {
const resolution = resolveSession({ cfg, sessionId });