Files
oh-my-opencode/src/hooks/preemptive-compaction.ts
T
YeonGyu-Kim 1ea0ee4319 fix(preemptive-compaction): notify user on failure and reduce timeout
Reports from david_66 on Discord: sessions get perceived as 'stuck' when
context usage crosses the 78% threshold. Investigation confirmed two
issues in the preemptive compaction hook:

1. PREEMPTIVE_COMPACTION_TIMEOUT_MS was 120s. While the summarize
   request is in flight, tool.execute.after short-circuits via the
   compactionInProgress guard. A hung summarize blocked the session
   for two full minutes before giving up, which users reasonably
   experience as a hang.

2. On failure (timeout or exception) only a log line was emitted. The
   user had no visibility into why their session was unresponsive or
   why auto-compaction never ran, so a transient upstream error could
   silently leave them well above the threshold with no signal.

Fix:

- Reduce timeout 120s -> 60s. Still gives the upstream a generous
  window, but caps the worst-case perceived hang at one minute.
- Show a warning toast via ctx.client.tui.showToast whenever the
  catch block fires, including the underlying error string so users
  can act (retry, manual /compact, or adjust provider).
- Include providerID/modelID in the Compaction failed log entry so
  wild failures are easier to correlate to a specific target model.

Two existing failure-path assertions were updated to match the new
log shape and a new test covers the toast notification contract.

Discord report: https://discord.com/channels/1452487457085063218/1490536332961906829/1491345441399505037
2026-04-08 20:10:25 +09:00

219 lines
6.5 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
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 compactedSessions = new Set<string>()
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 (compactedSessions.has(sessionID) || 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 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`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} 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)
compactedSessions.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,
})
}
compactedSessions.delete(info.sessionID)
await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID,
id: info.id,
})
}
}
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
}
}