fix(prompt-gate): harden internal prompt dispatch
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>(() => {})
|
||||
|
||||
Reference in New Issue
Block a user