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"
|
} from "./constants"
|
||||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||||
import { hasUnansweredQuestion } from "./pending-question-detection"
|
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||||
|
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||||
import { getIncompleteCount } from "./todo"
|
import { getIncompleteCount } from "./todo"
|
||||||
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
@@ -93,12 +94,14 @@ export async function handleSessionIdle(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!todos || todos.length === 0) {
|
if (!todos || todos.length === 0) {
|
||||||
|
sessionStateStore.resetContinuationProgress(sessionID)
|
||||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const incompleteCount = getIncompleteCount(todos)
|
const incompleteCount = getIncompleteCount(todos)
|
||||||
if (incompleteCount === 0) {
|
if (incompleteCount === 0) {
|
||||||
|
sessionStateStore.resetContinuationProgress(sessionID)
|
||||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -183,6 +186,11 @@ export async function handleSessionIdle(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount)
|
||||||
|
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
startCountdown({
|
startCountdown({
|
||||||
ctx,
|
ctx,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CONTINUATION_COOLDOWN_MS,
|
CONTINUATION_COOLDOWN_MS,
|
||||||
FAILURE_RESET_WINDOW_MS,
|
FAILURE_RESET_WINDOW_MS,
|
||||||
MAX_CONSECUTIVE_FAILURES,
|
MAX_CONSECUTIVE_FAILURES,
|
||||||
|
MAX_STAGNATION_COUNT,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
|
|
||||||
type TimerCallback = (...args: any[]) => void
|
type TimerCallback = (...args: any[]) => void
|
||||||
@@ -626,6 +627,21 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
const sessionID = "main-max-consecutive-failures"
|
const sessionID = "main-max-consecutive-failures"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
const mockInput = createMockPluginInput()
|
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) => {
|
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||||
promptCalls.push({
|
promptCalls.push({
|
||||||
sessionID: opts.path.id,
|
sessionID: opts.path.id,
|
||||||
@@ -657,6 +673,21 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
const sessionID = "main-recovery-after-max-failures"
|
const sessionID = "main-recovery-after-max-failures"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
const mockInput = createMockPluginInput()
|
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) => {
|
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||||
promptCalls.push({
|
promptCalls.push({
|
||||||
sessionID: opts.path.id,
|
sessionID: opts.path.id,
|
||||||
@@ -753,7 +784,7 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(3)
|
expect(promptCalls).toHaveLength(3)
|
||||||
}, { timeout: 30000 })
|
}, { 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
|
//#given
|
||||||
const sessionID = "main-no-stagnation-cap"
|
const sessionID = "main-no-stagnation-cap"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
@@ -784,8 +815,8 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
await fakeTimers.advanceBy(2500, true)
|
await fakeTimers.advanceBy(2500, true)
|
||||||
|
|
||||||
//#then — all 5 injections should fire (no stagnation cap)
|
// then
|
||||||
expect(promptCalls).toHaveLength(5)
|
expect(promptCalls).toHaveLength(MAX_STAGNATION_COUNT)
|
||||||
}, { timeout: 60000 })
|
}, { timeout: 60000 })
|
||||||
|
|
||||||
test("should skip idle handling while injection is in flight", async () => {
|
test("should skip idle handling while injection is in flight", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user