From df7e1ae16d5fa9a61bc8bbdee50b2d6ecb3fb8b2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Mar 2026 15:17:34 +0900 Subject: [PATCH] fix(todo-continuation): remove activity-based stagnation bypass --- .../session-state.regression.test.ts | 16 ++--- .../session-state.ts | 25 +------- .../stagnation-detection.test.ts | 62 ++++++++++++++++++- 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/session-state.regression.test.ts b/src/hooks/todo-continuation-enforcer/session-state.regression.test.ts index 08cd5fb9a..f7e26e431 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.regression.test.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.regression.test.ts @@ -18,7 +18,7 @@ describe("createSessionStateStore regressions", () => { describe("#given external activity happens after a successful continuation", () => { describe("#when todos stay unchanged", () => { - test("#then it treats the activity as progress instead of stagnation", () => { + test("#then it keeps counting stagnation", () => { const sessionID = "ses-activity-progress" const todos = [ { id: "1", content: "Task 1", status: "pending", priority: "high" }, @@ -37,9 +37,9 @@ describe("createSessionStateStore regressions", () => { trackedState.abortDetectedAt = undefined const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos) - expect(progressUpdate.hasProgressed).toBe(true) - expect(progressUpdate.progressSource).toBe("activity") - expect(progressUpdate.stagnationCount).toBe(0) + expect(progressUpdate.hasProgressed).toBe(false) + expect(progressUpdate.progressSource).toBe("none") + expect(progressUpdate.stagnationCount).toBe(1) }) }) }) @@ -72,7 +72,7 @@ describe("createSessionStateStore regressions", () => { describe("#given stagnation already halted a session", () => { describe("#when new activity appears before the next idle check", () => { - test("#then it resets the stop condition on the next progress check", () => { + test("#then it does not reset the stop condition", () => { const sessionID = "ses-stagnation-recovery" const todos = [ { id: "1", content: "Task 1", status: "pending", priority: "high" }, @@ -96,9 +96,9 @@ describe("createSessionStateStore regressions", () => { const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos) expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT) - expect(progressUpdate.hasProgressed).toBe(true) - expect(progressUpdate.progressSource).toBe("activity") - expect(progressUpdate.stagnationCount).toBe(0) + expect(progressUpdate.hasProgressed).toBe(false) + expect(progressUpdate.progressSource).toBe("none") + expect(progressUpdate.stagnationCount).toBe(MAX_STAGNATION_COUNT) }) }) }) diff --git a/src/hooks/todo-continuation-enforcer/session-state.ts b/src/hooks/todo-continuation-enforcer/session-state.ts index 810fdcfb4..8a151958f 100644 --- a/src/hooks/todo-continuation-enforcer/session-state.ts +++ b/src/hooks/todo-continuation-enforcer/session-state.ts @@ -16,8 +16,6 @@ interface TrackedSessionState { lastAccessedAt: number lastCompletedCount?: number lastTodoSnapshot?: string - activitySignalCount: number - lastObservedActivitySignalCount?: number } export interface ContinuationProgressUpdate { @@ -25,7 +23,7 @@ export interface ContinuationProgressUpdate { previousStagnationCount: number stagnationCount: number hasProgressed: boolean - progressSource: "none" | "todo" | "activity" + progressSource: "none" | "todo" } export interface SessionStateStore { @@ -98,17 +96,7 @@ export function createSessionStateStore(): SessionStateStore { const trackedSession: TrackedSessionState = { state: rawState, lastAccessedAt: Date.now(), - activitySignalCount: 0, } - trackedSession.state = new Proxy(rawState, { - set(target, property, value, receiver) { - if (property === "abortDetectedAt" && value === undefined) { - trackedSession.activitySignalCount += 1 - } - - return Reflect.set(target, property, value, receiver) - }, - }) sessions.set(sessionID, trackedSession) return trackedSession } @@ -137,7 +125,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 @@ -146,9 +133,6 @@ export function createSessionStateStore(): SessionStateStore { currentTodoSnapshot !== undefined && trackedSession.lastTodoSnapshot !== undefined && currentTodoSnapshot !== trackedSession.lastTodoSnapshot - const hasObservedExternalActivity = - trackedSession.lastObservedActivitySignalCount !== undefined - && currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true state.lastIncompleteCount = incompleteCount @@ -158,7 +142,6 @@ export function createSessionStateStore(): SessionStateStore { if (currentTodoSnapshot !== undefined) { trackedSession.lastTodoSnapshot = currentTodoSnapshot } - trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount if (previousIncompleteCount === undefined) { state.stagnationCount = 0 @@ -173,9 +156,7 @@ export function createSessionStateStore(): SessionStateStore { const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged ? "todo" - : hasObservedExternalActivity - ? "activity" - : "none" + : "none" if (progressSource !== "none") { state.stagnationCount = 0 @@ -223,8 +204,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 { diff --git a/src/hooks/todo-continuation-enforcer/stagnation-detection.test.ts b/src/hooks/todo-continuation-enforcer/stagnation-detection.test.ts index f309f44fa..2c9ce17df 100644 --- a/src/hooks/todo-continuation-enforcer/stagnation-detection.test.ts +++ b/src/hooks/todo-continuation-enforcer/stagnation-detection.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it as test } from "bun:test" import { MAX_STAGNATION_COUNT } from "./constants" +import { handleNonIdleEvent } from "./non-idle-events" +import { createSessionStateStore } from "./session-state" import { shouldStopForStagnation } from "./stagnation-detection" describe("shouldStopForStagnation", () => { @@ -25,7 +27,7 @@ describe("shouldStopForStagnation", () => { }) }) - describe("#when activity progress is detected after the halt", () => { + describe("#when todo progress is detected after the halt", () => { test("#then it clears the stop condition", () => { const shouldStop = shouldStopForStagnation({ sessionID: "ses-recovered", @@ -35,7 +37,7 @@ describe("shouldStopForStagnation", () => { previousStagnationCount: MAX_STAGNATION_COUNT, stagnationCount: 0, hasProgressed: true, - progressSource: "activity", + progressSource: "todo", }, }) @@ -43,4 +45,60 @@ describe("shouldStopForStagnation", () => { }) }) }) + + describe("#given only non-idle tool and message events happen between idle checks", () => { + describe("#when todo state does not change across three idle cycles", () => { + test("#then stagnation count reaches three", () => { + // given + const sessionStateStore = createSessionStateStore() + const sessionID = "ses-non-idle-activity-without-progress" + const state = sessionStateStore.getState(sessionID) + const todos = [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "medium" }, + ] + + sessionStateStore.trackContinuationProgress(sessionID, 2, todos) + + // when + state.awaitingPostInjectionProgressCheck = true + const firstCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos) + + handleNonIdleEvent({ + eventType: "tool.execute.before", + properties: { sessionID }, + sessionStateStore, + }) + handleNonIdleEvent({ + eventType: "message.updated", + properties: { info: { sessionID, role: "assistant" } }, + sessionStateStore, + }) + + state.awaitingPostInjectionProgressCheck = true + const secondCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos) + + handleNonIdleEvent({ + eventType: "tool.execute.after", + properties: { sessionID }, + sessionStateStore, + }) + handleNonIdleEvent({ + eventType: "message.part.updated", + properties: { info: { sessionID, role: "assistant" } }, + sessionStateStore, + }) + + state.awaitingPostInjectionProgressCheck = true + const thirdCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos) + + // then + expect(firstCycle.stagnationCount).toBe(1) + expect(secondCycle.stagnationCount).toBe(2) + expect(thirdCycle.stagnationCount).toBe(3) + + sessionStateStore.shutdown() + }) + }) + }) })