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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user