From 35cab4db10bfc469958df3d1510d661af88bd78a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 13:02:07 +0900 Subject: [PATCH] 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) + }) })