Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/hooks/todo-continuation-enforcer/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500

export const ABORT_WINDOW_MS = 3000
export const CONTINUATION_COOLDOWN_MS = 5_000
export const MAX_STAGNATION_COUNT = 3
export const MAX_CONSECUTIVE_FAILURES = 5
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
8 changes: 8 additions & 0 deletions src/hooks/todo-continuation-enforcer/idle-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions src/hooks/todo-continuation-enforcer/session-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, 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)
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(1)
expect(thirdUpdate.stagnationCount).toBe(2)
})

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)
})
})
62 changes: 62 additions & 0 deletions src/hooks/todo-continuation-enforcer/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ interface TrackedSessionState {
lastAccessedAt: number
}

export interface ContinuationProgressUpdate {
previousIncompleteCount?: number
stagnationCount: number
hasProgressed: boolean
}

export interface SessionStateStore {
getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined
trackContinuationProgress: (sessionID: string, incompleteCount: number) => ContinuationProgressUpdate
resetContinuationProgress: (sessionID: string) => void
cancelCountdown: (sessionID: string) => void
cleanup: (sessionID: string) => void
cancelAllCountdowns: () => void
Expand Down Expand Up @@ -46,6 +54,7 @@ export function createSessionStateStore(): SessionStateStore {
}

const state: SessionState = {
stagnationCount: 0,
consecutiveFailures: 0,
}
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
Expand All @@ -61,6 +70,57 @@ export function createSessionStateStore(): SessionStateStore {
return undefined
}

function trackContinuationProgress(
sessionID: string,
incompleteCount: number
): ContinuationProgressUpdate {
const state = getState(sessionID)
const previousIncompleteCount = state.lastIncompleteCount

state.lastIncompleteCount = incompleteCount

if (previousIncompleteCount === undefined) {
state.stagnationCount = 0
return {
previousIncompleteCount,
stagnationCount: state.stagnationCount,
hasProgressed: false,
}
}

if (incompleteCount < previousIncompleteCount) {
state.stagnationCount = 0
return {
previousIncompleteCount,
stagnationCount: state.stagnationCount,
hasProgressed: true,
}
}

if (state.lastInjectedAt === undefined) {
return {
previousIncompleteCount,
stagnationCount: state.stagnationCount,
hasProgressed: false,
}
}

state.stagnationCount += 1
return {
previousIncompleteCount,
stagnationCount: state.stagnationCount,
hasProgressed: false,
}
}

function resetContinuationProgress(sessionID: string): void {
const state = getExistingState(sessionID)
if (!state) return

state.lastIncompleteCount = undefined
state.stagnationCount = 0
}

function cancelCountdown(sessionID: string): void {
const tracked = sessions.get(sessionID)
if (!tracked) return
Expand Down Expand Up @@ -100,6 +160,8 @@ export function createSessionStateStore(): SessionStateStore {
return {
getState,
getExistingState,
trackContinuationProgress,
resetContinuationProgress,
cancelCountdown,
cleanup,
cancelAllCountdowns,
Expand Down
33 changes: 33 additions & 0 deletions src/hooks/todo-continuation-enforcer/stagnation-detection.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/todo-continuation-enforcer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ export interface SessionState {
isRecovering?: boolean
countdownStartedAt?: number
abortDetectedAt?: number
lastIncompleteCount?: number
lastInjectedAt?: number
inFlight?: boolean
stagnationCount: number
consecutiveFailures: number
}

Expand Down