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()
})
// #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 { 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_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
@@ -68,7 +69,7 @@ export function createPreemptiveCompactionHook(
modelCacheState?: ContextLimitModelCacheState,
) {
const compactionInProgress = new Set<string>()
const compactedSessions = new Set<string>()
const lastCompactedTokens = new Map<string, number>()
const lastCompactionTime = new Map<string, number>()
const tokenCache = new Map<string, CachedCompactionState>()
@@ -85,7 +86,7 @@ export function createPreemptiveCompactionHook(
_output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
if (compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
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 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
@@ -132,9 +142,17 @@ export function createPreemptiveCompactionHook(
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
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)
}
@@ -147,7 +165,7 @@ export function createPreemptiveCompactionHook(
const sessionID = (props?.info as { id?: string } | undefined)?.id
if (sessionID) {
compactionInProgress.delete(sessionID)
compactedSessions.delete(sessionID)
lastCompactedTokens.delete(sessionID)
lastCompactionTime.delete(sessionID)
tokenCache.delete(sessionID)
postCompactionMonitor.clear(sessionID)
@@ -184,7 +202,7 @@ export function createPreemptiveCompactionHook(
tokens: info.tokens,
})
}
compactedSessions.delete(info.sessionID)
lastCompactedTokens.delete(info.sessionID)
await postCompactionMonitor.onAssistantMessageUpdated({
sessionID: info.sessionID,