Merge pull request #2370 from code-yeongyu/fix/issue-2322

fix: stop stagnant todo continuation loops
This commit is contained in:
YeonGyu-Kim
2026-03-11 20:49:02 +09:00
committed by GitHub
8 changed files with 374 additions and 8 deletions

View File

@@ -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

View File

@@ -164,6 +164,7 @@ ${todoList}`
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.awaitingPostInjectionProgressCheck = true
injectionState.consecutiveFailures = 0
}
} catch (error) {

View File

@@ -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,

View File

@@ -0,0 +1,146 @@
/// <reference path="../../../bun-test.d.ts" />
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)
})
})

View File

@@ -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<string, TrackedSessionState>()
@@ -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,

View File

@@ -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
}

View File

@@ -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 () => {

View File

@@ -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
}