Skip to content

Commit af712b5

Browse files
authored
Merge 78a7ac9 into a7847ac
2 parents a7847ac + 78a7ac9 commit af712b5

9 files changed

Lines changed: 210 additions & 18 deletions

File tree

src/agents/command/attempt-execution.shared.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ export async function persistSessionEntry(
6161
},
6262
{
6363
resolveSingleEntryPersistence: (entry) =>
64-
entry ? { sessionKey: params.sessionKey, entry } : null,
64+
entry && !params.shouldPersist && (params.clearedFields?.length ?? 0) === 0
65+
? { sessionKey: params.sessionKey, entry, patch: params.entry }
66+
: null,
6567
takeCacheOwnership: true,
6668
},
6769
);

src/agents/command/session-store.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,16 @@ describe("updateSessionStoreAfterAgentRun", () => {
227227
sessionId,
228228
updatedAt: 2,
229229
} as SessionEntry),
230-
).toEqual({
230+
).toMatchObject({
231231
sessionKey,
232232
entry: {
233233
sessionId,
234234
updatedAt: 2,
235235
},
236+
patch: {
237+
model: "gpt-5.5",
238+
modelProvider: "openai",
239+
},
236240
});
237241
});
238242
});

src/agents/command/session-store.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,24 @@ function resolvePositiveInteger(value: number | undefined): number | undefined {
4646
return Math.floor(value);
4747
}
4848

