@@ -68,31 +68,86 @@ function sanitizeCallId(callId: string): string {
6868 * Parse conversation turns out of one JSONL segment. A crash can tear the final
6969 * line of the active (last) segment mid-write; when `tolerateTornTail` is set a
7070 * final line that fails to parse is dropped rather than aborting the resume.
71+ *
72+ * Null bytes (truncate-past-EOF padding from a stale keepBytes write) are stripped
73+ * so a poisoned segment can still yield its usable turns on resume. Errors name
74+ * `fileName` when provided so diagnostics point at the on-disk file, not a bare
75+ * Bun JSON token.
7176 */
72- function parseSegmentTurns ( text : string , tolerateTornTail : boolean ) : ConversationTurn [ ] {
77+ function parseSegmentTurns (
78+ text : string ,
79+ tolerateTornTail : boolean ,
80+ fileName = "turns segment" ,
81+ ) : ConversationTurn [ ] {
7382 if ( text . length === 0 ) return [ ] ;
74- const lines = text . split ( "\n" ) ;
83+ // POSIX truncate past EOF pads with `\0`. Strip them so the rest of the JSONL
84+ // remains parseable instead of dying on Unrecognized token '\u0000'.
85+ const cleaned = text . includes ( "\0" ) ? text . replaceAll ( "\0" , "" ) : text ;
86+ if ( cleaned . length === 0 ) return [ ] ;
87+ const lines = cleaned . split ( "\n" ) ;
7588 if ( lines [ lines . length - 1 ] === "" ) lines . pop ( ) ;
7689
7790 const turns : ConversationTurn [ ] = [ ] ;
7891 for ( let i = 0 ; i < lines . length ; i ++ ) {
92+ const line = lines [ i ] ! ;
93+ if ( line . length === 0 ) continue ;
7994 const isLast = i === lines . length - 1 ;
8095 let raw : unknown ;
8196 try {
82- raw = JSON . parse ( lines [ i ] ! ) ;
97+ raw = JSON . parse ( line ) ;
8398 } catch ( cause ) {
8499 if ( tolerateTornTail && isLast ) break ;
85- throw new Error ( "turns segment has malformed JSON" , { cause } ) ;
100+ throw new Error ( ` ${ fileName } has malformed JSON at line ${ i + 1 } ` , { cause } ) ;
86101 }
87102 const result = ConversationTurnSchema ( raw ) ;
88103 if ( result instanceof type . errors ) {
89- throw new Error ( `turns segment has unexpected structure: ${ result . summary } ` ) ;
104+ throw new Error ( `${ fileName } has unexpected structure at line ${ i + 1 } : ${ result . summary } ` ) ;
90105 }
91106 turns . push ( result ) ;
92107 }
93108 return turns ;
94109}
95110
111+ const EMPTY_TOKEN_USAGE = {
112+ input : 0 ,
113+ output : 0 ,
114+ cacheRead : 0 ,
115+ cacheWrite : 0 ,
116+ thinking : 0 ,
117+ } as const ;
118+
119+ function emptyMetadata ( ) : {
120+ pendingOperations : never [ ] ;
121+ tokenUsage : typeof EMPTY_TOKEN_USAGE ;
122+ connectorState : null ;
123+ } {
124+ return {
125+ pendingOperations : [ ] ,
126+ tokenUsage : { ...EMPTY_TOKEN_USAGE } ,
127+ connectorState : null ,
128+ } ;
129+ }
130+
131+ /**
132+ * Soft-default metadata when the recovery path cannot use the base store.
133+ * Corrupt or missing metadata.json must not abort resume of usable turns.
134+ */
135+ async function loadMetadataSoft ( dir : string ) : Promise < ReturnType < typeof emptyMetadata > > {
136+ const metadataPath = path . join ( dir , METADATA_FILE ) ;
137+ try {
138+ if ( ! ( await pathExists ( metadataPath ) ) ) return emptyMetadata ( ) ;
139+ const text = await fs . promises . readFile ( metadataPath , "utf-8" ) ;
140+ JSON . parse ( text ) ;
141+ // Schema lives in the base store; recovery only needs a safe shell.
142+ return emptyMetadata ( ) ;
143+ } catch ( cause ) {
144+ log . warn ( "metadata.json unreadable during resilient load; using empty defaults" , {
145+ cause : cause instanceof Error ? cause . message : String ( cause ) ,
146+ } ) ;
147+ return emptyMetadata ( ) ;
148+ }
149+ }
150+
96151// Mirrors assertWellFormedToolSequence without throwing. Used to choose the
97152// longest segment prefix the reactor will accept after a load. Unpaired
98153// trailing tool_calls are allowed; dups and orphan results fail.
@@ -333,12 +388,49 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
333388 // the complete turn history is the actual live conversation state, not an
334389 // optional convenience — callers that only need a recent tail (e.g. TUI
335390 // resume hydration) should use `loadRecentTurns` instead.
391+ //
392+ // When the base isogit store hard-fails (e.g. null-padded turns.jsonl from
393+ // a stale truncate), recover usable turns via resilient segment parse and
394+ // soft-default metadata so resume does not die on a bare Bun JSON token.
336395 async load ( signal ) {
337- const baseResult = await base . load ( signal ) ;
338- const extraTexts = await readExtraSegmentTexts ( dir , TURNS_FILE ) ;
339- if ( extraTexts . length === 0 ) return baseResult ;
340- const turns = await loadTurnsWithoutMalformedToolSequence ( baseResult . turns , extraTexts ) ;
341- return { ...baseResult , turns } ;
396+ try {
397+ const baseResult = await base . load ( signal ) ;
398+ const extraTexts = await readExtraSegmentTexts ( dir , TURNS_FILE ) ;
399+ if ( extraTexts . length === 0 ) return baseResult ;
400+ const turns = await loadTurnsWithoutMalformedToolSequence ( baseResult . turns , extraTexts ) ;
401+ return { ...baseResult , turns } ;
402+ } catch ( cause ) {
403+ log . warn (
404+ "base context store load failed; recovering turns from disk segments" ,
405+ { cause : cause instanceof Error ? cause . message : String ( cause ) } ,
406+ ) ;
407+ let baseTurns : ConversationTurn [ ] ;
408+ try {
409+ // Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
410+ const basePath = path . join ( dir , TURNS_FILE ) ;
411+ if ( await pathExists ( basePath ) ) {
412+ const text = await fs . promises . readFile ( basePath , "utf-8" ) ;
413+ baseTurns = parseSegmentTurns ( text , false , TURNS_FILE ) ;
414+ } else {
415+ baseTurns = [ ] ;
416+ }
417+ } catch ( parseCause ) {
418+ // Unrecoverable: rethrow with the file name in the message.
419+ throw new Error (
420+ `failed to load ${ TURNS_FILE } : ${
421+ parseCause instanceof Error ? parseCause . message : String ( parseCause )
422+ } `,
423+ { cause : parseCause } ,
424+ ) ;
425+ }
426+ const extraTexts = await readExtraSegmentTexts ( dir , TURNS_FILE ) ;
427+ const turns =
428+ extraTexts . length === 0
429+ ? baseTurns
430+ : await loadTurnsWithoutMalformedToolSequence ( baseTurns , extraTexts ) ;
431+ const metadata = await loadMetadataSoft ( dir ) ;
432+ return { turns, ...metadata } ;
433+ }
342434 } ,
343435 setConnectorState : ( state ) => base . setConnectorState ( state ) ,
344436 branch : ( name , signal ) => base . branch ( name , signal ) ,
0 commit comments