perf(todo-continuation): add cooldown and stagnation cap to prevent re-injection loops
This commit is contained in:
@@ -17,3 +17,5 @@ export const TOAST_DURATION_MS = 900
|
||||
export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
export const ABORT_WINDOW_MS = 3000
|
||||
export const CONTINUATION_COOLDOWN_MS = 30_000
|
||||
export const MAX_UNCHANGED_CYCLES = 3
|
||||
|
||||
@@ -114,6 +114,11 @@ export async function injectContinuation(args: {
|
||||
Remaining tasks:
|
||||
${todoList}`
|
||||
|
||||
const injectionState = sessionStateStore.getExistingState(sessionID)
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = true
|
||||
}
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, {
|
||||
sessionID,
|
||||
@@ -133,7 +138,14 @@ ${todoList}`
|
||||
})
|
||||
|
||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = false
|
||||
injectionState.lastInjectedAt = Date.now()
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { log } from "../../shared/logger"
|
||||
|
||||
import {
|
||||
ABORT_WINDOW_MS,
|
||||
CONTINUATION_COOLDOWN_MS,
|
||||
DEFAULT_SKIP_AGENTS,
|
||||
HOOK_NAME,
|
||||
MAX_UNCHANGED_CYCLES,
|
||||
} from "./constants"
|
||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||
import { getIncompleteCount } from "./todo"
|
||||
@@ -105,6 +107,29 @@ export async function handleSessionIdle(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.inFlight) {
|
||||
log(`[${HOOK_NAME}] Skipped: injection in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
|
||||
const todoHash = incompleteTodos.map((todo) => `${todo.id}:${todo.status}`).join("|")
|
||||
if (state.lastTodoHash === todoHash) {
|
||||
state.unchangedCycles = (state.unchangedCycles ?? 0) + 1
|
||||
if (state.unchangedCycles >= MAX_UNCHANGED_CYCLES) {
|
||||
log(`[${HOOK_NAME}] Skipped: stagnation cap reached`, { sessionID, cycles: state.unchangedCycles })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
state.unchangedCycles = 0
|
||||
}
|
||||
state.lastTodoHash = todoHash
|
||||
|
||||
let resolvedInfo: ResolvedMessageInfo | undefined
|
||||
let hasCompactionMessage = false
|
||||
try {
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
state.countdownInterval = undefined
|
||||
}
|
||||
|
||||
state.inFlight = false
|
||||
state.countdownStartedAt = undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "."
|
||||
import { CONTINUATION_COOLDOWN_MS } from "./constants"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
|
||||
@@ -507,6 +508,144 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject again when cooldown is active", async () => {
|
||||
//#given
|
||||
const sessionID = "main-cooldown-active"
|
||||
setupMainSessionWithBoulder(sessionID)
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
//#when
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("should inject again when cooldown expires", async () => {
|
||||
//#given
|
||||
const sessionID = "main-cooldown-expired"
|
||||
setupMainSessionWithBoulder(sessionID)
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
//#when
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should stop after stagnation cap and reset when todo hash changes", async () => {
|
||||
//#given
|
||||
const sessionID = "main-stagnation-cap"
|
||||
setupMainSessionWithBoulder(sessionID)
|
||||
let mutableTodoStatus: "pending" | "in_progress" = "pending"
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => ({ data: [
|
||||
{ id: "1", content: "Task 1", status: mutableTodoStatus, priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
]})
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
mutableTodoStatus = "in_progress"
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("should skip idle handling while injection is in flight", async () => {
|
||||
//#given
|
||||
const sessionID = "main-in-flight"
|
||||
setupMainSessionWithBoulder(sessionID)
|
||||
let resolvePrompt: (() => void) | undefined
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.promptAsync = async (opts: any) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
await new Promise<void>((resolve) => {
|
||||
resolvePrompt = resolve
|
||||
})
|
||||
return {}
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2100, true)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(3000, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
|
||||
resolvePrompt?.()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
test("should clear cooldown and stagnation state on session deleted", async () => {
|
||||
//#given
|
||||
const sessionID = "main-delete-state-reset"
|
||||
setupMainSessionWithBoulder(sessionID)
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
//#when
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
await hook.handler({
|
||||
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
|
||||
})
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should accept skipAgents option without error", async () => {
|
||||
// given - session with skipAgents configured for Prometheus
|
||||
const sessionID = "main-prometheus-option"
|
||||
@@ -556,16 +695,16 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(3500)
|
||||
await fakeTimers.advanceBy(3500, true)
|
||||
|
||||
// then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
|
||||
// when - immediately trigger second idle (no 10s wait needed)
|
||||
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(3500)
|
||||
await fakeTimers.advanceBy(3500, true)
|
||||
|
||||
// then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface SessionState {
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
abortDetectedAt?: number
|
||||
lastInjectedAt?: number
|
||||
inFlight?: boolean
|
||||
lastTodoHash?: string
|
||||
unchangedCycles?: number
|
||||
}
|
||||
|
||||
export interface MessageInfo {
|
||||
|
||||
Reference in New Issue
Block a user