Skip to content

Commit f33016d

Browse files
Add Ralph status dialog and session management improvements
1 parent 3d11d29 commit f33016d

5 files changed

Lines changed: 643 additions & 63 deletions

File tree

backend/src/routes/memory.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { resolveProjectId } from '../services/project-id-resolver'
88
import { getRepoById } from '../db/queries'
99
import { getWorkspacePath, getConfigPath } from '@opencode-manager/shared/config/env'
1010
import { parseJsonc } from '@opencode-manager/shared/utils'
11+
import { OPENCODE_SERVER_URL } from '../services/proxy'
1112
import {
1213
CreateMemoryRequestSchema,
1314
UpdateMemoryRequestSchema,
@@ -544,7 +545,7 @@ export function createMemoryRoutes(db: Database): Hono {
544545
const repo = getRepoById(db, repoId)
545546

546547
if (!repo) {
547-
return c.json({ loops: [] })
548+
return c.json({ loops: [], projectId: null })
548549
}
549550

550551
const projectId = await resolveProjectId(repo.fullPath)
@@ -565,7 +566,7 @@ export function createMemoryRoutes(db: Database): Hono {
565566
})
566567
.filter((loop): loop is RalphState => loop !== null)
567568

568-
return c.json({ loops })
569+
return c.json({ loops, projectId })
569570
} catch (error) {
570571
logger.error('Failed to get Ralph status:', error)
571572
return c.json({ error: 'Failed to get Ralph status' }, 500)
@@ -599,9 +600,16 @@ export function createMemoryRoutes(db: Database): Hono {
599600
return c.json({ cancelled: false })
600601
}
601602

602-
const state = kvEntry.data as { active?: boolean; worktreeName?: string } | undefined
603+
const result = RalphStateSchema.safeParse(kvEntry.data)
604+
605+
if (!result.success) {
606+
logger.warn('Failed to parse Ralph state for cancel:', result.error)
607+
return c.json({ cancelled: false })
608+
}
609+
610+
const state = result.data
603611

604-
if (!state?.active) {
612+
if (!state.active) {
605613
return c.json({ cancelled: false })
606614
}
607615

@@ -614,6 +622,14 @@ export function createMemoryRoutes(db: Database): Hono {
614622

615623
pluginMemory.setKv(projectId, `ralph:${sessionId}`, updatedState)
616624

625+
try {
626+
const abortUrl = new URL(`${OPENCODE_SERVER_URL}/session/${sessionId}/abort`)
627+
abortUrl.searchParams.set('directory', repo.fullPath)
628+
await fetch(abortUrl.toString(), { method: 'POST' })
629+
} catch {
630+
// Session may already be idle
631+
}
632+
617633
return c.json({ cancelled: true, worktreeName: state.worktreeName })
618634
} catch (error) {
619635
logger.error('Failed to cancel Ralph loop:', error)

packages/memory/src/hooks/ralph.ts

Lines changed: 132 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,19 @@ export function createRalphEventHandler(
6464
logger.error(`Ralph: failed to commit changes in worktree ${state.worktreeDir}`, err)
6565
}
6666

67-
try {
68-
const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: state.worktreeDir, encoding: 'utf-8' }).trim()
69-
const gitRoot = resolve(state.worktreeDir, gitCommonDir, '..')
70-
const removeResult = spawnSync('git', ['worktree', 'remove', '-f', state.worktreeDir], { cwd: gitRoot, encoding: 'utf-8' })
71-
if (removeResult.status !== 0) {
72-
throw new Error(removeResult.stderr || 'git worktree remove failed')
67+
if (state.worktreeDir && state.worktreeBranch) {
68+
try {
69+
const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: state.worktreeDir, encoding: 'utf-8' }).trim()
70+
const gitRoot = resolve(state.worktreeDir, gitCommonDir, '..')
71+
const removeResult = spawnSync('git', ['worktree', 'remove', '-f', state.worktreeDir], { cwd: gitRoot, encoding: 'utf-8' })
72+
if (removeResult.status !== 0) {
73+
throw new Error(removeResult.stderr || 'git worktree remove failed')
74+
}
75+
cleaned = true
76+
logger.log(`Ralph: removed worktree ${state.worktreeDir}, branch ${state.worktreeBranch} preserved`)
77+
} catch (err) {
78+
logger.error(`Ralph: failed to remove worktree ${state.worktreeDir}`, err)
7379
}
74-
cleaned = true
75-
logger.log(`Ralph: removed worktree ${state.worktreeDir}, branch ${state.worktreeBranch} preserved`)
76-
} catch (err) {
77-
logger.error(`Ralph: failed to remove worktree ${state.worktreeDir}`, err)
7880
}
7981

8082
return { committed, cleaned }
@@ -188,7 +190,7 @@ export function createRalphEventHandler(
188190
logger.log(`Ralph loop terminated: reason="${reason}", worktree="${state.worktreeName}", iteration=${state.iteration}`)
189191

190192
let commitResult: { committed: boolean; cleaned: boolean } | undefined
191-
if (reason === 'completed') {
193+
if (reason === 'completed' || reason === 'cancelled') {
192194
commitResult = await commitAndCleanupWorktree(state)
193195
}
194196
}
@@ -227,7 +229,7 @@ export function createRalphEventHandler(
227229
}
228230
}
229231

230-
async function getLastAssistantText(sessionId: string, worktreeDir: string): Promise<string | null> {
232+
async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null }> {
231233
try {
232234
const messagesResult = await v2Client.session.messages({
233235
sessionID: sessionId,
@@ -236,21 +238,25 @@ export function createRalphEventHandler(
236238
})
237239

238240
const messages = (messagesResult.data ?? []) as Array<{
239-
info: { role: string }
241+
info: { role: string; error?: { name?: string; data?: { message?: string } } }
240242
parts: Array<{ type: string; text?: string }>
241243
}>
242244

243245
const lastAssistant = [...messages].reverse().find((m) => m.info.role === 'assistant')
244246

245-
if (!lastAssistant) return null
247+
if (!lastAssistant) return { text: null, error: null }
246248

247-
return lastAssistant.parts
249+
const text = lastAssistant.parts
248250
.filter((p) => p.type === 'text' && typeof p.text === 'string')
249251
.map((p) => p.text as string)
250-
.join('\n')
252+
.join('\n') || null
253+
254+
const error = lastAssistant.info.error?.data?.message ?? lastAssistant.info.error?.name ?? null
255+
256+
return { text, error }
251257
} catch (err) {
252258
logger.error(`Ralph: could not read session messages`, err)
253-
return null
259+
return { text: null, error: null }
254260
}
255261
}
256262

@@ -292,9 +298,31 @@ export function createRalphEventHandler(
292298
return
293299
}
294300

301+
if (!currentState.worktreeDir) {
302+
logger.error(`Ralph: loop ${sessionId} missing worktreeDir in coding phase, terminating`)
303+
await terminateLoop(sessionId, currentState, 'missing_worktree_dir')
304+
return
305+
}
306+
307+
let assistantErrorDetected = false
295308
if (currentState.completionPromise) {
296-
const textContent = await getLastAssistantText(sessionId, currentState.worktreeDir)
297-
if (textContent && ralphService.checkCompletionPromise(textContent, currentState.completionPromise)) {
309+
const { text: textContent, error: assistantError } = await getLastAssistantInfo(sessionId, currentState.worktreeDir)
310+
if (assistantError) {
311+
assistantErrorDetected = true
312+
logger.error(`Ralph: assistant error detected in coding phase: ${assistantError}`)
313+
const isModelError = /provider|auth|model|api\s*error/i.test(assistantError)
314+
if (isModelError) {
315+
const nextErrorCount = (currentState.errorCount ?? 0) + 1
316+
if (nextErrorCount >= MAX_RETRIES) {
317+
await terminateLoop(sessionId, currentState, `error_max_retries: assistant error: ${assistantError}`)
318+
return
319+
}
320+
ralphService.setState(sessionId, { ...currentState, modelFailed: true, errorCount: nextErrorCount })
321+
logger.log(`Ralph: marking model as failed, will fall back to default model (error ${nextErrorCount}/${MAX_RETRIES})`)
322+
currentState = ralphService.getActiveState(sessionId)!
323+
}
324+
}
325+
if (textContent && currentState.completionPromise && ralphService.checkCompletionPromise(textContent, currentState.completionPromise)) {
298326
const currentAuditCount = currentState.auditCount ?? 0
299327
if (!currentState.audit || currentAuditCount >= minAudits) {
300328
await terminateLoop(sessionId, currentState, 'completed')
@@ -305,14 +333,20 @@ export function createRalphEventHandler(
305333
}
306334
}
307335

308-
if (currentState.maxIterations > 0 && currentState.iteration >= currentState.maxIterations) {
336+
if (!assistantErrorDetected && currentState.errorCount && currentState.errorCount > 0) {
337+
ralphService.setState(sessionId, { ...currentState, errorCount: 0 })
338+
logger.log(`Ralph: resetting error count after successful retry in coding phase`)
339+
currentState = ralphService.getActiveState(sessionId)!
340+
}
341+
342+
if ((currentState.maxIterations ?? 0) > 0 && (currentState.iteration ?? 0) >= (currentState.maxIterations ?? 0)) {
309343
await terminateLoop(sessionId, currentState, 'max_iterations')
310344
return
311345
}
312346

313347
if (currentState.audit) {
314348
ralphService.setState(sessionId, { ...currentState, phase: 'auditing', errorCount: 0 })
315-
logger.log(`Ralph iteration ${currentState.iteration} complete, running auditor for session ${sessionId}`)
349+
logger.log(`Ralph iteration ${currentState.iteration ?? 0} complete, running auditor for session ${sessionId}`)
316350

317351
const auditPrompt = {
318352
sessionID: sessionId,
@@ -353,19 +387,26 @@ export function createRalphEventHandler(
353387
logger.error(`Ralph: session rotation failed, continuing with existing session`, err)
354388
}
355389

356-
const nextIteration = currentState.iteration + 1
390+
const nextIteration = (currentState.iteration ?? 0) + 1
357391
ralphService.setState(activeSessionId, {
358392
...currentState,
359393
sessionId: activeSessionId,
360394
iteration: nextIteration,
361-
errorCount: 0,
395+
errorCount: assistantErrorDetected ? currentState.errorCount : 0,
362396
})
363397

364398
const continuationPrompt = ralphService.buildContinuationPrompt({ ...currentState, iteration: nextIteration })
365399
logger.log(`Ralph iteration ${nextIteration} for session ${activeSessionId}`)
366400

367401
const currentConfig = getConfig()
368-
const ralphModel = parseModelString(currentConfig.ralph?.model) ?? parseModelString(currentConfig.executionModel)
402+
const freshStateForModel = ralphService.getActiveState(activeSessionId)
403+
const ralphModel = freshStateForModel?.modelFailed
404+
? undefined
405+
: (parseModelString(currentConfig.ralph?.model) ?? parseModelString(currentConfig.executionModel))
406+
407+
if (freshStateForModel?.modelFailed) {
408+
logger.log(`Ralph: configured model previously failed, using default model`)
409+
}
369410

370411
const sendContinuationPromptWithModel = async () => {
371412
const freshState = ralphService.getActiveState(activeSessionId)
@@ -376,7 +417,7 @@ export function createRalphEventHandler(
376417
sessionID: activeSessionId,
377418
directory: freshState.worktreeDir,
378419
parts: [{ type: 'text' as const, text: continuationPrompt }],
379-
model: ralphModel!,
420+
model: ralphModel,
380421
})
381422
return { data: result.data, error: result.error }
382423
}
@@ -405,7 +446,8 @@ export function createRalphEventHandler(
405446
const retryFn = async () => {
406447
const result = await sendContinuationPromptWithoutModel()
407448
if (result.error) {
408-
throw result.error
449+
await handlePromptError(activeSessionId, currentState, 'retry failed', result.error)
450+
return
409451
}
410452
}
411453
await handlePromptError(activeSessionId, currentState, 'failed to send continuation prompt', promptResult.error, retryFn)
@@ -423,17 +465,46 @@ export function createRalphEventHandler(
423465

424466
async function handleAuditingPhase(sessionId: string, state: RalphState): Promise<void> {
425467
// Re-fetch and validate state to catch aborts that happened during idle event processing
426-
const currentState = ralphService.getActiveState(sessionId)
468+
let currentState = ralphService.getActiveState(sessionId)
427469
if (!currentState?.active) {
428470
logger.log(`Ralph: loop ${sessionId} no longer active, skipping auditing phase`)
429471
return
430472
}
431473

432-
const auditText = await getLastAssistantText(sessionId, currentState.worktreeDir)
474+
if (!currentState.worktreeDir) {
475+
logger.error(`Ralph: loop ${sessionId} missing worktreeDir in auditing phase, terminating`)
476+
await terminateLoop(sessionId, currentState, 'missing_worktree_dir')
477+
return
478+
}
479+
480+
const { text: auditText, error: assistantError } = await getLastAssistantInfo(sessionId, currentState.worktreeDir)
481+
482+
let assistantErrorDetected = false
483+
if (assistantError) {
484+
assistantErrorDetected = true
485+
logger.error(`Ralph: assistant error detected in auditing phase: ${assistantError}`)
486+
const isModelError = /provider|auth|model|api\s*error/i.test(assistantError)
487+
if (isModelError) {
488+
const nextErrorCount = (currentState.errorCount ?? 0) + 1
489+
if (nextErrorCount >= MAX_RETRIES) {
490+
await terminateLoop(sessionId, currentState, `error_max_retries: assistant error: ${assistantError}`)
491+
return
492+
}
493+
ralphService.setState(sessionId, { ...currentState, modelFailed: true, errorCount: nextErrorCount })
494+
logger.log(`Ralph: marking model as failed, will fall back to default model (error ${nextErrorCount}/${MAX_RETRIES})`)
495+
currentState = ralphService.getActiveState(sessionId)!
496+
}
497+
}
433498

434-
const nextIteration = currentState.iteration + 1
499+
if (!assistantErrorDetected && currentState.errorCount && currentState.errorCount > 0) {
500+
ralphService.setState(sessionId, { ...currentState, errorCount: 0 })
501+
logger.log(`Ralph: resetting error count after successful retry in auditing phase`)
502+
currentState = ralphService.getActiveState(sessionId)!
503+
}
504+
505+
const nextIteration = (currentState.iteration ?? 0) + 1
435506
const newAuditCount = (currentState.auditCount ?? 0) + 1
436-
logger.log(`Ralph audit ${newAuditCount} at iteration ${currentState.iteration}`)
507+
logger.log(`Ralph audit ${newAuditCount} at iteration ${currentState.iteration ?? 0}`)
437508

438509
// Always pass the full audit response to the code agent
439510
const auditFindings = auditText ?? undefined
@@ -450,7 +521,7 @@ export function createRalphEventHandler(
450521
}
451522
}
452523

453-
if (currentState.maxIterations > 0 && nextIteration > currentState.maxIterations) {
524+
if ((currentState.maxIterations ?? 0) > 0 && nextIteration > (currentState.maxIterations ?? 0)) {
454525
await terminateLoop(sessionId, currentState, 'max_iterations')
455526
return
456527
}
@@ -469,7 +540,7 @@ export function createRalphEventHandler(
469540
phase: 'coding',
470541
lastAuditResult: auditFindings,
471542
auditCount: newAuditCount,
472-
errorCount: 0,
543+
errorCount: assistantErrorDetected ? currentState.errorCount : 0,
473544
})
474545

475546
const continuationPrompt = ralphService.buildContinuationPrompt(
@@ -479,7 +550,14 @@ export function createRalphEventHandler(
479550
logger.log(`Ralph iteration ${nextIteration} for session ${activeSessionId}`)
480551

481552
const currentConfig = getConfig()
482-
const ralphModel = parseModelString(currentConfig.ralph?.model) ?? parseModelString(currentConfig.executionModel)
553+
const freshStateForModel = ralphService.getActiveState(activeSessionId)
554+
const ralphModel = freshStateForModel?.modelFailed
555+
? undefined
556+
: (parseModelString(currentConfig.ralph?.model) ?? parseModelString(currentConfig.executionModel))
557+
558+
if (freshStateForModel?.modelFailed) {
559+
logger.log(`Ralph: configured model previously failed, using default model`)
560+
}
483561

484562
const sendContinuationPromptWithModel = async () => {
485563
const freshState = ralphService.getActiveState(activeSessionId)
@@ -490,7 +568,7 @@ export function createRalphEventHandler(
490568
sessionID: activeSessionId,
491569
directory: freshState.worktreeDir,
492570
parts: [{ type: 'text' as const, text: continuationPrompt }],
493-
model: ralphModel!,
571+
model: ralphModel,
494572
})
495573
return { data: result.data, error: result.error }
496574
}
@@ -523,7 +601,8 @@ export function createRalphEventHandler(
523601
}
524602
const result = await sendContinuationPromptWithoutModel()
525603
if (result.error) {
526-
throw result.error
604+
await handlePromptError(activeSessionId, currentState, 'retry failed after audit', result.error)
605+
return
527606
}
528607
}
529608
await handlePromptError(activeSessionId, currentState, 'failed to send continuation prompt after audit', promptResult.error, retryFn)
@@ -558,17 +637,31 @@ export function createRalphEventHandler(
558637
}
559638

560639
if (event.type === 'session.error') {
561-
const errorProps = event.properties as { sessionID?: string; error?: { name?: string } }
640+
const errorProps = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
562641
const eventSessionId = errorProps?.sessionID
563642
const errorName = errorProps?.error?.name
564643
const isAbort = errorName === 'MessageAbortedError' || errorName === 'AbortError'
565644

566-
if (!eventSessionId || !isAbort) return
645+
if (!eventSessionId) return
646+
647+
if (isAbort) {
648+
const state = ralphService.getActiveState(eventSessionId)
649+
if (state?.active) {
650+
logger.log(`Ralph: session ${eventSessionId} aborted, terminating loop`)
651+
await terminateLoop(eventSessionId, state, 'user_aborted')
652+
}
653+
return
654+
}
567655

568656
const state = ralphService.getActiveState(eventSessionId)
569657
if (state?.active) {
570-
logger.log(`Ralph: session ${eventSessionId} aborted, terminating loop`)
571-
await terminateLoop(eventSessionId, state, 'user_aborted')
658+
const errorMessage = errorProps?.error?.data?.message ?? errorName ?? 'unknown error'
659+
logger.error(`Ralph: session error for ${eventSessionId}: ${errorMessage}`)
660+
const isModelError = /provider|auth|model|api\s*error/i.test(errorMessage)
661+
if (isModelError && !state.modelFailed) {
662+
logger.log(`Ralph: marking model as failed, will fall back to default on next iteration`)
663+
ralphService.setState(eventSessionId, { ...state, modelFailed: true })
664+
}
572665
}
573666
return
574667
}

0 commit comments

Comments
 (0)