9a1dd75608
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
166 lines
4.4 KiB
TypeScript
166 lines
4.4 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import { log } from "../../shared/logger"
|
|
import { HOOK_NAME } from "./constants"
|
|
import { extractOracleSessionID, isOracleVerified } from "./oracle-verification-detector"
|
|
import type { RalphLoopState } from "./types"
|
|
import { handleFailedVerification } from "./verification-failure-handler"
|
|
import { withTimeout } from "./with-timeout"
|
|
import type { IterationCommitExpectation } from "./types"
|
|
|
|
type OpenCodeSessionMessage = {
|
|
info?: { role?: string }
|
|
parts?: Array<{ type?: string; text?: string }>
|
|
}
|
|
|
|
function collectAssistantText(message: OpenCodeSessionMessage): string {
|
|
if (!Array.isArray(message.parts)) {
|
|
return ""
|
|
}
|
|
|
|
let text = ""
|
|
for (const part of message.parts) {
|
|
if (part.type !== "text" && part.type !== "tool_result") {
|
|
continue
|
|
}
|
|
text += `${text ? "\n" : ""}${part.text ?? ""}`
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
async function detectOracleVerificationFromParentSession(
|
|
ctx: PluginInput,
|
|
parentSessionID: string,
|
|
directory: string,
|
|
apiTimeoutMs: number,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const response = await withTimeout(
|
|
ctx.client.session.messages({
|
|
path: { id: parentSessionID },
|
|
query: { directory },
|
|
}),
|
|
apiTimeoutMs,
|
|
)
|
|
|
|
const messagesResponse: unknown = response
|
|
const responseData =
|
|
typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse
|
|
? (messagesResponse as { data?: unknown }).data
|
|
: undefined
|
|
const messageArray: unknown[] = Array.isArray(messagesResponse)
|
|
? messagesResponse
|
|
: Array.isArray(responseData)
|
|
? responseData
|
|
: []
|
|
|
|
for (let index = messageArray.length - 1; index >= 0; index -= 1) {
|
|
const message = messageArray[index] as OpenCodeSessionMessage
|
|
if (message.info?.role !== "assistant") {
|
|
continue
|
|
}
|
|
|
|
const assistantText = collectAssistantText(message)
|
|
if (!isOracleVerified(assistantText)) {
|
|
continue
|
|
}
|
|
|
|
const detectedOracleSessionID = extractOracleSessionID(assistantText)
|
|
if (detectedOracleSessionID) {
|
|
return detectedOracleSessionID
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
} catch (error) {
|
|
log(`[${HOOK_NAME}] Failed to scan parent session for oracle verification evidence`, {
|
|
parentSessionID,
|
|
error: String(error),
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
type LoopStateController = {
|
|
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
|
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
|
|
incrementIteration: (expected?: IterationCommitExpectation) => RalphLoopState | null
|
|
clear: () => boolean
|
|
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
|
|
}
|
|
|
|
export async function handlePendingVerification(
|
|
ctx: PluginInput,
|
|
input: {
|
|
sessionID: string
|
|
state: RalphLoopState
|
|
verificationSessionID?: string
|
|
matchesParentSession: boolean
|
|
matchesVerificationSession: boolean
|
|
loopState: LoopStateController
|
|
directory: string
|
|
apiTimeoutMs: number
|
|
},
|
|
): Promise<void> {
|
|
const {
|
|
sessionID,
|
|
state,
|
|
verificationSessionID,
|
|
matchesParentSession,
|
|
matchesVerificationSession,
|
|
loopState,
|
|
directory,
|
|
apiTimeoutMs,
|
|
} = input
|
|
|
|
if (matchesParentSession || (verificationSessionID && matchesVerificationSession)) {
|
|
if (!verificationSessionID && state.session_id) {
|
|
const recoveredVerificationSessionID = await detectOracleVerificationFromParentSession(
|
|
ctx,
|
|
state.session_id,
|
|
directory,
|
|
apiTimeoutMs,
|
|
)
|
|
|
|
if (recoveredVerificationSessionID) {
|
|
const updatedState = loopState.setVerificationSessionID(
|
|
state.session_id,
|
|
recoveredVerificationSessionID,
|
|
)
|
|
if (updatedState) {
|
|
log(`[${HOOK_NAME}] Recovered missing verification session from parent evidence`, {
|
|
parentSessionID: state.session_id,
|
|
recoveredVerificationSessionID,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
directory,
|
|
apiTimeoutMs,
|
|
})
|
|
if (restarted) {
|
|
return
|
|
}
|
|
}
|
|
|
|
log(`[${HOOK_NAME}] Waiting for oracle verification`, {
|
|
sessionID,
|
|
verificationSessionID,
|
|
iteration: state.iteration,
|
|
})
|
|
}
|