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()
|
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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user