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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-08 02:18:08 +09:00
parent e65366b5ce
commit fe12fc68b1
2 changed files with 42 additions and 3 deletions

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)
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
return
}
startCountdown({
ctx,
sessionID,

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