diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts index 0642a5fb9..c5983e872 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -90,6 +90,7 @@ describe("executeCompact lock management", () => { pendingCompact: new Set(), errorDataBySession: new Map(), retryStateBySession: new Map(), + retryTimerBySession: new Map(), truncateStateBySession: new Map(), emptyContentAttemptBySession: new Map(), compactionInProgress: new Set(), 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 15c0ee1f2..5ca26cfbb 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts @@ -5,6 +5,7 @@ import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config" import { parseAnthropicTokenLimitError } from "./parser" import { executeCompact, getLastAssistant } from "./executor" import { attemptDeduplicationRecovery } from "./deduplication-recovery" +import { clearSessionState } from "./state" import { log } from "../../shared/logger" export interface AnthropicContextWindowLimitRecoveryOptions { @@ -17,6 +18,7 @@ function createRecoveryState(): AutoCompactState { pendingCompact: new Set(), errorDataBySession: new Map(), retryStateBySession: new Map(), + retryTimerBySession: new Map(), truncateStateBySession: new Map(), emptyContentAttemptBySession: new Map(), compactionInProgress: new Set(), @@ -30,7 +32,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( ) { const autoCompactState = createRecoveryState() const experimental = options?.experimental - const pluginConfig = options?.pluginConfig! + const pluginConfig = options?.pluginConfig ?? {} as OhMyOpenCodeConfig const pendingCompactionTimeoutBySession = new Map>() const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { @@ -45,12 +47,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( pendingCompactionTimeoutBySession.delete(sessionInfo.id) } - autoCompactState.pendingCompact.delete(sessionInfo.id) - autoCompactState.errorDataBySession.delete(sessionInfo.id) - autoCompactState.retryStateBySession.delete(sessionInfo.id) - autoCompactState.truncateStateBySession.delete(sessionInfo.id) - autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id) - autoCompactState.compactionInProgress.delete(sessionInfo.id) + clearSessionState(autoCompactState, sessionInfo.id) } return } diff --git a/src/hooks/anthropic-context-window-limit-recovery/state.ts b/src/hooks/anthropic-context-window-limit-recovery/state.ts index 70fd69f53..52425fc85 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/state.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/state.ts @@ -28,6 +28,11 @@ export function clearSessionState( autoCompactState: AutoCompactState, sessionID: string, ): void { + const retryTimer = autoCompactState.retryTimerBySession.get(sessionID) + if (retryTimer !== undefined) { + clearTimeout(retryTimer) + autoCompactState.retryTimerBySession.delete(sessionID) + } autoCompactState.pendingCompact.delete(sessionID) autoCompactState.errorDataBySession.delete(sessionID) autoCompactState.retryStateBySession.delete(sessionID) @@ -36,6 +41,26 @@ export function clearSessionState( autoCompactState.compactionInProgress.delete(sessionID) } +export function setRetryTimer( + autoCompactState: AutoCompactState, + sessionID: string, + timeout: ReturnType, +): void { + const existingTimer = autoCompactState.retryTimerBySession.get(sessionID) + if (existingTimer !== undefined) { + clearTimeout(existingTimer) + } + autoCompactState.retryTimerBySession.set(sessionID, timeout) +} + +export function clearRetryTimer(autoCompactState: AutoCompactState, sessionID: string): void { + const retryTimer = autoCompactState.retryTimerBySession.get(sessionID) + if (retryTimer !== undefined) { + clearTimeout(retryTimer) + autoCompactState.retryTimerBySession.delete(sessionID) + } +} + export function getEmptyContentAttempt( autoCompactState: AutoCompactState, sessionID: string, 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 0818fbdd5..7c2e25b69 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 @@ -12,6 +12,7 @@ function createAutoCompactState(): AutoCompactState { pendingCompact: new Set(), errorDataBySession: new Map(), retryStateBySession: new Map(), + retryTimerBySession: new Map(), truncateStateBySession: new Map(), emptyContentAttemptBySession: new Map(), compactionInProgress: new Set(), @@ -97,10 +98,11 @@ describe("runSummarizeRetryStrategy", () => { return 1 as unknown as ReturnType }) as typeof setTimeout + autoCompactState.pendingCompact.add(sessionID) autoCompactState.retryStateBySession.set(sessionID, { attempt: 0, lastAttemptTime: Date.now(), - firstAttemptTime: Date.now() - 119900, + firstAttemptTime: Date.now() - 100000, }) summarizeMock.mockRejectedValueOnce(new Error("rate limited")) @@ -117,6 +119,36 @@ describe("runSummarizeRetryStrategy", () => { //#then expect(timeoutCalls.length).toBe(1) expect(timeoutCalls[0]!.delay).toBeGreaterThan(0) - expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(300) + expect(timeoutCalls[0]!.delay).toBeLessThanOrEqual(2000) + }) + + test("#given pending retry timer after session cleanup #when scheduled callback fires #then it does not recreate retry state", async () => { + //#given + let scheduledCallback: (() => void) | undefined + globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number) => { + scheduledCallback = () => callback() + return 1 as unknown as ReturnType + }) as typeof setTimeout + + autoCompactState.pendingCompact.add(sessionID) + summarizeMock.mockRejectedValueOnce(new Error("rate limited")) + + await runSummarizeRetryStrategy({ + sessionID, + msg: { providerID: "anthropic", modelID: "claude-sonnet-4-6" }, + autoCompactState, + client: client as never, + directory, + pluginConfig: {} as OhMyOpenCodeConfig, + }) + + autoCompactState.pendingCompact.delete(sessionID) + autoCompactState.retryStateBySession.delete(sessionID) + + //#when + scheduledCallback?.() + + //#then + expect(autoCompactState.retryStateBySession.has(sessionID)).toBe(false) }) }) diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts index 36a5d1a8c..2440f699d 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts @@ -2,7 +2,13 @@ import type { AutoCompactState } from "./types" import type { OhMyOpenCodeConfig } from "../../config" import { RETRY_CONFIG } from "./types" import type { Client } from "./client" -import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state" +import { + clearRetryTimer, + clearSessionState, + getEmptyContentAttempt, + getOrCreateRetryState, + setRetryTimer, +} from "./state" import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder" import { fixEmptyMessages } from "./empty-content-recovery" @@ -19,6 +25,11 @@ export async function runSummarizeRetryStrategy(params: { errorType?: string messageIndex?: number }): Promise { + if (!params.autoCompactState.pendingCompact.has(params.sessionID)) { + clearRetryTimer(params.autoCompactState, params.sessionID) + return + } + const retryState = getOrCreateRetryState(params.autoCompactState, params.sessionID) const now = Date.now() @@ -42,6 +53,8 @@ export async function runSummarizeRetryStrategy(params: { return } + clearRetryTimer(params.autoCompactState, params.sessionID) + if (params.errorType?.includes("non-empty content")) { const attempt = getEmptyContentAttempt(params.autoCompactState, params.sessionID) if (attempt < 3) { @@ -52,9 +65,11 @@ export async function runSummarizeRetryStrategy(params: { messageIndex: params.messageIndex, }) if (fixed) { - setTimeout(() => { + const timeout = setTimeout(() => { + params.autoCompactState.retryTimerBySession.delete(params.sessionID) void runSummarizeRetryStrategy(params) }, 500) + setRetryTimer(params.autoCompactState, params.sessionID, timeout) return } } else { @@ -138,9 +153,11 @@ export async function runSummarizeRetryStrategy(params: { Math.pow(RETRY_CONFIG.backoffFactor, retryState.attempt - 1) const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs, remainingTimeMs) - setTimeout(() => { + const timeout = setTimeout(() => { + params.autoCompactState.retryTimerBySession.delete(params.sessionID) void runSummarizeRetryStrategy(params) }, cappedDelay) + setRetryTimer(params.autoCompactState, params.sessionID, timeout) return } } else { diff --git a/src/hooks/anthropic-context-window-limit-recovery/types.ts b/src/hooks/anthropic-context-window-limit-recovery/types.ts index 5c62b81fb..4390b3468 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/types.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/types.ts @@ -23,6 +23,7 @@ export interface AutoCompactState { pendingCompact: Set errorDataBySession: Map retryStateBySession: Map + retryTimerBySession: Map> truncateStateBySession: Map emptyContentAttemptBySession: Map compactionInProgress: Set