From 0a7daa5d103aa0fa7c64236ca77ab362a4dba3fc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 15:38:46 +0900 Subject: [PATCH 1/5] fix(anthropic-recovery): add session timeout cleanup helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session-timeout-map.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/hooks/anthropic-context-window-limit-recovery/session-timeout-map.ts diff --git a/src/hooks/anthropic-context-window-limit-recovery/session-timeout-map.ts b/src/hooks/anthropic-context-window-limit-recovery/session-timeout-map.ts new file mode 100644 index 000000000..80712d03f --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/session-timeout-map.ts @@ -0,0 +1,20 @@ +export function clearSessionTimeout( + timeoutBySession: Map>, + sessionID: string, +): void { + const timeoutID = timeoutBySession.get(sessionID) + if (timeoutID !== undefined) { + clearTimeout(timeoutID) + timeoutBySession.delete(sessionID) + } +} + +export function clearAllSessionTimeouts( + timeoutBySession: Map>, +): void { + for (const timeoutID of timeoutBySession.values()) { + clearTimeout(timeoutID) + } + + timeoutBySession.clear() +} From 268946d117653b9239f245d5bf733e7e375232f1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 15:39:00 +0900 Subject: [PATCH 2/5] fix(anthropic-recovery): dispose recovery hook timers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../recovery-hook.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts index 2be7569ea..0a80d63bc 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts @@ -6,6 +6,7 @@ import { parseAnthropicTokenLimitError } from "./parser" import { executeCompact, getLastAssistant } from "./executor" import { attemptDeduplicationRecovery } from "./deduplication-recovery" import { clearSessionState } from "./state" +import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map" import { log } from "../../shared/logger" export interface AnthropicContextWindowLimitRecoveryOptions { @@ -48,21 +49,13 @@ export function createAnthropicContextWindowLimitRecoveryHook( } const pendingCompactionTimeoutBySession = new Map>() - function clearPendingCompactionTimeout(sessionID: string): void { - const timeoutID = pendingCompactionTimeoutBySession.get(sessionID) - if (timeoutID !== undefined) { - clearTimeout(timeoutID) - pendingCompactionTimeoutBySession.delete(sessionID) - } - } - const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { const props = event.properties as Record | undefined if (event.type === "session.deleted") { const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { - clearPendingCompactionTimeout(sessionInfo.id) + clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id) clearSessionState(autoCompactState, sessionInfo.id) } @@ -105,7 +98,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( }) .catch(() => {}) - clearPendingCompactionTimeout(sessionID) + clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID) const timeoutID = setTimeout(() => { pendingCompactionTimeoutBySession.delete(sessionID) @@ -149,7 +142,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( if (!autoCompactState.pendingCompact.has(sessionID)) return - clearPendingCompactionTimeout(sessionID) + clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID) const errorData = autoCompactState.errorDataBySession.get(sessionID) const lastAssistant = await dependencies.getLastAssistant( @@ -192,5 +185,9 @@ export function createAnthropicContextWindowLimitRecoveryHook( return { event: eventHandler, + dispose: (): void => { + clearAllSessionTimeouts(pendingCompactionTimeoutBySession) + clearAllSessionTimeouts(autoCompactState.retryTimerBySession) + }, } } From 2b55f65c36ac8619b79be0a761e61262e5bdb5d3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 15:39:12 +0900 Subject: [PATCH 3/5] fix(hooks): dispose anthropic recovery hook on shutdown Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/create-hooks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 67b75fbde..0e40ad480 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -19,14 +19,16 @@ export type DisposableCreatedHooks = { runtimeFallback?: DisposableHook todoContinuationEnforcer?: DisposableHook autoSlashCommand?: DisposableHook + anthropicContextWindowLimitRecovery?: DisposableHook } export function disposeCreatedHooks(hooks: DisposableCreatedHooks): void { - hooks.claudeCodeHooks?.dispose?.() - hooks.commentChecker?.dispose?.() + hooks.claudeCodeHooks?.dispose?.() + hooks.commentChecker?.dispose?.() hooks.runtimeFallback?.dispose?.() hooks.todoContinuationEnforcer?.dispose?.() hooks.autoSlashCommand?.dispose?.() + hooks.anthropicContextWindowLimitRecovery?.dispose?.() } export function createHooks(args: { From 5e8a0ccf84fd9eee30fcbc7cce93c09b33ac777e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 15:39:28 +0900 Subject: [PATCH 4/5] test(anthropic-recovery): cover recovery hook timer disposal Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../recovery-hook.test-support.ts | 8 +++- .../recovery-hook.test.ts | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test-support.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test-support.ts index e394a0040..bb04412bd 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test-support.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test-support.ts @@ -75,6 +75,7 @@ export function createMockContext(): PluginInput { export function setupDelayedTimeoutMocks(): { createUntrackedTimeout: () => ReturnType + runScheduledTimeout: (index: number) => void restore: () => void getClearTimeoutCalls: () => Array> getScheduledTimeouts: () => Array> @@ -83,6 +84,7 @@ export function setupDelayedTimeoutMocks(): { const originalClearTimeout = globalThis.clearTimeout const clearTimeoutCalls: Array> = [] const scheduledTimeouts: Array> = [] + const scheduledCallbacks: Array<() => void> = [] function createTimeoutHandle(): ReturnType { const timeoutID = originalSetTimeout(() => {}, 60_000) @@ -90,9 +92,10 @@ export function setupDelayedTimeoutMocks(): { return timeoutID } - globalThis.setTimeout = ((_: () => void, _delay?: number) => { + globalThis.setTimeout = ((callback: () => void, _delay?: number) => { const timeoutID = createTimeoutHandle() scheduledTimeouts.push(timeoutID) + scheduledCallbacks.push(callback) return timeoutID }) as typeof setTimeout @@ -103,6 +106,9 @@ export function setupDelayedTimeoutMocks(): { return { createUntrackedTimeout: createTimeoutHandle, + runScheduledTimeout: (index: number) => { + scheduledCallbacks[index]?.() + }, restore: () => { globalThis.setTimeout = originalSetTimeout globalThis.clearTimeout = originalClearTimeout diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts index 4291bb754..6300ab55e 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts @@ -94,4 +94,45 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { } }) + test("#given active pending and retry timers #when dispose is called #then it clears both timer maps", async () => { + //#given + const { createUntrackedTimeout, getClearTimeoutCalls, getScheduledTimeouts, restore, runScheduledTimeout } = + setupDelayedTimeoutMocks() + executeCompactMock.mockImplementationOnce(async (...args: Parameters) => { + const sessionID = args[0] + const autoCompactState = args[2] + + autoCompactState.retryTimerBySession.set(sessionID, createUntrackedTimeout()) + }) + const hook = createRecoveryHook() + + try { + await hook.event({ + event: { + type: "session.error", + properties: { sessionID: "session-retry", error: "prompt is too long" }, + }, + }) + + await hook.event({ + event: { + type: "session.error", + properties: { sessionID: "session-pending", error: "prompt is too long" }, + }, + }) + + runScheduledTimeout(0) + + const [retryTimer, pendingTimer] = getScheduledTimeouts() + + //#when + hook.dispose() + + //#then + expect(getClearTimeoutCalls()).toEqual(expect.arrayContaining([retryTimer, pendingTimer])) + } finally { + restore() + } + }) + }) From 42ac82a94a56fb82a41dcba5cfc6af3cfa653a23 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 15:39:44 +0900 Subject: [PATCH 5/5] test(anthropic-recovery): isolate summarize retry timer assertion Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../summarize-retry-strategy.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts index 2c0137c8b..332aeda20 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.test.ts @@ -4,6 +4,7 @@ import type { AutoCompactState, ParsedTokenLimitError, RetryState } from "./type import type { OhMyOpenCodeConfig } from "../../config" type TimeoutCall = { + handle: ReturnType delay: number } @@ -94,8 +95,9 @@ describe("runSummarizeRetryStrategy", () => { //#given const timeoutCalls: TimeoutCall[] = [] globalThis.setTimeout = ((_: (...args: unknown[]) => void, delay?: number) => { - timeoutCalls.push({ delay: delay ?? 0 }) - return 1 as unknown as ReturnType + const handle = timeoutCalls.length + 1 as unknown as ReturnType + timeoutCalls.push({ handle, delay: delay ?? 0 }) + return handle }) as typeof setTimeout autoCompactState.pendingCompact.add(sessionID) @@ -117,9 +119,12 @@ describe("runSummarizeRetryStrategy", () => { }) //#then - expect(timeoutCalls.length).toBe(1) - expect(timeoutCalls[0]!.delay).toBeGreaterThan(0) - expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(2000) + const retryTimer = autoCompactState.retryTimerBySession.get(sessionID) + const retryTimeoutCall = timeoutCalls.find(({ handle }) => handle === retryTimer) + + expect(retryTimeoutCall).toBeDefined() + expect(retryTimeoutCall?.delay).toBeGreaterThan(0) + expect(retryTimeoutCall?.delay).toBeLessThanOrEqual(2000) }) test("#given pending retry timer after session cleanup #when scheduled callback fires #then it does not recreate retry state", async () => {