Files
oh-my-opencode/src/hooks/preemptive-compaction.ts
T
YeonGyu-Kim 01dca82a45 fix(preemptive-compaction): prevent session stuck state and add failure notifications
Replace the boolean compactedSessions set with a lastCompactedTokens
map gated on 15% regrowth so sessions can re-compact after tokens
keep climbing, cut the summarize timeout from 120s to 60s to avoid
long-hang stalls, and surface a warning toast when compaction fails
so users aren't left staring at a silently stuck session.
2026-04-08 18:58:34 +09:00

219 lines
6.4 KiB
TypeScript

import { log } from "../shared/logger"
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
const PREEMPTIVE_COMPACTION_REGROWTH_RATIO = 1.15
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
type PluginInput = {
client: {
session: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: (...args: any[]) => any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
summarize: (...args: any[]) => any
}
tui: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
showToast: (...args: any[]) => any
}
}
directory: string
}
export function createPreemptiveCompactionHook(
ctx: PluginInput,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ContextLimitModelCacheState,
) {
const compactionInProgress = new Set<string>()
const lastCompactedTokens = new Map<string, number>()
const lastCompactionTime = new Map<string, number>()
const tokenCache = new Map<string, CachedCompactionState>()
const postCompactionMonitor = createPostCompactionDegradationMonitor({
client: ctx.client,
directory: ctx.directory,
pluginConfig,
tokenCache,
compactionInProgress,
})
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
_output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const previousCompactedTokens = lastCompactedTokens.get(sessionID)
if (
previousCompactedTokens !== undefined
&& totalInputTokens < previousCompactedTokens * PREEMPTIVE_COMPACTION_REGROWTH_RATIO
) {
return
}
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
lastCompactedTokens.set(sessionID, totalInputTokens)
} catch (error) {
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
ctx.client.tui.showToast({
body: {
title: "Compaction failed",
message: `Preemptive compaction timed out or failed for session. Context may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 8000,
},
}).catch(() => {})
} finally {
compactionInProgress.delete(sessionID)
}
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionID = (props?.info as { id?: string } | undefined)?.id
if (sessionID) {
compactionInProgress.delete(sessionID)
lastCompactedTokens.delete(sessionID)
lastCompactionTime.delete(sessionID)
tokenCache.delete(sessionID)
postCompactionMonitor.clear(sessionID)
}
return
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID as string | undefined)
?? (props?.info as { id?: string } | undefined)?.id
if (sessionID) {
postCompactionMonitor.onSessionCompacted(sessionID)
}
return
}
if (event.type === "message.updated") {
const info = props?.info as {
id?: string
role?: string
sessionID?: string
providerID?: string
modelID?: string
finish?: boolean
tokens?: TokenInfo
} | undefined
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
if (info.providerID && info.tokens) {
tokenCache.set(info.sessionID, {
providerID: info.providerID,
modelID: info.modelID ?? "",
tokens: info.tokens,
})
}
lastCompactedTokens.delete(info.sessionID)
await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID,
id: info.id,
})
}
}
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
}
}