clawdbot-403: preserve lifecycle patch row keys

This commit is contained in:
Josh Lehman
2026-06-02 00:21:41 -07:00
parent be8ca782d7
commit ddb5749f61
2 changed files with 121 additions and 1 deletions

View File

@@ -52,6 +52,88 @@ describe("session entry lifecycle seam", () => {
).toBeUndefined();
});
it("preserves an existing raw row key while exposing normalized context", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
GLOBAL: {
sessionId: "session-raw",
updatedAt: 10,
},
}),
);
let contextKey: string | undefined;
await patchSessionLifecycleEntry(
{ sessionKey: "GLOBAL", storePath },
(entry, context) => {
contextKey = context.sessionKey;
entry.fastMode = true;
return entry;
},
{ replaceEntry: true, skipMaintenance: true },
);
const store = loadSessionStore(storePath, { skipCache: true });
expect(contextKey).toBe("global");
expect(store.GLOBAL?.fastMode).toBe(true);
expect(store.global).toBeUndefined();
});
it("collapses legacy aliases when a usable canonical row exists", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
sessionId: "session-canonical",
updatedAt: 10,
fastMode: false,
},
"Agent:Main:Main": {
sessionId: "session-legacy",
updatedAt: 20,
fastMode: false,
},
}),
);
await patchSessionLifecycleEntry(
{ sessionKey: "Agent:Main:Main", storePath },
(entry) => {
entry.fastMode = true;
return entry;
},
{ replaceEntry: true, skipMaintenance: true },
);
const store = loadSessionStore(storePath, { skipCache: true });
expect(store["agent:main:main"]?.sessionId).toBe("session-legacy");
expect(store["agent:main:main"]?.fastMode).toBe(true);
expect(store["Agent:Main:Main"]).toBeUndefined();
});
it("preserves a fallback raw row key", async () => {
await patchSessionLifecycleEntry(
{ sessionKey: "UNKNOWN", storePath },
(entry) => {
entry.fastMode = true;
return entry;
},
{
fallbackEntry: {
sessionId: "session-fallback",
updatedAt: 10,
},
replaceEntry: true,
skipMaintenance: true,
},
);
const store = loadSessionStore(storePath, { skipCache: true });
expect(store.UNKNOWN?.fastMode).toBe(true);
expect(store.unknown).toBeUndefined();
});
it("patches multiple entries without exposing a mutable store", async () => {
await upsertSessionEntry(
{ sessionKey: "agent:main:one", storePath },

View File

@@ -1,5 +1,6 @@
import { getRuntimeConfig } from "../io.js";
import { resolveStorePath } from "./paths.js";
import { hasMismatchedCaseSensitiveDeliveryProof } from "./store-entry.js";
import {
archiveRemovedSessionTranscripts,
loadSessionStore,
@@ -63,6 +64,11 @@ export async function patchSessionLifecycleEntry(
if (!existing) {
return { changed: false, entry: null };
}
const storageKey = resolveLifecyclePatchStorageKey({
store,
resolved,
requestedKey: scope.sessionKey,
});
const patch = await update(structuredClone(existing), {
existingEntry: resolved.existing ? structuredClone(resolved.existing) : undefined,
sessionKey: resolved.normalizedKey,
@@ -78,8 +84,11 @@ export async function patchSessionLifecycleEntry(
: options.preserveActivity
? mergeSessionEntryPreserveActivity(existing, patch)
: mergeSessionEntry(existing, patch);
store[resolved.normalizedKey] = next;
store[storageKey] = next;
for (const legacyKey of resolved.legacyKeys) {
if (legacyKey === storageKey) {
continue;
}
delete store[legacyKey];
}
return { changed: true, entry: next };
@@ -219,6 +228,35 @@ function collectReferencedSessionIds(store: Record<string, SessionEntry>): Set<s
);
}
// Preserve the concrete row key for behavior-neutral file-backed updates. Callers
// still receive the normalized key in context for canonical decisions.
function resolveLifecyclePatchStorageKey(params: {
store: Record<string, SessionEntry>;
requestedKey: string;
resolved: ReturnType<typeof resolveSessionStoreEntry>;
}): string {
if (
params.resolved.existing &&
params.store[params.resolved.normalizedKey] === params.resolved.existing
) {
return params.resolved.normalizedKey;
}
const canonicalEntry = params.store[params.resolved.normalizedKey];
if (
canonicalEntry &&
!hasMismatchedCaseSensitiveDeliveryProof(canonicalEntry, params.resolved.normalizedKey)
) {
return params.resolved.normalizedKey;
}
const existingLegacyKey = params.resolved.legacyKeys.find(
(legacyKey) => params.store[legacyKey] === params.resolved.existing,
);
if (existingLegacyKey) {
return existingLegacyKey;
}
return params.requestedKey.trim() || params.resolved.normalizedKey;
}
function rememberRemovedSessionFile(
removedSessionFiles: Map<string, string | undefined>,
entry: SessionEntry,