From 9a1dd756084f0502ff6d965e1f576c78215a197f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 24 May 2026 15:00:26 +0900 Subject: [PATCH 1/2] fix(ralph-loop): skip handleFailedVerification when oracle dispatch is in flight (#4256) When verification_pending is true and the agent has dispatched an Oracle verification (verification_attempt_id is set), session.idle events that arrive before tool-execute-after stores the Oracle session ID (verification_session_id still undefined) caused handlePendingVerification to fall through to handleFailedVerification. This injected a duplicate 'verification failed' continuation prompt, spawning a second Oracle. The fix adds a guard in handlePendingVerification: when verification_attempt_id is set but verification_session_id is not, Oracle dispatch is in flight and the handler returns early instead of declaring failure. The pending wake will retry on the next session.idle. Regression test added in given/when/then style proving the race sequence: 1. ULW loop detects DONE, enters verification_pending 2. Oracle dispatch stamps verification_attempt_id (tool-execute-before) 3. Second session.idle fires before tool-execute-after stores session ID 4. Handler must NOT call handleFailedVerification RED (before fix): 2 prompt injections (duplicate Oracle) GREEN (after fix): 1 prompt injection (correct) Fixes #4256 Fixes #4019 --- .../oracle-double-fire-race.test.ts | 139 ++++++++++++++++++ .../pending-verification-handler.ts | 9 ++ 2 files changed, 148 insertions(+) create mode 100644 src/hooks/ralph-loop/oracle-double-fire-race.test.ts 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, From cdc937548fb3c97f8a8561737ce336db366f676e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 24 May 2026 15:01:58 +0900 Subject: [PATCH 2/2] chore: update bun.lock --- bun.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index c04f4125d..798170796 100644 --- a/bun.lock +++ b/bun.lock @@ -41,17 +41,17 @@ "zod": "^4.4.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "4.3.1", - "oh-my-opencode-darwin-x64": "4.3.1", - "oh-my-opencode-darwin-x64-baseline": "4.3.1", - "oh-my-opencode-linux-arm64": "4.3.1", - "oh-my-opencode-linux-arm64-musl": "4.3.1", - "oh-my-opencode-linux-x64": "4.3.1", - "oh-my-opencode-linux-x64-baseline": "4.3.1", - "oh-my-opencode-linux-x64-musl": "4.3.1", - "oh-my-opencode-linux-x64-musl-baseline": "4.3.1", - "oh-my-opencode-windows-x64": "4.3.1", - "oh-my-opencode-windows-x64-baseline": "4.3.1", + "oh-my-opencode-darwin-arm64": "4.4.0", + "oh-my-opencode-darwin-x64": "4.4.0", + "oh-my-opencode-darwin-x64-baseline": "4.4.0", + "oh-my-opencode-linux-arm64": "4.4.0", + "oh-my-opencode-linux-arm64-musl": "4.4.0", + "oh-my-opencode-linux-x64": "4.4.0", + "oh-my-opencode-linux-x64-baseline": "4.4.0", + "oh-my-opencode-linux-x64-musl": "4.4.0", + "oh-my-opencode-linux-x64-musl-baseline": "4.4.0", + "oh-my-opencode-windows-x64": "4.4.0", + "oh-my-opencode-windows-x64-baseline": "4.4.0", }, "peerDependencies": { "zod": "^4.0.0", @@ -399,27 +399,27 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Wb+XTZ0Me3yBUejhhxXaiFpvZUmjT33EmgQPSrcwVIqpW4//DGpm4n9ptsSDm4ASaPaqG+v/dfNWmaXXS4FdqQ=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.4.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-tGwtIIbxTDeeBqTkhBII4Mf/oBLavO1sSA2ZTlqNDY2srYu8677XRxq09+AF4aoexRB6JOyPOp3hpAwrRp+MHw=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VnaFatAHVNpteFW/o/dHYVr6/NNDYnNnYgIupunUsO3J5beTFYJaPL5dq+1LRRQban9By9yMbOyXj7SGxGHmtQ=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.4.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VqqywGHjd5dLZEEYuqKJ2OiEK+NQFK5kskfnMsHaYf/sMlp7tv/RTDvtomtwk1KWXU3rW5acYSGxLxgMlpNBoA=="], - "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Hd21GQR3pF5+4sTRSxypvQUUaSINke+fsbRtUWpun3uEDGpvpBfI3gcA+6ipM/VL0Qi35wvODU1W7Nl0welujA=="], + "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.4.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-tw4vJnLzbSPjdwYp+4vVnb/I2SysSptATylRmexiJGxAxpcAjqxk9yejzdHAhSALY/AlslKKzdpNJ7pGnFkiCA=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-veU2mfOxDH11O+biZVahIF/Tlrp2cwvlmTpL0Cl8SHMBRUb8qhAHxN545e8Val3MSU6ydUXr0Ra5eAoazcAZ5g=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.4.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-JWVfP9cze0y4eQZO9vs/5JQNmh6Bdfld0Vghwht75yQXTGIuzXw65ZjjB7/t5EMueVGt0xZJxFPGva1/Hk66Eg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-rcuGLYeQ2uD60o5k54VCx/DiquIHxkACIK0KmK4IAjeNTfYpflfnEl/DfnsPpv7toWSI3XNOZwP4ig7ZfR72Pg=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.4.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5ErkGwgH5o52mTZlwzc07pW7+0+S9hJXqeuqFZL5jAc8/UA0n+5pKOBT3tBF5CQdFFSNTX7FZeaCGaVQNtCvIg=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-dojsloeSYgu8wIZB2H7VoFDLm1sZvP+m00rWoQ6NqAW5/BlRfUtZql6JiVF31ofRzM8/UC0GJRwihtcg8piNLA=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.4.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+eZA9R+zbFijTAknDT9wwR+JYCVUTVPdKnchTXLOhll+7Zyo9iVK/4Usa3s/UbWNXGz4fXbTk1ESiMQROF0Tlg=="], - "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5lt3Rrkc3Y4idehke7JbMnawWZLZQjL5ghovjAlr9qsdgY5jsJEMAZW/TjgceAjTW0iAqSmtfVvVEtiPqLNk4w=="], + "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.4.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VhIsHJzVRsS+0jxY9e4ZCKvebcKIZj6Rw0pkxtYqm1xrZnPoiQzEkapoNJN2Jwr9ID2DubESl1ldOeqMhBRpSQ=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-26NV5DMaDETbecgw+LzjO/TpdzCnN0ZX/e74EQHFd0Z7ey7KK5p4x+dSBuY0F4NTZpFOQmbdZCJJOxyvDtUIHQ=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.4.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-HltQLU4IGOuvVofESBLFxl6tfIkwuWWzPehoy6dkSE/OuqEzakTj85m7NDRIjmHyCLu2QVu/gKmf3wKoShEE4g=="], - "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-M+SJCWAc1QySELO3hmv3/vkRSfb7FPhUYVFi+34wBGFiywOK4jMOElyvCrEnxO3J1wGywEHgPWPaASs6f5ooIQ=="], + "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.4.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-baeGUFMCSFvBYjJsNY+bfu01AQdII9KG67LzgCCkFZKbARZJ6LR/1sGVNt4dxs3Bvdg3kYatpIvqozeiw1mYUA=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-u38l63i/kq/vktaHsa7I+OBSoRXYWA8ExZ1tKv/d4adQuPzLfi9ruvTy+5fQ9GiDQZuRTlneenvrIYIfTJ4QYA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.4.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-MKjH5CIsMS8GDTimMpv9W+1SNgOaus9PeXC2H/hQd7BTkXZegN7JmH8mVJRLpHG4jDr432ky9O28ghRwqM76YQ=="], - "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-aeSyPkD/QDvc9x6l1q9VA4a1YwejefKzPPmuJKZAdWO6PSxdXN3nqx06+U2fR798KJbO4zNgg5vV331iMuvnqA=="], + "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.4.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-VqVK+PK0dg0x+y1HxY2TVa13ulZbrYW4F2zA6JEV0nLNWRk0s7YnLQqXsm/bRNnfjsAFs+Frk5QS7mNorF79JQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],