@@ -80,12 +80,34 @@ function sanitizeCallId(callId: string): string {
8080 * `fileName` when provided so diagnostics point at the on-disk file, not a bare
8181 * Bun JSON token.
8282 *
83- * `skipMalformed` is for display-only reads (see loadRecentTurns): a bad line
84- * anywhere in any segment drops that line and keeps the surrounding history,
85- * because a blank transcript is a worse answer than a transcript with a hole in
86- * it. The reactor's own load() must never use it — there, history *is* the live
87- * conversation state and silently dropping a turn would corrupt it (CL-5935).
83+ * `skipMalformed` drops (or partially recovers) a bad line anywhere in the
84+ * segment and keeps surrounding history. Used by display-only reads
85+ * (`loadRecentTurns`) and by the reactor's own `load()` recovery path so a
86+ * mid-file garbage/interleaved record does not kill resume (CL-7052). Earlier
87+ * CL-5935 kept reactor load strict; killing the session on one bad line was
88+ * worse than a hole in history.
89+ *
90+ * When a crash left a truncated stub glued to the next append (no newline),
91+ * the line fails as a whole; `recoverTurnFromGluedLine` still salvages a
92+ * trailing complete turn from that line when one is present.
8893 */
94+ function recoverTurnFromGluedLine ( line : string ) : ConversationTurn | null {
95+ // Walk every `{` start: a truncated prefix glued onto a complete record
96+ // parses only from the start of that complete record to end-of-line.
97+ for ( let i = 0 ; i < line . length ; i ++ ) {
98+ if ( line [ i ] !== "{" ) continue ;
99+ let raw : unknown ;
100+ try {
101+ raw = JSON . parse ( line . slice ( i ) ) ;
102+ } catch {
103+ continue ;
104+ }
105+ const result = ConversationTurnSchema ( raw ) ;
106+ if ( ! ( result instanceof type . errors ) ) return result ;
107+ }
108+ return null ;
109+ }
110+
89111function parseSegmentTurns (
90112 text : string ,
91113 tolerateTornTail : boolean ,
@@ -109,11 +131,21 @@ function parseSegmentTurns(
109131 try {
110132 raw = JSON . parse ( line ) ;
111133 } catch ( cause ) {
112- if ( tolerateTornTail && isLast ) break ;
113134 if ( skipMalformed ) {
135+ const recovered = recoverTurnFromGluedLine ( line ) ;
136+ if ( recovered !== null ) {
137+ log . warn ?.(
138+ `recovered trailing turn from glued/malformed JSON at ${ fileName } line ${ i + 1 } ` ,
139+ ) ;
140+ turns . push ( recovered ) ;
141+ continue ;
142+ }
143+ // Torn final line: drop it rather than warning as mid-file garbage.
144+ if ( tolerateTornTail && isLast ) break ;
114145 log . warn ?.( `skipping malformed JSON at ${ fileName } line ${ i + 1 } ` ) ;
115146 continue ;
116147 }
148+ if ( tolerateTornTail && isLast ) break ;
117149 throw new Error ( `${ fileName } has malformed JSON at line ${ i + 1 } ` , { cause } ) ;
118150 }
119151 const result = ConversationTurnSchema ( raw ) ;
@@ -253,7 +285,8 @@ export async function loadRecentTurns(dir: string, minTurns: number): Promise<Co
253285 // Only the active (last) segment can be mid-write; sealed ones are complete.
254286 // Display-only: skip lines that will not parse rather than losing the whole
255287 // transcript to one bad line, and name the segment in any error that does
256- // escape (CL-5935).
288+ // escape (CL-5935). Reactor load uses the same skip path for mid-file
289+ // garbage so resume does not die (CL-7052).
257290 const turns = parseSegmentTurns ( text , i === segments . length - 1 , name , true ) ;
258291 collectedNewestFirst . push ( turns ) ;
259292 total += turns . length ;
@@ -391,6 +424,7 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
391424 text ,
392425 index === extraTexts . length - 1 ,
393426 segmentFileName ( TURNS_FILE , index + 1 ) ,
427+ true ,
394428 ) ,
395429 ) ;
396430 const keepExtras = longestWellFormedExtraCount ( baseTurns , parsedExtras ) ;
@@ -414,10 +448,10 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
414448 // optional convenience — callers that only need a recent tail (e.g. TUI
415449 // resume hydration) should use `loadRecentTurns` instead.
416450 //
417- // When the base isogit store hard-fails (e.g. null-padded turns.jsonl from
418- // a stale truncate ), recover usable turns via resilient segment parse and
419- // re-read metadata via the base schema (soft-empty only if that fails too)
420- // so resume does not die on a bare Bun JSON token or wipe pending ops.
451+ // When the base isogit store hard-fails (e.g. null-padded or mid-file
452+ // garbage turns.jsonl ), recover usable turns via resilient segment parse
453+ // and re-read metadata via the base schema (soft-empty only if that fails
454+ // too) so resume does not die on a bare Bun JSON token or wipe pending ops.
421455 async load ( signal ) {
422456 try {
423457 const baseResult = await base . load ( signal ) ;
@@ -432,10 +466,12 @@ export async function createOptimizedContextStore(dir: string): Promise<ContextS
432466 let baseTurns : ConversationTurn [ ] ;
433467 try {
434468 // Prefer resilient parse of segment 0 alone so orphan-tail heal still runs.
469+ // skipMalformed: mid-file garbage/interleaved records must not kill resume
470+ // (CL-7052); null-pad stripping and torn-tail drop still apply.
435471 const basePath = path . join ( dir , TURNS_FILE ) ;
436472 if ( await pathExists ( basePath ) ) {
437473 const text = await fs . promises . readFile ( basePath , "utf-8" ) ;
438- baseTurns = parseSegmentTurns ( text , false , TURNS_FILE ) ;
474+ baseTurns = parseSegmentTurns ( text , true , TURNS_FILE , true ) ;
439475 } else {
440476 baseTurns = [ ] ;
441477 }
0 commit comments