Skip to content

Commit 91f8879

Browse files
bidahclaude
andcommitted
Fix session resume by adding service-level retry on stale session
When the sandbox times out and loses its filesystem, the Claude Agent SDK can't find the session file and the CLI exits with code 1 ("No conversation found with session ID"). The executor's try/catch never runs because the CLI calls process.exit(1) directly. Service fix (handles old+new sandboxes): - Detect "No conversation found" in stderr after execution failure - Clear stale session ID from DB - Re-run command without --continue (fresh session) Executor fix (for new sandbox templates): - Add unhandledRejection handler to suppress SDK async errors during resume - Break out of for-await loop immediately on resume failure - Don't stream error result to frontend during resume (prevents confusing "Task Failed" card before retry succeeds) - Wait for SDK microtasks to settle before retry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8f760ea commit 91f8879

3 files changed

Lines changed: 211 additions & 13 deletions

File tree

‎apps/web/lib/claude-code-service.ts‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,129 @@ export class ClaudeCodeService {
496496

497497
const executionDuration = Date.now() - executionStartTime
498498

499+
// Detect session resume failure — retry without --continue
500+
// The Claude Agent SDK stores sessions on the CLI's local filesystem.
501+
// If the session file is gone (sandbox restart, SDK version mismatch, etc.),
502+
// the CLI exits with "No conversation found with session ID: ..." and code 1.
503+
const isSessionResumeFailure = request.sessionId && (
504+
(execution?.stderr?.includes('No conversation found with session ID') ?? false) ||
505+
(executionError?.message?.includes('exit status 1') && !completionDetected && stdoutChunkCount < 10)
506+
)
507+
508+
if (isSessionResumeFailure) {
509+
console.warn('[Claude Code Service] 🔄 Session resume failed — retrying without --continue', {
510+
sessionId: request.sessionId,
511+
stderr: execution?.stderr?.substring(0, 300),
512+
exitCode: execution?.exitCode,
513+
})
514+
515+
// Clear stale session ID from DB
516+
try {
517+
await db.update(projects)
518+
.set({ conversationId: null, updatedAt: new Date() })
519+
.where(eq(projects.id, request.projectId))
520+
console.log('[Claude Code Service] Cleared stale session ID from DB')
521+
} catch (dbError) {
522+
console.error('[Claude Code Service] Failed to clear session ID:', dbError)
523+
}
524+
525+
// Re-run the command without --continue (fresh session)
526+
completionDetected = false
527+
capturedSessionId = null
528+
lineBuffer = ''
529+
receivedAnyOutput = false
530+
stdoutChunkCount = 0
531+
stderrChunkCount = 0
532+
execution = undefined
533+
executionError = null
534+
535+
const retryCommand = `cd /claude-sdk && bun start -- --prompt="${escapedMessage}"${systemPromptArg}${modelArg}${imageUrlsArg}${convexDeployArg}`
536+
console.log('[Claude Code Service] 🔄 Retrying with fresh session (no --continue)')
537+
538+
try {
539+
const retryHandle = await sandbox.commands.run(
540+
retryCommand,
541+
{
542+
background: true as const,
543+
envs: { ANTHROPIC_API_KEY: apiKeyToUse },
544+
timeoutMs: sandboxTimeoutMs,
545+
onStdout: (data: string) => {
546+
stdoutChunkCount++
547+
receivedAnyOutput = true
548+
lineBuffer += data
549+
550+
// Same parsing logic as original — extract streaming messages
551+
try {
552+
const lines = lineBuffer.split('\n')
553+
lineBuffer = lines.pop() || ''
554+
555+
for (const line of lines) {
556+
const trimmedLine = line.trim()
557+
if (trimmedLine === 'CLAUDE_CODE_COMPLETE') {
558+
completionDetected = true
559+
continue
560+
}
561+
if (line.includes('"type":"system"') && line.includes('"session_id"')) {
562+
try {
563+
const jsonMatch = line.match(/Streaming:\s*(\{.+\})/)
564+
if (jsonMatch) {
565+
const parsed = JSON.parse(jsonMatch[1])
566+
if (parsed.session_id && !capturedSessionId) {
567+
capturedSessionId = parsed.session_id
568+
console.log('[Claude Code Service] Captured session ID (retry):', capturedSessionId)
569+
}
570+
}
571+
} catch (e) { /* ignore */ }
572+
}
573+
if (line.includes('"type":"result"') && line.includes('"session_id"')) {
574+
try {
575+
const jsonMatch = line.match(/Streaming:\s*(\{.+\})/)
576+
if (jsonMatch) {
577+
const parsed = JSON.parse(jsonMatch[1])
578+
if (parsed.session_id && !capturedSessionId) {
579+
capturedSessionId = parsed.session_id
580+
}
581+
}
582+
} catch (e) { /* ignore */ }
583+
}
584+
if (trimmedLine && line.includes('Streaming:')) {
585+
const messageMatch = line.match(/Streaming:\s*(.+)/)
586+
if (messageMatch && messageMatch[1]) {
587+
const messageContent = messageMatch[1].trim()
588+
if (!messageContent.includes('[Heartbeat')) {
589+
if (messageContent.includes('Task failed')) {
590+
console.error('[Claude Code Service] ❌ TASK FAILED (retry):', messageContent)
591+
}
592+
callbacks.onMessage(messageContent)
593+
}
594+
}
595+
}
596+
}
597+
} catch (error) {
598+
callbacks.onMessage(data)
599+
lineBuffer = ''
600+
}
601+
},
602+
onStderr: (data: string) => {
603+
stderrChunkCount++
604+
receivedAnyOutput = true
605+
console.log(`[Claude Code Service] ⚠️ stderr (retry):`, data)
606+
},
607+
}
608+
)
609+
610+
execution = await retryHandle.wait()
611+
console.log('[Claude Code Service] ✅ Retry completed', {
612+
exitCode: execution?.exitCode,
613+
stdoutChunkCount,
614+
completionDetected,
615+
})
616+
} catch (retryError) {
617+
executionError = retryError instanceof Error ? retryError : new Error(String(retryError))
618+
console.error('[Claude Code Service] ❌ Retry also failed:', executionError.message)
619+
}
620+
}
621+
499622
// Handle execution failure
500623
// Note: With spawn() we no longer hit the 120-second timeout issue that run() had
501624
if (executionError) {

‎packages/agent/src/executor.ts‎

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ export async function runExecutor(
5252

5353
const messages: SDKMessage[] = []
5454

55+
// Suppress unhandled rejections from the Claude Agent SDK during session resume.
56+
// When resume fails with "No conversation found", the SDK yields an error result
57+
// AND throws an unhandled promise rejection from its internal readMessages() function.
58+
// This rejection bypasses try/catch and crashes the process before retry logic can run.
59+
let suppressedResumeError = false
60+
const rejectHandler = (reason: any) => {
61+
const msg = reason instanceof Error ? reason.message : String(reason)
62+
if (msg.includes('No conversation found') || msg.includes('error result')) {
63+
console.warn('Suppressed SDK unhandled rejection during resume:', msg)
64+
suppressedResumeError = true
65+
return // Suppress — retry logic will handle this
66+
}
67+
// Re-throw non-resume errors
68+
throw reason
69+
}
70+
process.on('unhandledRejection', rejectHandler)
71+
5572
// Heartbeat to keep connection alive during long operations
5673
const heartbeatInterval = setInterval(() => {
5774
console.log('Streaming: [Heartbeat - Agent is working...]')
@@ -171,23 +188,46 @@ export async function runExecutor(
171188
})) {
172189
messages.push(message)
173190

174-
// Stream slimified messages — small JSON that never spans multiple stdout chunks
175-
const slimMessages = slimifyMessage(message)
176-
for (const slim of slimMessages) {
177-
console.log(`Streaming: ${JSON.stringify(slim)}`)
178-
}
179-
180191
// Also stream completion status separately for easier detection
181192
if (message.type === 'result') {
182193
if (message.subtype === 'success') {
194+
// Stream slimified result and status
195+
const slimMessages = slimifyMessage(message)
196+
for (const slim of slimMessages) {
197+
console.log(`Streaming: ${JSON.stringify(slim)}`)
198+
}
183199
console.log(`Streaming: Task completed successfully`)
184200
console.log(`Streaming: Cost: $${message.total_cost_usd.toFixed(4)}, Duration: ${(message.duration_ms / 1000).toFixed(2)}s`)
185201
} else {
186202
const errors = (message as any).errors || []
203+
taskFailed = true
204+
205+
// When resuming, DON'T stream the error result to the frontend — we will
206+
// retry with a fresh session. Streaming it would show a confusing "Task Failed"
207+
// card before the retry succeeds.
208+
if (withResume) {
209+
console.warn('Task failed during resume — suppressing error result and breaking to retry')
210+
console.warn('Resume failure details:', message.subtype, JSON.stringify(errors))
211+
break
212+
}
213+
214+
// Non-resume failure: stream the error to the frontend
215+
const slimMessages = slimifyMessage(message)
216+
for (const slim of slimMessages) {
217+
console.log(`Streaming: ${JSON.stringify(slim)}`)
218+
}
187219
console.log(`Streaming: Task failed: ${message.subtype}`)
188220
console.log(`Streaming: Task failed errors: ${JSON.stringify(errors)}`)
189221
console.log(`Streaming: Task failed stop_reason: ${(message as any).stop_reason}`)
190-
taskFailed = true
222+
}
223+
} else {
224+
// Stream non-result messages normally (but skip during resume attempts
225+
// since they'll just show a brief init before retry)
226+
if (!withResume || message.type !== 'system') {
227+
const slimMessages = slimifyMessage(message)
228+
for (const slim of slimMessages) {
229+
console.log(`Streaming: ${JSON.stringify(slim)}`)
230+
}
191231
}
192232
}
193233
}
@@ -202,18 +242,22 @@ export async function runExecutor(
202242
console.log('Streaming: Session resume failed, starting fresh session...')
203243
messages.length = 0 // Clear any partial messages
204244
taskFailed = false
245+
// Allow any pending SDK microtasks (unhandled rejections) to settle
246+
await new Promise(resolve => setTimeout(resolve, 100))
205247
await runQuery(false)
206248
} else {
207249
throw resumeError
208250
}
209251
}
210252

211-
// If task failed with resume, retry without resume
253+
// If task failed with resume (broke out of loop), retry without resume
212254
if (taskFailed && useResume) {
213255
console.warn('Task failed with resume, retrying without resume...')
214256
console.log('Streaming: Retrying without session resume...')
215257
messages.length = 0
216258
taskFailed = false
259+
// Allow any pending SDK microtasks (unhandled rejections) to settle
260+
await new Promise(resolve => setTimeout(resolve, 100))
217261
await runQuery(false)
218262
}
219263

@@ -230,5 +274,6 @@ export async function runExecutor(
230274
return { success: false, messages, error: errorMessage }
231275
} finally {
232276
clearInterval(heartbeatInterval)
277+
process.removeListener('unhandledRejection', rejectHandler)
233278
}
234279
}

‎packages/sandbox/templates/shared/executor.mjs‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,17 @@ async function runExecutor(args, config, hooks) {
205205
console.error("Directory check failed:", err);
206206
}
207207
const messages = [];
208+
let suppressedResumeError = false;
209+
const rejectHandler = (reason) => {
210+
const msg = reason instanceof Error ? reason.message : String(reason);
211+
if (msg.includes("No conversation found") || msg.includes("error result")) {
212+
console.warn("Suppressed SDK unhandled rejection during resume:", msg);
213+
suppressedResumeError = true;
214+
return;
215+
}
216+
throw reason;
217+
};
218+
process.on("unhandledRejection", rejectHandler);
208219
const heartbeatInterval = setInterval(() => {
209220
console.log("Streaming: [Heartbeat - Agent is working...]");
210221
}, cfg.heartbeatInterval);
@@ -290,20 +301,36 @@ ${args.prompt}`;
290301
options
291302
})) {
292303
messages.push(message);
293-
const slimMessages = slimifyMessage(message);
294-
for (const slim of slimMessages) {
295-
console.log(`Streaming: ${JSON.stringify(slim)}`);
296-
}
297304
if (message.type === "result") {
298305
if (message.subtype === "success") {
306+
const slimMessages = slimifyMessage(message);
307+
for (const slim of slimMessages) {
308+
console.log(`Streaming: ${JSON.stringify(slim)}`);
309+
}
299310
console.log(`Streaming: Task completed successfully`);
300311
console.log(`Streaming: Cost: $${message.total_cost_usd.toFixed(4)}, Duration: ${(message.duration_ms / 1e3).toFixed(2)}s`);
301312
} else {
302313
const errors = message.errors || [];
314+
taskFailed = true;
315+
if (withResume) {
316+
console.warn("Task failed during resume \u2014 suppressing error result and breaking to retry");
317+
console.warn("Resume failure details:", message.subtype, JSON.stringify(errors));
318+
break;
319+
}
320+
const slimMessages = slimifyMessage(message);
321+
for (const slim of slimMessages) {
322+
console.log(`Streaming: ${JSON.stringify(slim)}`);
323+
}
303324
console.log(`Streaming: Task failed: ${message.subtype}`);
304325
console.log(`Streaming: Task failed errors: ${JSON.stringify(errors)}`);
305326
console.log(`Streaming: Task failed stop_reason: ${message.stop_reason}`);
306-
taskFailed = true;
327+
}
328+
} else {
329+
if (!withResume || message.type !== "system") {
330+
const slimMessages = slimifyMessage(message);
331+
for (const slim of slimMessages) {
332+
console.log(`Streaming: ${JSON.stringify(slim)}`);
333+
}
307334
}
308335
}
309336
}
@@ -316,6 +343,7 @@ ${args.prompt}`;
316343
console.log("Streaming: Session resume failed, starting fresh session...");
317344
messages.length = 0;
318345
taskFailed = false;
346+
await new Promise((resolve2) => setTimeout(resolve2, 100));
319347
await runQuery(false);
320348
} else {
321349
throw resumeError;
@@ -326,6 +354,7 @@ ${args.prompt}`;
326354
console.log("Streaming: Retrying without session resume...");
327355
messages.length = 0;
328356
taskFailed = false;
357+
await new Promise((resolve2) => setTimeout(resolve2, 100));
329358
await runQuery(false);
330359
}
331360
console.log("Query completed successfully");
@@ -339,6 +368,7 @@ ${args.prompt}`;
339368
return { success: false, messages, error: errorMessage };
340369
} finally {
341370
clearInterval(heartbeatInterval);
371+
process.removeListener("unhandledRejection", rejectHandler);
342372
}
343373
}
344374

0 commit comments

Comments
 (0)