diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 6b473ff91..38184a3b5 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -675,7 +675,11 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { // Stamp last* only on success: a failed /clear factory would otherwise // make the retry look like the same session and inherit stale extras. if (!inheritFromPrev) stickyFromStore = undefined; - const { storage, audit } = await createSessionStores(workdir); + // Session id is the stable owner so a resume's compaction stays on the + // shared transcript. + const { storage, audit } = await createSessionStores(workdir, { + ownerId: sessionId, + }); // Primary-only: workers never pass evidenceArchiveHolder, so they keep // plain storage without admission / authorize recording wraps. const archiveHolder = wiring.evidenceArchiveHolder; diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 727933807..f118421c5 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -9,6 +9,7 @@ import { createOptimizedContextStore, createSessionStores, loadRecentTurns, + versionedBaseName, } from "./optimized-context-store.js"; import { segmentFileName, listSegmentFiles } from "./incremental-jsonl.js"; @@ -597,6 +598,177 @@ describe("createSessionStores", () => { }); }); +describe("concurrent sessions write disjoint transcripts", () => { + test("a second session sharing the dir never overwrites the first session's turns", async () => { + const dir = tempDir(); + const storeA = await createOptimizedContextStore(dir); + const storeB = await createOptimizedContextStore(dir); + + const turnsA = Array.from({ length: 107 }, (_, i) => turn(`a-${i}`)); + await storeA.writeTurns([...turnsA]); + await storeA.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await storeA.commit({ message: "session A" }); + + const turnsB = Array.from({ length: 7 }, (_, i) => turn(`b-${i}`)); + await storeB.writeTurns([...turnsB]); + await storeB.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await storeB.commit({ message: "session B" }); + + expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual( + turnTexts(turnsA), + ); + + const versioned = versionedTurnsFiles(dir); + expect(versioned.length).toBeGreaterThan(0); + const bOnDisk = versioned.flatMap((name) => + turnTexts(readTurnsFromFile(path.join(dir, name))), + ); + expect(bOnDisk).toEqual(turnTexts(turnsB)); + + const all = [ + ...turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE))), + ...bOnDisk, + ]; + expect(new Set(all).size).toBe(turnsA.length + turnsB.length); + }); + + test("same-session resume with a differing first prompt keeps writing turns.jsonl", async () => { + const dir = tempDir(); + + // Reactor writes the prompt before writeTurns; that prompt may differ + // from on-disk turns. + const owner = await createOptimizedContextStore(dir); + const live = [turn("a"), turn("b")]; + await owner.writeTurns([...live]); + await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await owner.commit({ message: "owner" }); + + const resumed = await createOptimizedContextStore(dir); + await resumed.writePrompt([turn("a"), turn("b"), turn("ephemeral")]); + const next = [...live, turn("c")]; + await resumed.writeTurns([...next]); + await resumed.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await resumed.commit({ message: "resume append" }); + + expect(versionedTurnsFiles(dir)).toEqual([]); + expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual( + turnTexts(next), + ); + expect(turnTexts((await resumed.load()).turns)).toEqual(turnTexts(next)); + }); + + test("same-session compaction rewrite on resume is canonical, not diverted", async () => { + const dir = tempDir(); + const owner = await createOptimizedContextStore(dir, { + ownerId: "session-S", + }); + const original = [turn("a"), turn("b"), turn("c"), turn("d")]; + await owner.writeTurns([...original]); + await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await owner.commit({ message: "owner" }); + + const resumed = await createOptimizedContextStore(dir, { + ownerId: "session-S", + }); + const compacted = [turn("[Compacted prior context]"), turn("c"), turn("d")]; + await resumed.writeTurns([...compacted]); + await resumed.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await resumed.commit({ message: "resume compact" }); + + expect(versionedTurnsFiles(dir)).toEqual([]); + expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual( + turnTexts(compacted), + ); + expect(turnTexts((await resumed.load()).turns)).toEqual( + turnTexts(compacted), + ); + }); + + test("interleaved first writes cannot both claim the shared base", async () => { + const dir = tempDir(); + const storeA = await createOptimizedContextStore(dir); + const storeB = await createOptimizedContextStore(dir); + const turnsA = Array.from({ length: 50 }, (_, i) => turn(`a-${i}`)); + const turnsB = Array.from({ length: 50 }, (_, i) => turn(`b-${i}`)); + + await Promise.all([ + (async () => { + await storeA.writeTurns([...turnsA]); + await storeA.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await storeA.commit({ message: "A" }); + })(), + (async () => { + await storeB.writeTurns([...turnsB]); + await storeB.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await storeB.commit({ message: "B" }); + })(), + ]); + + const shared = turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE))); + const versioned = versionedTurnsFiles(dir); + expect(versioned).toHaveLength(1); + const other = turnTexts( + readTurnsFromFile(path.join(dir, defined(versioned[0]))), + ); + const sharedIsA = + JSON.stringify(shared) === JSON.stringify(turnTexts(turnsA)); + const sharedIsB = + JSON.stringify(shared) === JSON.stringify(turnTexts(turnsB)); + expect(sharedIsA || sharedIsB).toBe(true); + expect(other).toEqual(sharedIsA ? turnTexts(turnsB) : turnTexts(turnsA)); + expect(new Set([...shared, ...other]).size).toBe( + turnsA.length + turnsB.length, + ); + }); + + test("crash before the divert lands leaves no orphaned versioned file", async () => { + const dir = tempDir(); + const owner = await createOptimizedContextStore(dir); + await owner.writeTurns([turn("a"), turn("b")]); + await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await owner.commit({ message: "owner" }); + + const foreign = await createOptimizedContextStore(dir); + await foreign.writeTurns([turn("x"), turn("y")]); + expect(versionedTurnsFiles(dir)).toEqual([]); + + const resumed = await createOptimizedContextStore(dir); + await resumed.writeTurns([turn("a"), turn("b"), turn("c")]); + await resumed.writeMetadata(EMPTY_CHECKPOINT_METADATA); + await resumed.commit({ message: "resume" }); + expect(versionedTurnsFiles(dir)).toEqual([]); + expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual([ + "a", + "b", + "c", + ]); + }); + + test("versioned base names are unique even within the same millisecond", () => { + const names = Array.from({ length: 100 }, () => + versionedBaseName(TURNS_FILE), + ); + expect(new Set(names).size).toBe(100); + for (const name of names) { + expect(/^turns-\d+-[0-9a-f]+\.jsonl$/.test(name)).toBe(true); + } + }); +}); + +function versionedTurnsFiles(dir: string): string[] { + return fs + .readdirSync(dir) + .filter((name) => /^turns-\d+-[0-9a-f]+\.jsonl$/.test(name)); +} + +function readTurnsFromFile(fullPath: string): ConversationTurn[] { + const text = fs.readFileSync(fullPath, "utf8"); + return text + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as ConversationTurn); +} + async function gitLsTree(dir: string): Promise { const proc = Bun.spawn( ["git", "-C", dir, "ls-tree", "-r", "--name-only", "HEAD"], diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index a490998d5..011e624af 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -34,6 +34,7 @@ const MANIFEST_FILE = "manifest.jsonl"; const METADATA_FILE = "metadata.json"; const TOOL_OUTPUT_DIR = "tool-output"; const EVIDENCE_ARCHIVE_DIR = "evidence-archive"; +const OWNER_MARKER_FILE = "session-owner.json"; const log = getLogger([LOG_NAMESPACE_ROOT, "session", "context-store"]); @@ -84,6 +85,18 @@ function sanitizeCallId(callId: string): string { return callId.replace(UNSAFE_FILENAME_CHARS, "_"); } +/** + * Epoch+uuid avoids numbered segments (`turns-0001.jsonl`) that base + * readers treat as live history. + */ +export function versionedBaseName(baseName: string): string { + const dot = baseName.lastIndexOf("."); + const stem = dot === -1 ? baseName : baseName.slice(0, dot); + const ext = dot === -1 ? "" : baseName.slice(dot); + const uuid = globalThis.crypto.randomUUID().replaceAll("-", ""); + return `${stem}-${Date.now()}-${uuid}${ext}`; +} + /** * Parse conversation turns out of one JSONL segment. A crash can tear the * final line of the active segment mid-write; `tolerateTornTail` drops it. @@ -341,6 +354,38 @@ async function readBaseTurnsFromDisk(dir: string): Promise { return parseSegmentTurns(text, true, TURNS_FILE, true); } +async function readOwnerMarker( + dir: string, +): Promise<{ ownerId: string } | null> { + try { + const raw = await fs.promises.readFile( + path.join(dir, OWNER_MARKER_FILE), + "utf-8", + ); + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { ownerId?: unknown }).ownerId === "string" + ) { + return { ownerId: (parsed as { ownerId: string }).ownerId }; + } + return null; + } catch { + return null; + } +} + +async function writeOwnerMarker(dir: string, ownerId: string): Promise { + const full = path.join(dir, OWNER_MARKER_FILE); + const tmp = `${full}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; + await fs.promises.writeFile( + tmp, + JSON.stringify({ ownerId, createdAt: Date.now() }), + ); + await fs.promises.rename(tmp, full); +} + async function listIndexPaths(dir: string): Promise> { return new Set(await git.listFiles({ fs, dir })); } @@ -395,11 +440,14 @@ async function headOid(dir: string): Promise { * Captured before a staged rewrite lands so a failed commit can restore the * published generation. */ -async function snapshotTurnSegments(dir: string): Promise> { +async function snapshotTurnSegments( + dir: string, + baseName: string, +): Promise> { const snapshot = new Map(); - const highest = await highestSegmentIndex(dir, TURNS_FILE); + const highest = await highestSegmentIndex(dir, baseName); for (let index = 0; index <= highest; index++) { - const name = segmentFileName(TURNS_FILE, index); + const name = segmentFileName(baseName, index); try { snapshot.set( name, @@ -421,11 +469,12 @@ async function snapshotTurnSegments(dir: string): Promise> { async function restoreTurnSegments( dir: string, snapshot: ReadonlyMap, + baseName: string, ): Promise { const names = new Set(snapshot.keys()); - const highest = await highestSegmentIndex(dir, TURNS_FILE); + const highest = await highestSegmentIndex(dir, baseName); for (let index = 0; index <= highest; index++) { - names.add(segmentFileName(TURNS_FILE, index)); + names.add(segmentFileName(baseName, index)); } for (const name of names) { const full = path.join(dir, name); @@ -503,6 +552,15 @@ export interface SessionStores { audit: AuditStore; } +export interface SessionStoreOptions { + signer?: CommitSigner; + /** + * Same id shares the marker; omit for a random token, foreign unless the + * transcript extends history. + */ + ownerId?: string; +} + /** * Interchange git-store wrapper that avoids O(session length) work per * checkpoint: turns and prompts write as rolling segments so only the active @@ -511,17 +569,30 @@ export interface SessionStores { */ export async function createSessionStores( dir: string, - opts?: { signer?: CommitSigner }, + opts?: SessionStoreOptions, ): Promise { const signer = opts?.signer ?? (await loadOrCreateCommitSigner(dir)); const base = await createIsogitStore(dir, signer); const pendingBlobFilepaths = new Set(); const pendingSegmentPaths = new Set(); + const ownerToken = opts?.ownerId ?? globalThis.crypto.randomUUID(); + let turnsBaseName = TURNS_FILE; + let promptBaseName = PROMPT_FILE; + let versionedBase: string | null = null; let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE); let writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE); let liveTurnRefs: readonly ConversationTurn[] | null = null; let unpublishedRewrite: ConversationTurn[] | null = null; + function divertToVersionedBases(): void { + if (versionedBase !== null) return; + versionedBase = versionedBaseName(TURNS_FILE); + turnsBaseName = versionedBase; + writeTurnsSegmented = createSegmentedJSONLWriter(dir, turnsBaseName); + promptBaseName = versionedBaseName(PROMPT_FILE); + writePromptSegmented = createSegmentedJSONLWriter(dir, promptBaseName); + } + function refPrefixLength( prev: readonly ConversationTurn[], next: readonly ConversationTurn[], @@ -571,6 +642,44 @@ export async function createSessionStores( liveTurnRefs = [...turns]; return; } + // First claim is per-dir so two fresh stores cannot both own the base. + await withResolvedDirLock(dir, async () => { + if (liveTurnRefs !== null) { + if (refPrefixLength(liveTurnRefs, turns) < liveTurnRefs.length) { + unpublishedRewrite = [...turns]; + return; + } + await writeSegmented(writeTurnsSegmented, turns); + liveTurnRefs = [...turns]; + return; + } + const marker = await readOwnerMarker(dir); + if (marker === null) { + await writeOwnerMarker(dir, ownerToken); + await writeSegmented(writeTurnsSegmented, turns); + liveTurnRefs = [...turns]; + return; + } + const live = await loadLiveTurnsFromDisk(); + if (marker.ownerId === ownerToken) { + // Matching owner keeps compaction on the shared base, not a divert. + await writeSegmented(writeTurnsSegmented, turns); + liveTurnRefs = [...turns]; + return; + } + if (live.length > 0 && contentPrefixLength(live, turns) < live.length) { + // Stage until commit so a crash leaves no orphaned versioned file. + divertToVersionedBases(); + unpublishedRewrite = [...turns]; + return; + } + // Prefix extension with no stable id resumes the shared base. + await writeSegmented(writeTurnsSegmented, turns); + liveTurnRefs = [...turns]; + }); + } + + async function loadLiveTurnsFromDisk(): Promise { const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE); let baseTurns: ConversationTurn[]; try { @@ -585,16 +694,9 @@ export async function createSessionStores( ); baseTurns = await readBaseTurnsFromDisk(dir); } - const live = - extraTexts.length === 0 - ? baseTurns - : await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts); - if (live.length > 0 && contentPrefixLength(live, turns) < live.length) { - unpublishedRewrite = [...turns]; - return; - } - await writeSegmented(writeTurnsSegmented, turns); - liveTurnRefs = [...turns]; + return extraTexts.length === 0 + ? baseTurns + : await loadTurnsWithoutMalformedToolSequence(baseTurns, extraTexts); } function promptEqualsLiveTurns( @@ -643,23 +745,28 @@ export async function createSessionStores( live = null; } } + if (versionedBase !== null && liveTurnRefs === null) { + // No versioned file before commit; a crash would orphan it. + return; + } if (live === null || !promptEqualsLiveTurns(live, turns)) { + // A differing prompt is normal on resume and must not divert. await writeSegmented(writePromptSegmented, turns); return; } // Identical: converge disk to no prompt segment so a stale snapshot // cannot linger; removals join pendingSegmentPaths for commit staging. - const highest = await highestSegmentIndex(dir, PROMPT_FILE); + const highest = await highestSegmentIndex(dir, promptBaseName); let removed = false; for (let index = 0; index <= highest; index++) { - const name = segmentFileName(PROMPT_FILE, index); + const name = segmentFileName(promptBaseName, index); if (!(await pathExists(path.join(dir, name)))) continue; await fs.promises.unlink(path.join(dir, name)); pendingSegmentPaths.add(name); removed = true; } if (removed) { - writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE); + writePromptSegmented = createSegmentedJSONLWriter(dir, promptBaseName); } } @@ -792,7 +899,9 @@ export async function createSessionStores( // published generation. The rewrite stays staged so a retry can still // publish it. const segmentSnapshot = - stagedRewrite === null ? null : await snapshotTurnSegments(dir); + stagedRewrite === null + ? null + : await snapshotTurnSegments(dir, turnsBaseName); const headBefore = stagedRewrite === null ? null : await headOid(dir); let extraPaths: string[] = []; @@ -814,8 +923,8 @@ export async function createSessionStores( // Disk is the source of truth for which segments stay tracked after a // rewrite or heal, even when pendingSegmentPaths was lost. - await reconcileSegmentStaging(dir, TURNS_FILE, toAdd, toRemove); - await reconcileSegmentStaging(dir, PROMPT_FILE, toAdd, toRemove); + await reconcileSegmentStaging(dir, turnsBaseName, toAdd, toRemove); + await reconcileSegmentStaging(dir, promptBaseName, toAdd, toRemove); if (await pathExists(path.join(dir, EVIDENCE_ARCHIVE_DIR))) { toAdd.push(EVIDENCE_ARCHIVE_DIR); @@ -882,14 +991,17 @@ export async function createSessionStores( headBefore !== null && headNow !== null && headNow !== headBefore; if (!landed) { try { - await restoreTurnSegments(dir, segmentSnapshot); + await restoreTurnSegments(dir, segmentSnapshot, turnsBaseName); } catch { // A partial restore must not mask the real commit error. } } // Drop the writer's stale in-memory state so a retry rewrites from // scratch. - writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE); + writeTurnsSegmented = createSegmentedJSONLWriter( + dir, + turnsBaseName, + ); liveTurnRefs = null; } throw cause; @@ -909,7 +1021,7 @@ export async function createSessionStores( export async function createOptimizedContextStore( dir: string, - opts?: { signer?: CommitSigner }, + opts?: SessionStoreOptions, ): Promise { const { storage } = await createSessionStores(dir, opts); return storage;