fix(#2736): prevent infinite compaction loop by setting cooldown before try

lastCompactionTime was only set on successful compaction. When compaction
failed (rate limit, timeout, etc.), no cooldown was recorded, causing
immediate retries in an infinite loop.

Fix: set lastCompactionTime before the try block so both success and
failure respect the cooldown window.

- test: add failed-compaction cooldown enforcement test
- test: fix timeout retry test to advance past cooldown
This commit is contained in:
YeonGyu-Kim
2026-03-27 14:25:38 +09:00
parent 7ce7a85768
commit 041770ff42
2 changed files with 107 additions and 8 deletions

View File

@@ -284,6 +284,74 @@ describe("preemptive-compaction", () => {
})
})
// #given compaction fails
// #when tool.execute.after is called again immediately
// #then should NOT retry due to cooldown
it("should enforce cooldown even after failed compaction to prevent rapid retry loops", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_fail_cooldown"
ctx.client.session.summarize.mockRejectedValueOnce(new Error("rate limited"))
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_fail" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
//#when - message.updated clears compactedSessions, but cooldown should still 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_fail_2" },
{ title: "", output: "test", metadata: null }
)
//#then - should NOT have retried
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
})
it("should use 1M limit when model cache flag is enabled", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
@@ -399,17 +467,48 @@ describe("preemptive-compaction", () => {
{ title: "", output: "test", metadata: null },
)
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_timeout_2" },
{ title: "", output: "test", metadata: null },
)
//#then
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(2)
//#then - first call timed out
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", {
sessionID,
error: expect.stringContaining("Compaction summarize timed out"),
})
//#when - advance past cooldown, clear compactedSessions via message.updated, then retry
const originalNow = Date.now
Date.now = () => originalNow() + 61_000
try {
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_timeout_2" },
{ title: "", output: "test", metadata: null },
)
//#then - should have retried after cooldown
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(2)
} finally {
Date.now = originalNow
}
} finally {
restoreTimeouts()
}

View File

@@ -112,6 +112,7 @@ export function createPreemptiveCompactionHook(
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
@@ -132,7 +133,6 @@ export function createPreemptiveCompactionHook(
)
compactedSessions.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
} catch (error) {
log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) })
} finally {