diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts new file mode 100644 index 000000000..096a01aad --- /dev/null +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -0,0 +1,412 @@ +/// +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createRalphLoopHook } from "./index" +import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { clearState, writeState } from "./storage" +import { handleFailedVerification } from "./verification-failure-handler" + +describe("ralph-loop dispatch failure invariants", () => { + const testDirectory = join(tmpdir(), `ralph-loop-dispatch-failure-${Date.now()}`) + let promptCalls: Array<{ sessionID: string; text: string }> + let toastCalls: Array<{ title: string; message: string; variant: string }> + let messagesCalls: Array<{ sessionID: string }> + let createSessionCalls: Array<{ parentID: string }> + + beforeEach(() => { + promptCalls = [] + toastCalls = [] + messagesCalls = [] + createSessionCalls = [] + mkdirSync(testDirectory, { recursive: true }) + clearState(testDirectory) + }) + + afterEach(() => { + clearState(testDirectory) + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + test("#given idle path #when promptAsync throws #then no state or toast advance", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async () => { + throw new Error("simulated dispatch failure") + }, + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true) + }) + + test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async () => { + throw new Error("simulated dispatch failure") + }, + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // then + expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true) + }) + + test("#given verification-failure path #when promptAsync throws #then iteration not advanced", async () => { + // given + const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl") + const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl") + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + if (options.path.id === "session-123") { + return { data: [{}, {}, {}] } + } + return { data: [] } + }, + promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => { + if (options.body.parts[0]?.text.includes("Verification failed")) { + throw new Error("simulated dispatch failure") + } + return {} + }, + prompt: async () => ({}), + abort: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath, + }) + + hook.startLoop("session-123", "Build API", { ultrawork: true }) + writeState(testDirectory, { + ...hook.getState()!, + iteration: 2, + verification_pending: true, + verification_session_id: "ses-oracle", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + initial_completion_promise: "DONE", + }) + writeState(testDirectory, { + ...hook.getState()!, + verification_session_id: "ses-oracle", + }) + writeFileSync( + oracleTranscriptPath, + `${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`, + ) + + const preRestartIteration = hook.getState()?.iteration + + // when + await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } }) + + // then + expect(preRestartIteration).toBe(2) + expect(hook.getState()).toBeNull() + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true) + }) + + test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + create: async (options: { body: { parentID: string } }) => { + createSessionCalls.push({ parentID: options.body.parentID }) + return { error: "fail", data: undefined } + }, + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + strategy: "reset", + }) + expect(hook.getState()?.iteration).toBe(1) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(hook.getState()).toBeNull() + expect(promptCalls).toHaveLength(0) + expect(createSessionCalls).toHaveLength(1) + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true) + }) + + test("#given idle path #when state rebound during settle window #then no dispatch against new owner", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" }) + return {} + }, + prompt: async () => ({}), + create: async () => ({ data: { id: "new-session-id" } }), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + idleSettleMs: 50, + }) + + hook.startLoop("session-A", "Keep working", { messageCountAtStart: 0, maxIterations: 5 }) + expect(hook.getState()?.session_id).toBe("session-A") + + // when + const eventPromise = hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-A" } }, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + writeState(testDirectory, { ...hook.getState()!, session_id: "session-B" }) + await eventPromise + + // then + expect(promptCalls).toHaveLength(0) + expect(hook.getState()?.session_id).toBe("session-B") + expect(hook.getState()?.iteration).toBe(1) + }) + + test("#given verification-failure path #when incrementIteration fails #then loud failure not success", async () => { + // given + const loopState = { + clearVerificationState: () => ({ + active: true, + iteration: 2, + prompt: "Build API", + started_at: new Date().toISOString(), + session_id: "session-123", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + message_count_at_start: 3, + }), + incrementIteration: () => null, + clear: () => true, + } + + const result = await handleFailedVerification({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [{}, {}, {}] }), + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" }) + return {} + }, + abort: async () => ({}), + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never, { + state: { + active: true, + iteration: 2, + prompt: "Build API", + started_at: new Date().toISOString(), + session_id: "session-123", + completion_promise: ULTRAWORK_VERIFICATION_PROMISE, + verification_pending: true, + verification_session_id: "ses-oracle", + }, + directory: testDirectory, + apiTimeoutMs: 5000, + loopState, + }) + + // then + expect(result).toBe(false) + expect(promptCalls).toHaveLength(1) + expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false) + expect( + toastCalls.some( + (toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("iteration commit failed"), + ), + ).toBe(true) + }) + + test("#given reset strategy #when session.create throws #then dispatch failure surfaces", async () => { + // given + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async () => ({ data: [] }), + promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" }) + return {} + }, + prompt: async () => ({}), + create: async () => { + throw new Error("simulated network error during session.create") + }, + }, + tui: { + showToast: async (options: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(options.body) + return {} + }, + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + strategy: "reset", + }) + + // when + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then + expect(hook.getState()).toBeNull() + expect(promptCalls).toHaveLength(0) + expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true) + }) +}) diff --git a/src/hooks/ralph-loop/iteration-continuation.ts b/src/hooks/ralph-loop/iteration-continuation.ts index be067b76c..af43955fa 100644 --- a/src/hooks/ralph-loop/iteration-continuation.ts +++ b/src/hooks/ralph-loop/iteration-continuation.ts @@ -15,11 +15,16 @@ type ContinuationOptions = { } } +export type ContinuationResult = + | { status: "dispatched" } + | { status: "session_creation_rejected" } + | { status: "dispatch_rejected"; error: unknown } + export async function continueIteration( ctx: PluginInput, state: RalphLoopState, options: ContinuationOptions, -): Promise { +): Promise { const strategy = state.strategy ?? "continue" const continuationPrompt = buildContinuationPrompt(state) @@ -30,16 +35,20 @@ export async function continueIteration( options.directory, ) if (!newSessionID) { - return + return { status: "session_creation_rejected" } } - await injectContinuationPrompt(ctx, { - sessionID: newSessionID, - inheritFromSessionID: options.previousSessionID, - prompt: continuationPrompt, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - }) + try { + await injectContinuationPrompt(ctx, { + sessionID: newSessionID, + inheritFromSessionID: options.previousSessionID, + prompt: continuationPrompt, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + }) + } catch (error: unknown) { + return { status: "dispatch_rejected", error } + } await selectSessionInTui(ctx.client, newSessionID) @@ -49,16 +58,22 @@ export async function continueIteration( previousSessionID: options.previousSessionID, newSessionID, }) - return + return { status: "dispatched" } } - return + return { status: "dispatched" } } - await injectContinuationPrompt(ctx, { - sessionID: options.previousSessionID, - prompt: continuationPrompt, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - }) + try { + await injectContinuationPrompt(ctx, { + sessionID: options.previousSessionID, + prompt: continuationPrompt, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + }) + } catch (error: unknown) { + return { status: "dispatch_rejected", error } + } + + return { status: "dispatched" } } diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 2a455412a..3679a3dab 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -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 + }, } } diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 420a2f935..1976ec9aa 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -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 } diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 3f20ccf34..167454b58 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -19,6 +19,7 @@ type LoopStateController = { markVerificationPending: (sessionID: string) => RalphLoopState | null setVerificationSessionID: (sessionID: string, verificationSessionID: string) => 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 } @@ -272,34 +273,59 @@ export function createRalphLoopEventHandler( return } - const newState = options.loopState.incrementIteration() - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID }) + await sleep(options.idleSettleMs) + const stateAfterSettle = options.loopState.getState() + if (!stateAfterSettle || !stateAfterSettle.active) { + return + } + if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) { + log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, { + sessionID, + currentOwner: stateAfterSettle.session_id, + }) + return + } + if (stateAfterSettle.verification_pending) { + log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) return } + const nextIteration = stateAfterSettle.iteration + 1 + const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } + log(`[${HOOK_NAME}] Continuing loop`, { sessionID, - iteration: newState.iteration, - max: newState.max_iterations, + iteration: nextIteration, + max: previewState.max_iterations, }) - showIterationToast(ctx, newState) - await sleep(options.idleSettleMs) + const result = await continueIteration(ctx, previewState, { + previousSessionID: sessionID, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + loopState: options.loopState, + }) - try { - await continueIteration(ctx, newState, { - previousSessionID: sessionID, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - loopState: options.loopState, - }) - } catch (err) { - log(`[${HOOK_NAME}] Failed to inject continuation`, { - sessionID, - error: String(err), - }) + if (result.status === "dispatched") { + const committed = options.loopState.incrementIteration() + if (committed) { + showIterationToast(ctx, committed) + } else { + log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID }) + } + return } + + 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 } finally { inFlightSessions.delete(sessionID) @@ -381,28 +407,54 @@ export function createRalphLoopEventHandler( return } - const newState = options.loopState.incrementIteration() - if (!newState) { - log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID }) + await sleep(options.idleSettleMs) + const stateAfterSettle = options.loopState.getState() + if (!stateAfterSettle || !stateAfterSettle.active) { + return + } + if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) { + log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, { + sessionID, + currentOwner: stateAfterSettle.session_id, + }) + return + } + if (stateAfterSettle.verification_pending) { + log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID }) return } - showIterationToast(ctx, newState) - await sleep(options.idleSettleMs) - try { - await continueIteration(ctx, newState, { - previousSessionID: sessionID, - directory: options.directory, - apiTimeoutMs: options.apiTimeoutMs, - loopState: options.loopState, - }) - runtimeErrorRetriedSessions.set(sessionID, newState.iteration) - } catch (err) { - log(`[${HOOK_NAME}] Failed to retry after runtime error`, { - sessionID, - error: String(err), - }) + const nextIteration = stateAfterSettle.iteration + 1 + const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration } + + const result = await continueIteration(ctx, previewState, { + previousSessionID: sessionID, + directory: options.directory, + apiTimeoutMs: options.apiTimeoutMs, + loopState: options.loopState, + }) + + if (result.status === "dispatched") { + const committed = options.loopState.incrementIteration() + if (committed) { + 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 { inFlightSessions.delete(sessionID) } diff --git a/src/hooks/ralph-loop/session-reset-strategy.ts b/src/hooks/ralph-loop/session-reset-strategy.ts index d6854727d..bf8d3b5af 100644 --- a/src/hooks/ralph-loop/session-reset-strategy.ts +++ b/src/hooks/ralph-loop/session-reset-strategy.ts @@ -7,23 +7,31 @@ export async function createIterationSession( parentSessionID: string, directory: string, ): Promise { - const createResult = await ctx.client.session.create({ - body: { - parentID: parentSessionID, - title: "Ralph Loop Iteration", - }, - query: { directory }, - }) + try { + const createResult = await ctx.client.session.create({ + body: { + parentID: parentSessionID, + title: "Ralph Loop Iteration", + }, + query: { directory }, + }) - if (createResult.error || !createResult.data?.id) { - log("[ralph-loop] Failed to create iteration session", { + if (createResult.error || !createResult.data?.id) { + log("[ralph-loop] Failed to create iteration session", { + parentSessionID, + error: String(createResult.error ?? "No session ID returned"), + }) + return null + } + + return createResult.data.id + } catch (error: unknown) { + log("[ralph-loop] session.create threw during iteration session creation", { parentSessionID, - error: String(createResult.error ?? "No session ID returned"), + error: String(error), }) return null } - - return createResult.data.id } export async function selectSessionInTui( diff --git a/src/hooks/ralph-loop/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index f6ea8f522..89917f033 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -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,53 @@ 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 }) + loopState.clear() + showToastBestEffort(ctx, { + title: "Ralph Loop Failed", + message: "Verification continuation dispatched but iteration commit failed", + variant: "warning", + duration: 5000, + }) + return false + } await ctx.client.tui?.showToast?.({ body: {