From fbcbf2371482c1034697d5c9a1e76f0486b3e6ca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 10 Sep 2026 20:52:16 -0700 Subject: [PATCH] Reconcile extra session segments before skipping empty checkpoints Skip used the in-memory pending set, so extra turn and prompt segments left on disk after a crash never entered the managed-path diff and were left untracked. --- src/session/optimized-context-store.test.ts | 62 +++++++++++++++++++++ src/session/optimized-context-store.ts | 47 ++++++++-------- 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index e98ed5096..691c70834 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -728,6 +728,68 @@ describe("createOptimizedContextStore checkpoint", () => { expect(tree).not.toContain("partial.jsonl"); expect(tree).not.toContain("untracked-junk.txt"); }); + + test("commits an on-disk extra turn segment that is not in HEAD without writeTurns", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + await store.writeTurns([turn("a")]); + await store.writePrompt([turn("p0")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + const first = await store.commit({ message: "base" }); + + const extraTurns = segmentFileName(TURNS_FILE, 1); + const extraPrompt = segmentFileName("prompt.jsonl", 1); + fs.writeFileSync(path.join(dir, extraTurns), jsonl([turn("b")])); + fs.writeFileSync(path.join(dir, extraPrompt), jsonl([turn("p")])); + + const crashed = await createOptimizedContextStore(dir); + const second = await crashed.commit({ message: "heal extra segments" }); + expect(second.hash).not.toBe(first.hash); + + const tree = await gitLsTree(dir); + expect(tree).toContain(extraTurns); + expect(tree).toContain(extraPrompt); + }); + + test("pending extra turn segment prevents empty-checkpoint skip", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + const turns: ConversationTurn[] = []; + const big = "x".repeat(20_000); + for (let i = 0; i < 14; i++) { + turns.push(turn(`${i}-${big}`)); + await store.writeTurns([...turns]); + } + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + const first = await store.commit({ message: "base" }); + + for (let i = 14; i < 18; i++) { + turns.push(turn(`${i}-${big}`)); + await store.writeTurns([...turns]); + } + const extraTurns = segmentFileName(TURNS_FILE, 1); + expect(fs.existsSync(path.join(dir, extraTurns))).toBe(true); + + const second = await store.commit({ message: "pending extra" }); + expect(second.hash).not.toBe(first.hash); + expect(await gitLsTree(dir)).toContain(extraTurns); + }); + + test("staged compact with unchanged disk still commits instead of returning HEAD", async () => { + const dir = tempDir(); + const store = await createOptimizedContextStore(dir); + await store.writeTurns([turn("keep-a"), turn("keep-b"), turn("drop-me")]); + await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); + const first = await store.commit({ message: "published original" }); + + await store.writeTurns([turn("[Compacted prior context]"), turn("keep-b")]); + const second = await store.commit({ message: "publish compact" }); + expect(second.hash).not.toBe(first.hash); + expect(turnTexts((await store.load()).turns)).toEqual([ + "[Compacted prior context]", + "keep-b", + ]); + }); }); describe("createSessionStores", () => { diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index e7d54e33e..e2b16f2f0 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -742,28 +742,6 @@ export async function createSessionStores( const headBefore = stagedRewrite === null ? null : await headOid(dir); let extraPaths: string[] = []; - // Empty managed checkpoints must not create a new commit or stage - // session junk such as partial.jsonl. A staged unpublished rewrite - // is still unpublished on disk — skip would swallow the compact - // without writing it, so that path always goes through commit. - if (stagedRewrite === null) { - const managedFilepaths = [ - ...VENDOR_COMMIT_ROOT_FILES, - TOOL_OUTPUT_DIR, - EVIDENCE_ARCHIVE_DIR, - ...pendingSegmentPaths, - ...pendingBlobFilepaths, - ]; - if (!(await managedPathsDiffer(dir, managedFilepaths))) { - const [head] = await base.log(1); - if (head !== undefined) { - pendingBlobFilepaths.clear(); - pendingSegmentPaths.clear(); - return head; - } - } - } - try { if (stagedRewrite !== null) { await writeSegmented(writeTurnsSegmented, stagedRewrite); @@ -789,6 +767,31 @@ export async function createSessionStores( toAdd.push(EVIDENCE_ARCHIVE_DIR); } + // Empty managed checkpoints must not create a new commit or stage + // session junk such as partial.jsonl. Discover extra turn/prompt + // segments first so a crash that left them untracked cannot skip. + // A staged unpublished rewrite is still unpublished on disk — skip + // would swallow the compact without writing it. + if (stagedRewrite === null) { + const managedFilepaths = [ + ...VENDOR_COMMIT_ROOT_FILES, + TOOL_OUTPUT_DIR, + EVIDENCE_ARCHIVE_DIR, + ...pendingSegmentPaths, + ...pendingBlobFilepaths, + ...toAdd, + ...toRemove, + ]; + if (!(await managedPathsDiffer(dir, managedFilepaths))) { + const [head] = await base.log(1); + if (head !== undefined) { + pendingBlobFilepaths.clear(); + pendingSegmentPaths.clear(); + return head; + } + } + } + const add = extraCommitPaths([...new Set(toAdd)]); const remove = extraCommitPaths([...new Set(toRemove)]).filter( (p) => !add.includes(p),