Merge pull request #4380 from code-yeongyu/fix/ralph-loop-oracle-double-fire-race
fix(ralph-loop): skip premature verification failure when oracle dispatch is in flight
This commit is contained in:
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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 <promise>DONE</promise>.
|
||||
// 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<Parameters<typeof createRalphLoopHook>[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 <promise>DONE</promise>" })}\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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user