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
@@ -181,4 +181,37 @@ describe("unstable-agent-babysitter hook", () => {
expect(promptCalls.length).toBe(1)
Date.now = originalNow
})
test("skips follow-up reminder after the main session is cancelled", async () => {
setMainSession("main-1")
const promptCalls: Array<{ input: unknown }> = []
const ctx = createMockPluginInput({
messagesBySession: {
"main-1": [
{ info: { agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-4" } } },
],
"bg-1": [
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
],
},
promptCalls,
})
const backgroundManager = createBackgroundManager([createTask()])
const hook = createUnstableAgentBabysitterHook(ctx, {
backgroundManager,
config: { timeout_ms: 120000 },
})
const firstNow = Date.now()
const originalNow = Date.now
let currentNow = firstNow
Date.now = () => currentNow
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
await hook.event({ event: { type: "session.error", properties: { sessionID: "main-1", error: { name: "AbortError" } } } })
currentNow += 5 * 60 * 1000 + 1
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
expect(promptCalls.length).toBe(1)
Date.now = originalNow
})
})