From 0c52d42f8b776e4f3fb1d7ebbe897ea395602835 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 18:39:54 +0900 Subject: [PATCH] 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