From 68e9d54fa5f44185c0637ee86d989cf4d6e634ec Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Fri, 10 Apr 2026 20:07:56 +0200 Subject: [PATCH] fix(todo-continuation): remove activity-based stagnation bypass Activity signals (tool calls like compress, grep, bash) were treated as 'progress' by the stagnation detector, resetting the stagnation counter every cycle. This prevented MAX_STAGNATION_COUNT from being reached, causing infinite continuation loops when models degrade to minimal responses in long sessions (e.g. GLM-5.1 at ~100K tokens). Stagnation now only tracks actual todo state changes: incomplete count decrease, completed count increase, or todo snapshot change. Tool-level activity no longer resets the stagnation counter. --- .../idle-event.test.ts | 1 - .../todo-continuation-enforcer/idle-event.ts | 9 ----- .../non-idle-events.ts | 5 --- .../session-state.test.ts | 32 ++--------------- .../session-state.ts | 34 +++---------------- src/hooks/todo-continuation-enforcer/types.ts | 4 --- 6 files changed, 7 insertions(+), 78 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.test.ts b/src/hooks/todo-continuation-enforcer/idle-event.test.ts index 3e9746fdc..ede5e005c 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.test.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.test.ts @@ -28,7 +28,6 @@ function createStateStore(): { getState: () => state, getExistingState: () => state, startPruneInterval: () => {}, - recordActivity: () => {}, trackContinuationProgress: () => progressUpdate, resetContinuationProgress: (sessionID: string) => { resetCalls.push(sessionID) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 0f3a6a71a..340e1ab26 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -16,14 +16,6 @@ import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compactio import type { SessionStateStore } from "./session-state" import { startCountdown } from "./countdown" -function shouldAllowActivityProgress(modelID: string | undefined): boolean { - if (!modelID) { - return false - } - - return !modelID.toLowerCase().includes("codex") -} - export async function handleSessionIdle(args: { ctx: PluginInput sessionID: string @@ -204,7 +196,6 @@ export async function handleSessionIdle(args: { sessionID, incompleteCount, todos, - { allowActivityProgress: shouldAllowActivityProgress(resolvedInfo?.model?.modelID) }, ) if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { return diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index dc21fa8f1..116369614 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -30,7 +30,6 @@ export function handleNonIdleEvent(args: { state.abortDetectedAt = undefined state.wasCancelled = false state.tokenLimitDetected = false - sessionStateStore.recordActivity(sessionID) } sessionStateStore.cancelCountdown(sessionID) return @@ -41,7 +40,6 @@ export function handleNonIdleEvent(args: { if (state) { state.abortDetectedAt = undefined state.wasCancelled = false - sessionStateStore.recordActivity(sessionID) } sessionStateStore.cancelCountdown(sessionID) return @@ -57,7 +55,6 @@ export function handleNonIdleEvent(args: { const state = sessionStateStore.getExistingState(targetSessionID) if (state) { state.abortDetectedAt = undefined - sessionStateStore.recordActivity(targetSessionID) } sessionStateStore.cancelCountdown(targetSessionID) } @@ -71,7 +68,6 @@ export function handleNonIdleEvent(args: { if (state) { state.abortDetectedAt = undefined state.wasCancelled = false - sessionStateStore.recordActivity(sessionID) } sessionStateStore.cancelCountdown(sessionID) } @@ -85,7 +81,6 @@ export function handleNonIdleEvent(args: { if (state) { state.abortDetectedAt = undefined state.wasCancelled = false - sessionStateStore.recordActivity(sessionID) } sessionStateStore.cancelCountdown(sessionID) } diff --git a/src/hooks/todo-continuation-enforcer/session-state.test.ts b/src/hooks/todo-continuation-enforcer/session-state.test.ts index c2ec32f8c..3313ab039 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.test.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.test.ts @@ -144,9 +144,9 @@ describe("createSessionStateStore", () => { expect(stagnatedAgainUpdate.stagnationCount).toBe(1) }) - test("given non-codex activity happens after a successful continuation, treats it as progress", () => { + test("given tool activity happens after a successful continuation without todo changes, keeps counting stagnation", () => { // given - const sessionID = "ses-non-codex-activity-progress" + const sessionID = "ses-activity-stagnation" const state = sessionStateStore.getState(sessionID) const todos = [ { id: "1", content: "Task 1", status: "pending", priority: "high" }, @@ -154,40 +154,12 @@ describe("createSessionStateStore", () => { sessionStateStore.trackContinuationProgress(sessionID, 1, todos) state.awaitingPostInjectionProgressCheck = true - sessionStateStore.recordActivity(sessionID) // when const progressUpdate = sessionStateStore.trackContinuationProgress( sessionID, 1, todos, - { allowActivityProgress: true }, - ) - - // then - expect(progressUpdate.hasProgressed).toBe(true) - expect(progressUpdate.progressSource).toBe("activity") - expect(progressUpdate.stagnationCount).toBe(0) - }) - - test("given codex activity happens after a successful continuation, keeps counting stagnation", () => { - // given - const sessionID = "ses-codex-activity-stagnation" - const state = sessionStateStore.getState(sessionID) - const todos = [ - { id: "1", content: "Task 1", status: "pending", priority: "high" }, - ] - - sessionStateStore.trackContinuationProgress(sessionID, 1, todos) - state.awaitingPostInjectionProgressCheck = true - sessionStateStore.recordActivity(sessionID) - - // when - const progressUpdate = sessionStateStore.trackContinuationProgress( - sessionID, - 1, - todos, - { allowActivityProgress: false }, ) // then diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index dcd88629e..615aade0c 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -1,4 +1,4 @@ -import type { ContinuationProgressOptions, SessionState, Todo } from "./types" +import type { SessionState, Todo } from "./types" type TimerHandle = number | { unref?: () => void } @@ -16,8 +16,6 @@ interface TrackedSessionState { lastAccessedAt: number lastCompletedCount?: number lastTodoSnapshot?: string - activitySignalCount: number - lastObservedActivitySignalCount?: number } export interface ContinuationProgressUpdate { @@ -25,19 +23,17 @@ export interface ContinuationProgressUpdate { previousStagnationCount: number stagnationCount: number hasProgressed: boolean - progressSource: "none" | "todo" | "activity" + progressSource: "none" | "todo" } export interface SessionStateStore { getState: (sessionID: string) => SessionState getExistingState: (sessionID: string) => SessionState | undefined startPruneInterval: () => void - recordActivity: (sessionID: string) => void trackContinuationProgress: ( sessionID: string, incompleteCount: number, todos?: Todo[], - options?: ContinuationProgressOptions, ) => ContinuationProgressUpdate resetContinuationProgress: (sessionID: string) => void cancelCountdown: (sessionID: string) => void @@ -113,7 +109,6 @@ export function createSessionStateStore(): SessionStateStore { const trackedSession: TrackedSessionState = { state: rawState, lastAccessedAt: Date.now(), - activitySignalCount: 0, } sessions.set(sessionID, trackedSession) return trackedSession @@ -132,16 +127,10 @@ export function createSessionStateStore(): SessionStateStore { return undefined } - function recordActivity(sessionID: string): void { - const trackedSession = getTrackedSession(sessionID) - trackedSession.activitySignalCount += 1 - } - function trackContinuationProgress( sessionID: string, incompleteCount: number, todos?: Todo[], - options: ContinuationProgressOptions = {}, ): ContinuationProgressUpdate { const trackedSession = getTrackedSession(sessionID) const state = trackedSession.state @@ -149,7 +138,6 @@ export function createSessionStateStore(): SessionStateStore { const previousStagnationCount = state.stagnationCount const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined - const currentActivitySignalCount = trackedSession.activitySignalCount const hasCompletedMoreTodos = currentCompletedCount !== undefined && trackedSession.lastCompletedCount !== undefined @@ -158,10 +146,6 @@ export function createSessionStateStore(): SessionStateStore { currentTodoSnapshot !== undefined && trackedSession.lastTodoSnapshot !== undefined && currentTodoSnapshot !== trackedSession.lastTodoSnapshot - const hasObservedExternalActivity = - options.allowActivityProgress === true - && trackedSession.lastObservedActivitySignalCount !== undefined - && currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true state.lastIncompleteCount = incompleteCount @@ -171,7 +155,6 @@ export function createSessionStateStore(): SessionStateStore { if (currentTodoSnapshot !== undefined) { trackedSession.lastTodoSnapshot = currentTodoSnapshot } - trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount if (previousIncompleteCount === undefined) { state.stagnationCount = 0 @@ -184,13 +167,9 @@ export function createSessionStateStore(): SessionStateStore { } } - const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged - ? "todo" - : hasObservedExternalActivity - ? "activity" - : "none" + const hasProgressed = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged - if (progressSource !== "none") { + if (hasProgressed) { state.stagnationCount = 0 state.awaitingPostInjectionProgressCheck = false return { @@ -198,7 +177,7 @@ export function createSessionStateStore(): SessionStateStore { previousStagnationCount, stagnationCount: state.stagnationCount, hasProgressed: true, - progressSource, + progressSource: "todo", } } @@ -236,8 +215,6 @@ export function createSessionStateStore(): SessionStateStore { state.awaitingPostInjectionProgressCheck = false trackedSession.lastCompletedCount = undefined trackedSession.lastTodoSnapshot = undefined - trackedSession.activitySignalCount = 0 - trackedSession.lastObservedActivitySignalCount = undefined } function cancelCountdown(sessionID: string): void { @@ -282,7 +259,6 @@ export function createSessionStateStore(): SessionStateStore { getState, getExistingState, startPruneInterval, - recordActivity, trackContinuationProgress, resetContinuationProgress, cancelCountdown, diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index 3d0e61770..261aa47c0 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -68,7 +68,3 @@ export interface ResolveLatestMessageInfoResult { encounteredCompaction: boolean latestMessageWasCompaction: boolean } - -export interface ContinuationProgressOptions { - allowActivityProgress?: boolean -}