diff --git a/src/hooks/runtime-fallback/event-handler.test.ts b/src/hooks/runtime-fallback/event-handler.test.ts index 3bad84bef..a2a323ee2 100644 --- a/src/hooks/runtime-fallback/event-handler.test.ts +++ b/src/hooks/runtime-fallback/event-handler.test.ts @@ -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([]) + }) }) diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index 97f972fdd..5776041ed 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -6,6 +6,7 @@ import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableErro import { createFallbackState } from "./fallback-state" import { getFallbackModelsForSession } from "./fallback-models" import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { isAbortError } from "../../shared/is-abort-error" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { createSessionStatusHandler } from "./session-status-handler" @@ -13,6 +14,19 @@ import { createSessionStatusHandler } from "./session-status-handler" export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps const sessionStatusHandler = createSessionStatusHandler(deps, helpers, sessionStatusRetryKeys) + const cancelledSessions = new Set() + + const resetRetryState = (sessionID: string) => { + const state = sessionStates.get(sessionID) + if (state) { + sessionStates.set(sessionID, createFallbackState(state.originalModel)) + } + + sessionRetryInFlight.delete(sessionID) + sessionAwaitingFallbackResult.delete(sessionID) + sessionStatusRetryKeys.delete(sessionID) + helpers.clearSessionFallbackTimeout(sessionID) + } const handleSessionCreated = (props: Record | undefined) => { const sessionInfo = props?.info as { id?: string; model?: string } | undefined @@ -32,6 +46,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { if (sessionID) { log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID }) + cancelledSessions.delete(sessionID) sessionStates.delete(sessionID) sessionLastAccess.delete(sessionID) sessionRetryInFlight.delete(sessionID) @@ -46,28 +61,35 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const sessionID = props?.sessionID as string | undefined if (!sessionID) return - helpers.clearSessionFallbackTimeout(sessionID) - if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) { await helpers.abortSessionRequest(sessionID, "session.stop") } - sessionRetryInFlight.delete(sessionID) - sessionAwaitingFallbackResult.delete(sessionID) - sessionStatusRetryKeys.delete(sessionID) - - const state = sessionStates.get(sessionID) - if (state?.pendingFallbackModel) { - state.pendingFallbackModel = undefined - } + cancelledSessions.add(sessionID) + resetRetryState(sessionID) log(`[${HOOK_NAME}] Cleared fallback retry state on session.stop`, { sessionID }) } + const handleMessageUpdated = (props: Record | undefined) => { + const info = props?.info as Record | undefined + const sessionID = info?.sessionID as string | undefined + const role = info?.role as string | undefined + if (!sessionID || role !== "user") return + + cancelledSessions.delete(sessionID) + } + const handleSessionIdle = (props: Record | undefined) => { const sessionID = props?.sessionID as string | undefined if (!sessionID) return + if (cancelledSessions.has(sessionID)) { + resetRetryState(sessionID) + log(`[${HOOK_NAME}] Cleared fallback retry state for cancelled session on idle`, { sessionID }) + return + } + if (sessionAwaitingFallbackResult.has(sessionID)) { log(`[${HOOK_NAME}] session.idle while awaiting fallback result; keeping timeout armed`, { sessionID }) return @@ -100,6 +122,13 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent) + if (isAbortError(error)) { + cancelledSessions.add(sessionID) + resetRetryState(sessionID) + log(`[${HOOK_NAME}] session.error matched cancellation; cleared retry state`, { sessionID, resolvedAgent }) + return + } + if (sessionRetryInFlight.has(sessionID)) { log(`[${HOOK_NAME}] session.error skipped - retry in flight`, { sessionID, @@ -176,6 +205,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { if (event.type === "session.created") { handleSessionCreated(props); return } if (event.type === "session.deleted") { handleSessionDeleted(props); return } if (event.type === "session.stop") { await handleSessionStop(props); return } + if (event.type === "message.updated") { handleMessageUpdated(props); return } if (event.type === "session.idle") { handleSessionIdle(props); return } if (event.type === "session.status") { await sessionStatusHandler(props); return } if (event.type === "session.error") { await handleSessionError(props); return } diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index f5b2b84e1..5b2c43c91 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -61,6 +61,11 @@ export async function injectContinuation(args: { return } + if (state?.wasCancelled) { + log(`[${HOOK_NAME}] Skipped injection: session was cancelled`, { sessionID }) + return + } + if (isContinuationStopped?.(sessionID)) { log(`[${HOOK_NAME}] Skipped injection: continuation stopped for session`, { sessionID }) return @@ -145,6 +150,11 @@ Remaining tasks: ${todoList}` const injectionState = sessionStateStore.getExistingState(sessionID) + if (injectionState?.wasCancelled) { + log(`[${HOOK_NAME}] Skipped injection: session was cancelled before prompt`, { sessionID }) + return + } + if (injectionState) { injectionState.inFlight = true } diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index 2ee354d4a..e94167501 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -37,7 +37,13 @@ export function createTodoContinuationHandler(args: { const error = props?.error as { name?: string } | undefined if (error?.name === "MessageAbortedError" || error?.name === "AbortError") { const state = sessionStateStore.getState(sessionID) + state.wasCancelled = true state.abortDetectedAt = Date.now() + state.lastIncompleteCount = undefined + state.lastInjectedAt = undefined + state.awaitingPostInjectionProgressCheck = false + state.stagnationCount = 0 + state.consecutiveFailures = 0 log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name }) } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index e03424aee..4416ff3b9 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -42,6 +42,11 @@ export async function handleSessionIdle(args: { return } + if (state.wasCancelled) { + log(`[${HOOK_NAME}] Skipped: session was cancelled`, { sessionID }) + return + } + if (state.abortDetectedAt) { const timeSinceAbort = Date.now() - state.abortDetectedAt if (timeSinceAbort < ABORT_WINDOW_MS) { diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index 0ab6dbfaa..8a6a0b3af 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -25,14 +25,20 @@ export function handleNonIdleEvent(args: { return } } - if (state) state.abortDetectedAt = undefined + if (state) { + state.abortDetectedAt = undefined + state.wasCancelled = false + } sessionStateStore.cancelCountdown(sessionID) return } if (role === "assistant") { const state = sessionStateStore.getExistingState(sessionID) - if (state) state.abortDetectedAt = undefined + if (state) { + state.abortDetectedAt = undefined + state.wasCancelled = false + } sessionStateStore.cancelCountdown(sessionID) return } @@ -60,7 +66,10 @@ export function handleNonIdleEvent(args: { const sessionID = properties?.sessionID as string | undefined if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) - if (state) state.abortDetectedAt = undefined + if (state) { + state.abortDetectedAt = undefined + state.wasCancelled = false + } sessionStateStore.cancelCountdown(sessionID) } return @@ -70,7 +79,10 @@ export function handleNonIdleEvent(args: { const sessionID = properties?.sessionID as string | undefined if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) - if (state) state.abortDetectedAt = undefined + if (state) { + state.abortDetectedAt = undefined + state.wasCancelled = false + } sessionStateStore.cancelCountdown(sessionID) } return diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 59fca1569..e126bfd1c 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1270,7 +1270,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" @@ -1299,8 +1299,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 () => { @@ -1343,6 +1342,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 @@ -1866,4 +1903,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 }) + + }) diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 80b646a02..fc67b7016 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -26,6 +26,7 @@ export interface SessionState { countdownTimer?: ReturnType countdownInterval?: ReturnType isRecovering?: boolean + wasCancelled?: boolean countdownStartedAt?: number abortDetectedAt?: number lastIncompleteCount?: number diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index 8dd6fa038..38cd2a87b 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -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 + }) }) diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index 9bfdbb01a..018236394 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -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() + const cancelledSessions = new Set() const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { + const props = event.properties as Record | 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 | 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 | 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 diff --git a/src/shared/is-abort-error.ts b/src/shared/is-abort-error.ts new file mode 100644 index 000000000..3a8c92c1a --- /dev/null +++ b/src/shared/is-abort-error.ts @@ -0,0 +1,20 @@ +export function isAbortError(error: unknown): boolean { + if (!error) return false + + if (typeof error === "object") { + const errObj = error as Record + const name = errObj.name as string | undefined + const message = (errObj.message as string | undefined)?.toLowerCase() ?? "" + + if (name === "MessageAbortedError" || name === "AbortError") return true + if (name === "DOMException" && message.includes("abort")) return true + if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true + } + + if (typeof error === "string") { + const lower = error.toLowerCase() + return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt") + } + + return false +}