@@ -123,6 +123,66 @@ function accumulateUsage(prev, curr) {
123123 usageTotals . totalCalls += Math . max ( 0 , curr . totalCalls - ( prev ?. totalCalls || 0 ) ) ;
124124}
125125
126+ // ── Text rescue: extract findings from agent text responses ──────
127+
128+ function extractLastAssistantText ( sessionState ) {
129+ const messages = sessionState . messages || [ ] ;
130+ for ( let i = messages . length - 1 ; i >= 0 ; i -- ) {
131+ const msg = messages [ i ] ;
132+ if ( msg . role !== "assistant" ) continue ;
133+ const textBlocks = ( msg . content || [ ] ) . filter ( c => c . type === "text" ) ;
134+ const text = textBlocks . map ( c => c . text ) . join ( "" ) . trim ( ) ;
135+ if ( ! text || text . length < 50 ) continue ;
136+ // Only return text that looks like it might contain JSON (has braces)
137+ if ( text . includes ( "{" ) && text . includes ( "}" ) ) return text ;
138+ }
139+ return null ;
140+ }
141+
142+ function tryParseJsonFromText ( text ) {
143+ if ( ! text ) return null ;
144+ try {
145+ const parsed = JSON . parse ( text ) ;
146+ if ( isValidFindingsPayload ( parsed ) ) return parsed ;
147+ } catch { }
148+ const jsonBlockPattern = / ` ` ` (?: j s o n ) ? \s * \n ? ( [ \s \S ] * ?) \n ? \s * ` ` ` / g;
149+ let match ;
150+ while ( ( match = jsonBlockPattern . exec ( text ) ) !== null ) {
151+ try {
152+ const parsed = JSON . parse ( match [ 1 ] . trim ( ) ) ;
153+ if ( isValidFindingsPayload ( parsed ) ) return parsed ;
154+ } catch { }
155+ }
156+ // Try to find a JSON object with "findings" — use JSON.parse error position
157+ // to progressively find valid JSON instead of naive brace matching
158+ // (which breaks on braces inside string values like code snippets)
159+ const braceStart = text . indexOf ( '{"findings"' ) ;
160+ if ( braceStart < 0 ) return null ;
161+ // Try progressively longer substrings from braceStart
162+ for ( let end = text . indexOf ( "}" , braceStart ) ; end >= 0 ; end = text . indexOf ( "}" , end + 1 ) ) {
163+ try {
164+ const candidate = text . slice ( braceStart , end + 1 ) ;
165+ const parsed = JSON . parse ( candidate ) ;
166+ if ( isValidFindingsPayload ( parsed ) ) return parsed ;
167+ } catch { }
168+ }
169+ return null ;
170+ }
171+
172+ function tryRescueFromTextResponse ( sessionState ) {
173+ const text = extractLastAssistantText ( sessionState ) ;
174+ if ( ! text ) return false ;
175+ try { writeFileSync ( `${ OUTPUT } /last-assistant-text.txt` , text ) ; } catch { }
176+ const payload = tryParseJsonFromText ( text ) ;
177+ if ( ! payload ) {
178+ console . error ( `[pi-runner] Text rescue: found text (${ text . length } chars) but no valid JSON. First 200: ${ text . slice ( 0 , 200 ) } ` ) ;
179+ return false ;
180+ }
181+ console . error ( `[pi-runner] Text rescue: extracted ${ payload . findings . length } findings` ) ;
182+ writeFileSync ( `${ OUTPUT } /result.json` , JSON . stringify ( payload , null , 2 ) ) ;
183+ return checkResultFile ( ) ;
184+ }
185+
126186// ── Main ─────────────────────────────────────────────────────────
127187
128188const prompt = readFileSync ( "/workspace/.prompt" , "utf-8" ) . trim ( ) ;
@@ -233,62 +293,99 @@ async function main() {
233293 process . exit ( 0 ) ;
234294 }
235295
236- // ── Attempt 2: Continuation (same session, directive prompt) ──
296+ // ── Validate & retry: if result.json is missing, re-prompt the agent ──
297+
298+ // Extract what the agent actually said
299+ const agentText = extractLastAssistantText ( session . state ) ;
300+ if ( agentText ) {
301+ console . error ( `[pi-runner] Agent produced text (${ agentText . length } chars) but no result.json` ) ;
302+ // Try to rescue valid JSON from the text
303+ if ( tryRescueFromTextResponse ( session . state ) ) {
304+ console . error ( `[pi-runner] SUCCESS: rescued valid JSON from agent text` ) ;
305+ unsubscribe ( ) ;
306+ process . exit ( 0 ) ;
307+ }
308+ }
237309
238- console . error ( `[pi-runner] Continuation: directing agent to write findings NOW` ) ;
310+ // Re-prompt: tell the agent exactly what went wrong
311+ console . error ( `[pi-runner] Re-prompting agent to write result.json` ) ;
239312
240- let contAborted = false ;
241- const contTimer = setTimeout ( ( ) => {
242- contAborted = true ;
243- console . error ( `[pi-runner] Continuation hard timeout — aborting` ) ;
313+ let retryAborted = false ;
314+ const retryTimer = setTimeout ( ( ) => {
315+ retryAborted = true ;
316+ console . error ( `[pi-runner] Retry hard timeout — aborting` ) ;
244317 session . agent . abort ( ) ;
245318 } , CONTINUATION_TIMEOUT_MS ) ;
246319
247- const contStartMs = Date . now ( ) ;
320+ const retryStartMs = Date . now ( ) ;
321+
322+ // Build retry prompt based on what actually happened.
323+ // Check timeout first — it's the most operationally relevant signal.
324+ let retryPrompt ;
325+ if ( softTimeoutFired || hardAborted ) {
326+ retryPrompt =
327+ `You ran out of time before writing result.json. ` +
328+ `Based on what you already analyzed, write the result.json file NOW using the write tool. ` +
329+ `Include one finding per practice. For any you did not analyze, use POSITIVE with confidence 0.70. ` +
330+ `The JSON needs a "findings" array and a "delivery.mrNote" string. ` +
331+ `Write to /workspace/.output/result.json immediately. Do not explain — just call the write tool.` ;
332+ } else if ( agentText ) {
333+ retryPrompt =
334+ `You completed your analysis but output the result as text instead of writing it to a file. ` +
335+ `You MUST use the write tool to save the JSON to /workspace/.output/result.json. ` +
336+ `The JSON needs a "findings" array and a "delivery.mrNote" string. ` +
337+ `Call the write tool NOW. Do not explain — just write the file.` ;
338+ } else {
339+ retryPrompt =
340+ `Your previous response did not write the required output file. ` +
341+ `You MUST call the write tool to save a JSON object to /workspace/.output/result.json. ` +
342+ `The JSON needs a "findings" array and a "delivery.mrNote" string. ` +
343+ `Do not explain — just call the write tool with the JSON.` ;
344+ }
248345
249346 try {
250- await session . prompt (
251- `You ran out of time. You have already read the diff and practice criteria in this session. ` +
252- `Do NOT read any more files. Do NOT grep anything. Do NOT explore the repo. ` +
253- `Based on what you have already analyzed, IMMEDIATELY write the result.json file using the write tool. ` +
254- `Include findings for ALL practices in the index. For practices you analyzed in detail, use your analysis. ` +
255- `For practices you did not fully analyze, emit POSITIVE with confidence 0.70 and a brief positive note. ` +
256- `Write the COMPLETE JSON to /workspace/.output/result.json NOW. This is your ONLY task.`
257- ) ;
347+ await session . prompt ( retryPrompt ) ;
258348 } catch ( err ) {
259- console . error ( `[pi-runner] Continuation error: ${ err . message } ` ) ;
349+ console . error ( `[pi-runner] Retry error: ${ err . message } ` ) ;
260350 }
261351
262- clearTimeout ( contTimer ) ;
352+ clearTimeout ( retryTimer ) ;
263353
264- const contDurationMs = Date . now ( ) - contStartMs ;
265- const contUsage = extractUsageFromSession ( session . state ) ;
266- accumulateUsage ( prevUsage , contUsage ) ;
354+ const retryDurationMs = Date . now ( ) - retryStartMs ;
355+ const retryUsage = extractUsageFromSession ( session . state ) ;
356+ accumulateUsage ( prevUsage , retryUsage ) ;
357+ prevUsage = retryUsage ;
267358
268359 runnerDebug . attempts . push ( {
269- label : "continuation " ,
270- durationMs : contDurationMs ,
271- hardAborted : contAborted ,
272- assistantMessages : contUsage . assistantMessages ,
273- stopReasons : contUsage . stopReasons ,
274- usage : contUsage ,
360+ label : "retry " ,
361+ durationMs : retryDurationMs ,
362+ hardAborted : retryAborted ,
363+ assistantMessages : retryUsage . assistantMessages ,
364+ stopReasons : retryUsage . stopReasons ,
365+ usage : retryUsage ,
275366 resultFilePresent : existsSync ( `${ OUTPUT } /result.json` ) ,
276367 } ) ;
277368 persistRunnerDebug ( ) ;
278369 persistUsage ( ) ;
279370
280- console . error ( `[pi-runner] Continuation : ${ ( contDurationMs / 1000 ) . toFixed ( 1 ) } s, resultFile=${ existsSync ( `${ OUTPUT } /result.json` ) } ` ) ;
371+ console . error ( `[pi-runner] Retry : ${ ( retryDurationMs / 1000 ) . toFixed ( 1 ) } s, resultFile=${ existsSync ( `${ OUTPUT } /result.json` ) } ` ) ;
281372
282373 unsubscribe ( ) ;
283374
284375 if ( checkResultFile ( ) ) {
285- console . error ( `[pi-runner] SUCCESS: result.json valid after continuation` ) ;
376+ console . error ( `[pi-runner] SUCCESS: result.json valid after retry` ) ;
377+ process . exit ( 0 ) ;
378+ }
379+
380+ // Last attempt: try to rescue from text
381+ if ( tryRescueFromTextResponse ( session . state ) ) {
382+ console . error ( `[pi-runner] SUCCESS: rescued valid JSON from retry text` ) ;
286383 process . exit ( 0 ) ;
287384 }
288385
289386 // ── Failed ───────────────────────────────────────────────────
290387
291- console . error ( `[pi-runner] FAILED: no valid result.json after initial + continuation ` ) ;
388+ console . error ( `[pi-runner] FAILED: no valid result.json after initial + retry ` ) ;
292389 process . exit ( 1 ) ;
293390}
294391
0 commit comments