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.
This commit is contained in:
YeonGyu-Kim
2026-03-11 18:39:54 +09:00
parent b590d8335f
commit 0c52d42f8b
5 changed files with 66 additions and 3 deletions

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

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ export interface SessionState {
abortDetectedAt?: number
lastIncompleteCount?: number
lastInjectedAt?: number
awaitingPostInjectionProgressCheck?: boolean
inFlight?: boolean
stagnationCount: number
consecutiveFailures: number