Skip to content

Commit 88e4590

Browse files
committed
Stage compaction rewrites until the store publishes
Persist blobs and stage writeTurns first. Replace reactor memory only after commit so an interrupt resumes the old generation.
1 parent 72a6a58 commit 88e4590

6 files changed

Lines changed: 455 additions & 9 deletions

File tree

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,110 @@ describe("createSessionStores", () => {
722722
expect(typeof audit.loadAudit).toBe("function");
723723
});
724724
});
725+
726+
function turnTexts(turns: ConversationTurn[]): string[] {
727+
return turns.map((t) => (t.content[0] as { text: string }).text);
728+
}
729+
730+
async function gitLsTree(dir: string): Promise<string[]> {
731+
const proc = Bun.spawn(["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], {
732+
stdout: "pipe",
733+
stderr: "pipe",
734+
});
735+
const [exitCode, stdout, stderr] = await Promise.all([
736+
proc.exited,
737+
new Response(proc.stdout).text(),
738+
new Response(proc.stderr).text(),
739+
]);
740+
if (exitCode !== 0) {
741+
throw new Error(`git ls-tree failed: ${stderr.trim() || stdout.trim()}`);
742+
}
743+
return stdout.split("\n").filter((line) => line.length > 0);
744+
}
745+
746+
describe("createOptimizedContextStore unpublished rewrite", () => {
747+
test("rewrite writeTurns stays off the live generation until commit", async () => {
748+
const dir = tempDir();
749+
const store = await createOptimizedContextStore(dir);
750+
const original = [turn("keep-a"), turn("keep-b"), turn("drop-me")];
751+
await store.writeTurns(original);
752+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
753+
await store.commit({ message: "published original" });
754+
755+
const compacted = [turn("[Compacted prior context]"), turn("keep-b")];
756+
await store.writeTurns(compacted);
757+
758+
const loaded = await store.load();
759+
expect(turnTexts(loaded.turns)).toEqual(["keep-a", "keep-b", "drop-me"]);
760+
761+
await store.commit({ message: "publish compact" });
762+
const published = await store.load();
763+
expect(turnTexts(published.turns)).toEqual(["[Compacted prior context]", "keep-b"]);
764+
});
765+
766+
test("omitting commit leaves a new store on the old generation", async () => {
767+
const dir = tempDir();
768+
const store = await createOptimizedContextStore(dir);
769+
await store.writeTurns([turn("old-a"), turn("old-b")]);
770+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
771+
await store.commit({ message: "old" });
772+
773+
await store.writeBlob(
774+
"new-blob",
775+
new TextEncoder().encode("needed-by-new-turns"),
776+
"text/plain",
777+
);
778+
await store.writeTurns([turn("[Compacted prior context]")]);
779+
780+
const crashed = await createOptimizedContextStore(dir);
781+
const loaded = await crashed.load();
782+
expect(turnTexts(loaded.turns)).toEqual(["old-a", "old-b"]);
783+
});
784+
785+
test("append writeTurns is still visible before commit", async () => {
786+
const dir = tempDir();
787+
const store = await createOptimizedContextStore(dir);
788+
const first = turn("one");
789+
await store.writeTurns([first]);
790+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
791+
await store.commit({ message: "one" });
792+
793+
await store.writeTurns([first, turn("two")]);
794+
const loaded = await store.load();
795+
expect(turnTexts(loaded.turns)).toEqual(["one", "two"]);
796+
});
797+
798+
test("folds evidence-archive into the compact commit tree", async () => {
799+
const dir = tempDir();
800+
const store = await createOptimizedContextStore(dir);
801+
await store.writeTurns([turn("old")]);
802+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
803+
await store.commit({ message: "old" });
804+
805+
const archiveDir = path.join(dir, "evidence-archive");
806+
fs.mkdirSync(archiveDir, { recursive: true });
807+
fs.writeFileSync(path.join(archiveDir, "index.jsonl"), "{}\n");
808+
await store.writeTurns([turn("compacted")]);
809+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
810+
await store.commit({ message: "compact" });
811+
812+
expect(await gitLsTree(dir)).toContain("evidence-archive/index.jsonl");
813+
const loaded = await store.load();
814+
expect(turnTexts(loaded.turns)).toEqual(["compacted"]);
815+
});
816+
817+
test("readAt of the old hash is not the load completeness path", async () => {
818+
const dir = tempDir();
819+
const store = await createOptimizedContextStore(dir);
820+
await store.writeTurns([turn("era-1")]);
821+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
822+
const first = await store.commit({ message: "era-1" });
823+
824+
await store.writeTurns([turn("era-2")]);
825+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
826+
await store.commit({ message: "era-2" });
827+
828+
expect(turnTexts(await store.readAt(first.hash))).toEqual(["era-1"]);
829+
expect(turnTexts((await store.load()).turns)).toEqual(["era-2"]);
830+
});
831+
});

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

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const RESPONSE_FILE = "response.jsonl";
3333
const MANIFEST_FILE = "manifest.jsonl";
3434
const METADATA_FILE = "metadata.json";
3535
const TOOL_OUTPUT_DIR = "tool-output";
36+
const EVIDENCE_ARCHIVE_DIR = "evidence-archive";
3637

