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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user