diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index 39799c531..a26c7bc09 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/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 23b11ba68..5e4cb124c 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -164,6 +164,7 @@ ${todoList}` if (injectionState) { injectionState.inFlight = false injectionState.lastInjectedAt = Date.now() + injectionState.awaitingPostInjectionProgressCheck = true injectionState.consecutiveFailures = 0 } } catch (error) { diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 1f944db59..b8419efb1 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, todos) + 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 000000000..8c7464c5c --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/session-state.test.ts @@ -0,0 +1,146 @@ +/// + +import { afterEach, beforeEach, describe, expect, it as 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) + + // when + const firstUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + state.awaitingPostInjectionProgressCheck = true + const secondUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + state.awaitingPostInjectionProgressCheck = true + const thirdUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2) + + // then + expect(firstUpdate.stagnationCount).toBe(0) + expect(secondUpdate.stagnationCount).toBe(1) + expect(thirdUpdate.stagnationCount).toBe(2) + }) + + test("given injection did not succeed, repeated incomplete counts do not track stagnation", () => { + // given + const sessionID = "ses-failed-injection" + 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(0) + expect(thirdUpdate.stagnationCount).toBe(0) + }) + + 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) + }) + + test("given one todo completes while another is added, resets stagnation even when incomplete count stays the same", () => { + // given + const sessionID = "ses-completion-with-addition" + const state = sessionStateStore.getState(sessionID) + state.lastInjectedAt = Date.now() + const initialTodos = [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + ] + const progressedTodos = [ + { id: "1", content: "Task 1", status: "completed", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + { id: "3", content: "Task 3", status: "pending", priority: "low" }, + ] + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + + // when + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos) + + // then + expect(progressUpdate.hasProgressed).toBe(true) + expect(progressUpdate.stagnationCount).toBe(0) + }) + + test("given todo status changes without count changes, treats it as progress", () => { + // given + const sessionID = "ses-status-change-progress" + const state = sessionStateStore.getState(sessionID) + state.lastInjectedAt = Date.now() + const initialTodos = [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + ] + const progressedTodos = [ + { id: "1", content: "Task 1", status: "in_progress", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + ] + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + + // when + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos) + + // then + expect(progressUpdate.hasProgressed).toBe(true) + expect(progressUpdate.stagnationCount).toBe(0) + }) + + test("given progress resumes after stagnation, restarts the stagnation count from zero", () => { + // given + const sessionID = "ses-progress-restarts-stagnation" + const state = sessionStateStore.getState(sessionID) + state.lastInjectedAt = Date.now() + const initialTodos = [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + ] + const progressedTodos = [ + { id: "1", content: "Task 1", status: "in_progress", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + ] + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + state.awaitingPostInjectionProgressCheck = true + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + state.awaitingPostInjectionProgressCheck = true + sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos) + + // when + state.awaitingPostInjectionProgressCheck = true + const stagnatedAgainUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos) + + // then + expect(stagnatedAgainUpdate.hasProgressed).toBe(false) + expect(stagnatedAgainUpdate.stagnationCount).toBe(1) + }) +}) diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index a02a5e5ad..6c1165627 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -1,4 +1,4 @@ -import type { SessionState } from "./types" +import type { SessionState, Todo } from "./types" // TTL for idle session state entries (10 minutes) const SESSION_STATE_TTL_MS = 10 * 60 * 1000 @@ -8,17 +8,34 @@ const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000 interface TrackedSessionState { state: SessionState lastAccessedAt: number + lastCompletedCount?: number + lastTodoStatusSignature?: string +} + +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, todos?: Todo[]) => ContinuationProgressUpdate + resetContinuationProgress: (sessionID: string) => void cancelCountdown: (sessionID: string) => void cleanup: (sessionID: string) => void cancelAllCountdowns: () => void shutdown: () => void } +function getTodoStatusSignature(todos: Todo[]): string { + return todos + .map((todo) => `${todo.id ?? `${todo.content}:${todo.priority}`}:${todo.status}`) + .sort() + .join("|") +} + export function createSessionStateStore(): SessionStateStore { const sessions = new Map() @@ -38,18 +55,27 @@ export function createSessionStateStore(): SessionStateStore { pruneInterval.unref() } - function getState(sessionID: string): SessionState { + function getTrackedSession(sessionID: string): TrackedSessionState { const existing = sessions.get(sessionID) if (existing) { existing.lastAccessedAt = Date.now() - return existing.state + return existing } const state: SessionState = { + stagnationCount: 0, consecutiveFailures: 0, } - sessions.set(sessionID, { state, lastAccessedAt: Date.now() }) - return state + const trackedSession: TrackedSessionState = { + state, + lastAccessedAt: Date.now(), + } + sessions.set(sessionID, trackedSession) + return trackedSession + } + + function getState(sessionID: string): SessionState { + return getTrackedSession(sessionID).state } function getExistingState(sessionID: string): SessionState | undefined { @@ -61,6 +87,85 @@ export function createSessionStateStore(): SessionStateStore { return undefined } + function trackContinuationProgress( + sessionID: string, + incompleteCount: number, + todos?: Todo[] + ): ContinuationProgressUpdate { + const trackedSession = getTrackedSession(sessionID) + const state = trackedSession.state + const previousIncompleteCount = state.lastIncompleteCount + const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length + const currentTodoStatusSignature = todos ? getTodoStatusSignature(todos) : undefined + const hasCompletedMoreTodos = + currentCompletedCount !== undefined + && trackedSession.lastCompletedCount !== undefined + && currentCompletedCount > trackedSession.lastCompletedCount + const hasTodoStatusChanged = + currentTodoStatusSignature !== undefined + && trackedSession.lastTodoStatusSignature !== undefined + && currentTodoStatusSignature !== trackedSession.lastTodoStatusSignature + const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true + + state.lastIncompleteCount = incompleteCount + if (currentCompletedCount !== undefined) { + trackedSession.lastCompletedCount = currentCompletedCount + } + if (currentTodoStatusSignature !== undefined) { + trackedSession.lastTodoStatusSignature = currentTodoStatusSignature + } + + if (previousIncompleteCount === undefined) { + state.stagnationCount = 0 + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: false, + } + } + + if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) { + state.stagnationCount = 0 + state.awaitingPostInjectionProgressCheck = false + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: true, + } + } + + if (!hadSuccessfulInjectionAwaitingProgressCheck) { + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: false, + } + } + + state.awaitingPostInjectionProgressCheck = false + state.stagnationCount += 1 + return { + previousIncompleteCount, + stagnationCount: state.stagnationCount, + hasProgressed: false, + } + } + + function resetContinuationProgress(sessionID: string): void { + const trackedSession = sessions.get(sessionID) + if (!trackedSession) return + + trackedSession.lastAccessedAt = Date.now() + + const { state } = trackedSession + + state.lastIncompleteCount = undefined + state.stagnationCount = 0 + state.awaitingPostInjectionProgressCheck = false + trackedSession.lastCompletedCount = undefined + trackedSession.lastTodoStatusSignature = undefined + } + function cancelCountdown(sessionID: string): void { const tracked = sessions.get(sessionID) if (!tracked) return @@ -100,6 +205,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 000000000..ff35a357e --- /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 dee3d0bf4..508cef6a4 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,57 @@ 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, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + throw new Error("simulated auth failure") + } + const hook = createTodoContinuationEnforcer(mockInput, {}) + + //#when + for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) { + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + if (index < MAX_CONSECUTIVE_FAILURES - 1) { + await fakeTimers.advanceClockBy(1_000_000) + } + } + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + + //#then + expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES) + }, { timeout: 30000 }) + + test("should not stop retries early for unchanged todos when injections keep failing", async () => { + //#given + const sessionID = "main-unchanged-todos-max-failures" + setMainSession(sessionID) + const mockInput = createMockPluginInput() + mockInput.client.session.todo = async () => ({ + data: [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + ], + }) mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => { promptCalls.push({ sessionID: opts.path.id, @@ -657,6 +709,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 +820,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 +851,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 20c28d6f3..2a0ffc00b 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -27,8 +27,11 @@ export interface SessionState { isRecovering?: boolean countdownStartedAt?: number abortDetectedAt?: number + lastIncompleteCount?: number lastInjectedAt?: number + awaitingPostInjectionProgressCheck?: boolean inFlight?: boolean + stagnationCount: number consecutiveFailures: number }