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
}