@@ -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,63 @@ async function resetIndexPaths(
377390 }
378391}
379392
393+ async function headOid ( dir : string ) : Promise < string | null > {
394+ try {
395+ return await git . resolveRef ( { fs, dir, ref : "HEAD" } ) ;
396+ } catch {
397+ return null ;
398+ }
399+ }
400+
401+ /**
402+ * Contents of every turn segment on disk (`turns.jsonl` plus numbered tails,
403+ * gapped strays included), keyed by relative name. Captured before a staged
404+ * rewrite lands so a failed commit can put the working tree back on the
405+ * published generation.
406+ */
407+ async function snapshotTurnSegments ( dir : string ) : Promise < Map < string , string > > {
408+ const snapshot = new Map < string , string > ( ) ;
409+ const highest = await highestSegmentIndex ( dir , TURNS_FILE ) ;
410+ for ( let index = 0 ; index <= highest ; index ++ ) {
411+ const name = segmentFileName ( TURNS_FILE , index ) ;
412+ try {
413+ snapshot . set (
414+ name ,
415+ await fs . promises . readFile ( path . join ( dir , name ) , "utf-8" ) ,
416+ ) ;
417+ } catch ( cause ) {
418+ if ( cause instanceof Error && "code" in cause && cause . code === "ENOENT" )
419+ continue ;
420+ throw cause ;
421+ }
422+ }
423+ return snapshot ;
424+ }
425+
426+ /**
427+ * Inverse of snapshotTurnSegments: write every snapshotted segment back and
428+ * unlink any segment the landed rewrite created.
429+ */
430+ async function restoreTurnSegments (
431+ dir : string ,
432+ snapshot : ReadonlyMap < string , string > ,
433+ ) : Promise < void > {
434+ const names = new Set < string > ( snapshot . keys ( ) ) ;
435+ const highest = await highestSegmentIndex ( dir , TURNS_FILE ) ;
436+ for ( let index = 0 ; index <= highest ; index ++ ) {
437+ names . add ( segmentFileName ( TURNS_FILE , index ) ) ;
438+ }
439+ for ( const name of names ) {
440+ const full = path . join ( dir , name ) ;
441+ const text = snapshot . get ( name ) ;
442+ if ( text === undefined ) {
443+ if ( await pathExists ( full ) ) await fs . promises . unlink ( full ) ;
444+ } else {
445+ await fs . promises . writeFile ( full , text ) ;
446+ }
447+ }
448+ }
449+
380450/**
381451 * Stage every contiguous on-disk segment for `baseName` and unstage any
382452 * higher-numbered or gapped segment still on disk or tracked after a rewrite
@@ -493,14 +563,23 @@ export async function createSessionStores(
493563 return ;
494564 }
495565 const extraTexts = await readExtraSegmentTexts ( dir , TURNS_FILE ) ;
496- const baseResult = await base . load ( ) ;
566+ let baseTurns : ConversationTurn [ ] ;
567+ try {
568+ baseTurns = ( await base . load ( ) ) . turns ;
569+ } catch ( cause ) {
570+ // A torn or poisoned base tail must not block the write that heals it;
571+ // recover the usable base turns the same way load() does. This also lets
572+ // a corrupt metadata.json slide — writeTurns only needs the turns.
573+ log . warn (
574+ "base context store load failed during writeTurns; recovering base segment from disk" ,
575+ { cause : cause instanceof Error ? cause . message : String ( cause ) } ,
576+ ) ;
577+ baseTurns = await readBaseTurnsFromDisk ( dir ) ;
578+ }
497579 const live =
498580 extraTexts . length === 0
499- ? baseResult . turns
500- : await loadTurnsWithoutMalformedToolSequence (
501- baseResult . turns ,
502- extraTexts ,
503- ) ;
581+ ? baseTurns
582+ : await loadTurnsWithoutMalformedToolSequence ( baseTurns , extraTexts ) ;
504583 if ( live . length > 0 && contentPrefixLength ( live , turns ) < live . length ) {
505584 unpublishedRewrite = [ ...turns ] ;
506585 return ;
@@ -577,13 +656,7 @@ export async function createSessionStores(
577656 // Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
578657 // skipMalformed: mid-file garbage/interleaved records must not kill resume
579658 // (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- }
659+ baseTurns = await readBaseTurnsFromDisk ( dir ) ;
587660 } catch ( parseCause ) {
588661 // Unrecoverable: rethrow with the file name in the message.
589662 throw new Error (
@@ -642,36 +715,46 @@ export async function createSessionStores(
642715 async commit ( options , signal ) {
643716 return withResolvedDirLock ( dir , async ( ) => {
644717 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- }
718+ // The staged rewrite lands on the working-tree segments before the git
719+ // operations below; snapshot them so a failed commit can put the files
720+ // back on the published generation. `unpublishedRewrite` stays staged
721+ // so a retried commit can still publish it.
722+ const segmentSnapshot =
723+ stagedRewrite === null ? null : await snapshotTurnSegments ( dir ) ;
724+ const headBefore = stagedRewrite === null ? null : await headOid ( dir ) ;
725+ let extraPaths : string [ ] = [ ] ;
658726
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 ) ;
727+ try {
728+ if ( stagedRewrite !== null ) {
729+ await writeSegmented ( writeTurnsSegmented , stagedRewrite ) ;
730+ }
731+ const toAdd : string [ ] = [ ] ;
732+ const toRemove : string [ ] = [ ] ;
733+
734+ for ( const filepath of [
735+ ...pendingSegmentPaths ,
736+ ...pendingBlobFilepaths ,
737+ ] ) {
738+ if ( await pathExists ( path . join ( dir , filepath ) ) )
739+ toAdd . push ( filepath ) ;
740+ else toRemove . push ( filepath ) ;
741+ }
663742
664- if ( await pathExists ( path . join ( dir , EVIDENCE_ARCHIVE_DIR ) ) ) {
665- toAdd . push ( EVIDENCE_ARCHIVE_DIR ) ;
666- }
743+ // Disk is source of truth for which turn/prompt segments should remain
744+ // tracked after a rewrite or heal, even if pendingSegmentPaths was lost.
745+ await reconcileSegmentStaging ( dir , TURNS_FILE , toAdd , toRemove ) ;
746+ await reconcileSegmentStaging ( dir , PROMPT_FILE , toAdd , toRemove ) ;
667747
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 ] ) ] ;
748+ if ( await pathExists ( path . join ( dir , EVIDENCE_ARCHIVE_DIR ) ) ) {
749+ toAdd . push ( EVIDENCE_ARCHIVE_DIR ) ;
750+ }
751+
752+ const add = extraCommitPaths ( [ ...new Set ( toAdd ) ] ) ;
753+ const remove = extraCommitPaths ( [ ...new Set ( toRemove ) ] ) . filter (
754+ ( p ) => ! add . includes ( p ) ,
755+ ) ;
756+ extraPaths = [ ...new Set ( [ ...add , ...remove ] ) ] ;
673757
674- try {
675758 for ( const filepath of add ) {
676759 await git . add ( { fs, dir, filepath } ) ;
677760 }
@@ -692,7 +775,25 @@ export async function createSessionStores(
692775 return committed ;
693776 } catch ( cause ) {
694777 await resetIndexPaths ( dir , extraPaths ) ;
695- if ( stagedRewrite !== null ) {
778+ if ( segmentSnapshot !== null ) {
779+ // The rewrite already landed on the working-tree segments; restore
780+ // them so load() keeps serving the published generation — unless
781+ // the commit actually landed despite throwing (a ref write or
782+ // post-commit check can fail after HEAD moved), in which case the
783+ // on-disk rewrite already matches the new HEAD.
784+ const headNow = await headOid ( dir ) ;
785+ const landed =
786+ headBefore !== null && headNow !== null && headNow !== headBefore ;
787+ if ( ! landed ) {
788+ try {
789+ await restoreTurnSegments ( dir , segmentSnapshot ) ;
790+ } catch {
791+ // A partial restore must not mask the real commit error.
792+ }
793+ }
794+ // Drop the writer's stale in-memory state so a retry rewrites the
795+ // staged segments from scratch.
796+ writeTurnsSegmented = createSegmentedJSONLWriter ( dir , TURNS_FILE ) ;
696797 liveTurnRefs = null ;
697798 }
698799 throw cause ;
0 commit comments