refactor: harden session store updates

Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-15 23:06:42 +00:00
parent 35492f8513
commit 688a0ce439
24 changed files with 441 additions and 215 deletions

View File

@@ -12,6 +12,7 @@ import {
resolveSessionTranscriptPath,
resolveSessionTranscriptsDir,
updateLastRoute,
updateSessionStore,
updateSessionStoreEntry,
} from "./sessions.js";
@@ -137,6 +138,56 @@ describe("sessions", () => {
expect(store[mainSessionKey]?.compactionCount).toBe(2);
});
it("updateSessionStore preserves concurrent additions", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "{}", "utf-8");
await Promise.all([
updateSessionStore(storePath, (store) => {
store["agent:main:one"] = { sessionId: "sess-1", updatedAt: 1 };
}),
updateSessionStore(storePath, (store) => {
store["agent:main:two"] = { sessionId: "sess-2", updatedAt: 2 };
}),
]);
const store = loadSessionStore(storePath);
expect(store["agent:main:one"]?.sessionId).toBe("sess-1");
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
});
it("updateSessionStore keeps deletions when concurrent writes happen", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:old": { sessionId: "sess-old", updatedAt: 1 },
"agent:main:keep": { sessionId: "sess-keep", updatedAt: 2 },
},
null,
2,
),
"utf-8",
);
await Promise.all([
updateSessionStore(storePath, (store) => {
delete store["agent:main:old"];
}),
updateSessionStore(storePath, (store) => {
store["agent:main:new"] = { sessionId: "sess-new", updatedAt: 3 };
}),
]);
const store = loadSessionStore(storePath);
expect(store["agent:main:old"]).toBeUndefined();
expect(store["agent:main:keep"]?.sessionId).toBe("sess-keep");
expect(store["agent:main:new"]?.sessionId).toBe("sess-new");
});
it("loadSessionStore auto-migrates legacy provider keys to channel keys", async () => {
const mainSessionKey = "agent:main:main";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));

View File

@@ -45,9 +45,16 @@ export function clearSessionStoreCacheForTest(): void {
SESSION_STORE_CACHE.clear();
}
export function loadSessionStore(storePath: string): Record<string, SessionEntry> {
type LoadSessionStoreOptions = {
skipCache?: boolean;
};
export function loadSessionStore(
storePath: string,
opts: LoadSessionStoreOptions = {},
): Record<string, SessionEntry> {
// Check cache first if enabled
if (isSessionStoreCacheEnabled()) {
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
const cached = SESSION_STORE_CACHE.get(storePath);
if (cached && isSessionStoreCacheValid(cached)) {
const currentMtimeMs = getFileMtimeMs(storePath);
@@ -88,7 +95,7 @@ export function loadSessionStore(storePath: string): Record<string, SessionEntry
}
// Cache the result if caching is enabled
if (isSessionStoreCacheEnabled()) {
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
SESSION_STORE_CACHE.set(storePath, {
store: structuredClone(store), // Store a copy to prevent external mutations
loadedAt: Date.now(),
@@ -168,6 +175,19 @@ export async function saveSessionStore(
});
}
export async function updateSessionStore<T>(
storePath: string,
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
): Promise<T> {
return await withSessionStoreLock(storePath, async () => {
// Always re-read inside the lock to avoid clobbering concurrent writers.
const store = loadSessionStore(storePath, { skipCache: true });
const result = await mutator(store);
await saveSessionStoreUnlocked(storePath, store);
return result;
});
}
type SessionStoreLockOptions = {
timeoutMs?: number;
pollIntervalMs?: number;