From 0ae4812beedf7dcc6d50c9ec30cee5288018447c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:17:38 +0900 Subject: [PATCH 1/9] fix(todo-continuation-enforcer): add stagnation limit constant Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/hooks/todo-continuation-enforcer/constants.ts | 1 + 1 file changed, 1 insertion(+) 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 From d7349b62daaf676b0e0897eeb2ca471c5a057ddd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:17:44 +0900 Subject: [PATCH 2/9] fix(todo-continuation-enforcer): add stagnation state fields Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/hooks/todo-continuation-enforcer/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 20c28d6f3..699b726cd 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 } From 07e8b32ed161447ca5bde3f1ecb0c9bdbe3d30a5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:17:51 +0900 Subject: [PATCH 3/9] fix(todo-continuation-enforcer): track continuation stagnation state Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../session-state.test.ts | 49 +++++++++++++++ .../session-state.ts | 62 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/hooks/todo-continuation-enforcer/session-state.test.ts 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..32f2ba627 --- /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 a02a5e5ad..2116a6af0 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, From e65366b5cef369c9ff091b5a6ac6a1b8a2a7c1ef Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:18:00 +0900 Subject: [PATCH 4/9] fix(todo-continuation-enforcer): add stagnation guard helper Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../stagnation-detection.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/hooks/todo-continuation-enforcer/stagnation-detection.ts 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 +} From fe12fc68b1ce5111fee3f3a948a8165233f6dae3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:18:08 +0900 Subject: [PATCH 5/9] fix(todo-continuation-enforcer): stop idle continuation after repeated stagnation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../todo-continuation-enforcer/idle-event.ts | 8 ++++ .../todo-continuation-enforcer.test.ts | 37 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 1f944db59..6797c97c1 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/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index dee3d0bf4..2e41b3bc7 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 () => { From 51bf82389394472297ecfa0cc06fad24551e7e42 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 17:56:43 +0900 Subject: [PATCH 6/9] fix(todo-continuation-enforcer): track todo state changes for stagnation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session-state.ts | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index 2116a6af0..d6ca26cb6 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,6 +8,8 @@ const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000 interface TrackedSessionState { state: SessionState lastAccessedAt: number + lastCompletedCount?: number + lastTodoStatusSignature?: string } export interface ContinuationProgressUpdate { @@ -19,7 +21,7 @@ export interface ContinuationProgressUpdate { export interface SessionStateStore { getState: (sessionID: string) => SessionState getExistingState: (sessionID: string) => SessionState | undefined - trackContinuationProgress: (sessionID: string, incompleteCount: number) => ContinuationProgressUpdate + trackContinuationProgress: (sessionID: string, incompleteCount: number, todos?: Todo[]) => ContinuationProgressUpdate resetContinuationProgress: (sessionID: string) => void cancelCountdown: (sessionID: string) => void cleanup: (sessionID: string) => void @@ -27,6 +29,13 @@ export interface SessionStateStore { 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() @@ -46,19 +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 { @@ -72,12 +89,30 @@ export function createSessionStateStore(): SessionStateStore { function trackContinuationProgress( sessionID: string, - incompleteCount: number + incompleteCount: number, + todos?: Todo[] ): ContinuationProgressUpdate { - const state = getState(sessionID) + 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 state.lastIncompleteCount = incompleteCount + if (currentCompletedCount !== undefined) { + trackedSession.lastCompletedCount = currentCompletedCount + } + if (currentTodoStatusSignature !== undefined) { + trackedSession.lastTodoStatusSignature = currentTodoStatusSignature + } if (previousIncompleteCount === undefined) { state.stagnationCount = 0 @@ -88,7 +123,7 @@ export function createSessionStateStore(): SessionStateStore { } } - if (incompleteCount < previousIncompleteCount) { + if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) { state.stagnationCount = 0 return { previousIncompleteCount, @@ -114,11 +149,17 @@ export function createSessionStateStore(): SessionStateStore { } function resetContinuationProgress(sessionID: string): void { - const state = getExistingState(sessionID) - if (!state) return + const trackedSession = sessions.get(sessionID) + if (!trackedSession) return + + trackedSession.lastAccessedAt = Date.now() + + const { state } = trackedSession state.lastIncompleteCount = undefined state.stagnationCount = 0 + trackedSession.lastCompletedCount = undefined + trackedSession.lastTodoStatusSignature = undefined } function cancelCountdown(sessionID: string): void { From 5952bbabb40bfb2242817d0c98b664410f77c606 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 17:56:49 +0900 Subject: [PATCH 7/9] fix(todo-continuation-enforcer): pass todos into stagnation tracking Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/todo-continuation-enforcer/idle-event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 6797c97c1..b8419efb1 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -186,7 +186,7 @@ export async function handleSessionIdle(args: { return } - const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount) + const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos) if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { return } From b590d8335f62d12abcd053bfc0a6778cb0955f34 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 17:56:54 +0900 Subject: [PATCH 8/9] test(todo-continuation-enforcer): cover stagnation progress edge cases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session-state.test.ts | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/hooks/todo-continuation-enforcer/session-state.test.ts b/src/hooks/todo-continuation-enforcer/session-state.test.ts index 32f2ba627..de2bb5bda 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.test.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +/// + +import { afterEach, beforeEach, describe, expect, it as test } from "bun:test" import { createSessionStateStore, type SessionStateStore } from "./session-state" @@ -46,4 +48,78 @@ describe("createSessionStateStore", () => { 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) + sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos) + sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos) + + // when + const stagnatedAgainUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos) + + // then + expect(stagnatedAgainUpdate.hasProgressed).toBe(false) + expect(stagnatedAgainUpdate.stagnationCount).toBe(1) + }) }) From 0c52d42f8b776e4f3fb1d7ebbe897ea395602835 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 18:39:54 +0900 Subject: [PATCH 9/9] fix(todo-continuation-enforcer): gate stagnation on successful injections Keep failed or skipped injections on the MAX_CONSECUTIVE_FAILURES path so unchanged todos do not trip stagnation first. --- .../continuation-injection.ts | 1 + .../session-state.test.ts | 25 +++++++++++-- .../session-state.ts | 6 +++- .../todo-continuation-enforcer.test.ts | 36 +++++++++++++++++++ src/hooks/todo-continuation-enforcer/types.ts | 1 + 5 files changed, 66 insertions(+), 3 deletions(-) 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/session-state.test.ts b/src/hooks/todo-continuation-enforcer/session-state.test.ts index de2bb5bda..8c7464c5c 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.test.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.test.ts @@ -19,6 +19,24 @@ describe("createSessionStateStore", () => { // 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 @@ -28,8 +46,8 @@ describe("createSessionStateStore", () => { // then expect(firstUpdate.stagnationCount).toBe(0) - expect(secondUpdate.stagnationCount).toBe(1) - expect(thirdUpdate.stagnationCount).toBe(2) + expect(secondUpdate.stagnationCount).toBe(0) + expect(thirdUpdate.stagnationCount).toBe(0) }) test("given incomplete count decreases, resets stagnation tracking", () => { @@ -112,10 +130,13 @@ describe("createSessionStateStore", () => { { 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 diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index d6ca26cb6..6c1165627 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -105,6 +105,7 @@ export function createSessionStateStore(): SessionStateStore { currentTodoStatusSignature !== undefined && trackedSession.lastTodoStatusSignature !== undefined && currentTodoStatusSignature !== trackedSession.lastTodoStatusSignature + const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true state.lastIncompleteCount = incompleteCount if (currentCompletedCount !== undefined) { @@ -125,6 +126,7 @@ export function createSessionStateStore(): SessionStateStore { if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) { state.stagnationCount = 0 + state.awaitingPostInjectionProgressCheck = false return { previousIncompleteCount, stagnationCount: state.stagnationCount, @@ -132,7 +134,7 @@ export function createSessionStateStore(): SessionStateStore { } } - if (state.lastInjectedAt === undefined) { + if (!hadSuccessfulInjectionAwaitingProgressCheck) { return { previousIncompleteCount, stagnationCount: state.stagnationCount, @@ -140,6 +142,7 @@ export function createSessionStateStore(): SessionStateStore { } } + state.awaitingPostInjectionProgressCheck = false state.stagnationCount += 1 return { previousIncompleteCount, @@ -158,6 +161,7 @@ export function createSessionStateStore(): SessionStateStore { state.lastIncompleteCount = undefined state.stagnationCount = 0 + state.awaitingPostInjectionProgressCheck = false trackedSession.lastCompletedCount = undefined trackedSession.lastTodoStatusSignature = undefined } 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 2e41b3bc7..508cef6a4 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -668,6 +668,42 @@ describe("todo-continuation-enforcer", () => { 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, + 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 resume retries after reset window when max failures reached", async () => { //#given const sessionID = "main-recovery-after-max-failures" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 699b726cd..2a0ffc00b 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -29,6 +29,7 @@ export interface SessionState { abortDetectedAt?: number lastIncompleteCount?: number lastInjectedAt?: number + awaitingPostInjectionProgressCheck?: boolean inFlight?: boolean stagnationCount: number consecutiveFailures: number