@@ -336,6 +336,19 @@ export async function loadRecentTurns(
336336 return turns ;
337337}
338338
339+ /**
340+ * Resilient parse of the base turn segment alone (`turns.jsonl`); extra
341+ * segments are merged by the caller. Mirrors the recovery `load()` applies
342+ * when the isogit base store hard-fails, so a torn or poisoned base cannot
343+ * block the write that heals it.
344+ */
345+ async function readBaseTurnsFromDisk ( dir : string ) : Promise < ConversationTurn [ ] > {
346+ const basePath = path . join ( dir , TURNS_FILE ) ;
347+ if ( ! ( await pathExists ( basePath ) ) ) return [ ] ;
348+ const text = await fs . promises . readFile ( basePath , "utf-8" ) ;
349+ return parseSegmentTurns ( text , true , TURNS_FILE , true ) ;
350+ }
351+
339352async function listIndexPaths ( dir : string ) : Promise < Set < string > > {
340353 return new Set ( await git . listFiles ( { fs, dir } ) ) ;
341354}
@@ -377,6 +390,55 @@ async function resetIndexPaths(
377390 }
378391}
379392
393+ /**
394+ * Contents of every turn segment on disk (`turns.jsonl` plus numbered tails,
395+ * gapped strays included), keyed by relative name. Captured before a staged
396+ * rewrite lands so a failed commit can put the working tree back on the
397+ * published generation.
398+ */
399+ async function snapshotTurnSegments ( dir : string ) : Promise < Map < string , string > > {
400+ const snapshot = new Map < string , string > ( ) ;
401+ const highest = await highestSegmentIndex ( dir , TURNS_FILE ) ;
402+ for ( let index = 0 ; index <= highest ; index ++ ) {
403+ const name = segmentFileName ( TURNS_FILE , index ) ;
404+ try {
405+ snapshot . set (
406+ name ,
407+ await fs . promises . readFile ( path . join ( dir , name ) , "utf-8" ) ,
408+ ) ;
409+ } catch ( cause ) {
410+ if ( cause instanceof Error && "code" in cause && cause . code === "ENOENT" )
411+ continue ;
412+ throw cause ;
413+ }
414+ }
415+ return snapshot ;
416+ }
417+
418+ /**
419+ * Inverse of snapshotTurnSegments: write every snapshotted segment back and
420+ * unlink any segment the landed rewrite created.
421+ */
422+ async function restoreTurnSegments (
423+ dir : string ,
424+ snapshot : ReadonlyMap < string , string > ,
425+ ) : Promise < void > {
426+ const names = new Set < string > ( snapshot . keys ( ) ) ;
427+ const highest = await highestSegmentIndex ( dir , TURNS_FILE ) ;
428+ for ( let index = 0 ; index <= highest ; index ++ ) {
429+ names . add ( segmentFileName ( TURNS_FILE , index ) ) ;
430+ }
431+ for ( const name of names ) {
432+ const full = path . join ( dir , name ) ;
433+ const text = snapshot . get ( name ) ;
434+ if ( text === undefined ) {
435+ if ( await pathExists ( full ) ) await fs . promises . unlink ( full ) ;
436+ } else {
437+ await fs . promises . writeFile ( full , text ) ;
438+ }
439+ }
440+ }
441+
380442/**
381443 * Stage every contiguous on-disk segment for `baseName` and unstage any
382444 * higher-numbered or gapped segment still on disk or tracked after a rewrite
@@ -493,14 +555,22 @@ export async function createSessionStores(
493555 return ;
494556 }
495557 const extraTexts = await readExtraSegmentTexts ( dir , TURNS_FILE ) ;
496- const baseResult = await base . load ( ) ;
558+ let baseTurns : ConversationTurn [ ] ;
559+ try {
560+ baseTurns = ( await base . load ( ) ) . turns ;
561+ } catch ( cause ) {
562+ // A torn or poisoned base tail must not block the write that heals it;
563+ // recover the usable base turns the same way load() does.
564+ log . warn (
565+ "base context store load failed during writeTurns; recovering base segment from disk" ,
566+ { cause : cause instanceof Error ? cause . message : String ( cause ) } ,
567+ ) ;
568+ baseTurns = await readBaseTurnsFromDisk ( dir ) ;
569+ }
497570 const live =
498571 extraTexts . length === 0
499- ? baseResult . turns
500- : await loadTurnsWithoutMalformedToolSequence (
501- baseResult . turns ,
502- extraTexts ,
503- ) ;
572+ ? baseTurns
573+ : await loadTurnsWithoutMalformedToolSequence ( baseTurns , extraTexts ) ;
504574 if ( live . length > 0 && contentPrefixLength ( live , turns ) < live . length ) {
505575 unpublishedRewrite = [ ...turns ] ;
506576 return ;
@@ -577,13 +647,7 @@ export async function createSessionStores(
577647 // Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
578648 // skipMalformed: mid-file garbage/interleaved records must not kill resume
579649 // (CL-7052); null-pad stripping and torn-tail drop still apply.
580- const basePath = path . join ( dir , TURNS_FILE ) ;
581- if ( await pathExists ( basePath ) ) {
582- const text = await fs . promises . readFile ( basePath , "utf-8" ) ;
583- baseTurns = parseSegmentTurns ( text , true , TURNS_FILE , true ) ;
584- } else {
585- baseTurns = [ ] ;
586- }
650+ baseTurns = await readBaseTurnsFromDisk ( dir ) ;
587651 } catch ( parseCause ) {
588652 // Unrecoverable: rethrow with the file name in the message.
589653 throw new Error (
@@ -642,36 +706,45 @@ export async function createSessionStores(
642706 async commit ( options , signal ) {
643707 return withResolvedDirLock ( dir , async ( ) => {
644708 const stagedRewrite = unpublishedRewrite ;
645- if ( stagedRewrite !== null ) {
646- await writeSegmented ( writeTurnsSegmented , stagedRewrite ) ;
647- }
648- const toAdd : string [ ] = [ ] ;
649- const toRemove : string [ ] = [ ] ;
650-
651- for ( const filepath of [
652- ...pendingSegmentPaths ,
653- ...pendingBlobFilepaths ,
654- ] ) {
655- if ( await pathExists ( path . join ( dir , filepath ) ) ) toAdd . push ( filepath ) ;
656- else toRemove . push ( filepath ) ;
657- }
709+ // The staged rewrite lands on the working-tree segments before the git
710+ // operations below; snapshot them so a failed commit can put the files
711+ // back on the published generation. `unpublishedRewrite` stays staged
712+ // so a retried commit can still publish it.
713+ const segmentSnapshot =
714+ stagedRewrite === null ? null : await snapshotTurnSegments ( dir ) ;
715+ let extraPaths : string [ ] = [ ] ;
658716
659- // Disk is source of truth for which turn/prompt segments should remain
660- // tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
661- await reconcileSegmentStaging ( dir , TURNS_FILE , toAdd , toRemove ) ;
662- await reconcileSegmentStaging ( dir , PROMPT_FILE , toAdd , toRemove ) ;
717+ try {
718+ if ( stagedRewrite !== null ) {
719+ await writeSegmented ( writeTurnsSegmented , stagedRewrite ) ;
720+ }
721+ const toAdd : string [ ] = [ ] ;
722+ const toRemove : string [ ] = [ ] ;
723+
724+ for ( const filepath of [
725+ ...pendingSegmentPaths ,
726+ ...pendingBlobFilepaths ,
727+ ] ) {
728+ if ( await pathExists ( path . join ( dir , filepath ) ) )
729+ toAdd . push ( filepath ) ;
730+ else toRemove . push ( filepath ) ;
731+ }
663732
664- if ( await pathExists ( path . join ( dir , EVIDENCE_ARCHIVE_DIR ) ) ) {
665- toAdd . push ( EVIDENCE_ARCHIVE_DIR ) ;
666- }
733+ // Disk is source of truth for which turn/prompt segments should remain
734+ // tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
735+ await reconcileSegmentStaging ( dir , TURNS_FILE , toAdd , toRemove ) ;
736+ await reconcileSegmentStaging ( dir , PROMPT_FILE , toAdd , toRemove ) ;
667737
668- const add = extraCommitPaths ( [ ...new Set ( toAdd ) ] ) ;
669- const remove = extraCommitPaths ( [ ...new Set ( toRemove ) ] ) . filter (
670- ( p ) => ! add . includes ( p ) ,
671- ) ;
672- const extraPaths = [ ...new Set ( [ ...add , ...remove ] ) ] ;
738+ if ( await pathExists ( path . join ( dir , EVIDENCE_ARCHIVE_DIR ) ) ) {
739+ toAdd . push ( EVIDENCE_ARCHIVE_DIR ) ;
740+ }
741+
742+ const add = extraCommitPaths ( [ ...new Set ( toAdd ) ] ) ;
743+ const remove = extraCommitPaths ( [ ...new Set ( toRemove ) ] ) . filter (
744+ ( p ) => ! add . includes ( p ) ,
745+ ) ;
746+ extraPaths = [ ...new Set ( [ ...add , ...remove ] ) ] ;
673747
674- try {
675748 for ( const filepath of add ) {
676749 await git . add ( { fs, dir, filepath } ) ;
677750 }
@@ -692,7 +765,12 @@ export async function createSessionStores(
692765 return committed ;
693766 } catch ( cause ) {
694767 await resetIndexPaths ( dir , extraPaths ) ;
695- if ( stagedRewrite !== null ) {
768+ if ( segmentSnapshot !== null ) {
769+ // The rewrite already landed on the working-tree segments; restore
770+ // them so load() keeps serving the published generation, and drop
771+ // the writer's stale in-memory state so a retry rewrites them.
772+ await restoreTurnSegments ( dir , segmentSnapshot ) ;
773+ writeTurnsSegmented = createSegmentedJSONLWriter ( dir , TURNS_FILE ) ;
696774 liveTurnRefs = null ;
697775 }
698776 throw cause ;
0 commit comments