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.
This commit is contained in:
YeonGyu-Kim
2026-04-08 18:58:34 +09:00
parent 06b825dd74
commit 01dca82a45
2 changed files with 186 additions and 6 deletions
+162
View File
@@ -712,4 +712,166 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).toHaveBeenCalled() expect(ctx.client.session.summarize).toHaveBeenCalled()
}) })
// #given successful compaction at 180k and no new message.updated
// #when tool.execute.after fires again with tokens below 15% regrowth
// #then should NOT re-compact (within-turn guard)
it("should block re-compaction when tokens have not grown 15% past last compaction", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_regrowth_block"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_regrowth_1" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
//#when - advance past cooldown without a new message.updated (lastCompactedTokens still set)
const originalNow = Date.now
Date.now = () => originalNow() + 61_000
try {
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_regrowth_2" },
{ title: "", output: "test", metadata: null }
)
//#then - regrowth gate blocks re-compaction because tokens unchanged
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
} finally {
Date.now = originalNow
}
})
// #given compaction fails with an error
// #when tool.execute.after runs
// #then a warning toast should be shown
it("should show a warning toast when compaction fails", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_toast_fail"
const summarizeError = new Error("boom")
ctx.client.session.summarize.mockRejectedValueOnce(summarizeError)
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
//#when
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_toast" },
{ title: "", output: "test", metadata: null }
)
//#then
expect(ctx.client.tui.showToast).toHaveBeenCalledTimes(1)
expect(ctx.client.tui.showToast).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
title: "Compaction failed",
variant: "warning",
duration: 8000,
message: expect.stringContaining("boom"),
}),
}),
)
})
// #given session has cached compaction state and last-compacted tokens
// #when session.deleted event fires
// #then all per-session state should be cleared and subsequent tool.execute.after should no-op
it("should clear lastCompactedTokens state on session.deleted", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_cleanup"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_cleanup_1" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
//#when
await hook.event({
event: {
type: "session.deleted",
properties: { info: { id: sessionID } },
},
})
const originalNow = Date.now
Date.now = () => originalNow() + 61_000
try {
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_cleanup_2" },
{ title: "", output: "test", metadata: null }
)
//#then - tokenCache wiped on deletion → no-op, summarize not re-invoked
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
} finally {
Date.now = originalNow
}
})
}) })
+24 -6
View File
@@ -8,9 +8,10 @@ import {
import { resolveCompactionModel } from "./shared/compaction-model-resolver" import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000 const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
const PREEMPTIVE_COMPACTION_REGROWTH_RATIO = 1.15
declare function setTimeout(handler: () => void, timeout?: number): unknown declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void declare function clearTimeout(timeoutID: unknown): void
@@ -68,7 +69,7 @@ export function createPreemptiveCompactionHook(
modelCacheState?: ContextLimitModelCacheState, modelCacheState?: ContextLimitModelCacheState,
) { ) {
const compactionInProgress = new Set<string>() const compactionInProgress = new Set<string>()
const compactedSessions = new Set<string>() const lastCompactedTokens = new Map<string, number>()
const lastCompactionTime = new Map<string, number>() const lastCompactionTime = new Map<string, number>()
const tokenCache = new Map<string, CachedCompactionState>() const tokenCache = new Map<string, CachedCompactionState>()
@@ -85,7 +86,7 @@ export function createPreemptiveCompactionHook(
_output: { title: string; output: string; metadata: unknown } _output: { title: string; output: string; metadata: unknown }
) => { ) => {
const { sessionID } = input const { sessionID } = input
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return if (compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID) const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
@@ -108,6 +109,15 @@ export function createPreemptiveCompactionHook(
} }
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0) 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 const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
@@ -132,9 +142,17 @@ export function createPreemptiveCompactionHook(
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`, `Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
) )
compactedSessions.add(sessionID) lastCompactedTokens.set(sessionID, totalInputTokens)
} catch (error) { } catch (error) {
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(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 { } finally {
compactionInProgress.delete(sessionID) compactionInProgress.delete(sessionID)
} }
@@ -147,7 +165,7 @@ export function createPreemptiveCompactionHook(
const sessionID = (props?.info as { id?: string } | undefined)?.id const sessionID = (props?.info as { id?: string } | undefined)?.id
if (sessionID) { if (sessionID) {
compactionInProgress.delete(sessionID) compactionInProgress.delete(sessionID)
compactedSessions.delete(sessionID) lastCompactedTokens.delete(sessionID)
lastCompactionTime.delete(sessionID) lastCompactionTime.delete(sessionID)
tokenCache.delete(sessionID) tokenCache.delete(sessionID)
postCompactionMonitor.clear(sessionID) postCompactionMonitor.clear(sessionID)
@@ -184,7 +202,7 @@ export function createPreemptiveCompactionHook(
tokens: info.tokens, tokens: info.tokens,
}) })
} }
compactedSessions.delete(info.sessionID) lastCompactedTokens.delete(info.sessionID)
await postCompactionMonitor.onAssistantMessageUpdated({ await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID, sessionID: info.sessionID,