fix(prompt-gate): harden internal prompt dispatch

This commit is contained in:
YeonGyu-Kim
2026-05-19 16:02:18 +09:00
parent 6c63372ef9
commit 1492bffd20
49 changed files with 1488 additions and 141 deletions
+12 -2
View File
@@ -13,8 +13,10 @@ import { extractSessionMessages } from "./session-messages"
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
import {
dispatchInternalPrompt,
isInternalPromptDispatchAccepted,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
const SESSION_TTL_MS = 30 * 60 * 1000
@@ -141,6 +143,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
const previousPendingFallbackModel = sessionStates.get(sessionID)?.pendingFallbackModel
sessionRetryInFlight.add(sessionID)
let retryDispatched = false
let retryMayHaveBeenAccepted = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
@@ -180,9 +183,16 @@ export function createAutoRetryHelpers(deps: HookDeps) {
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPromptDispatchFailure(promptResult.error)) {
retryMayHaveBeenAccepted = true
log(`[${HOOK_NAME}] Auto-retry prompt failed after dispatch may have been accepted (${source}); preserving fallback state`, {
sessionID,
error: String(promptResult.error),
})
}
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
sessionID,
status: promptResult.status,
@@ -201,7 +211,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
} finally {
sessionRetryInFlight.delete(sessionID)
if (!retryDispatched) {
if (!retryDispatched && !retryMayHaveBeenAccepted) {
if (hadAwaitingFallbackResult) {
sessionAwaitingFallbackResult.add(sessionID)
} else {
+71
View File
@@ -8,6 +8,10 @@ import {
} from "../../shared/delegated-child-session-bootstrap"
import * as loggerModule from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import {
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import type { RuntimeFallbackPluginInput } from "./types"
type RuntimeFallbackModule = typeof import("./hook")
@@ -23,6 +27,7 @@ describe("runtime-fallback", () => {
toastCalls = []
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
releaseAllPromptAsyncReservationsForTesting()
const cacheBuster = `${Date.now()}-${Math.random()}`
@@ -40,6 +45,7 @@ describe("runtime-fallback", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
releaseAllPromptAsyncReservationsForTesting()
mock.restore()
})
@@ -1350,6 +1356,71 @@ describe("runtime-fallback", () => {
void sessionErrorPromise
})
test("#given promptAsync fails after fallback retry may have been accepted #when the gate hold expires and the same error repeats #then the pending fallback state prevents a duplicate retry prompt", async () => {
// given
let promptCalls = 0
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
}),
promptAsync: async () => {
promptCalls += 1
throw new Error("JSON Parse error: Unexpected EOF")
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"provider-a/model-a",
"provider-b/model-b",
]),
}
)
const sessionID = "test-runtime-fallback-eof-preserves-pending"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
},
})
// when
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
model: "google/gemini-2.5-pro",
error: { statusCode: 429, message: "Rate limit" },
},
},
})
const released = releasePromptAsyncReservation(sessionID, "test:simulate-expired-hold", {
reservedBy: "runtime-fallback:session.error",
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
model: "google/gemini-2.5-pro",
error: { statusCode: 429, message: "Rate limit" },
},
},
})
// then
expect(released).toBe(true)
expect(promptCalls).toBe(1)
const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped - awaiting fallback result"))
expect(skipLog).toBeDefined()
})
test("should force advance fallback from message.updated when Copilot auto-retry signal appears during in-flight retry", async () => {
const retriedModels: string[] = []
const pending = new Promise<never>(() => {})