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 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 = { type LoopStateController = {
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null 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 setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
} }
@@ -6,10 +6,22 @@ import { injectContinuationPrompt } from "./continuation-prompt-injector"
import type { RalphLoopState } from "./types" import type { RalphLoopState } from "./types"
type LoopStateController = { type LoopStateController = {
restartAfterFailedVerification: ( clearVerificationState: (
sessionID: string, sessionID: string,
messageCountAtStart?: number, messageCountAtStart?: number,
) => RalphLoopState | null ) => 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 { function getMessageCountFromResponse(messagesResponse: unknown): number {
@@ -72,23 +84,45 @@ export async function handleFailedVerification(
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {}) ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
} }
const resumedState = loopState.restartAfterFailedVerification( const clearedState = loopState.clearVerificationState(
parentSessionID, parentSessionID,
messageCountAtStart, messageCountAtStart,
) )
if (!resumedState) { if (!clearedState) {
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, { log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
parentSessionID, parentSessionID,
}) })
return false return false
} }
await injectContinuationPrompt(ctx, { const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(resumedState), try {
directory, await injectContinuationPrompt(ctx, {
apiTimeoutMs, 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?.({ await ctx.client.tui?.showToast?.({
body: { body: {