Merge pull request #3319 from EZotoff/fix/remove-activity-stagnation-bypass

This commit is contained in:
YeonGyu-Kim
2026-05-15 21:36:09 +09:00
committed by GitHub
6 changed files with 16 additions and 87 deletions
@@ -28,7 +28,6 @@ function createStateStore(): {
getState: () => state,
getExistingState: () => state,
startPruneInterval: () => {},
recordActivity: () => {},
trackContinuationProgress: () => progressUpdate,
resetContinuationProgress: (sessionID: string) => {
resetCalls.push(sessionID)
@@ -2,27 +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"
function shouldAllowActivityProgress(modelID: string | undefined): boolean {
if (!modelID) {
return false
}
return !modelID.toLowerCase().includes("codex")
}
import type { MessageWithInfo, ResolvedMessageInfo, Todo } from "./types"
export async function handleSessionIdle(args: {
ctx: PluginInput
@@ -140,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
@@ -204,7 +196,6 @@ export async function handleSessionIdle(args: {
sessionID,
incompleteCount,
todos,
{ allowActivityProgress: shouldAllowActivityProgress(resolvedInfo?.model?.modelID) },
)
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
return
@@ -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"
@@ -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)
}
@@ -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 no todo changes after a successful continuation, keeps counting stagnation", () => {
// given
const sessionID = "ses-non-codex-activity-progress"
const sessionID = "ses-no-todo-change-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
@@ -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,
@@ -68,7 +68,3 @@ export interface ResolveLatestMessageInfoResult {
encounteredCompaction: boolean
latestMessageWasCompaction: boolean
}
export interface ContinuationProgressOptions {
allowActivityProgress?: boolean
}