perf(todo-continuation): add cooldown and stagnation cap to prevent re-injection loops
This commit is contained in:
parent
a6372feaae
commit
10a60854dc
@ -17,3 +17,5 @@ export const TOAST_DURATION_MS = 900
|
|||||||
export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||||
|
|
||||||
export const ABORT_WINDOW_MS = 3000
|
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:
|
Remaining tasks:
|
||||||
${todoList}`
|
${todoList}`
|
||||||
|
|
||||||
|
const injectionState = sessionStateStore.getExistingState(sessionID)
|
||||||
|
if (injectionState) {
|
||||||
|
injectionState.inFlight = true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log(`[${HOOK_NAME}] Injecting continuation`, {
|
log(`[${HOOK_NAME}] Injecting continuation`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@ -133,7 +138,14 @@ ${todoList}`
|
|||||||
})
|
})
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||||
|
if (injectionState) {
|
||||||
|
injectionState.inFlight = false
|
||||||
|
injectionState.lastInjectedAt = Date.now()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(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 {
|
import {
|
||||||
ABORT_WINDOW_MS,
|
ABORT_WINDOW_MS,
|
||||||
|
CONTINUATION_COOLDOWN_MS,
|
||||||
DEFAULT_SKIP_AGENTS,
|
DEFAULT_SKIP_AGENTS,
|
||||||
HOOK_NAME,
|
HOOK_NAME,
|
||||||
|
MAX_UNCHANGED_CYCLES,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||||
import { getIncompleteCount } from "./todo"
|
import { getIncompleteCount } from "./todo"
|
||||||
@ -105,6 +107,29 @@ export async function handleSessionIdle(args: {
|
|||||||
return
|
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 resolvedInfo: ResolvedMessageInfo | undefined
|
||||||
let hasCompactionMessage = false
|
let hasCompactionMessage = false
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
state.countdownInterval = undefined
|
state.countdownInterval = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.inFlight = false
|
||||||
state.countdownStartedAt = undefined
|
state.countdownStartedAt = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
import { setMainSession, subagentSessions, _resetForTesting } from "../../features/claude-code-session-state"
|
||||||
import { createTodoContinuationEnforcer } from "."
|
import { createTodoContinuationEnforcer } from "."
|
||||||
|
import { CONTINUATION_COOLDOWN_MS } from "./constants"
|
||||||
|
|
||||||
type TimerCallback = (...args: any[]) => void
|
type TimerCallback = (...args: any[]) => void
|
||||||
|
|
||||||
@ -507,6 +508,144 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
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 () => {
|
test("should accept skipAgents option without error", async () => {
|
||||||
// given - session with skipAgents configured for Prometheus
|
// given - session with skipAgents configured for Prometheus
|
||||||
const sessionID = "main-prometheus-option"
|
const sessionID = "main-prometheus-option"
|
||||||
@ -556,16 +695,16 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: { type: "session.idle", properties: { sessionID } },
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
})
|
})
|
||||||
await fakeTimers.advanceBy(3500)
|
await fakeTimers.advanceBy(3500, true)
|
||||||
|
|
||||||
// then - first injection happened
|
// then - first injection happened
|
||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls.length).toBe(1)
|
||||||
|
|
||||||
// when - immediately trigger second idle (no 10s wait needed)
|
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true)
|
||||||
await hook.handler({
|
await hook.handler({
|
||||||
event: { type: "session.idle", properties: { sessionID } },
|
event: { type: "session.idle", properties: { sessionID } },
|
||||||
})
|
})
|
||||||
await fakeTimers.advanceBy(3500)
|
await fakeTimers.advanceBy(3500, true)
|
||||||
|
|
||||||
// then - second injection also happened (no throttle blocking)
|
// then - second injection also happened (no throttle blocking)
|
||||||
expect(promptCalls.length).toBe(2)
|
expect(promptCalls.length).toBe(2)
|
||||||
|
|||||||
@ -27,6 +27,10 @@ export interface SessionState {
|
|||||||
isRecovering?: boolean
|
isRecovering?: boolean
|
||||||
countdownStartedAt?: number
|
countdownStartedAt?: number
|
||||||
abortDetectedAt?: number
|
abortDetectedAt?: number
|
||||||
|
lastInjectedAt?: number
|
||||||
|
inFlight?: boolean
|
||||||
|
lastTodoHash?: string
|
||||||
|
unchangedCycles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInfo {
|
export interface MessageInfo {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user