Skip to content

Commit 380101d

Browse files
committed
fix(session): concurrent sessions write disjoint transcripts (CL-10372)
A fresh context store whose transcript diverges from an on-disk transcript it did not author (a concurrent session sharing the dir) previously staged an unpublished rewrite that clobbered the shared turns.jsonl at commit, losing the other session's records. Detect the divergence on the fresh store's first write and divert that store's turns and prompt to unique versioned base names (turns-<epoch>-<rand>.jsonl), so concurrent sessions keep disjoint files. Repro test: two stores on one dir, 107-record transcript + second session commit; asserts no overwrite, no lost records, disjoint files.
1 parent 40e19ce commit 380101d

2 files changed

Lines changed: 128 additions & 14 deletions

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,56 @@ describe("createSessionStores", () => {
597597
});
598598
});
599599

600+
describe("concurrent sessions write disjoint transcripts (CL-10372)", () => {
601+
test("a second session sharing the dir never overwrites the first session's turns", async () => {
602+
const dir = tempDir();
603+
const storeA = await createOptimizedContextStore(dir);
604+
const storeB = await createOptimizedContextStore(dir);
605+
606+
// Session A lands a 107-record transcript (the ticket's verified size).
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+
// Session B starts concurrently on the same dir with its own transcript.
613+
const turnsB = Array.from({ length: 7 }, (_, i) => turn(`b-${i}`));
614+
await storeB.writeTurns([...turnsB]);
615+
await storeB.writeMetadata(EMPTY_CHECKPOINT_METADATA);
616+
await storeB.commit({ message: "session B" });
617+
618+
// A's transcript must survive intact: no overwrite.
619+
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
620+
turnTexts(turnsA),
621+
);
622+
623+
// B's records must survive in a disjoint, versioned file: no lost records.
624+
const versioned = fs
625+
.readdirSync(dir)
626+
.filter((name) => /^turns-\d+-[0-9a-f]+\.jsonl$/.test(name));
627+
expect(versioned.length).toBeGreaterThan(0);
628+
const bOnDisk = versioned.flatMap((name) =>
629+
turnTexts(readTurnsFromFile(path.join(dir, name))),
630+
);
631+
expect(bOnDisk).toEqual(turnTexts(turnsB));
632+
633+
// Disjoint: every record from both sessions survives exactly once.
634+
const all = [
635+
...turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE))),
636+
...bOnDisk,
637+
];
638+
expect(new Set(all).size).toBe(turnsA.length + turnsB.length);
639+
});
640+
});
641+
642+
function readTurnsFromFile(fullPath: string): ConversationTurn[] {
643+
const text = fs.readFileSync(fullPath, "utf8");
644+
return text
645+
.split("\n")
646+
.filter((line) => line.length > 0)
647+
.map((line) => JSON.parse(line) as ConversationTurn);
648+
}
649+
600650
async function gitLsTree(dir: string): Promise<string[]> {
601651
const proc = Bun.spawn(
602652
["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"],

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

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ function sanitizeCallId(callId: string): string {
8484
return callId.replace(UNSAFE_FILENAME_CHARS, "_");
8585
}
8686

87+
/**
88+
* Unique versioned base name for a transcript file (e.g.
89+
* `turns-1730000000000-a1b2c3d4.jsonl`). A store whose transcript diverges
90+
* from an on-disk transcript it did not author writes to one of these instead
91+
* of the shared base, so concurrent sessions keep disjoint files. The
92+
* `-<epoch>-<hex>` infix never collides with the numbered segment pattern
93+
* (`turns-0001.jsonl`), so the base readers ignore these by design.
94+
*/
95+
function versionedBaseName(baseName: string): string {
96+
const dot = baseName.lastIndexOf(".");
97+
const stem = dot === -1 ? baseName : baseName.slice(0, dot);
98+
const ext = dot === -1 ? "" : baseName.slice(dot);
99+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
100+
return `${stem}-${stamp}${ext}`;
101+
}
102+
87103
/**
88104
* Parse conversation turns out of one JSONL segment. A crash can tear the
89105
* final line of the active segment mid-write; `tolerateTornTail` drops it.
@@ -395,11 +411,14 @@ async function headOid(dir: string): Promise<string | null> {
395411
* Captured before a staged rewrite lands so a failed commit can restore the
396412
* published generation.
397413
*/
398-
async function snapshotTurnSegments(dir: string): Promise<Map<string, string>> {
414+
async function snapshotTurnSegments(
415+
dir: string,
416+
baseName: string,
417+
): Promise<Map<string, string>> {
399418
const snapshot = new Map<string, string>();
400-
const highest = await highestSegmentIndex(dir, TURNS_FILE);
419+
const highest = await highestSegmentIndex(dir, baseName);
401420
for (let index = 0; index <= highest; index++) {
402-
const name = segmentFileName(TURNS_FILE, index);
421+
const name = segmentFileName(baseName, index);
403422
try {
404423
snapshot.set(
405424
name,
@@ -421,11 +440,12 @@ async function snapshotTurnSegments(dir: string): Promise<Map<string, string>> {
421440
async function restoreTurnSegments(
422441
dir: string,
423442
snapshot: ReadonlyMap<string, string>,
443+
baseName: string,
424444
): Promise<void> {
425445
const names = new Set<string>(snapshot.keys());
426-
const highest = await highestSegmentIndex(dir, TURNS_FILE);
446+
const highest = await highestSegmentIndex(dir, baseName);
427447
for (let index = 0; index <= highest; index++) {
428-
names.add(segmentFileName(TURNS_FILE, index));
448+
names.add(segmentFileName(baseName, index));
429449
}
430450
for (const name of names) {
431451
const full = path.join(dir, name);
@@ -517,11 +537,26 @@ export async function createSessionStores(
517537
const base = await createIsogitStore(dir, signer);
518538
const pendingBlobFilepaths = new Set<string>();
519539
const pendingSegmentPaths = new Set<string>();
540+
// Base file each writer owns. A store that detects an on-disk transcript it
541+
// did not author (concurrent session sharing the dir) switches both bases to
542+
// disjoint versioned names so its writes never clobber the other session.
543+
let turnsBaseName = TURNS_FILE;
544+
let promptBaseName = PROMPT_FILE;
545+
let versionedBase: string | null = null;
520546
let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
521547
let writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
522548
let liveTurnRefs: readonly ConversationTurn[] | null = null;
523549
let unpublishedRewrite: ConversationTurn[] | null = null;
524550

551+
function divertToVersionedBases(): void {
552+
if (versionedBase !== null) return;
553+
versionedBase = versionedBaseName(TURNS_FILE);
554+
turnsBaseName = versionedBase;
555+
writeTurnsSegmented = createSegmentedJSONLWriter(dir, turnsBaseName);
556+
promptBaseName = versionedBaseName(PROMPT_FILE);
557+
writePromptSegmented = createSegmentedJSONLWriter(dir, promptBaseName);
558+
}
559+
525560
function refPrefixLength(
526561
prev: readonly ConversationTurn[],
527562
next: readonly ConversationTurn[],
@@ -571,6 +606,13 @@ export async function createSessionStores(
571606
liveTurnRefs = [...turns];
572607
return;
573608
}
609+
if (versionedBase !== null) {
610+
// Already diverted (e.g. a colliding prompt wrote first): this store's
611+
// transcript owns a disjoint versioned base; write turns there too.
612+
await writeSegmented(writeTurnsSegmented, turns);
613+
liveTurnRefs = [...turns];
614+
return;
615+
}
574616
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
575617
let baseTurns: ConversationTurn[];
576618
try {
@@ -590,7 +632,13 @@ export async function createSessionStores(
590632
? baseTurns
591633
: await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts);
592634
if (live.length > 0 && contentPrefixLength(live, turns) < live.length) {
593-
unpublishedRewrite = [...turns];
635+
// This fresh store's transcript is not an extension of the on-disk
636+
// history, so the on-disk transcript was authored by another session
637+
// sharing the dir (CL-10372). Rewriting the shared base would clobber
638+
// that session's records; divert to a disjoint versioned base instead.
639+
divertToVersionedBases();
640+
await writeSegmented(writeTurnsSegmented, turns);
641+
liveTurnRefs = [...turns];
594642
return;
595643
}
596644
await writeSegmented(writeTurnsSegmented, turns);
@@ -644,22 +692,33 @@ export async function createSessionStores(
644692
}
645693
}
646694
if (live === null || !promptEqualsLiveTurns(live, turns)) {
695+
// A fresh instance whose on-disk live turns came from another session
696+
// (CL-10372) must not clobber that session's prompt snapshot either;
697+
// divert this store's prompt to its own disjoint versioned base.
698+
if (
699+
versionedBase === null &&
700+
liveTurnRefs === null &&
701+
live !== null &&
702+
live.length > 0
703+
) {
704+
divertToVersionedBases();
705+
}
647706
await writeSegmented(writePromptSegmented, turns);
648707
return;
649708
}
650709
// Identical: converge disk to no prompt segment so a stale snapshot
651710
// cannot linger; removals join pendingSegmentPaths for commit staging.
652-
const highest = await highestSegmentIndex(dir, PROMPT_FILE);
711+
const highest = await highestSegmentIndex(dir, promptBaseName);
653712
let removed = false;
654713
for (let index = 0; index <= highest; index++) {
655-
const name = segmentFileName(PROMPT_FILE, index);
714+
const name = segmentFileName(promptBaseName, index);
656715
if (!(await pathExists(path.join(dir, name)))) continue;
657716
await fs.promises.unlink(path.join(dir, name));
658717
pendingSegmentPaths.add(name);
659718
removed = true;
660719
}
661720
if (removed) {
662-
writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
721+
writePromptSegmented = createSegmentedJSONLWriter(dir, promptBaseName);
663722
}
664723
}
665724

@@ -792,7 +851,9 @@ export async function createSessionStores(
792851
// published generation. The rewrite stays staged so a retry can still
793852
// publish it.
794853
const segmentSnapshot =
795-
stagedRewrite === null ? null : await snapshotTurnSegments(dir);
854+
stagedRewrite === null
855+
? null
856+
: await snapshotTurnSegments(dir, turnsBaseName);
796857
const headBefore = stagedRewrite === null ? null : await headOid(dir);
797858
let extraPaths: string[] = [];
798859

@@ -814,8 +875,8 @@ export async function createSessionStores(
814875

815876
// Disk is the source of truth for which segments stay tracked after a
816877
// rewrite or heal, even when pendingSegmentPaths was lost.
817-
await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove);
818-
await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove);
878+
await reconcileSegmentStaging(dir, turnsBaseName, toAdd, toRemove);
879+
await reconcileSegmentStaging(dir, promptBaseName, toAdd, toRemove);
819880

820881
if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) {
821882
toAdd.push(EVIDENCE_ARCHIVE_DIR);
@@ -882,14 +943,17 @@ export async function createSessionStores(
882943
headBefore !== null && headNow !== null && headNow !== headBefore;
883944
if (!landed) {
884945
try {
885-
await restoreTurnSegments(dir, segmentSnapshot);
946+
await restoreTurnSegments(dir, segmentSnapshot, turnsBaseName);
886947
} catch {
887948
// A partial restore must not mask the real commit error.
888949
}
889950
}
890951
// Drop the writer's stale in-memory state so a retry rewrites from
891952
// scratch.
892-
writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
953+
writeTurnsSegmented = createSegmentedJSONLWriter(
954+
dir,
955+
turnsBaseName,
956+
);
893957
liveTurnRefs = null;
894958
}
895959
throw cause;

0 commit comments

Comments
 (0)