From 68e9d54fa5f44185c0637ee86d989cf4d6e634ec Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Fri, 10 Apr 2026 20:07:56 +0200 Subject: [PATCH 1/3] 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 -} From 047ca069a28c8954a4367235f164840caf2dd873 Mon Sep 17 00:00:00 2001 From: Evgeny Zotov Date: Fri, 10 Apr 2026 23:10:54 +0200 Subject: [PATCH 2/3] test: rename test to reflect todo-only stagnation check Address review feedback: test name no longer references 'tool activity' since activity tracking was removed. --- src/hooks/todo-continuation-enforcer/session-state.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/session-state.test.ts b/src/hooks/todo-continuation-enforcer/session-state.test.ts index 3313ab039..faf075ea2 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 tool activity happens after a successful continuation without todo changes, keeps counting stagnation", () => { + test("given no todo changes after a successful continuation, keeps counting stagnation", () => { // given - const sessionID = "ses-activity-stagnation" + const sessionID = "ses-no-todo-change-stagnation" const state = sessionStateStore.getState(sessionID) const todos = [ { id: "1", content: "Task 1", status: "pending", priority: "high" }, From 65c1283338032ba79c0413eb9f1df662f1dea9c5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 21:29:21 +0900 Subject: [PATCH 3/3] fix(todo-continuation): clean up idle event diagnostics --- .../todo-continuation-enforcer/idle-event.ts | 16 ++++++++-------- .../non-idle-events.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 340e1ab26..4e4b63654 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -2,19 +2,19 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" import { getSessionAgent } from "../../features/claude-code-session-state" import { normalizeSDKResponse } from "../../shared" -import { log } from "../../shared/logger" import { getAgentConfigKey } from "../../shared/agent-display-names" +import { log } from "../../shared/logger" -import { ABORT_WINDOW_MS, CONTINUATION_COOLDOWN_MS, DEFAULT_SKIP_AGENTS, FAILURE_RESET_WINDOW_MS, HOOK_NAME, MAX_CONSECUTIVE_FAILURES } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" +import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compaction-guard" +import { ABORT_WINDOW_MS, CONTINUATION_COOLDOWN_MS, DEFAULT_SKIP_AGENTS, FAILURE_RESET_WINDOW_MS, HOOK_NAME, MAX_CONSECUTIVE_FAILURES } from "./constants" +import { startCountdown } from "./countdown" import { hasUnansweredQuestion } from "./pending-question-detection" +import { resolveLatestMessageInfo } from "./resolve-message-info" +import type { SessionStateStore } from "./session-state" import { shouldStopForStagnation } from "./stagnation-detection" import { getIncompleteCount } from "./todo" -import type { MessageInfo, MessageWithInfo, ResolvedMessageInfo, Todo } from "./types" -import { resolveLatestMessageInfo } from "./resolve-message-info" -import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compaction-guard" -import type { SessionStateStore } from "./session-state" -import { startCountdown } from "./countdown" +import type { MessageWithInfo, ResolvedMessageInfo, Todo } from "./types" export async function handleSessionIdle(args: { ctx: PluginInput @@ -132,7 +132,7 @@ export async function handleSessionIdle(args: { } const effectiveCooldown = - CONTINUATION_COOLDOWN_MS * Math.pow(2, Math.min(state.consecutiveFailures, 5)) + CONTINUATION_COOLDOWN_MS * 2 ** Math.min(state.consecutiveFailures, 5) if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) { log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID, effectiveCooldown, consecutiveFailures: state.consecutiveFailures }) return diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index 116369614..25a4de113 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -1,5 +1,5 @@ -import { log } from "../../shared/logger" import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" +import { log } from "../../shared/logger" import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants" import type { SessionStateStore } from "./session-state"