Skip to content

Commit 4ebf366

Browse files
author
corbits-builder
committed
fix(session): skip duplicate prompt snapshot writes
1 parent ca26a70 commit 4ebf366

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

‎src/session/optimized-context-store.test.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -979,9 +979,7 @@ describe("createOptimizedContextStore prompt dedupe (CL-9026)", () => {
979979
}
980980

981981
function cloneTurns(turns: ConversationTurn[]): ConversationTurn[] {
982-
return turns.map(
983-
(t) => JSON.parse(JSON.stringify(t)) as ConversationTurn,
984-
);
982+
return turns.map((t) => JSON.parse(JSON.stringify(t)) as ConversationTurn);
985983
}
986984

987985
test("identical writePrompt writes no prompt segment", async () => {
@@ -1024,9 +1022,9 @@ describe("createOptimizedContextStore prompt dedupe (CL-9026)", () => {
10241022
await ephemeralStore.writeTurns([...live]);
10251023
const withEphemeral = [...cloneTurns(live), turn("ephemeral")];
10261024
await ephemeralStore.writePrompt(withEphemeral);
1027-
expect(
1028-
fs.readFileSync(path.join(ephemeralDir, PROMPT_FILE), "utf-8"),
1029-
).toBe(jsonl(withEphemeral));
1025+
expect(fs.readFileSync(path.join(ephemeralDir, PROMPT_FILE), "utf-8")).toBe(
1026+
jsonl(withEphemeral),
1027+
);
10301028
});
10311029

10321030
test("turns-only store loads live turns and commits no prompt file", async () => {

‎src/session/optimized-context-store.ts‎

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ export async function createSessionStores(
527527
const pendingBlobFilepaths = new Set<string>();
528528
const pendingSegmentPaths = new Set<string>();
529529
let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
530-
const writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
530+
let writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
531531
let liveTurnRefs: readonly ConversationTurn[] | null = null;
532532
let unpublishedRewrite: ConversationTurn[] | null = null;
533533

@@ -606,6 +606,75 @@ export async function createSessionStores(
606606
liveTurnRefs = [...turns];
607607
}
608608

609+
function promptEqualsLiveTurns(
610+
live: readonly ConversationTurn[],
611+
prompt: readonly ConversationTurn[],
612+
): boolean {
613+
if (live.length !== prompt.length) return false;
614+
for (let index = 0; index < prompt.length; index++) {
615+
if (live[index] === prompt[index]) continue;
616+
if (JSON.stringify(live[index]) !== JSON.stringify(prompt[index]))
617+
return false;
618+
}
619+
return true;
620+
}
621+
622+
// The reactor checkpoints the materialized prompt every cycle, but most
623+
// cycles run no transform that changes it — writing an identical snapshot
624+
// next to turns.jsonl doubles disk and re-hash cost for zero information.
625+
// load() never reads prompt.jsonl (base turns + TURNS_FILE extras only),
626+
// so skipping the write leaves resume behavior unchanged; prompts that
627+
// actually differ still write exactly as before.
628+
async function writePromptIfDiffered(
629+
turns: readonly ConversationTurn[],
630+
): Promise<void> {
631+
let live: readonly ConversationTurn[] | null = null;
632+
if (unpublishedRewrite !== null) live = unpublishedRewrite;
633+
else if (liveTurnRefs !== null) live = liveTurnRefs;
634+
else {
635+
// Fresh instance with no writeTurns yet: recover live turns from disk
636+
// the same way writeTurnsLiveOrStage does. Any failure falls through
637+
// to a normal write — an unreadable baseline must not drop the snapshot.
638+
try {
639+
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
640+
let baseTurns: ConversationTurn[];
641+
try {
642+
baseTurns = (await base.load()).turns;
643+
} catch {
644+
baseTurns = await readBaseTurnsFromDisk(dir);
645+
}
646+
live =
647+
extraTexts.length === 0
648+
? baseTurns
649+
: await loadTurnsWithoutMalformedToolSequence(
650+
baseTurns,
651+
extraTexts,
652+
);
653+
} catch {
654+
live = null;
655+
}
656+
}
657+
if (live === null || !promptEqualsLiveTurns(live, turns)) {
658+
await writeSegmented(writePromptSegmented, turns);
659+
return;
660+
}
661+
// Identical to live turns: converge disk to no prompt segment so a stale
662+
// snapshot from an earlier differing write cannot linger. Removals join
663+
// pendingSegmentPaths so commit stages them out of the tree.
664+
const highest = await highestSegmentIndex(dir, PROMPT_FILE);
665+
let removed = false;
666+
for (let index = 0; index <= highest; index++) {
667+
const name = segmentFileName(PROMPT_FILE, index);
668+
if (!(await pathExists(path.join(dir, name)))) continue;
669+
await fs.promises.unlink(path.join(dir, name));
670+
pendingSegmentPaths.add(name);
671+
removed = true;
672+
}
673+
if (removed) {
674+
writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
675+
}
676+
}
677+
609678
// Prefer the longest prefix of base + extras whose tool sequence the reactor
610679
// will accept. Orphan tails left by a fresh-writer compaction rewrite are
611680
// dropped and unlinked so the next load does not re-poison the session.
@@ -718,7 +787,9 @@ export async function createSessionStores(
718787
return [...baseTurns, ...parsedExtras.slice(0, keepExtras).flat()];
719788
},
720789
readBlob: (key, signal) => base.readBlob(key, signal),
721-
writePrompt: (turns) => writeSegmented(writePromptSegmented, turns),
790+
// Skipped identical snapshots fall back to live turns in load(), which
791+
// never reads prompt.jsonl.
792+
writePrompt: (turns) => writePromptIfDiffered(turns),
722793
writeResponse: (turn, signal) => base.writeResponse(turn, signal),
723794
writeManifest: (records, signal) => base.writeManifest(records, signal),
724795
writeTurns: (turns) => writeTurnsLiveOrStage(turns),

0 commit comments

Comments
 (0)