3738
const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]);
3839

@@ -439,6 +440,33 @@ export async function createSessionStores(
439440
const pendingSegmentPaths = new Set<string>();
440441
const writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
441442
const writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
443+
let liveTurnRefs: readonly ConversationTurn[] | null = null;
444+
let unpublishedRewrite: ConversationTurn[] | null = null;
445+
446+
function refPrefixLength(
447+
prev: readonly ConversationTurn[],
448+
next: readonly ConversationTurn[],
449+
): number {
450+
const max = Math.min(prev.length, next.length);
451+
let prefix = 0;
452+
while (prefix < max && prev[prefix] === next[prefix]) prefix++;
453+
return prefix;
454+
}
455+
456+
function contentPrefixLength(
457+
prev: readonly ConversationTurn[],
458+
next: readonly ConversationTurn[],
459+
): number {
460+
const max = Math.min(prev.length, next.length);
461+
let prefix = 0;
462+
while (
463+
prefix < max &&
464+
JSON.stringify(prev[prefix]) === JSON.stringify(next[prefix])
465+
) {
466+
prefix++;
467+
}
468+
return prefix;
469+
}
442470

443471
async function writeSegmented(
444472
writer: ReturnType<typeof createSegmentedJSONLWriter>,
@@ -448,6 +476,39 @@ export async function createSessionStores(
448476
for (const filepath of modifiedPaths) pendingSegmentPaths.add(filepath);
449477
}
450478

479+
async function writeTurnsLiveOrStage(
480+
turns: readonly ConversationTurn[],
481+
): Promise<void> {
482+
if (unpublishedRewrite !== null) {
483+
unpublishedRewrite = [...turns];
484+
return;
485+
}
486+
if (liveTurnRefs !== null) {
487+
if (refPrefixLength(liveTurnRefs, turns) < liveTurnRefs.length) {
488+
unpublishedRewrite = [...turns];
489+
return;
490+
}
491+
await writeSegmented(writeTurnsSegmented, turns);
492+
liveTurnRefs = [...turns];
493+
return;
494+
}
495+
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
496+
const baseResult = await base.load();
497+
const live =
498+
extraTexts.length === 0
499+
? baseResult.turns
500+
: await loadTurnsWithoutMalformedToolSequence(
501+
baseResult.turns,
502+
extraTexts,
503+
);
504+
if (live.length > 0 && contentPrefixLength(live, turns) < live.length) {
505+
unpublishedRewrite = [...turns];
506+
return;
507+
}
508+
await writeSegmented(writeTurnsSegmented, turns);
509+
liveTurnRefs = [...turns];
510+
}
511+
451512
// Prefer the longest prefix of base + extras whose tool sequence the reactor
452513
// will accept. Orphan tails left by a fresh-writer compaction rewrite are
453514
// dropped and unlinked so the next load does not re-poison the session.
@@ -569,7 +630,7 @@ export async function createSessionStores(
569630
writePrompt: (turns) => writeSegmented(writePromptSegmented, turns),
570631
writeResponse: (turn, signal) => base.writeResponse(turn, signal),
571632
writeManifest: (records, signal) => base.writeManifest(records, signal),
572-
writeTurns: (turns) => writeSegmented(writeTurnsSegmented, turns),
633+
writeTurns: (turns) => writeTurnsLiveOrStage(turns),
573634
writeMetadata: (metadata, signal) => base.writeMetadata(metadata, signal),
574635
readManifestHistory: (limit, signal) =>
575636
base.readManifestHistory(limit, signal),
@@ -580,6 +641,11 @@ export async function createSessionStores(
580641
},
581642
async commit(options, signal) {
582643
return withResolvedDirLock(dir, async () => {
644+
if (unpublishedRewrite !== null) {
645+
await writeSegmented(writeTurnsSegmented, unpublishedRewrite);
646+
liveTurnRefs = unpublishedRewrite;
647+
unpublishedRewrite = null;
648+
}
583649
const toAdd: string[] = [];
584650
const toRemove: string[] = [];
585651

@@ -596,6 +662,10 @@ export async function createSessionStores(
596662
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
597663
await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove);
598664

665+
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
666+
toAdd.push(EVIDENCE_ARCHIVE_DIR);
667+
}
668+
599669
const add = extraCommitPaths([...new Set(toAdd)]);
600670
const remove = extraCommitPaths([...new Set(toRemove)]).filter(
601671
(p) => !add.includes(p),

0 commit comments

Comments
 (0)