fix(ralph-loop): commit iteration only after continuation is dispatched

Reorder the session.idle and session.error retry paths so the durable\niteration counter and the progress toast advance only when continueIteration\nreturns dispatched. On dispatch_rejected or session_creation_rejected,\nclear the loop state and emit a loud failure toast instead of silently\nlogging while the loop appears to make progress.\n\nAdds an explicit settle-window state check so a session.deleted firing\nduring the idleSettleMs sleep no longer feeds dispatch against a cleared\nloop. Keeps idleSettleMs intact for the original idle-settle race.
This commit is contained in:
YeonGyu-Kim
2026-05-11 12:46:07 +09:00
parent d4cdeaccbe
commit 09c45c3acb
@@ -19,6 +19,7 @@ type LoopStateController = {
markVerificationPending: (sessionID: string) => RalphLoopState | null markVerificationPending: (sessionID: string) => RalphLoopState | null
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
} }
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController } type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
@@ -272,34 +273,48 @@ export function createRalphLoopEventHandler(
return return
} }
const newState = options.loopState.incrementIteration() await sleep(options.idleSettleMs)
if (!newState) { const stateAfterSettle = options.loopState.getState()
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) if (!stateAfterSettle || !stateAfterSettle.active) {
return return
} }
const nextIteration = stateAfterSettle.iteration + 1
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
log(`[${HOOK_NAME}] Continuing loop`, { log(`[${HOOK_NAME}] Continuing loop`, {
sessionID, sessionID,
iteration: newState.iteration, iteration: nextIteration,
max: newState.max_iterations, max: previewState.max_iterations,
}) })
showIterationToast(ctx, newState) const result = await continueIteration(ctx, previewState, {
await sleep(options.idleSettleMs) previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
try { if (result.status === "dispatched") {
await continueIteration(ctx, newState, { const committed = options.loopState.incrementIteration()
previousSessionID: sessionID, if (committed) {
directory: options.directory, showIterationToast(ctx, committed)
apiTimeoutMs: options.apiTimeoutMs, } else {
loopState: options.loopState, log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID })
}) }
} catch (err) { return
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
} }
log(`[${HOOK_NAME}] Dispatch failed`, { sessionID, status: result.status })
options.loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: result.status === "dispatch_rejected"
? `Dispatch ${result.status}: ${String(result.error)}`
: `Dispatch ${result.status}`,
variant: "warning",
duration: 5000,
})
return return
} finally { } finally {
inFlightSessions.delete(sessionID) inFlightSessions.delete(sessionID)
@@ -381,28 +396,43 @@ export function createRalphLoopEventHandler(
return return
} }
const newState = options.loopState.incrementIteration() await sleep(options.idleSettleMs)
if (!newState) { const stateAfterSettle = options.loopState.getState()
log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID }) if (!stateAfterSettle || !stateAfterSettle.active) {
return return
} }
showIterationToast(ctx, newState) const nextIteration = stateAfterSettle.iteration + 1
await sleep(options.idleSettleMs) const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
try {
await continueIteration(ctx, newState, { const result = await continueIteration(ctx, previewState, {
previousSessionID: sessionID, previousSessionID: sessionID,
directory: options.directory, directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs, apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState, loopState: options.loopState,
}) })
runtimeErrorRetriedSessions.set(sessionID, newState.iteration)
} catch (err) { if (result.status === "dispatched") {
log(`[${HOOK_NAME}] Failed to retry after runtime error`, { const committed = options.loopState.incrementIteration()
sessionID, if (committed) {
error: String(err), showIterationToast(ctx, committed)
}) runtimeErrorRetriedSessions.set(sessionID, committed.iteration)
} else {
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed after runtime error`, { sessionID })
}
return
} }
log(`[${HOOK_NAME}] Dispatch failed after runtime error`, { sessionID, status: result.status })
options.loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: result.status === "dispatch_rejected"
? `Dispatch ${result.status}: ${String(result.error)}`
: `Dispatch ${result.status}`,
variant: "warning",
duration: 5000,
})
} finally { } finally {
inFlightSessions.delete(sessionID) inFlightSessions.delete(sessionID)
} }