49-
function removeLifecycleStateFromMetadataPatch(entry: SessionEntry): SessionEntry {
50-
const next = { ...entry };
51-
delete next.status;
52-
delete next.startedAt;
53-
delete next.endedAt;
54-
delete next.runtimeMs;
55-
return next;
49+
const TRANSIENT_LIFECYCLE_SESSION_FIELDS = new Set(["status", "startedAt", "endedAt", "runtimeMs"]);
50+
51+
function sameJsonValue(left: unknown, right: unknown): boolean {
52+
return JSON.stringify(left) === JSON.stringify(right);
53+
}
54+
55+
function deriveRunMetadataPatch(previous: SessionEntry, next: SessionEntry): Partial<SessionEntry> {
56+
const patch: Record<string, unknown> = {};
57+
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
58+
for (const key of keys) {
59+
if (TRANSIENT_LIFECYCLE_SESSION_FIELDS.has(key)) {
60+
continue;
61+
}
62+
if (!sameJsonValue(previous[key as keyof SessionEntry], next[key as keyof SessionEntry])) {
63+
patch[key] = next[key as keyof SessionEntry];
64+
}
65+
}
66+
return patch as Partial<SessionEntry>;
5667
}
5768

5869
/** Applies run result metadata, usage, and CLI bindings to a session entry. */
@@ -291,7 +302,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
291302
updatedAt: next.updatedAt,
292303
...(touchInteraction ? { lastInteractionAt: next.lastInteractionAt } : {}),
293304
}
294-
: removeLifecycleStateFromMetadataPatch(next);
305+
: deriveRunMetadataPatch(entry, next);
295306
const maintenanceConfig = resolveMaintenanceConfigFromInput(cfg.session?.maintenance);
296307
const persisted = await updateSessionStore(
297308
storePath,
@@ -307,7 +318,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
307318
takeCacheOwnership: true,
308319
maintenanceConfig,
309320
resolveSingleEntryPersistence: (entryLocal) =>
310-
entryLocal ? { sessionKey, entry: entryLocal } : undefined,
321+
entryLocal ? { sessionKey, entry: entryLocal, patch: metadataPatch } : undefined,
311322
},
312323
);
313324
if (persisted) {

src/auto-reply/reply/commands-session-store.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export async function persistSessionEntry(params: CommandParams): Promise<boolea
2424
},
2525
{
2626
resolveSingleEntryPersistence: (entry) =>
27-
entry ? { sessionKey: params.sessionKey, entry } : null,
27+
entry ? { sessionKey: params.sessionKey, entry, patch: params.sessionEntry } : null,
2828
skipMaintenance: true,
2929
},
3030
);
@@ -50,6 +50,12 @@ export async function persistAbortTargetEntry(params: {
5050
sessionStore[key] = entry;
5151

5252
if (storePath) {
53+
const patch: Partial<SessionEntry> = {
54+
abortedLastRun: true,
55+
abortCutoffMessageSid: abortCutoff?.messageSid,
56+
abortCutoffTimestamp: abortCutoff?.timestamp,
57+
updatedAt: entry.updatedAt,
58+
};
5359
await updateSessionStore(
5460
storePath,
5561
(store) => {
@@ -65,7 +71,7 @@ export async function persistAbortTargetEntry(params: {
6571
},
6672
{
6773
resolveSingleEntryPersistence: (updated) =>
68-
updated ? { sessionKey: key, entry: updated } : null,
74+
updated ? { sessionKey: key, entry: updated, patch } : null,
6975
},
7076
);
7177
}

src/auto-reply/reply/session-updates.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ async function persistSessionEntryUpdate(params: {
5252
},
5353
{
5454
resolveSingleEntryPersistence: (entry) =>
55-
entry && params.sessionKey ? { sessionKey: params.sessionKey, entry } : null,
55+
entry && params.sessionKey
56+
? { sessionKey: params.sessionKey, entry, patch: params.nextEntry }
57+
: null,
5658
},
5759
);
5860
}

src/config/sessions.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,41 @@ describe("sessions", () => {
10481048
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
10491049
});
10501050

1051+
it("updateSessionStoreEntry preserves external SQLite writes made while callback runs", async () => {
1052+
const mainSessionKey = "agent:main:main";
1053+
const { storePath } = await createSessionStoreFixture({
1054+
prefix: "updateSessionStoreEntry-atomic-reread",
1055+
entries: {
1056+
[mainSessionKey]: {
1057+
sessionId: "sess-1",
1058+
updatedAt: 123,
1059+
thinkingLevel: "low",
1060+
},
1061+
},
1062+
});
1063+
1064+
await updateSessionStoreEntry({
1065+
storePath,
1066+
sessionKey: mainSessionKey,
1067+
update: async (entry) => {
1068+
expect(entry.providerOverride).toBeUndefined();
1069+
replaceSqliteSessionStoreBehindCache(storePath, {
1070+
[mainSessionKey]: {
1071+
sessionId: "sess-1",
1072+
updatedAt: 124,
1073+
thinkingLevel: "low",
1074+
providerOverride: "anthropic",
1075+
},
1076+
});
1077+
return { thinkingLevel: "high" };
1078+
},
1079+
});
1080+
1081+
const store = loadSessionStore(storePath, { skipCache: true });
1082+
expect(store[mainSessionKey]?.providerOverride).toBe("anthropic");
1083+
expect(store[mainSessionKey]?.thinkingLevel).toBe("high");
1084+
});
1085+
10511086
it("updateSessionStoreEntry can skip maintenance for existing-entry metadata writes", async () => {
10521087
const mainSessionKey = "agent:main:main";
10531088
const staleSessionKey = "agent:main:stale";

src/config/sessions/store-sqlite.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import {
2222
resolveAgentIdFromSessionStorePath,
2323
resolveStateDirFromSessionStorePath,
2424
} from "./paths.js";
25-
import type { SessionEntry } from "./types.js";
25+
import {
26+
mergeSessionEntry,
27+
mergeSessionEntryPreserveActivity,
28+
type SessionEntry,
29+
} from "./types.js";
2630

2731
const SESSION_STORE_SCOPE = "session_entries";
2832

@@ -176,6 +180,69 @@ export function replaceSqliteSessionStore(
176180
database.walMaintenance.checkpoint();
177181
}
178182

183+
export type PatchSqliteSessionEntryMode = "merge" | "preserve-activity" | "replace";
184+
185+
export function patchSqliteSessionEntry(params: {
186+
storePath: string;
187+
sessionKey: string;
188+
fallbackEntry: SessionEntry;
189+
patch: Partial<SessionEntry>;
190+
mode?: PatchSqliteSessionEntryMode;
191+
}): SessionEntry {
192+
const databaseOptions = resolveSessionStoreDatabaseOptions(params.storePath);
193+
let persisted = params.fallbackEntry;
194+
runOpenClawAgentWriteTransaction((database) => {
195+
const db = getNodeSqliteKysely<SessionStoreDatabase>(database.db);
196+
const row =
197+
executeSqliteQueryTakeFirstSync(
198+
database.db,
199+
db
200+
.selectFrom("cache_entries")
201+
.select(["value_json"])
202+
.where("scope", "=", SESSION_STORE_SCOPE)
203+
.where("key", "=", params.sessionKey),
204+
) ?? null;
205+
const current = row ? parseSessionEntryValue(row.value_json) : undefined;
206+
persisted =
207+
params.mode === "replace"
208+
? params.fallbackEntry
209+
: current
210+
? params.mode === "preserve-activity"
211+
? mergeSessionEntryPreserveActivity(current, params.patch)
212+
: mergeSessionEntry(current, params.patch)
213+
: params.fallbackEntry;
214+
const valueJson = JSON.stringify(persisted);
215+
persisted = parseSessionEntryValue(valueJson) ?? persisted;
216+
const updatedAt =
217+
typeof persisted.updatedAt === "number" && Number.isFinite(persisted.updatedAt)
218+
? persisted.updatedAt
219+
: Date.now();
220+
executeSqliteQuerySync(
221+
database.db,
222+
db
223+
.insertInto("cache_entries")
224+
.values({
225+
scope: SESSION_STORE_SCOPE,
226+
key: params.sessionKey,
227+
value_json: valueJson,
228+
blob: null,
229+
expires_at: null,
230+
updated_at: updatedAt,
231+
})
232+
.onConflict((conflict) =>
233+
conflict.columns(["scope", "key"]).doUpdateSet({
234+
value_json: valueJson,
235+
blob: null,
236+
expires_at: null,
237+
updated_at: updatedAt,
238+
}),
239+
),
240+
);
241+
}, databaseOptions);
242+
openOpenClawAgentDatabase(databaseOptions).walMaintenance.checkpoint();
243+
return persisted;
244+
}
245+
179246
export function clearExistingSqliteSessionStore(
180247
storePath: string,
181248
opts?: { compact?: boolean },

0 commit comments

Comments
 (0)