diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index 39799c5314..a26c7bc090 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -18,5 +18,6 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 export const CONTINUATION_COOLDOWN_MS = 5_000 +export const MAX_STAGNATION_COUNT = 3 export const MAX_CONSECUTIVE_FAILURES = 5 export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000 diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 1f944db597..6797c97c12 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -16,6 +16,7 @@ import { } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" import { hasUnansweredQuestion } from "./pending-question-detection" +import { shouldStopForStagnation } from "./stagnation-detection" import { getIncompleteCount } from "./todo" import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" import type { SessionStateStore } from "./session-state" @@ -93,12 +94,14 @@ export async function handleSessionIdle(args: { } if (!todos || todos.length === 0) { + sessionStateStore.resetContinuationProgress(sessionID) log(`[${HOOK_NAME}] No todos`, { sessionID }) return } const incompleteCount = getIncompleteCount(todos) if (incompleteCount === 0) { + sessionStateStore.resetContinuationProgress(sessionID) log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) return } @@ -183,6 +186,11 @@ export async function handleSessionIdle(args: { return } + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount) + if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { + return + } + startCountdown({ ctx, sessionID, diff --git a/src/hooks/todo-continuation-enforcer/session-state.test.ts b/src/hooks/todo-continuation-enforcer/session-state.test.ts new file mode 100644 index 0000000000..32f2ba627e --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/session-state.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" + +import { createSessionStateStore, type SessionStateStore } from "./session-state" + +describe("createSessionStateStore", () => { + let sessionStateStore: SessionStateStore + + beforeEach(() => { + sessionStateStore = createSessionStateStore() + }) + + afterEach(() => { + sessionStateStore.shutdown() + }) + + test("given repeated incomplete counts after a continuation, tracks stagnation", () => { + // given + const sessionID = "ses-stagnation" + const state = sessionStateStore.getState(sessionID) + state.lastInjectedAt = Date.now() + + // when + const firstUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + const secondUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + const thirdUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + + // then + expect(firstUpdate.stagnationCount).toBe(0) + expect(secondUpdate.stagnationCount).toBe(1) + expect(thirdUpdate.stagnationCount).toBe(2) + }) + + test("given incomplete count decreases, resets stagnation tracking", () => { + // given + const sessionID = "ses-progress-reset" + const state = sessionStateStore.getState(sessionID) + state.lastInjectedAt = Date.now() + sessionStateStore.trackContinuationProgress(sessionID, 3) + sessionStateStore.trackContinuationProgress(sessionID, 3) + + // when + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + + // then + expect(progressUpdate.hasProgressed).toBe(true) + expect(progressUpdate.stagnationCount).toBe(0) + expect(sessionStateStore.getState(sessionID).lastIncompleteCount).toBe(2) + }) +}) diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index a02a5e5ada..2116a6af0f 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -10,9 +10,17 @@ interface TrackedSessionState { lastAccessedAt: number } +export interface ContinuationProgressUpdate { + previousIncompleteCount?: number + stagnationCount: number + hasProgressed: boolean +} + export interface SessionStateStore { getState: (sessionID: string) => SessionState getExistingState: (sessionID: string) => SessionState | undefined + trackContinuationProgress: (sessionID: string, incompleteCount: number) => ContinuationProgressUpdate + resetContinuationProgress: (sessionID: string) => void cancelCountdown: (sessionID: string) => void cleanup: (sessionID: string) => void cancelAllCountdowns: () => void @@ -46,6 +54,7 @@ export function createSessionStateStore(): SessionStateStore { } const state: SessionState = { + stagnationCount: 0, consecutiveFailures: 0, } sessions.set(sessionID, { state, lastAccessedAt: Date.now() }) @@ -61,6 +70,57 @@ export function createSessionStateStore(): SessionStateStore { return undefined } + function trackContinuationProgress( + sessionID: string, + incompleteCount: number + ): ContinuationProgressUpdate { + const state = getState(sessionID) + const previousIncompleteCount = state.lastIncompleteCount + + state.lastIncompleteCount = incompleteCount + + if (previousIncompleteCount === undefined) { + state.stagnationCount = 0 + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: false, + } + } + + if (incompleteCount < previousIncompleteCount) { + state.stagnationCount = 0 + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: true, + } + } + + if (state.lastInjectedAt === undefined) { + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: false, + } + } + + state.stagnationCount += 1 + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: false, + } + } + + function resetContinuationProgress(sessionID: string): void { + const state = getExistingState(sessionID) + if (!state) return + + state.lastIncompleteCount = undefined + state.stagnationCount = 0 + } + function cancelCountdown(sessionID: string): void { const tracked = sessions.get(sessionID) if (!tracked) return @@ -100,6 +160,8 @@ export function createSessionStateStore(): SessionStateStore { return { getState, getExistingState, + trackContinuationProgress, + resetContinuationProgress, cancelCountdown, cleanup, cancelAllCountdowns, diff --git a/src/hooks/todo-continuation-enforcer/stagnation-detection.ts b/src/hooks/todo-continuation-enforcer/stagnation-detection.ts new file mode 100644 index 0000000000..ff35a357e2 --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/stagnation-detection.ts @@ -0,0 +1,33 @@ +import { log } from "../../shared/logger" + +import { HOOK_NAME, MAX_STAGNATION_COUNT } from "./constants" +import type { ContinuationProgressUpdate } from "./session-state" + +export function shouldStopForStagnation(args: { + sessionID: string + incompleteCount: number + progressUpdate: ContinuationProgressUpdate +}): boolean { + const { sessionID, incompleteCount, progressUpdate } = args + + if (progressUpdate.hasProgressed) { + log(`[${HOOK_NAME}] Progress detected: reset stagnation count`, { + sessionID, + previousIncompleteCount: progressUpdate.previousIncompleteCount, + incompleteCount, + }) + } + + if (progressUpdate.stagnationCount < MAX_STAGNATION_COUNT) { + return false + } + + log(`[${HOOK_NAME}] Skipped: todo continuation stagnated`, { + sessionID, + incompleteCount, + previousIncompleteCount: progressUpdate.previousIncompleteCount, + stagnationCount: progressUpdate.stagnationCount, + maxStagnationCount: MAX_STAGNATION_COUNT, + }) + return true +} diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index dee3d0bf4f..2e41b3bc71 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -8,6 +8,7 @@ import { CONTINUATION_COOLDOWN_MS, FAILURE_RESET_WINDOW_MS, MAX_CONSECUTIVE_FAILURES, + MAX_STAGNATION_COUNT, } from "./constants" type TimerCallback = (...args: any[]) => void @@ -626,6 +627,21 @@ describe("todo-continuation-enforcer", () => { const sessionID = "main-max-consecutive-failures" setMainSession(sessionID) const mockInput = createMockPluginInput() + const incompleteCounts = [5, 4, 5, 4, 5, 4] + let todoCallCount = 0 + mockInput.client.session.todo = async () => { + const countIndex = Math.min(Math.floor(todoCallCount / 2), incompleteCounts.length - 1) + const incompleteCount = incompleteCounts[countIndex] ?? incompleteCounts[incompleteCounts.length - 1] ?? 1 + todoCallCount += 1 + return { + data: Array.from({ length: incompleteCount }, (_, index) => ({ + id: String(index + 1), + content: `Task ${index + 1}`, + status: "pending", + priority: "high", + })), + } + } mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { promptCalls.push({ sessionID: opts.path.id, @@ -657,6 +673,21 @@ describe("todo-continuation-enforcer", () => { const sessionID = "main-recovery-after-max-failures" setMainSession(sessionID) const mockInput = createMockPluginInput() + const incompleteCounts = [5, 4, 5, 4, 5, 4, 5] + let todoCallCount = 0 + mockInput.client.session.todo = async () => { + const countIndex = Math.min(Math.floor(todoCallCount / 2), incompleteCounts.length - 1) + const incompleteCount = incompleteCounts[countIndex] ?? incompleteCounts[incompleteCounts.length - 1] ?? 1 + todoCallCount += 1 + return { + data: Array.from({ length: incompleteCount }, (_, index) => ({ + id: String(index + 1), + content: `Task ${index + 1}`, + status: "pending", + priority: "high", + })), + } + } mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { promptCalls.push({ sessionID: opts.path.id, @@ -753,7 +784,7 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(3) }, { timeout: 30000 }) - test("should keep injecting even when todos remain unchanged across cycles", async () => { + test("should stop injecting after max stagnation cycles when todos remain unchanged across cycles", async () => { //#given const sessionID = "main-no-stagnation-cap" setMainSession(sessionID) @@ -784,8 +815,8 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) - //#then — all 5 injections should fire (no stagnation cap) - expect(promptCalls).toHaveLength(5) + // then + expect(promptCalls).toHaveLength(MAX_STAGNATION_COUNT) }, { timeout: 60000 }) test("should skip idle handling while injection is in flight", async () => { diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 20c28d6f36..699b726cd9 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -27,8 +27,10 @@ export interface SessionState { isRecovering?: boolean countdownStartedAt?: number abortDetectedAt?: number + lastIncompleteCount?: number lastInjectedAt?: number inFlight?: boolean + stagnationCount: number consecutiveFailures: number }