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:
@@ -164,6 +164,7 @@ ${todoList}`
|
|||||||
if (injectionState) {
|
if (injectionState) {
|
||||||
injectionState.inFlight = false
|
injectionState.inFlight = false
|
||||||
injectionState.lastInjectedAt = Date.now()
|
injectionState.lastInjectedAt = Date.now()
|
||||||
|
injectionState.awaitingPostInjectionProgressCheck = true
|
||||||
injectionState.consecutiveFailures = 0
|
injectionState.consecutiveFailures = 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ describe("createSessionStateStore", () => {
|
|||||||
// given
|
// given
|
||||||
const sessionID = "ses-stagnation"
|
const sessionID = "ses-stagnation"
|
||||||
const state = sessionStateStore.getState(sessionID)
|
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()
|
state.lastInjectedAt = Date.now()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -28,8 +46,8 @@ describe("createSessionStateStore", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(firstUpdate.stagnationCount).toBe(0)
|
expect(firstUpdate.stagnationCount).toBe(0)
|
||||||
expect(secondUpdate.stagnationCount).toBe(1)
|
expect(secondUpdate.stagnationCount).toBe(0)
|
||||||
expect(thirdUpdate.stagnationCount).toBe(2)
|
expect(thirdUpdate.stagnationCount).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("given incomplete count decreases, resets stagnation tracking", () => {
|
test("given incomplete count decreases, resets stagnation tracking", () => {
|
||||||
@@ -112,10 +130,13 @@ describe("createSessionStateStore", () => {
|
|||||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||||
]
|
]
|
||||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||||
|
state.awaitingPostInjectionProgressCheck = true
|
||||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||||
|
state.awaitingPostInjectionProgressCheck = true
|
||||||
sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
|
state.awaitingPostInjectionProgressCheck = true
|
||||||
const stagnatedAgainUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
const stagnatedAgainUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
currentTodoStatusSignature !== undefined
|
currentTodoStatusSignature !== undefined
|
||||||
&& trackedSession.lastTodoStatusSignature !== undefined
|
&& trackedSession.lastTodoStatusSignature !== undefined
|
||||||
&& currentTodoStatusSignature !== trackedSession.lastTodoStatusSignature
|
&& currentTodoStatusSignature !== trackedSession.lastTodoStatusSignature
|
||||||
|
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||||
|
|
||||||
state.lastIncompleteCount = incompleteCount
|
state.lastIncompleteCount = incompleteCount
|
||||||
if (currentCompletedCount !== undefined) {
|
if (currentCompletedCount !== undefined) {
|
||||||
@@ -125,6 +126,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
|
|
||||||
if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) {
|
if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) {
|
||||||
state.stagnationCount = 0
|
state.stagnationCount = 0
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
return {
|
return {
|
||||||
previousIncompleteCount,
|
previousIncompleteCount,
|
||||||
stagnationCount: state.stagnationCount,
|
stagnationCount: state.stagnationCount,
|
||||||
@@ -132,7 +134,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.lastInjectedAt === undefined) {
|
if (!hadSuccessfulInjectionAwaitingProgressCheck) {
|
||||||
return {
|
return {
|
||||||
previousIncompleteCount,
|
previousIncompleteCount,
|
||||||
stagnationCount: state.stagnationCount,
|
stagnationCount: state.stagnationCount,
|
||||||
@@ -140,6 +142,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
state.stagnationCount += 1
|
state.stagnationCount += 1
|
||||||
return {
|
return {
|
||||||
previousIncompleteCount,
|
previousIncompleteCount,
|
||||||
@@ -158,6 +161,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
|
|
||||||
state.lastIncompleteCount = undefined
|
state.lastIncompleteCount = undefined
|
||||||
state.stagnationCount = 0
|
state.stagnationCount = 0
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
trackedSession.lastCompletedCount = undefined
|
trackedSession.lastCompletedCount = undefined
|
||||||
trackedSession.lastTodoStatusSignature = undefined
|
trackedSession.lastTodoStatusSignature = undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,6 +668,42 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
|
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
|
||||||
}, { timeout: 30000 })
|
}, { 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 () => {
|
test("should resume retries after reset window when max failures reached", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "main-recovery-after-max-failures"
|
const sessionID = "main-recovery-after-max-failures"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface SessionState {
|
|||||||
abortDetectedAt?: number
|
abortDetectedAt?: number
|
||||||
lastIncompleteCount?: number
|
lastIncompleteCount?: number
|
||||||
lastInjectedAt?: number
|
lastInjectedAt?: number
|
||||||
|
awaitingPostInjectionProgressCheck?: boolean
|
||||||
inFlight?: boolean
|
inFlight?: boolean
|
||||||
stagnationCount: number
|
stagnationCount: number
|
||||||
consecutiveFailures: number
|
consecutiveFailures: number
|
||||||
|
|||||||
Reference in New Issue
Block a user