diff --git a/src/hooks/ralph-loop/oracle-double-fire-race.test.ts b/src/hooks/ralph-loop/oracle-double-fire-race.test.ts new file mode 100644 index 000000000..e25856dcf --- /dev/null +++ b/src/hooks/ralph-loop/oracle-double-fire-race.test.ts @@ -0,0 +1,139 @@ +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 { clearState, writeState } from "./storage" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" + +// Regression lock for Race A: Oracle verification fires twice during ULW loop. +// +// Race A reproduction sequence: +// 1. ULW loop detects DONE. +// 2. handleDetectedCompletion → markVerificationPending() flips +// state.verification_pending=true, clears verification_session_id. +// 3. Verification prompt injected into parent session (prompt #1). +// 4. Model calls task(subagent_type="oracle"). tool-execute-before.ts:147-159 +// writes verification_attempt_id to state (Oracle dispatch in-flight). +// verification_session_id is NOT YET stored: tool-execute-after.ts:127-130 +// only writes it once the sync Oracle task returns. +// 5. parent session.idle fires before tool-execute-after.ts has run +// (e.g. via message.part.updated → idle, background activity, or a stale +// idle that survives the inFlightSessions guard). +// 6. ralph-loop-event-handler.ts:348-366 sees state.verification_pending=true, +// verificationSessionID=undefined, matchesParentSession=true. +// 7. pending-verification-handler.ts:116-149 attempts recovery via +// detectOracleVerificationFromParentSession(). Parent messages have no +// verification evidence yet because Oracle is still running. +// 8. Falls through to handleFailedVerification() (line 140). +// 9. handleFailedVerification injects "Verification failed" prompt (#2), +// clears verification_pending, increments iteration → DUPLICATE ORACLE. +// +// The discriminator the fix must use: verification_attempt_id is set but +// verification_session_id is not. That state means tool-execute-before has +// stamped a dispatch and the Oracle is mid-execution. The handler must wait +// instead of declaring failure. +describe("ulw-loop oracle double-fire race (Race A)", () => { + const testDir = join(tmpdir(), `oracle-double-fire-race-${Date.now()}`) + let promptCalls: Array<{ sessionID: string; text: string }> + let toastCalls: Array<{ title: string; message: string; variant: string }> + let abortCalls: Array<{ id: string }> + let parentTranscriptPath: string + let oracleTranscriptPath: string + + function createMockPluginInput() { + return unsafeTestValue[0]>({ + client: { + session: { + promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => { + promptCalls.push({ + sessionID: opts.path.id, + text: opts.body.parts[0].text, + }) + return {} + }, + messages: async () => ({ data: [] }), + abort: async (opts: { path: { id: string } }) => { + abortCalls.push({ id: opts.path.id }) + return {} + }, + }, + tui: { + showToast: async (opts: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(opts.body) + return {} + }, + }, + }, + directory: testDir, + }) + } + + beforeEach(() => { + promptCalls = [] + toastCalls = [] + abortCalls = [] + parentTranscriptPath = join(testDir, "transcript-parent.jsonl") + oracleTranscriptPath = join(testDir, "transcript-oracle.jsonl") + + if (!existsSync(testDir)) { + mkdirSync(testDir, { recursive: true }) + } + + clearState(testDir) + }) + + afterEach(() => { + clearState(testDir) + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }) + } + }) + + test("#given oracle dispatch is in-flight with verification_attempt_id set but verification_session_id undefined #when parent session.idle fires before tool-execute-after stores the oracle session id #then handleFailedVerification must NOT fire prematurely", async () => { + // given: ULW loop reaches DONE, enters verification_pending state + const hook = createRalphLoopHook(createMockPluginInput(), { + getTranscriptPath: (sessionID) => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath, + }) + hook.startLoop("session-123", "Build API", { ultrawork: true }) + writeFileSync( + parentTranscriptPath, + `${JSON.stringify({ type: "assistant", timestamp: new Date().toISOString(), content: "done DONE" })}\n`, + ) + await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) + + // sanity: verification phase started, exactly one verification prompt injected + const stateAfterDone = hook.getState() + expect(stateAfterDone?.verification_pending).toBe(true) + expect(stateAfterDone?.verification_session_id).toBeUndefined() + expect(promptCalls).toHaveLength(1) + + // simulate Oracle dispatch in-flight: + // tool-execute-before.ts:147-159 has stamped verification_attempt_id + // but tool-execute-after.ts:127-130 has NOT yet stored verification_session_id + // because the sync Oracle subagent is still running. + writeState(testDir, { + ...stateAfterDone!, + verification_attempt_id: "attempt-uuid-12345", + verification_session_id: undefined, + }) + + // when: a second session.idle fires on the parent while Oracle is mid-execution + // (real-world triggers: stale idle survives inFlightSessions guard, message.part.updated + // loop, background activity in parent, or runtime fallback retry cleanup). + await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } }) + await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) + + // then: handleFailedVerification must NOT have fired. + // No duplicate "Verification failed" prompt should have been injected. + // verification_pending stays true, verification_attempt_id is preserved, + // iteration is NOT incremented. + expect(promptCalls).toHaveLength(1) + expect(promptCalls.every((call) => !call.text.includes("Verification failed"))).toBe(true) + + const stateAfterRace = hook.getState() + expect(stateAfterRace?.verification_pending).toBe(true) + expect(stateAfterRace?.verification_attempt_id).toBe("attempt-uuid-12345") + expect(stateAfterRace?.iteration).toBe(1) + }) +}) diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 78f970550..5065f2dd1 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -137,6 +137,15 @@ export async function handlePendingVerification( } } + if (state.verification_attempt_id && !state.verification_session_id) { + log(`[${HOOK_NAME}] Skipped verification failure: oracle dispatch in flight`, { + sessionID, + verificationAttemptId: state.verification_attempt_id, + iteration: state.iteration, + }) + return + } + const restarted = await handleFailedVerification(ctx, { state, loopState,