fix(ralph-loop): commit iteration only after verification continuation dispatches

Split the verification-failure restart into clearVerificationState (clears\nthe verification flags so we cleanly transition back to the main loop)\nfollowed by injectContinuationPrompt, with incrementIteration only on\nsuccessful injection. On rejection: clear the loop state and emit a loud\nwarning toast. Mirrors the dispatch-before-commit contract enforced for\nthe idle and session.error paths.
This commit is contained in:
YeonGyu-Kim
2026-05-11 12:46:35 +09:00
parent 09c45c3acb
commit caaae19155
3 changed files with 68 additions and 9 deletions
@@ -174,5 +174,27 @@ export function createLoopStateController(options: {
return state
},
clearVerificationState(sessionID: string, messageCountAtStart?: number): RalphLoopState | null {
const state = readState(directory, stateDir)
if (!state || state.session_id !== sessionID || !state.ultrawork || !state.verification_pending) {
return null
}
state.started_at = new Date().toISOString()
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
state.verification_pending = undefined
state.verification_attempt_id = undefined
state.verification_session_id = undefined
if (typeof messageCountAtStart === "number") {
state.message_count_at_start = messageCountAtStart
}
if (!writeState(directory, state, stateDir)) {
return null
}
return state
},
}
}
@@ -82,6 +82,9 @@ async function detectOracleVerificationFromParentSession(
type LoopStateController = {
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
incrementIteration: () => RalphLoopState | null
clear: () => boolean
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
}
@@ -6,10 +6,22 @@ import { injectContinuationPrompt } from "./continuation-prompt-injector"
import type { RalphLoopState } from "./types"
type LoopStateController = {
restartAfterFailedVerification: (
clearVerificationState: (
sessionID: string,
messageCountAtStart?: number,
) => RalphLoopState | null
incrementIteration: () => RalphLoopState | null
clear: () => boolean
}
function showToastBestEffort(
ctx: PluginInput,
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
): void {
try {
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
} catch {
}
}
function getMessageCountFromResponse(messagesResponse: unknown): number {
@@ -72,23 +84,45 @@ export async function handleFailedVerification(
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
}
const resumedState = loopState.restartAfterFailedVerification(
const clearedState = loopState.clearVerificationState(
parentSessionID,
messageCountAtStart,
)
if (!resumedState) {
if (!clearedState) {
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
parentSessionID,
})
return false
}
await injectContinuationPrompt(ctx, {
sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(resumedState),
directory,
apiTimeoutMs,
})
const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
try {
await injectContinuationPrompt(ctx, {
sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(previewState),
directory,
apiTimeoutMs,
})
} catch (error) {
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
parentSessionID,
error: String(error),
})
loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: `Verification continuation rejected: ${String(error)}`,
variant: "warning",
duration: 5000,
})
return false
}
const committed = loopState.incrementIteration()
if (!committed) {
log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID })
}
await ctx.client.tui?.showToast?.({
body: {