Skip to content

Commit fbcbf23

Browse files
committed
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.
1 parent 4dd0d7c commit fbcbf23

2 files changed

Lines changed: 87 additions & 22 deletions

File tree

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,68 @@ describe("createOptimizedContextStore checkpoint", () => {
728728
expect(tree).not.toContain("partial.jsonl");
729729
expect(tree).not.toContain("untracked-junk.txt");
730730
});
731+
732+
test("commits an on-disk extra turn segment that is not in HEAD without writeTurns", async () => {
733+
const dir = tempDir();
734+
const store = await createOptimizedContextStore(dir);
735+
await store.writeTurns([turn("a")]);
736+
await store.writePrompt([turn("p0")]);
737+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
738+
const first = await store.commit({ message: "base" });
739+
740+
const extraTurns = segmentFileName(TURNS_FILE, 1);
741+
const extraPrompt = segmentFileName("prompt.jsonl", 1);
742+
fs.writeFileSync(path.join(dir, extraTurns), jsonl([turn("b")]));
743+
fs.writeFileSync(path.join(dir, extraPrompt), jsonl([turn("p")]));
744+
745+
const crashed = await createOptimizedContextStore(dir);
746+
const second = await crashed.commit({ message: "heal extra segments" });
747+
expect(second.hash).not.toBe(first.hash);
748+
749+
const tree = await gitLsTree(dir);
750+
expect(tree).toContain(extraTurns);
751+
expect(tree).toContain(extraPrompt);
752+
});
753+
754+
test("pending extra turn segment prevents empty-checkpoint skip", async () => {
755+
const dir = tempDir();
756+
const store = await createOptimizedContextStore(dir);
757+
const turns: ConversationTurn[] = [];
758+
const big = "x".repeat(20_000);
759+
for (let i = 0; i < 14; i++) {
760+
turns.push(turn(`${i}-${big}`));
761+
await store.writeTurns([...turns]);
762+
}
763+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
764+
const first = await store.commit({ message: "base" });
765+
766+
for (let i = 14; i < 18; i++) {
767+
turns.push(turn(`${i}-${big}`));
768+
await store.writeTurns([...turns]);
769+
}
770+
const extraTurns = segmentFileName(TURNS_FILE, 1);
771+
expect(fs.existsSync(path.join(dir, extraTurns))).toBe(true);
772+
773+
const second = await store.commit({ message: "pending extra" });
774+
expect(second.hash).not.toBe(first.hash);
775+
expect(await gitLsTree(dir)).toContain(extraTurns);
776+
});
777+
778+
test("staged compact with unchanged disk still commits instead of returning HEAD", async () => {
779+
const dir = tempDir();
780+
const store = await createOptimizedContextStore(dir);
781+
await store.writeTurns([turn("keep-a"), turn("keep-b"), turn("drop-me")]);
782+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
783+
const first = await store.commit({ message: "published original" });
784+
785+
await store.writeTurns([turn("[Compacted prior context]"), turn("keep-b")]);
786+
const second = await store.commit({ message: "publish compact" });
787+
expect(second.hash).not.toBe(first.hash);
788+
expect(turnTexts((await store.load()).turns)).toEqual([
789+
"[Compacted prior context]",
790+
"keep-b",
791+
]);
792+
});
731793
});
732794

733795
describe("createSessionStores", () => {

src/session/optimized-context-store.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -742,28 +742,6 @@ export async function createSessionStores(
742742
const headBefore = stagedRewrite === null ? null : await headOid(dir);
743743
let extraPaths: string[] = [];
744744

745-
// Empty managed checkpoints must not create a new commit or stage
746-
// session junk such as partial.jsonl. A staged unpublished rewrite
747-
// is still unpublished on disk — skip would swallow the compact
748-
// without writing it, so that path always goes through commit.
749-
if (stagedRewrite === null) {
750-
const managedFilepaths = [
751-
...VENDOR_COMMIT_ROOT_FILES,
752-
TOOL_OUTPUT_DIR,
753-
EVIDENCE_ARCHIVE_DIR,
754-
...pendingSegmentPaths,
755-
...pendingBlobFilepaths,
756-
];
757-
if (!(await managedPathsDiffer(dir, managedFilepaths))) {
758-
const [head] = await base.log(1);
759-
if (head !== undefined) {
760-
pendingBlobFilepaths.clear();
761-
pendingSegmentPaths.clear();
762-
return head;
763-
}
764-
}
765-
}
766-
767745
try {
768746
if (stagedRewrite !== null) {
769747
await writeSegmented(writeTurnsSegmented, stagedRewrite);
@@ -789,6 +767,31 @@ export async function createSessionStores(
789767
toAdd.push(EVIDENCE_ARCHIVE_DIR);
790768
}
791769

770+
// Empty managed checkpoints must not create a new commit or stage
771+
// session junk such as partial.jsonl. Discover extra turn/prompt
772+
// segments first so a crash that left them untracked cannot skip.
773+
// A staged unpublished rewrite is still unpublished on disk — skip
774+
// would swallow the compact without writing it.
775+
if (stagedRewrite === null) {
776+
const managedFilepaths = [
777+
...VENDOR_COMMIT_ROOT_FILES,
778+
TOOL_OUTPUT_DIR,
779+
EVIDENCE_ARCHIVE_DIR,
780+
...pendingSegmentPaths,
781+
...pendingBlobFilepaths,
782+
...toAdd,
783+
...toRemove,
784+
];
785+
if (!(await managedPathsDiffer(dir, managedFilepaths))) {
786+
const [head] = await base.log(1);
787+
if (head !== undefined) {
788+
pendingBlobFilepaths.clear();
789+
pendingSegmentPaths.clear();
790+
return head;
791+
}
792+
}
793+
}
794+
792795
const add = extraCommitPaths([...new Set(toAdd)]);
793796
const remove = extraCommitPaths([...new Set(toRemove)]).filter(
794797
(p) => !add.includes(p),

0 commit comments

Comments
 (0)