Skip to content

Commit 75db48a

Browse files
authored
fix(sessions): compare reply init revisions using persisted shape (#97657)
1 parent 0392ff7 commit 75db48a

4 files changed

Lines changed: 89 additions & 5 deletions

File tree

src/config/sessions/session-accessor.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { SessionManager } from "../../agents/sessions/session-manager.js";
66
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
7+
import { createCanonicalFixtureSkill } from "../../skills/test-support/test-helpers.js";
78
import type { OpenClawConfig } from "../types.openclaw.js";
89
import {
910
applyRestartRecoveryLifecycle,
@@ -533,6 +534,60 @@ describe("session accessor file-backed seam", () => {
533534
});
534535
});
535536

537+
it("commits reply session initialization despite runtime-only skill snapshot cache", async () => {
538+
const sessionKey = "agent:main:main";
539+
await upsertSessionEntry(
540+
{ sessionKey, storePath },
541+
{
542+
sessionId: "first-session",
543+
skillsSnapshot: {
544+
prompt: `<available_skills>${"x".repeat(600)}</available_skills>`,
545+
skills: [{ name: "skill-0" }],
546+
version: 1,
547+
},
548+
updatedAt: 10,
549+
},
550+
);
551+
552+
const snapshot = loadReplySessionInitializationSnapshot({ sessionKey, storePath });
553+
const cachedStore = loadSessionStore(storePath, { clone: false });
554+
const cachedEntry = cachedStore[sessionKey];
555+
if (!cachedEntry?.skillsSnapshot) {
556+
throw new Error("expected cached skills snapshot");
557+
}
558+
cachedEntry.skillsSnapshot = {
559+
...cachedEntry.skillsSnapshot,
560+
resolvedSkills: [
561+
createCanonicalFixtureSkill({
562+
baseDir: "/skills/skill-0",
563+
description: "skill-0 description",
564+
filePath: "/skills/skill-0/SKILL.md",
565+
name: "skill-0",
566+
source: `# skill-0\n\n${"x".repeat(3000)}`,
567+
}),
568+
],
569+
};
570+
571+
const committed = await commitReplySessionInitialization({
572+
activeSessionKey: sessionKey,
573+
agentId: "main",
574+
expectedRevision: snapshot.revision,
575+
previousEntry: snapshot.currentEntry,
576+
sessionEntry: {
577+
sessionId: "next-session",
578+
updatedAt: 20,
579+
},
580+
sessionKey,
581+
storePath,
582+
});
583+
584+
expect(committed.ok).toBe(true);
585+
if (!committed.ok) {
586+
throw new Error("expected reply session initialization to commit");
587+
}
588+
expect(committed.sessionEntry.sessionId).toBe("next-session");
589+
});
590+
536591
it("can borrow cached entry objects for read-only hot paths", async () => {
537592
const scope = {
538593
clone: false,

src/config/sessions/session-accessor.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
patchSessionEntry as patchFileSessionEntry,
5353
patchSessionEntryWithKey as patchFileSessionEntryWithKey,
5454
purgeDeletedAgentSessionEntries as purgeFileDeletedAgentSessionEntries,
55+
projectSessionEntryForPersistenceRevision,
5556
readSessionUpdatedAt as readFileSessionUpdatedAt,
5657
resolveSessionStoreEntry,
5758
resetSessionEntryLifecycle as resetFileSessionEntryLifecycle,
@@ -1155,8 +1156,17 @@ function cloneSessionEntries(store: Record<string, SessionEntry>): Record<string
11551156
);
11561157
}
11571158

1158-
function createReplySessionInitializationRevision(entry: SessionEntry | undefined): string {
1159-
return JSON.stringify(entry ?? null);
1159+
function createReplySessionInitializationRevision(params: {
1160+
entry: SessionEntry | undefined;
1161+
storePath: string;
1162+
}): string {
1163+
const { entry, storePath } = params;
1164+
// Snapshot reads may see promptRef-only disk entries while commit reads can
1165+
// see hydrated prompt text and runtime-only resolvedSkills cache entries.
1166+
// Compare the canonical persisted shape so cache hydration is not a conflict.
1167+
return JSON.stringify(
1168+
entry ? projectSessionEntryForPersistenceRevision({ storePath, entry }) : null,
1169+
);
11601170
}
11611171

11621172
function resolveInitializedReplySessionEntry(params: {
@@ -1711,7 +1721,10 @@ export function loadReplySessionInitializationSnapshot(params: {
17111721
const entry = resolveSessionStoreEntry({ store: entries, sessionKey }).existing;
17121722
return entry ? { ...entry } : undefined;
17131723
},
1714-
revision: createReplySessionInitializationRevision(currentEntry),
1724+
revision: createReplySessionInitializationRevision({
1725+
entry: currentEntry,
1726+
storePath: params.storePath,
1727+
}),
17151728
};
17161729
}
17171730

@@ -1742,7 +1755,10 @@ export async function commitReplySessionInitialization(params: {
17421755
async (store): Promise<ReplySessionInitializationCommitResult> => {
17431756
const resolved = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey });
17441757
const currentEntry = resolved.existing ? { ...resolved.existing } : undefined;
1745-
const revision = createReplySessionInitializationRevision(currentEntry);
1758+
const revision = createReplySessionInitializationRevision({
1759+
entry: currentEntry,
1760+
storePath: params.storePath,
1761+
});
17461762
if (revision !== params.expectedRevision) {
17471763
return {
17481764
ok: false,

src/config/sessions/store-load.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry {
339339
// sessions.json by orders of magnitude when many sessions are active. Strip
340340
// it from every entry that flows through normalize, so neither the in-memory
341341
// store reloaded from disk nor the JSON serialized back to disk carries it.
342-
function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
342+
export function stripPersistedSkillsCache(entry: SessionEntry): SessionEntry {
343343
const snapshot = entry.skillsSnapshot;
344344
if (!snapshot || snapshot.resolvedSkills === undefined) {
345345
return entry;

src/config/sessions/store.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
normalizeSessionStore,
5858
readSessionEntries,
5959
readSessionEntry,
60+
stripPersistedSkillsCache,
6061
} from "./store-load.js";
6162
import {
6263
applyFileBackedSessionStoreMaintenance,
@@ -347,6 +348,18 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
347348
return cloneSessionStoreRecord({ entry }).entry;
348349
}
349350

351+
export function projectSessionEntryForPersistenceRevision(params: {
352+
storePath: string;
353+
entry: SessionEntry;
354+
}): SessionEntry {
355+
const stripped = stripPersistedSkillsCache(params.entry);
356+
const projected = projectSessionStoreForPersistence({
357+
storePath: params.storePath,
358+
store: { entry: stripped },
359+
});
360+
return projected.store.entry ?? stripped;
361+
}
362+
350363
function resolveSessionWorkflowStorePath(
351364
options: SessionEntryWorkflowOptions & { sessionKey?: string },
352365
): string {

0 commit comments

Comments
 (0)