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:
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user