From 2a4009e69226d049376de81718be69ab20dfe3d6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 15:27:00 +0900 Subject: [PATCH] fix: add post-max-failure recovery window for todo continuation --- .../todo-continuation-enforcer/constants.ts | 1 + .../continuation-injection.ts | 2 +- .../todo-continuation-enforcer/idle-event.ts | 13 ++++++ .../todo-continuation-enforcer.test.ts | 46 ++++++++++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index 0731f2ed..db4d7b1c 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -19,3 +19,4 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 export const CONTINUATION_COOLDOWN_MS = 30_000 export const MAX_CONSECUTIVE_FAILURES = 5 +export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000 diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 0db4156f..3f44db3a 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -148,7 +148,7 @@ ${todoList}` if (injectionState) { injectionState.inFlight = false injectionState.lastInjectedAt = Date.now() - injectionState.consecutiveFailures += 1 + injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1 } } } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 52218e41..cb039b69 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -8,6 +8,7 @@ import { ABORT_WINDOW_MS, CONTINUATION_COOLDOWN_MS, DEFAULT_SKIP_AGENTS, + FAILURE_RESET_WINDOW_MS, HOOK_NAME, MAX_CONSECUTIVE_FAILURES, } from "./constants" @@ -100,6 +101,18 @@ export async function handleSessionIdle(args: { return } + if ( + state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES + && state.lastInjectedAt + && Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS + ) { + state.consecutiveFailures = 0 + log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { + sessionID, + failureResetWindowMs: FAILURE_RESET_WINDOW_MS, + }) + } + if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { log(`[${HOOK_NAME}] Skipped: max consecutive failures reached`, { sessionID, 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 765556b6..18a2aad6 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state" import { createTodoContinuationEnforcer } from "." -import { CONTINUATION_COOLDOWN_MS, MAX_CONSECUTIVE_FAILURES } from "./constants" +import { + CONTINUATION_COOLDOWN_MS, + FAILURE_RESET_WINDOW_MS, + MAX_CONSECUTIVE_FAILURES, +} from "./constants" type TimerCallback = (...args: any[]) => void @@ -606,7 +610,9 @@ describe("todo-continuation-enforcer", () => { for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) - await fakeTimers.advanceClockBy(1_000_000) + if (index < MAX_CONSECUTIVE_FAILURES - 1) { + await fakeTimers.advanceClockBy(1_000_000) + } } await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) @@ -615,6 +621,42 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES) }, { timeout: 30000 }) + test("should resume retries after reset window when max failures reached", async () => { + //#given + const sessionID = "main-recovery-after-max-failures" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + throw new Error("simulated auth failure") + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + if (index < MAX_CONSECUTIVE_FAILURES - 1) { + await fakeTimers.advanceClockBy(1_000_000) + } + } + + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + await fakeTimers.advanceClockBy(FAILURE_RESET_WINDOW_MS) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES + 1) + }, { timeout: 30000 }) + test("should increase cooldown exponentially after consecutive failures", async () => { //#given const sessionID = "main-exponential-backoff"