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:
@@ -1179,7 +1179,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject when abort flag is stale (>3s old)", async () => {
|
||||
test("should keep skipping after cancel even when the abort window is stale", async () => {
|
||||
fakeTimers.restore()
|
||||
// given - session with incomplete todos and old abort timestamp
|
||||
const sessionID = "main-stale-abort"
|
||||
@@ -1208,8 +1208,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
await wait(3000)
|
||||
|
||||
// then - continuation injected (abort flag is stale)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should clear abort flag on user message activity", async () => {
|
||||
@@ -1252,6 +1251,44 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should reset failure state and keep skipping after a cancelled run", async () => {
|
||||
fakeTimers.restore()
|
||||
const sessionID = "main-reset-after-cancel"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await wait(2500)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
|
||||
promptCalls.length = 0
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
await wait(3100)
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await wait(2500)
|
||||
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should clear abort flag on assistant message activity", async () => {
|
||||
fakeTimers.restore()
|
||||
// given - session with abort detected
|
||||
@@ -1775,4 +1812,64 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should reset consecutiveFailures after user-initiated abort and resume after fresh activity [regression #2984]", async () => {
|
||||
fakeTimers.restore()
|
||||
const sessionID = "main-abort-recovery"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => ({
|
||||
data: [
|
||||
{ id: "1", content: "Write tests", status: "pending", priority: "high" },
|
||||
],
|
||||
})
|
||||
|
||||
let shouldFail = true
|
||||
let promptCallCount = 0
|
||||
mockInput.client.session.promptAsync = async (_opts: PromptRequestOptions) => {
|
||||
promptCallCount++
|
||||
if (shouldFail) {
|
||||
throw new Error("promptAsync failed (3ms) unknown error")
|
||||
}
|
||||
promptCalls.push({
|
||||
sessionID: _opts.path.id,
|
||||
agent: _opts.body.agent,
|
||||
model: _opts.body.model,
|
||||
text: _opts.body.parts[0].text,
|
||||
})
|
||||
}
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await wait(2500)
|
||||
expect(promptCallCount).toBe(1)
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
shouldFail = false
|
||||
await wait(9000)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await wait(2500)
|
||||
expect(promptCallCount).toBe(1)
|
||||
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } },
|
||||
},
|
||||
})
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await wait(2500)
|
||||
|
||||
expect(promptCallCount).toBe(2)
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
}, { timeout: 20000 })
|
||||
|
||||
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user