fix(runtime-fallback): keep pending retry state on gate skip

This commit is contained in:
YeonGyu-Kim
2026-05-19 12:29:59 +09:00
parent 98c3fee181
commit 67bd324987
2 changed files with 100 additions and 6 deletions
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import { createAutoRetryHelpers } from "./auto-retry"
import { createFallbackState } from "./fallback-state"
import type { HookDeps, RuntimeFallbackPluginInput } from "./types"
function createContext(promptCalls: { count: number }): RuntimeFallbackPluginInput {
const session = {
abort: async () => ({}),
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "retry this" }],
},
],
}),
promptAsync: async () => {
promptCalls.count += 1
return {}
},
status: async () => ({ data: { "session-auto-retry": { type: "busy" } } }),
}
return {
client: {
session,
tui: {
showToast: async () => ({}),
},
},
directory: "/test/dir",
}
}
function createDeps(promptCalls: { count: number }): HookDeps {
return {
ctx: createContext(promptCalls),
config: {
enabled: true,
retry_on_errors: [429, 503, 529],
max_fallback_attempts: 3,
cooldown_seconds: 60,
timeout_seconds: 0,
notify_on_fallback: false,
},
options: undefined,
pluginConfig: undefined,
sessionStates: new Map(),
sessionLastAccess: new Map(),
sessionRetryInFlight: new Set(),
sessionAwaitingFallbackResult: new Set(),
sessionFallbackTimeouts: new Map(),
sessionStatusRetryKeys: new Map(),
internallyAbortedSessions: new Set(),
}
}
describe("createAutoRetryHelpers", () => {
test("#given an existing fallback result is pending #when a new fallback retry is skipped by the prompt gate #then the previous pending state is preserved", async () => {
// given
const promptCalls = { count: 0 }
const deps = createDeps(promptCalls)
const helpers = createAutoRetryHelpers(deps)
const sessionID = "session-auto-retry"
const state = createFallbackState("anthropic/claude-opus-4-7")
state.pendingFallbackModel = "openai/gpt-5.4"
deps.sessionStates.set(sessionID, state)
deps.sessionAwaitingFallbackResult.add(sessionID)
// when
await helpers.autoRetryWithFallback(sessionID, "google/gemini-2.5-pro", undefined, "session.status")
// then
expect(promptCalls.count).toBe(0)
expect(deps.sessionAwaitingFallbackResult.has(sessionID)).toBe(true)
expect(state.pendingFallbackModel).toBe("openai/gpt-5.4")
})
})
+22 -6
View File
@@ -137,6 +137,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
return
}
const hadAwaitingFallbackResult = sessionAwaitingFallbackResult.has(sessionID)
const previousPendingFallbackModel = sessionStates.get(sessionID)?.pendingFallbackModel
sessionRetryInFlight.add(sessionID)
let retryDispatched = false
try {
@@ -154,8 +156,10 @@ export function createAutoRetryHelpers(deps: HookDeps) {
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
const launchAgent = resolveRegisteredAgentName(retryAgent)
sessionAwaitingFallbackResult.add(sessionID)
scheduleSessionFallbackTimeout(sessionID, retryAgent)
if (!hadAwaitingFallbackResult) {
sessionAwaitingFallbackResult.add(sessionID)
scheduleSessionFallbackTimeout(sessionID, retryAgent)
}
const promptResult = await dispatchInternalPrompt({
mode: "async",
@@ -185,6 +189,10 @@ export function createAutoRetryHelpers(deps: HookDeps) {
})
return
}
sessionAwaitingFallbackResult.add(sessionID)
if (hadAwaitingFallbackResult) {
scheduleSessionFallbackTimeout(sessionID, retryAgent)
}
retryDispatched = true
} else {
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
@@ -194,11 +202,19 @@ export function createAutoRetryHelpers(deps: HookDeps) {
} finally {
sessionRetryInFlight.delete(sessionID)
if (!retryDispatched) {
sessionAwaitingFallbackResult.delete(sessionID)
clearSessionFallbackTimeout(sessionID)
if (hadAwaitingFallbackResult) {
sessionAwaitingFallbackResult.add(sessionID)
} else {
sessionAwaitingFallbackResult.delete(sessionID)
clearSessionFallbackTimeout(sessionID)
}
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
state.pendingFallbackModel = undefined
if (state) {
if (hadAwaitingFallbackResult) {
state.pendingFallbackModel = previousPendingFallbackModel
} else if (state.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
}
}
}