@@ -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>> {
421440async 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