Skip to content

Commit 39f7736

Browse files
Merge pull request #1408 from corbitsdev/cl-10372-transcript-overwrite
fix(session): concurrent sessions write disjoint transcripts
2 parents 0b39977 + 30ce6dd commit 39f7736

3 files changed

Lines changed: 314 additions & 26 deletions

File tree

‎src/session/assemble-runtime.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,11 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
675675
// Stamp last* only on success: a failed /clear factory would otherwise
676676
// make the retry look like the same session and inherit stale extras.
677677
if (!inheritFromPrev) stickyFromStore = undefined;
678-
const { storage, audit } = await createSessionStores(workdir);
678+
// Session id is the stable owner so a resume's compaction stays on the
679+
// shared transcript.
680+
const { storage, audit } = await createSessionStores(workdir, {
681+
ownerId: sessionId,
682+
});
679683
// Primary-only: workers never pass evidenceArchiveHolder, so they keep
680684
// plain storage without admission / authorize recording wraps.
681685
const archiveHolder = wiring.evidenceArchiveHolder;

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

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
createOptimizedContextStore,
1010
createSessionStores,
1111
loadRecentTurns,
12+
versionedBaseName,
1213
} from "./optimized-context-store.js";
1314
import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js";
1415

@@ -597,6 +598,177 @@ describe("createSessionStores", () => {
597598
});
598599
});
599600

