|
9 | 9 | createOptimizedContextStore, |
10 | 10 | createSessionStores, |
11 | 11 | loadRecentTurns, |
| 12 | + versionedBaseName, |
12 | 13 | } from "./optimized-context-store.js"; |
13 | 14 | import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js"; |
14 | 15 |
|
@@ -597,6 +598,177 @@ describe("createSessionStores", () => { |
597 | 598 | }); |
598 | 599 | }); |
599 | 600 |
|
| 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 | + |
600 | 772 | async function gitLsTree(dir: string): Promise<string[]> { |
601 | 773 | const proc = Bun.spawn( |
602 | 774 | ["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], |
|
0 commit comments