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:
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user