601+
describe("concurrent sessions write disjoint transcripts", () => {
602+
test("a second session sharing the dir never overwrites the first session's turns", async () => {
603+
const dir = tempDir();
604+
const storeA = await createOptimizedContextStore(dir);
605+
const storeB = await createOptimizedContextStore(dir);
606+
607+
const turnsA = Array.from({ length: 107 }, (_, i) => turn(`a-${i}`));
608+
await storeA.writeTurns([...turnsA]);
609+
await storeA.writeMetadata(EMPTY_CHECKPOINT_METADATA);
610+
await storeA.commit({ message: "session A" });
611+
612+
const turnsB = Array.from({ length: 7 }, (_, i) => turn(`b-${i}`));
613+
await storeB.writeTurns([...turnsB]);
614+
await storeB.writeMetadata(EMPTY_CHECKPOINT_METADATA);
615+
await storeB.commit({ message: "session B" });
616+
617+
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
618+
turnTexts(turnsA),
619+
);
620+
621+
const versioned = versionedTurnsFiles(dir);
622+
expect(versioned.length).toBeGreaterThan(0);
623+
const bOnDisk = versioned.flatMap((name) =>
624+
turnTexts(readTurnsFromFile(path.join(dir, name))),
625+
);
626+
expect(bOnDisk).toEqual(turnTexts(turnsB));
627+
628+
const all = [
629+
...turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE))),
630+
...bOnDisk,
631+
];
632+
expect(new Set(all).size).toBe(turnsA.length + turnsB.length);
633+
});
634+
635+
test("same-session resume with a differing first prompt keeps writing turns.jsonl", async () => {
636+
const dir = tempDir();
637+
638+
// Reactor writes the prompt before writeTurns; that prompt may differ
639+
// from on-disk turns.
640+
const owner = await createOptimizedContextStore(dir);
641+
const live = [turn("a"), turn("b")];
642+
await owner.writeTurns([...live]);
643+
await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA);
644+
await owner.commit({ message: "owner" });
645+
646+
const resumed = await createOptimizedContextStore(dir);
647+
await resumed.writePrompt([turn("a"), turn("b"), turn("ephemeral")]);
648+
const next = [...live, turn("c")];
649+
await resumed.writeTurns([...next]);
650+
await resumed.writeMetadata(EMPTY_CHECKPOINT_METADATA);
651+
await resumed.commit({ message: "resume append" });
652+
653+
expect(versionedTurnsFiles(dir)).toEqual([]);
654+
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
655+
turnTexts(next),
656+
);
657+
expect(turnTexts((await resumed.load()).turns)).toEqual(turnTexts(next));
658+
});
659+
660+
test("same-session compaction rewrite on resume is canonical, not diverted", async () => {
661+
const dir = tempDir();
662+
const owner = await createOptimizedContextStore(dir, {
663+
ownerId: "session-S",
664+
});
665+
const original = [turn("a"), turn("b"), turn("c"), turn("d")];
666+
await owner.writeTurns([...original]);
667+
await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA);
668+
await owner.commit({ message: "owner" });
669+
670+
const resumed = await createOptimizedContextStore(dir, {
671+
ownerId: "session-S",
672+
});
673+
const compacted = [turn("[Compacted prior context]"), turn("c"), turn("d")];
674+
await resumed.writeTurns([...compacted]);
675+
await resumed.writeMetadata(EMPTY_CHECKPOINT_METADATA);
676+
await resumed.commit({ message: "resume compact" });
677+
678+
expect(versionedTurnsFiles(dir)).toEqual([]);
679+
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
680+
turnTexts(compacted),
681+
);
682+
expect(turnTexts((await resumed.load()).turns)).toEqual(
683+
turnTexts(compacted),
684+
);
685+
});
686+
687+
test("interleaved first writes cannot both claim the shared base", async () => {
688+
const dir = tempDir();
689+
const storeA = await createOptimizedContextStore(dir);
690+
const storeB = await createOptimizedContextStore(dir);
691+
const turnsA = Array.from({ length: 50 }, (_, i) => turn(`a-${i}`));
692+
const turnsB = Array.from({ length: 50 }, (_, i) => turn(`b-${i}`));
693+
694+
await Promise.all([
695+
(async () => {
696+
await storeA.writeTurns([...turnsA]);
697+
await storeA.writeMetadata(EMPTY_CHECKPOINT_METADATA);
698+
await storeA.commit({ message: "A" });
699+
})(),
700+
(async () => {
701+
await storeB.writeTurns([...turnsB]);
702+
await storeB.writeMetadata(EMPTY_CHECKPOINT_METADATA);
703+
await storeB.commit({ message: "B" });
704+
})(),
705+
]);
706+
707+
const shared = turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)));
708+
const versioned = versionedTurnsFiles(dir);
709+
expect(versioned).toHaveLength(1);
710+
const other = turnTexts(
711+
readTurnsFromFile(path.join(dir, defined(versioned[0]))),
712+
);
713+
const sharedIsA =
714+
JSON.stringify(shared) === JSON.stringify(turnTexts(turnsA));
715+
const sharedIsB =
716+
JSON.stringify(shared) === JSON.stringify(turnTexts(turnsB));
717+
expect(sharedIsA || sharedIsB).toBe(true);
718+
expect(other).toEqual(sharedIsA ? turnTexts(turnsB) : turnTexts(turnsA));
719+
expect(new Set([...shared, ...other]).size).toBe(
720+
turnsA.length + turnsB.length,
721+
);
722+
});
723+
724+
test("crash before the divert lands leaves no orphaned versioned file", async () => {
725+
const dir = tempDir();
726+
const owner = await createOptimizedContextStore(dir);
727+
await owner.writeTurns([turn("a"), turn("b")]);
728+
await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA);
729+
await owner.commit({ message: "owner" });
730+
731+
const foreign = await createOptimizedContextStore(dir);
732+
await foreign.writeTurns([turn("x"), turn("y")]);
733+
expect(versionedTurnsFiles(dir)).toEqual([]);
734+
735+
const resumed = await createOptimizedContextStore(dir);
736+
await resumed.writeTurns([turn("a"), turn("b"), turn("c")]);
737+
await resumed.writeMetadata(EMPTY_CHECKPOINT_METADATA);
738+
await resumed.commit({ message: "resume" });
739+
expect(versionedTurnsFiles(dir)).toEqual([]);
740+
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual([
741+
"a",
742+
"b",
743+
"c",
744+
]);
745+
});
746+
747+
test("versioned base names are unique even within the same millisecond", () => {
748+
const names = Array.from({ length: 100 }, () =>
749+
versionedBaseName(TURNS_FILE),
750+
);
751+
expect(new Set(names).size).toBe(100);
752+
for (const name of names) {
753+
expect(/^turns-\d+-[0-9a-f]+\.jsonl$/.test(name)).toBe(true);
754+
}
755+
});
756+
});
757+
758+
function versionedTurnsFiles(dir: string): string[] {
759+
return fs
760+
.readdirSync(dir)
761+
.filter((name) => /^turns-\d+-[0-9a-f]+\.jsonl$/.test(name));
762+
}
763+
764+
function readTurnsFromFile(fullPath: string): ConversationTurn[] {
765+
const text = fs.readFileSync(fullPath, "utf8");
766+
return text
767+
.split("\n")
768+
.filter((line) => line.length > 0)
769+
.map((line) => JSON.parse(line) as ConversationTurn);
770+
}
771+
600772
async function gitLsTree(dir: string): Promise<string[]> {
601773
const proc = Bun.spawn(
602774
["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"],

0 commit comments

Comments
 (0)