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
@@ -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([])
})
})