fix(prompt-gate): pin duplicate prompt dispatches

Keep prompt reservations briefly after successful dispatch so rapid idle/message/error transitions cannot inject the same follow-up twice.

Route all production session prompt calls through the shared gate, restore skipped background resume state, release holds after abort/recovery paths, and preserve Ralph/ULW loop state when a dispatch is deferred.

Add regression coverage for session routing, static prompt route auditing, team-mode live messaging, model suggestion retries, call-omo-agent reuse, background parent wakes, runtime fallback, compaction recovery, Atlas, and Ralph/ULW loops.
This commit is contained in:
YeonGyu-Kim
2026-05-15 13:19:10 +09:00
parent 05189700fb
commit c2aa180e7e
26 changed files with 893 additions and 48 deletions
+1 -1
View File
@@ -1702,7 +1702,7 @@ session_id: ses_untrusted_999
// then - stale idle is consumed, not converted into another scheduled continuation
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
expect(scheduledDelays).toHaveLength(0)
expect(scheduledDelays.filter((delay) => delay >= 5_000)).toHaveLength(0)
} finally {
globalThis.setTimeout = originalSetTimeout
}
@@ -21,7 +21,7 @@ import {
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
import type { CompactionContextClient } from "./types"
import type { TailMonitorState } from "./tail-monitor"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
import { promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../shared/prompt-async-gate"
export function createRecoveryLogic(
ctx: CompactionContextClient | undefined,
@@ -117,6 +117,7 @@ export function createRecoveryLogic(
hasTools: !!tools,
recoveredPromptConfig,
})
releasePromptAsyncReservation(sessionID, "compaction-context-injector:incomplete-recovery")
return false
}
@@ -22,6 +22,7 @@ type MessageInfo = {
export type ContinuationPromptResult =
| { status: "dispatched" }
| { status: "deferred"; reason: "active" | "reserved" }
| { status: "rejected"; error: Error }
function extractPromptAsyncError(response: unknown): unknown | undefined {
@@ -141,6 +142,9 @@ export async function injectContinuationPrompt(
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status === "active" || promptResult.status === "reserved") {
return { status: "deferred", reason: promptResult.status }
}
if (promptResult.status !== "dispatched") {
return {
status: "rejected",
+25
View File
@@ -871,6 +871,24 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2)
})
test("#given duplicate real idle fires before assistant activity #then loop state is preserved without another prompt", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 0 })
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
// when - duplicate idle events arrive without any intervening activity
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then - the second dispatch is deferred, not treated as loop failure
expect(hook.getState()?.iteration).toBe(2)
expect(promptCalls.length).toBe(1)
})
test("should handle multiple iterations correctly", async () => {
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
@@ -880,6 +898,9 @@ describe("ralph-loop", () => {
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
await hook.event({
event: { type: "message.part.updated", properties: { sessionID: "session-123" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
@@ -1127,6 +1148,9 @@ describe("ralph-loop", () => {
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
await hook.event({
event: { type: "message.part.updated", properties: { sessionID: "session-A" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
@@ -1328,6 +1352,7 @@ Original task: Build something`
// when - delayed start snapshot resolves after the loop has already advanced
resolveInitialMessages?.({ data: mockSessionMessages })
await new Promise((resolve) => setTimeout(resolve, 0))
await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } })
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
// then - the late snapshot must not hide the DONE message from verification gating
@@ -18,6 +18,7 @@ type ContinuationOptions = {
export type ContinuationResult =
| { status: "dispatched"; sessionID: string }
| { status: "dispatch_deferred"; reason: "active" | "reserved" }
| { status: "session_creation_rejected" }
| { status: "dispatch_rejected"; error: unknown }
@@ -48,6 +49,9 @@ export async function continueIteration(
apiTimeoutMs: options.apiTimeoutMs,
idleSettleMs: options.idleSettleMs,
})
if (promptResult.status === "deferred") {
return { status: "dispatch_deferred", reason: promptResult.reason }
}
if (promptResult.status === "rejected") {
return { status: "dispatch_rejected", error: promptResult.error }
}
@@ -77,6 +81,9 @@ export async function continueIteration(
apiTimeoutMs: options.apiTimeoutMs,
idleSettleMs: options.idleSettleMs,
})
if (promptResult.status === "deferred") {
return { status: "dispatch_deferred", reason: promptResult.reason }
}
if (promptResult.status === "rejected") {
return { status: "dispatch_rejected", error: promptResult.error }
}
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { isSessionActive } from "../shared/session-idle-settle"
import { releasePromptAsyncReservation } from "../shared/prompt-async-gate"
import type { IterationCommitExpectation, RalphLoopOptions, RalphLoopState } from "./types"
import { HOOK_NAME } from "./constants"
import { handleDetectedCompletion } from "./completion-handler"
@@ -196,6 +197,7 @@ export function createRalphLoopEventHandler(
const props = event.properties as Record<string, unknown> | undefined
const runtimeRetryActivitySessionID = getRuntimeRetryActivitySessionID(event.type, props)
if (runtimeRetryActivitySessionID) {
releasePromptAsyncReservation(runtimeRetryActivitySessionID, "ralph-loop:activity")
runtimeErrorRetriedSessions.delete(runtimeRetryActivitySessionID)
recentHandledSyntheticIdleAt.delete(runtimeRetryActivitySessionID)
}
@@ -396,6 +398,10 @@ export function createRalphLoopEventHandler(
}
return
}
if (result.status === "dispatch_deferred") {
log(`[${HOOK_NAME}] Dispatch deferred`, { sessionID, reason: result.reason })
return
}
log(`[${HOOK_NAME}] Dispatch failed`, { sessionID, status: result.status })
options.loopState.clear()
@@ -563,6 +569,10 @@ export function createRalphLoopEventHandler(
}
return
}
if (result.status === "dispatch_deferred") {
log(`[${HOOK_NAME}] Dispatch deferred after runtime error`, { sessionID, reason: result.reason })
return
}
log(`[${HOOK_NAME}] Dispatch failed after runtime error`, { sessionID, status: result.status })
options.loopState.clear()
+4
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { RalphLoopOptions, RalphLoopState } from "./types"
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
import { releasePromptAsyncReservation } from "../shared/prompt-async-gate"
import { createLoopStateController } from "./loop-state-controller"
import { createRalphLoopEventHandler } from "./ralph-loop-event-handler"
@@ -69,6 +70,9 @@ export function createRalphLoopHook(
event,
startLoop: (sessionID, prompt, loopOptions): boolean => {
const startSuccess = loopState.startLoop(sessionID, prompt, loopOptions)
if (startSuccess) {
releasePromptAsyncReservation(sessionID, "ralph-loop:start-loop")
}
if (!startSuccess || typeof loopOptions?.messageCountAtStart === "number") {
return startSuccess
}
@@ -176,10 +176,11 @@ describe("ulw-loop verification", () => {
`${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" } } })
const stateAfterDone = hook.getState()
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
const stateAfterDone = hook.getState()
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } })
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
expect(stateAfterDone?.verification_pending).toBe(true)
expect(hook.getState()?.iteration).toBe(2)
@@ -208,10 +209,11 @@ describe("ulw-loop verification", () => {
writeFileSync(
oracleTranscriptPath,
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "still checking" } })}\n`,
)
const stateBeforeWait = hook.getState()
)
const stateBeforeWait = hook.getState()
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
await hook.event({ event: { type: "message.part.updated", properties: { sessionID: "session-123" } } })
await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
expect(stateBeforeWait?.verification_session_id).toBe("ses-oracle")
expect(hook.getState()?.iteration).toBe(2)
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { releasePromptAsyncReservation } from "../shared/prompt-async-gate"
import { buildVerificationFailurePrompt } from "./continuation-prompt-builder"
import { HOOK_NAME } from "./constants"
import { injectContinuationPrompt } from "./continuation-prompt-injector"
@@ -80,30 +81,29 @@ export async function handleFailedVerification(
return false
}
if (state.verification_session_id) {
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
const previewState: RalphLoopState = {
...state,
verification_pending: undefined,
verification_session_id: undefined,
message_count_at_start: messageCountAtStart,
iteration: state.iteration + 1,
}
const clearedState = loopState.clearVerificationState(
parentSessionID,
messageCountAtStart,
)
if (!clearedState) {
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
parentSessionID,
})
return false
}
const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
try {
releasePromptAsyncReservation(parentSessionID, "ralph-loop:verification-failed")
const promptResult = await injectContinuationPrompt(ctx, {
sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(previewState),
directory,
apiTimeoutMs,
})
if (promptResult.status === "deferred") {
log(`[${HOOK_NAME}] Deferred verification failure prompt`, {
parentSessionID,
reason: promptResult.reason,
})
return false
}
if (promptResult.status === "rejected") {
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
parentSessionID,
@@ -133,6 +133,21 @@ export async function handleFailedVerification(
return false
}
if (state.verification_session_id) {
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
}
const clearedState = loopState.clearVerificationState(
parentSessionID,
messageCountAtStart,
)
if (!clearedState) {
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
parentSessionID,
})
return false
}
const committed = loopState.incrementIteration()
if (!committed) {
log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID })
-1
View File
@@ -147,7 +147,6 @@ export function createAutoRetryHelpers(deps: HookDeps) {
sessionID,
source: `runtime-fallback:${source}`,
settleMs: 0,
postDispatchHoldMs: 0,
input: {
path: { id: sessionID },
body: {
@@ -66,14 +66,14 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
}
if (sessionID && role === "assistant" && error) {
sessionAwaitingFallbackResult.delete(sessionID)
const wasAwaitingFallbackResult = sessionAwaitingFallbackResult.delete(sessionID)
if (sessionRetryInFlight.has(sessionID) && !retrySignal) {
log(`[${HOOK_NAME}] message.updated fallback skipped (retry in flight)`, { sessionID })
return
}
if (retrySignal && sessionRetryInFlight.has(sessionID) && timeoutEnabled) {
log(`[${HOOK_NAME}] Overriding in-flight retry due to provider auto-retry signal`, {
if (retrySignal && timeoutEnabled && (sessionRetryInFlight.has(sessionID) || wasAwaitingFallbackResult)) {
log(`[${HOOK_NAME}] Overriding active retry due to provider auto-retry signal`, {
sessionID,
model,
})
@@ -118,6 +118,42 @@ describe("promptAsyncAfterSessionIdle", () => {
expect(promptCalls).toBe(0)
})
test("#given dispatch hold has expired #when the same session prompts again #then the next promptAsync is accepted", async () => {
// given
let promptCalls = 0
const client = {
session: {
promptAsync: async () => {
promptCalls += 1
},
},
}
// when
const first = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_expired_hold",
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
source: "test:expired:first",
settleMs: 0,
postDispatchHoldMs: 1,
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await promptAsyncAfterSessionIdle({
client,
sessionID: "ses_expired_hold",
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
source: "test:expired:second",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(first.status).toBe("dispatched")
expect(second.status).toBe("dispatched")
expect(promptCalls).toBe(2)
})
test("#given two internal prompt calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => {
// given
let promptCalls = 0