Merge pull request #3065 from code-yeongyu/fix/issue-2984-v2
fix: reset consecutiveFailures on abort so session recovers after user cancel
This commit is contained in:
@@ -104,4 +104,62 @@ describe("createEventHandler", () => {
|
||||
expect(abortCalls).toEqual([])
|
||||
expect(state.pendingFallbackModel).toBe(undefined)
|
||||
})
|
||||
|
||||
it("#given a cancelled session #when session.error receives an abort error #then fallback retry state is reset", async () => {
|
||||
const sessionID = "session-cancelled"
|
||||
const deps = createDeps()
|
||||
const abortCalls: string[] = []
|
||||
const clearCalls: string[] = []
|
||||
const state = createFallbackState("google/gemini-2.5-pro")
|
||||
state.currentModel = "openai/gpt-5.4"
|
||||
state.fallbackIndex = 1
|
||||
state.attemptCount = 2
|
||||
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||
state.failedModels.set("google/gemini-2.5-pro", Date.now())
|
||||
deps.sessionStates.set(sessionID, state)
|
||||
deps.sessionRetryInFlight.add(sessionID)
|
||||
deps.sessionAwaitingFallbackResult.add(sessionID)
|
||||
deps.sessionStatusRetryKeys.set(sessionID, "retry:2")
|
||||
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||
|
||||
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "AbortError" } } } })
|
||||
|
||||
const resetState = deps.sessionStates.get(sessionID)
|
||||
expect(resetState?.originalModel).toBe("google/gemini-2.5-pro")
|
||||
expect(resetState?.currentModel).toBe("google/gemini-2.5-pro")
|
||||
expect(resetState?.fallbackIndex).toBe(-1)
|
||||
expect(resetState?.attemptCount).toBe(0)
|
||||
expect(resetState?.pendingFallbackModel).toBe(undefined)
|
||||
expect(resetState?.failedModels.size).toBe(0)
|
||||
expect(deps.sessionRetryInFlight.has(sessionID)).toBe(false)
|
||||
expect(deps.sessionAwaitingFallbackResult.has(sessionID)).toBe(false)
|
||||
expect(deps.sessionStatusRetryKeys.has(sessionID)).toBe(false)
|
||||
expect(clearCalls).toEqual([sessionID])
|
||||
expect(abortCalls).toEqual([])
|
||||
})
|
||||
|
||||
it("#given a cancelled session #when session.idle fires #then fallback retry state stays cleared", async () => {
|
||||
const sessionID = "session-cancelled-idle"
|
||||
const deps = createDeps()
|
||||
const abortCalls: string[] = []
|
||||
const clearCalls: string[] = []
|
||||
const state = createFallbackState("google/gemini-2.5-pro")
|
||||
state.currentModel = "openai/gpt-5.4"
|
||||
state.fallbackIndex = 1
|
||||
state.attemptCount = 2
|
||||
state.pendingFallbackModel = "openai/gpt-5.4"
|
||||
deps.sessionStates.set(sessionID, state)
|
||||
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||
|
||||
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
||||
clearCalls.length = 0
|
||||
|
||||
await handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
|
||||
const resetState = deps.sessionStates.get(sessionID)
|
||||
expect(resetState?.currentModel).toBe("google/gemini-2.5-pro")
|
||||
expect(resetState?.attemptCount).toBe(0)
|
||||
expect(clearCalls).toEqual([sessionID])
|
||||
expect(abortCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableErro
|
||||
import { createFallbackState } from "./fallback-state"
|
||||
import { getFallbackModelsForSession } from "./fallback-models"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { isAbortError } from "../../shared/is-abort-error"
|
||||
import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model"
|
||||
import { dispatchFallbackRetry } from "./fallback-retry-dispatcher"
|
||||
import { createSessionStatusHandler } from "./session-status-handler"
|
||||
@@ -13,6 +14,19 @@ import { createSessionStatusHandler } from "./session-status-handler"
|
||||
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps
|
||||
const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys)
|
||||
const cancelledSessions = new Set<string>()
|
||||
|
||||
const resetRetryState = (sessionID: string) => {
|
||||
const state = sessionStates.get(sessionID)
|
||||
if (state) {
|
||||
sessionStates.set(sessionID, createFallbackState(state.originalModel))
|
||||
}
|
||||
|
||||
sessionRetryInFlight.delete(sessionID)
|
||||
sessionAwaitingFallbackResult.delete(sessionID)
|
||||
sessionStatusRetryKeys.delete(sessionID)
|
||||
helpers.clearSessionFallbackTimeout(sessionID)
|
||||
}
|
||||
|
||||
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
|
||||
@@ -32,6 +46,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
if (sessionID) {
|
||||
log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID })
|
||||
cancelledSessions.delete(sessionID)
|
||||
sessionStates.delete(sessionID)
|
||||
sessionLastAccess.delete(sessionID)
|
||||
sessionRetryInFlight.delete(sessionID)
|
||||
@@ -46,28 +61,35 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
helpers.clearSessionFallbackTimeout(sessionID)
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) {
|
||||
await helpers.abortSessionRequest(sessionID, "session.stop")
|
||||
}
|
||||
|
||||
sessionRetryInFlight.delete(sessionID)
|
||||
sessionAwaitingFallbackResult.delete(sessionID)
|
||||
sessionStatusRetryKeys.delete(sessionID)
|
||||
|
||||
const state = sessionStates.get(sessionID)
|
||||
if (state?.pendingFallbackModel) {
|
||||
state.pendingFallbackModel = undefined
|
||||
}
|
||||
cancelledSessions.add(sessionID)
|
||||
resetRetryState(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] Cleared fallback retry state on session.stop`, { sessionID })
|
||||
}
|
||||
|
||||
const handleMessageUpdated = (props: Record<string, unknown> | undefined) => {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
if (!sessionID || role !== "user") return
|
||||
|
||||
cancelledSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
const handleSessionIdle = (props: Record<string, unknown> | undefined) => {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (cancelledSessions.has(sessionID)) {
|
||||
resetRetryState(sessionID)
|
||||
log(`[${HOOK_NAME}] Cleared fallback retry state for cancelled session on idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionAwaitingFallbackResult.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] session.idle while awaiting fallback result; keeping timeout armed`, { sessionID })
|
||||
return
|
||||
@@ -100,6 +122,13 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
|
||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||
|
||||
if (isAbortError(error)) {
|
||||
cancelledSessions.add(sessionID)
|
||||
resetRetryState(sessionID)
|
||||
log(`[${HOOK_NAME}] session.error matched cancellation; cleared retry state`, { sessionID, resolvedAgent })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionRetryInFlight.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] session.error skipped - retry in flight`, {
|
||||
sessionID,
|
||||
@@ -176,6 +205,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
||||
if (event.type === "session.created") { handleSessionCreated(props); return }
|
||||
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
|
||||
if (event.type === "session.stop") { await handleSessionStop(props); return }
|
||||
if (event.type === "message.updated") { handleMessageUpdated(props); return }
|
||||
if (event.type === "session.idle") { handleSessionIdle(props); return }
|
||||
if (event.type === "session.status") { await sessionStatusHandler(props); return }
|
||||
if (event.type === "session.error") { await handleSessionError(props); return }
|
||||
|
||||
Reference in New Issue
Block a user