From f0857a88fb5f23b3e59fdd77bc1919514c9248ef Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:45:51 +0900 Subject: [PATCH 1/6] test(ralph-loop): add dispatch-failure invariant tests Lock the contract that durable iteration state and visible UI must only\nadvance when the continuation dispatch is semantically accepted. Adds 4\npermanent invariant tests covering the idle, session.error retry, and\nverification-failure orchestration paths, plus the reset-strategy\nsilent-null path. --- .../dispatch-failure-invariant.test.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/hooks/ralph-loop/dispatch-failure-invariant.test.ts 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..174ce459a --- /dev/null +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -0,0 +1,254 @@ +/// +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" + +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) + }) +}) From d4cdeaccbecfc4604f4a1d4e91f97f9e9b5d72b5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:45:59 +0900 Subject: [PATCH 2/6] fix(ralph-loop): return typed ContinuationResult from continueIteration Replace silent returns in continueIteration with a discriminated union\n(dispatched | session_creation_rejected | dispatch_rejected). Wraps\ninjectContinuationPrompt in try/catch so reset-strategy createIterationSession\nreturning null and promptAsync rejections both surface as typed failures\nthe caller can react to. --- .../ralph-loop/iteration-continuation.ts | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) 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" } } From 09c45c3acb7f339065d0b65795e821621386fc8b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:46:07 +0900 Subject: [PATCH 3/6] 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. --- .../ralph-loop/ralph-loop-event-handler.ts | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 3f20ccf34..3af7c1a3a 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,48 @@ 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 } + 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 +396,43 @@ 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 } - 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) } From caaae191556d2740a508f7da1a7826c87afd2b31 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 12:46:35 +0900 Subject: [PATCH 4/6] 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. --- src/hooks/ralph-loop/loop-state-controller.ts | 22 ++++++++ .../pending-verification-handler.ts | 3 ++ .../verification-failure-handler.ts | 52 +++++++++++++++---- 3 files changed, 68 insertions(+), 9 deletions(-) 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/verification-failure-handler.ts b/src/hooks/ralph-loop/verification-failure-handler.ts index f6ea8f522..6b8e2d2cb 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,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: { From 35cab4db10bfc469958df3d1510d661af88bd78a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:02:07 +0900 Subject: [PATCH 5/6] test(ralph-loop): cover ownership race, verification commit failure, session.create throw Three additional invariant tests addressing the gaps surfaced by Cubic and the post-implementation review: - idle path must not dispatch when state ownership changes during the idleSettleMs window - verification-failure path must treat incrementIteration failure as a loud failure, not a success - reset strategy must surface session.create rejections as session_creation_rejected even when the SDK throws instead of returning an error envelope --- .../dispatch-failure-invariant.test.ts | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts index 174ce459a..096a01aad 100644 --- a/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts +++ b/src/hooks/ralph-loop/dispatch-failure-invariant.test.ts @@ -6,6 +6,7 @@ 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()}`) @@ -251,4 +252,161 @@ describe("ralph-loop dispatch failure invariants", () => { 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) + }) }) From 2e36a92c25458572d12d2da3382e9223962a88c2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:02:16 +0900 Subject: [PATCH 6/6] fix(ralph-loop): revalidate ownership and harden commit/session-creation failures Three correctness fixes on top of the dispatch-before-commit invariant: - ralph-loop-event-handler.ts: after idleSettleMs, also require state ownership and non-verification-pending to match the event source before dispatching. Applied to both the session.idle and session.error retry paths. - verification-failure-handler.ts: if incrementIteration fails after a successful continuation injection, clear the loop state and emit a warning toast instead of returning success. - session-reset-strategy.ts: catch thrown session.create errors so they route through the typed session_creation_rejected path instead of surfacing as an unhandled rejection. --- .../ralph-loop/ralph-loop-event-handler.ts | 22 +++++++++++++ .../ralph-loop/session-reset-strategy.ts | 32 ++++++++++++------- .../verification-failure-handler.ts | 8 +++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 3af7c1a3a..167454b58 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -278,6 +278,17 @@ export function createRalphLoopEventHandler( 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 } @@ -401,6 +412,17 @@ export function createRalphLoopEventHandler( 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 } 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 6b8e2d2cb..89917f033 100644 --- a/src/hooks/ralph-loop/verification-failure-handler.ts +++ b/src/hooks/ralph-loop/verification-failure-handler.ts @@ -122,6 +122,14 @@ export async function handleFailedVerification( 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?.({