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:
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { isAbortError } from "../../shared/is-abort-error"
|
||||
import {
|
||||
buildReminder,
|
||||
extractMessages,
|
||||
@@ -117,17 +118,70 @@ async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Pr
|
||||
|
||||
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
|
||||
const reminderCooldowns = new Map<string, number>()
|
||||
const cancelledSessions = new Set<string>()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID || !isAbortError(props?.error)) return
|
||||
|
||||
cancelledSessions.add(sessionID)
|
||||
reminderCooldowns.clear()
|
||||
log(`[${HOOK_NAME}] Marked session cancelled`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.stop") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.add(sessionID)
|
||||
reminderCooldowns.clear()
|
||||
log(`[${HOOK_NAME}] Marked session cancelled via session.stop`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
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" && role !== "assistant")) return
|
||||
|
||||
cancelledSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
cancelledSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (!sessionInfo?.id) return
|
||||
|
||||
cancelledSessions.delete(sessionInfo.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (!mainSessionID || sessionID !== mainSessionID) return
|
||||
|
||||
if (cancelledSessions.has(mainSessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped reminder: session was cancelled`, { sessionID: mainSessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = options.backgroundManager.getTasksByParentSession(mainSessionID)
|
||||
if (tasks.length === 0) return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user