Skip to content

Commit 703abed

Browse files
committed
clawdbot-403: preserve lifecycle patch row keys
1 parent bcdd8ac commit 703abed

2 files changed

Lines changed: 121 additions & 1 deletion

File tree

src/config/sessions/session-entry-lifecycle.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,88 @@ describe("session entry lifecycle seam", () => {
5252
).toBeUndefined();
5353
});
5454

55+
it("preserves an existing raw row key while exposing normalized context", async () => {
56+
fs.writeFileSync(
57+
storePath,
58+
JSON.stringify({
59+
GLOBAL: {
60+
sessionId: "session-raw",
61+
updatedAt: 10,
62+
},
63+
}),
64+
);
65+
let contextKey: string | undefined;
66+
67+
await patchSessionLifecycleEntry(
68+
{ sessionKey: "GLOBAL", storePath },
69+
(entry, context) => {
70+
contextKey = context.sessionKey;
71+
entry.fastMode = true;
72+
return entry;
73+
},
74+
{ replaceEntry: true, skipMaintenance: true },
75+
);
76+
77+
const store = loadSessionStore(storePath, { skipCache: true });
78+
expect(contextKey).toBe("global");
79+
expect(store.GLOBAL?.fastMode).toBe(true);
80+
expect(store.global).toBeUndefined();
81+
});
82+
83+
it("collapses legacy aliases when a usable canonical row exists", async () => {
84+
fs.writeFileSync(
85+
storePath,
86+
JSON.stringify({
87+
"agent:main:main": {
88+
sessionId: "session-canonical",
89+
updatedAt: 10,
90+
fastMode: false,
91+
},
92+
"Agent:Main:Main": {
93+
sessionId: "session-legacy",
94+
updatedAt: 20,
95+
fastMode: false,
96+
},
97+
}),
98+
);
99+
100+
await patchSessionLifecycleEntry(
101+
{ sessionKey: "Agent:Main:Main", storePath },
102+
(entry) => {
103+
entry.fastMode = true;
104+
return entry;
105+
},
106+
{ replaceEntry: true, skipMaintenance: true },
107+
);
108+
109+
const store = loadSessionStore(storePath, { skipCache: true });
110+
expect(store["agent:main:main"]?.sessionId).toBe("session-legacy");
111+
expect(store["agent:main:main"]?.fastMode).toBe(true);
112+
expect(store["Agent:Main:Main"]).toBeUndefined();
113+
});
114+
115+
it("preserves a fallback raw row key", async () => {
116+
await patchSessionLifecycleEntry(
117+
{ sessionKey: "UNKNOWN", storePath },
118+
(entry) => {
119+
entry.fastMode = true;
120+
return entry;
121+
},
122+
{
123+
fallbackEntry: {
124+
sessionId: "session-fallback",
125+
updatedAt: 10,
126+
},
127+
replaceEntry: true,
128+
skipMaintenance: true,
129+
},
130+
);
131+
132+
const store = loadSessionStore(storePath, { skipCache: true });
133+
expect(store.UNKNOWN?.fastMode).toBe(true);
134+
expect(store.unknown).toBeUndefined();
135+
});
136+
55137
it("patches multiple entries without exposing a mutable store", async () => {
56138
await upsertSessionEntry(
57139
{ sessionKey: "agent:main:one", storePath },

src/config/sessions/session-entry-lifecycle.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getRuntimeConfig } from "../io.js";
22
import { resolveStorePath } from "./paths.js";
3+
import { hasMismatchedCaseSensitiveDeliveryProof } from "./store-entry.js";
34
import {
45
archiveRemovedSessionTranscripts,
56
loadSessionStore,
@@ -63,6 +64,11 @@ export async function patchSessionLifecycleEntry(
6364
if (!existing) {
6465
return { changed: false, entry: null };
6566
}
67+
const storageKey = resolveLifecyclePatchStorageKey({
68+
store,
69+
resolved,
70+
requestedKey: scope.sessionKey,
71+
});
6672
const patch = await update(structuredClone(existing), {
6773
existingEntry: resolved.existing ? structuredClone(resolved.existing) : undefined,
6874
sessionKey: resolved.normalizedKey,
@@ -78,8 +84,11 @@ export async function patchSessionLifecycleEntry(
7884
: options.preserveActivity
7985
? mergeSessionEntryPreserveActivity(existing, patch)
8086
: mergeSessionEntry(existing, patch);
81-
store[resolved.normalizedKey] = next;
87+
store[storageKey] = next;
8288
for (const legacyKey of resolved.legacyKeys) {
89+
if (legacyKey === storageKey) {
90+
continue;
91+
}
8392
delete store[legacyKey];
8493
}
8594
return { changed: true, entry: next };
@@ -219,6 +228,35 @@ function collectReferencedSessionIds(store: Record<string, SessionEntry>): Set<s
219228
);
220229
}
221230

231+
// Preserve the concrete row key for behavior-neutral file-backed updates. Callers
232+
// still receive the normalized key in context for canonical decisions.
233+
function resolveLifecyclePatchStorageKey(params: {
234+
store: Record<string, SessionEntry>;
235+
requestedKey: string;
236+
resolved: ReturnType<typeof resolveSessionStoreEntry>;
237+
}): string {
238+
if (
239+
params.resolved.existing &&
240+
params.store[params.resolved.normalizedKey] === params.resolved.existing
241+
) {
242+
return params.resolved.normalizedKey;
243+
}
244+
const canonicalEntry = params.store[params.resolved.normalizedKey];
245+
if (
246+
canonicalEntry &&
247+
!hasMismatchedCaseSensitiveDeliveryProof(canonicalEntry, params.resolved.normalizedKey)
248+
) {
249+
return params.resolved.normalizedKey;
250+
}
251+
const existingLegacyKey = params.resolved.legacyKeys.find(
252+
(legacyKey) => params.store[legacyKey] === params.resolved.existing,
253+
);
254+
if (existingLegacyKey) {
255+
return existingLegacyKey;
256+
}
257+
return params.requestedKey.trim() || params.resolved.normalizedKey;
258+
}
259+
222260
function rememberRemovedSessionFile(
223261
removedSessionFiles: Map<string, string | undefined>,
224262
entry: SessionEntry,

0 commit comments

Comments
 (0)