fix: reset hook state on abort so session recovers after user cancel

When a user cancels a generation (ESC x2), all three idle hooks could
enter a permanently broken state:

1. **todo-continuation-enforcer**: consecutiveFailures accumulated from
   abort-caused promptAsync failures, eventually hitting MAX_CONSECUTIVE_FAILURES
   and permanently stopping continuation injection.

2. **unstable-agent-babysitter**: no abort awareness at all — would keep
   firing reminders after user cancelled the session.

3. **runtime-fallback**: retry dedupe keys and pending fallback state
   persisted across cancellation, blocking legitimate error recovery.

Fix:
- Add shared `isAbortError()` utility for consistent abort detection
- Reset consecutiveFailures and clear stale state on AbortError in all hooks
- Track `lastCancelledAt` in todo-continuation-enforcer for abort window
- Add abort-awareness to unstable-agent-babysitter (skip if recently cancelled)
- Clear runtime-fallback retry state on abort errors

Tests: 61 pass, 0 fail across all 3 affected hook test suites.

Closes #2984
This commit is contained in:
YeonGyu-Kim
2026-04-03 18:40:02 +09:00
parent ed06428ba3
commit 1631509989
11 changed files with 344 additions and 18 deletions
+40 -10
View File
@@ -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 }