Merge pull request #2370 from code-yeongyu/fix/issue-2322
fix: stop stagnant todo continuation loops
This commit is contained in:
@@ -18,5 +18,6 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
|||||||
|
|
||||||
export const ABORT_WINDOW_MS = 3000
|
export const ABORT_WINDOW_MS = 3000
|
||||||
export const CONTINUATION_COOLDOWN_MS = 5_000
|
export const CONTINUATION_COOLDOWN_MS = 5_000
|
||||||
|
export const MAX_STAGNATION_COUNT = 3
|
||||||
export const MAX_CONSECUTIVE_FAILURES = 5
|
export const MAX_CONSECUTIVE_FAILURES = 5
|
||||||
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
|
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, todos)
|
||||||
|
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
startCountdown({
|
startCountdown({
|
||||||
ctx,
|
ctx,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/// <reference path="../../../bun-test.d.ts" />
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it as test } from "bun:test"
|
||||||
|
|
||||||
|
import { createSessionStateStore, type SessionStateStore } from "./session-state"
|
||||||
|
|
||||||
|
describe("createSessionStateStore", () => {
|
||||||
|
let sessionStateStore: SessionStateStore
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStateStore = createSessionStateStore()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStateStore.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given repeated incomplete counts after a continuation, tracks stagnation", () => {
|
||||||
|
// 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
|
||||||
|
const firstUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||||
|
const secondUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||||
|
const thirdUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(firstUpdate.stagnationCount).toBe(0)
|
||||||
|
expect(secondUpdate.stagnationCount).toBe(0)
|
||||||
|
expect(thirdUpdate.stagnationCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given incomplete count decreases, resets stagnation tracking", () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses-progress-reset"
|
||||||
|
const state = sessionStateStore.getState(sessionID)
|
||||||
|
state.lastInjectedAt = Date.now()
|
||||||
|
sessionStateStore.trackContinuationProgress(sessionID, 3)
|
||||||
|
sessionStateStore.trackContinuationProgress(sessionID, 3)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progressUpdate.hasProgressed).toBe(true)
|
||||||
|
expect(progressUpdate.stagnationCount).toBe(0)
|
||||||
|
expect(sessionStateStore.getState(sessionID).lastIncompleteCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given one todo completes while another is added, resets stagnation even when incomplete count stays the same", () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses-completion-with-addition"
|
||||||
|
const state = sessionStateStore.getState(sessionID)
|
||||||
|
state.lastInjectedAt = Date.now()
|
||||||
|
const initialTodos = [
|
||||||
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
|
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||||
|
]
|
||||||
|
const progressedTodos = [
|
||||||
|
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
|
||||||
|
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||||
|
{ id: "3", content: "Task 3", status: "pending", priority: "low" },
|
||||||
|
]
|
||||||
|
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||||
|
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progressUpdate.hasProgressed).toBe(true)
|
||||||
|
expect(progressUpdate.stagnationCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given todo status changes without count changes, treats it as progress", () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses-status-change-progress"
|
||||||
|
const state = sessionStateStore.getState(sessionID)
|
||||||
|
state.lastInjectedAt = Date.now()
|
||||||
|
const initialTodos = [
|
||||||
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
|
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||||
|
]
|
||||||
|
const progressedTodos = [
|
||||||
|
{ id: "1", content: "Task 1", status: "in_progress", priority: "high" },
|
||||||
|
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||||
|
]
|
||||||
|
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||||
|
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(progressUpdate.hasProgressed).toBe(true)
|
||||||
|
expect(progressUpdate.stagnationCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("given progress resumes after stagnation, restarts the stagnation count from zero", () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses-progress-restarts-stagnation"
|
||||||
|
const state = sessionStateStore.getState(sessionID)
|
||||||
|
state.lastInjectedAt = Date.now()
|
||||||
|
const initialTodos = [
|
||||||
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
|
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||||
|
]
|
||||||
|
const progressedTodos = [
|
||||||
|
{ id: "1", content: "Task 1", status: "in_progress", priority: "high" },
|
||||||
|
{ 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
|
||||||
|
expect(stagnatedAgainUpdate.hasProgressed).toBe(false)
|
||||||
|
expect(stagnatedAgainUpdate.stagnationCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionState } from "./types"
|
import type { SessionState, Todo } from "./types"
|
||||||
|
|
||||||
// TTL for idle session state entries (10 minutes)
|
// TTL for idle session state entries (10 minutes)
|
||||||
const SESSION_STATE_TTL_MS = 10 * 60 * 1000
|
const SESSION_STATE_TTL_MS = 10 * 60 * 1000
|
||||||
@@ -8,17 +8,34 @@ const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000
|
|||||||
interface TrackedSessionState {
|
interface TrackedSessionState {
|
||||||
state: SessionState
|
state: SessionState
|
||||||
lastAccessedAt: number
|
lastAccessedAt: number
|
||||||
|
lastCompletedCount?: number
|
||||||
|
lastTodoStatusSignature?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContinuationProgressUpdate {
|
||||||
|
previousIncompleteCount?: number
|
||||||
|
stagnationCount: number
|
||||||
|
hasProgressed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionStateStore {
|
export interface SessionStateStore {
|
||||||
getState: (sessionID: string) => SessionState
|
getState: (sessionID: string) => SessionState
|
||||||
getExistingState: (sessionID: string) => SessionState | undefined
|
getExistingState: (sessionID: string) => SessionState | undefined
|
||||||
|
trackContinuationProgress: (sessionID: string, incompleteCount: number, todos?: Todo[]) => ContinuationProgressUpdate
|
||||||
|
resetContinuationProgress: (sessionID: string) => void
|
||||||
cancelCountdown: (sessionID: string) => void
|
cancelCountdown: (sessionID: string) => void
|
||||||
cleanup: (sessionID: string) => void
|
cleanup: (sessionID: string) => void
|
||||||
cancelAllCountdowns: () => void
|
cancelAllCountdowns: () => void
|
||||||
shutdown: () => void
|
shutdown: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTodoStatusSignature(todos: Todo[]): string {
|
||||||
|
return todos
|
||||||
|
.map((todo) => `${todo.id ?? `${todo.content}:${todo.priority}`}:${todo.status}`)
|
||||||
|
.sort()
|
||||||
|
.join("|")
|
||||||
|
}
|
||||||
|
|
||||||
export function createSessionStateStore(): SessionStateStore {
|
export function createSessionStateStore(): SessionStateStore {
|
||||||
const sessions = new Map<string, TrackedSessionState>()
|
const sessions = new Map<string, TrackedSessionState>()
|
||||||
|
|
||||||
@@ -38,18 +55,27 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
pruneInterval.unref()
|
pruneInterval.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(sessionID: string): SessionState {
|
function getTrackedSession(sessionID: string): TrackedSessionState {
|
||||||
const existing = sessions.get(sessionID)
|
const existing = sessions.get(sessionID)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.lastAccessedAt = Date.now()
|
existing.lastAccessedAt = Date.now()
|
||||||
return existing.state
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: SessionState = {
|
const state: SessionState = {
|
||||||
|
stagnationCount: 0,
|
||||||
consecutiveFailures: 0,
|
consecutiveFailures: 0,
|
||||||
}
|
}
|
||||||
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
|
const trackedSession: TrackedSessionState = {
|
||||||
return state
|
state,
|
||||||
|
lastAccessedAt: Date.now(),
|
||||||
|
}
|
||||||
|
sessions.set(sessionID, trackedSession)
|
||||||
|
return trackedSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState(sessionID: string): SessionState {
|
||||||
|
return getTrackedSession(sessionID).state
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExistingState(sessionID: string): SessionState | undefined {
|
function getExistingState(sessionID: string): SessionState | undefined {
|
||||||
@@ -61,6 +87,85 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackContinuationProgress(
|
||||||
|
sessionID: string,
|
||||||
|
incompleteCount: number,
|
||||||
|
todos?: Todo[]
|
||||||
|
): ContinuationProgressUpdate {
|
||||||
|
const trackedSession = getTrackedSession(sessionID)
|
||||||
|
const state = trackedSession.state
|
||||||
|
const previousIncompleteCount = state.lastIncompleteCount
|
||||||
|
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
||||||
|
const currentTodoStatusSignature = todos ? getTodoStatusSignature(todos) : undefined
|
||||||
|
const hasCompletedMoreTodos =
|
||||||
|
currentCompletedCount !== undefined
|
||||||
|
&& trackedSession.lastCompletedCount !== undefined
|
||||||
|
&& currentCompletedCount > trackedSession.lastCompletedCount
|
||||||
|
const hasTodoStatusChanged =
|
||||||
|
currentTodoStatusSignature !== undefined
|
||||||
|
&& trackedSession.lastTodoStatusSignature !== undefined
|
||||||
|
&& currentTodoStatusSignature !== trackedSession.lastTodoStatusSignature
|
||||||
|
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||||
|
|
||||||
|
state.lastIncompleteCount = incompleteCount
|
||||||
|
if (currentCompletedCount !== undefined) {
|
||||||
|
trackedSession.lastCompletedCount = currentCompletedCount
|
||||||
|
}
|
||||||
|
if (currentTodoStatusSignature !== undefined) {
|
||||||
|
trackedSession.lastTodoStatusSignature = currentTodoStatusSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousIncompleteCount === undefined) {
|
||||||
|
state.stagnationCount = 0
|
||||||
|
return {
|
||||||
|
previousIncompleteCount,
|
||||||
|
stagnationCount: state.stagnationCount,
|
||||||
|
hasProgressed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoStatusChanged) {
|
||||||
|
state.stagnationCount = 0
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
|
return {
|
||||||
|
previousIncompleteCount,
|
||||||
|
stagnationCount: state.stagnationCount,
|
||||||
|
hasProgressed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hadSuccessfulInjectionAwaitingProgressCheck) {
|
||||||
|
return {
|
||||||
|
previousIncompleteCount,
|
||||||
|
stagnationCount: state.stagnationCount,
|
||||||
|
hasProgressed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
|
state.stagnationCount += 1
|
||||||
|
return {
|
||||||
|
previousIncompleteCount,
|
||||||
|
stagnationCount: state.stagnationCount,
|
||||||
|
hasProgressed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetContinuationProgress(sessionID: string): void {
|
||||||
|
const trackedSession = sessions.get(sessionID)
|
||||||
|
if (!trackedSession) return
|
||||||
|
|
||||||
|
trackedSession.lastAccessedAt = Date.now()
|
||||||
|
|
||||||
|
const { state } = trackedSession
|
||||||
|
|
||||||
|
state.lastIncompleteCount = undefined
|
||||||
|
state.stagnationCount = 0
|
||||||
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
|
trackedSession.lastCompletedCount = undefined
|
||||||
|
trackedSession.lastTodoStatusSignature = undefined
|
||||||
|
}
|
||||||
|
|
||||||
function cancelCountdown(sessionID: string): void {
|
function cancelCountdown(sessionID: string): void {
|
||||||
const tracked = sessions.get(sessionID)
|
const tracked = sessions.get(sessionID)
|
||||||
if (!tracked) return
|
if (!tracked) return
|
||||||
@@ -100,6 +205,8 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
return {
|
return {
|
||||||
getState,
|
getState,
|
||||||
getExistingState,
|
getExistingState,
|
||||||
|
trackContinuationProgress,
|
||||||
|
resetContinuationProgress,
|
||||||
cancelCountdown,
|
cancelCountdown,
|
||||||
cleanup,
|
cleanup,
|
||||||
cancelAllCountdowns,
|
cancelAllCountdowns,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
import { HOOK_NAME, MAX_STAGNATION_COUNT } from "./constants"
|
||||||
|
import type { ContinuationProgressUpdate } from "./session-state"
|
||||||
|
|
||||||
|
export function shouldStopForStagnation(args: {
|
||||||
|
sessionID: string
|
||||||
|
incompleteCount: number
|
||||||
|
progressUpdate: ContinuationProgressUpdate
|
||||||
|
}): boolean {
|
||||||
|
const { sessionID, incompleteCount, progressUpdate } = args
|
||||||
|
|
||||||
|
if (progressUpdate.hasProgressed) {
|
||||||
|
log(`[${HOOK_NAME}] Progress detected: reset stagnation count`, {
|
||||||
|
sessionID,
|
||||||
|
previousIncompleteCount: progressUpdate.previousIncompleteCount,
|
||||||
|
incompleteCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressUpdate.stagnationCount < MAX_STAGNATION_COUNT) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[${HOOK_NAME}] Skipped: todo continuation stagnated`, {
|
||||||
|
sessionID,
|
||||||
|
incompleteCount,
|
||||||
|
previousIncompleteCount: progressUpdate.previousIncompleteCount,
|
||||||
|
stagnationCount: progressUpdate.stagnationCount,
|
||||||
|
maxStagnationCount: MAX_STAGNATION_COUNT,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -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,57 @@ 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) => {
|
||||||
|
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 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) => {
|
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||||
promptCalls.push({
|
promptCalls.push({
|
||||||
sessionID: opts.path.id,
|
sessionID: opts.path.id,
|
||||||
@@ -657,6 +709,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 +820,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 +851,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 () => {
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ export interface SessionState {
|
|||||||
isRecovering?: boolean
|
isRecovering?: boolean
|
||||||
countdownStartedAt?: number
|
countdownStartedAt?: number
|
||||||
abortDetectedAt?: number
|
abortDetectedAt?: number
|
||||||
|
lastIncompleteCount?: number
|
||||||
lastInjectedAt?: number
|
lastInjectedAt?: number
|
||||||
|
awaitingPostInjectionProgressCheck?: boolean
|
||||||
inFlight?: boolean
|
inFlight?: boolean
|
||||||
|
stagnationCount: number
|
||||||
consecutiveFailures: number
|
consecutiveFailures: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user