Merge pull request #3319 from EZotoff/fix/remove-activity-stagnation-bypass
This commit is contained in:
@@ -28,7 +28,6 @@ function createStateStore(): {
|
|||||||
getState: () => state,
|
getState: () => state,
|
||||||
getExistingState: () => state,
|
getExistingState: () => state,
|
||||||
startPruneInterval: () => {},
|
startPruneInterval: () => {},
|
||||||
recordActivity: () => {},
|
|
||||||
trackContinuationProgress: () => progressUpdate,
|
trackContinuationProgress: () => progressUpdate,
|
||||||
resetContinuationProgress: (sessionID: string) => {
|
resetContinuationProgress: (sessionID: string) => {
|
||||||
resetCalls.push(sessionID)
|
resetCalls.push(sessionID)
|
||||||
|
|||||||
@@ -2,27 +2,19 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
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 { 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 { hasUnansweredQuestion } from "./pending-question-detection"
|
||||||
|
import { resolveLatestMessageInfo } from "./resolve-message-info"
|
||||||
|
import type { SessionStateStore } from "./session-state"
|
||||||
import { shouldStopForStagnation } from "./stagnation-detection"
|
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||||
import { getIncompleteCount } from "./todo"
|
import { getIncompleteCount } from "./todo"
|
||||||
import type { MessageInfo, MessageWithInfo, ResolvedMessageInfo, Todo } from "./types"
|
import type { 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleSessionIdle(args: {
|
export async function handleSessionIdle(args: {
|
||||||
ctx: PluginInput
|
ctx: PluginInput
|
||||||
@@ -140,7 +132,7 @@ export async function handleSessionIdle(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveCooldown =
|
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) {
|
if (state.lastInjectedAt && Date.now() - state.lastInjectedAt < effectiveCooldown) {
|
||||||
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID, effectiveCooldown, consecutiveFailures: state.consecutiveFailures })
|
log(`[${HOOK_NAME}] Skipped: cooldown active`, { sessionID, effectiveCooldown, consecutiveFailures: state.consecutiveFailures })
|
||||||
return
|
return
|
||||||
@@ -204,7 +196,6 @@ export async function handleSessionIdle(args: {
|
|||||||
sessionID,
|
sessionID,
|
||||||
incompleteCount,
|
incompleteCount,
|
||||||
todos,
|
todos,
|
||||||
{ allowActivityProgress: shouldAllowActivityProgress(resolvedInfo?.model?.modelID) },
|
|
||||||
)
|
)
|
||||||
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { log } from "../../shared/logger"
|
|
||||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants"
|
||||||
import type { SessionStateStore } from "./session-state"
|
import type { SessionStateStore } from "./session-state"
|
||||||
@@ -30,7 +30,6 @@ export function handleNonIdleEvent(args: {
|
|||||||
state.abortDetectedAt = undefined
|
state.abortDetectedAt = undefined
|
||||||
state.wasCancelled = false
|
state.wasCancelled = false
|
||||||
state.tokenLimitDetected = false
|
state.tokenLimitDetected = false
|
||||||
sessionStateStore.recordActivity(sessionID)
|
|
||||||
}
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
return
|
return
|
||||||
@@ -41,7 +40,6 @@ export function handleNonIdleEvent(args: {
|
|||||||
if (state) {
|
if (state) {
|
||||||
state.abortDetectedAt = undefined
|
state.abortDetectedAt = undefined
|
||||||
state.wasCancelled = false
|
state.wasCancelled = false
|
||||||
sessionStateStore.recordActivity(sessionID)
|
|
||||||
}
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
return
|
return
|
||||||
@@ -57,7 +55,6 @@ export function handleNonIdleEvent(args: {
|
|||||||
const state = sessionStateStore.getExistingState(targetSessionID)
|
const state = sessionStateStore.getExistingState(targetSessionID)
|
||||||
if (state) {
|
if (state) {
|
||||||
state.abortDetectedAt = undefined
|
state.abortDetectedAt = undefined
|
||||||
sessionStateStore.recordActivity(targetSessionID)
|
|
||||||
}
|
}
|
||||||
sessionStateStore.cancelCountdown(targetSessionID)
|
sessionStateStore.cancelCountdown(targetSessionID)
|
||||||
}
|
}
|
||||||
@@ -71,7 +68,6 @@ export function handleNonIdleEvent(args: {
|
|||||||
if (state) {
|
if (state) {
|
||||||
state.abortDetectedAt = undefined
|
state.abortDetectedAt = undefined
|
||||||
state.wasCancelled = false
|
state.wasCancelled = false
|
||||||
sessionStateStore.recordActivity(sessionID)
|
|
||||||
}
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
}
|
}
|
||||||
@@ -85,7 +81,6 @@ export function handleNonIdleEvent(args: {
|
|||||||
if (state) {
|
if (state) {
|
||||||
state.abortDetectedAt = undefined
|
state.abortDetectedAt = undefined
|
||||||
state.wasCancelled = false
|
state.wasCancelled = false
|
||||||
sessionStateStore.recordActivity(sessionID)
|
|
||||||
}
|
}
|
||||||
sessionStateStore.cancelCountdown(sessionID)
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,9 +144,9 @@ describe("createSessionStateStore", () => {
|
|||||||
expect(stagnatedAgainUpdate.stagnationCount).toBe(1)
|
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
|
// given
|
||||||
const sessionID = "ses-non-codex-activity-progress"
|
const sessionID = "ses-no-todo-change-stagnation"
|
||||||
const state = sessionStateStore.getState(sessionID)
|
const state = sessionStateStore.getState(sessionID)
|
||||||
const todos = [
|
const todos = [
|
||||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||||
@@ -154,40 +154,12 @@ describe("createSessionStateStore", () => {
|
|||||||
|
|
||||||
sessionStateStore.trackContinuationProgress(sessionID, 1, todos)
|
sessionStateStore.trackContinuationProgress(sessionID, 1, todos)
|
||||||
state.awaitingPostInjectionProgressCheck = true
|
state.awaitingPostInjectionProgressCheck = true
|
||||||
sessionStateStore.recordActivity(sessionID)
|
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const progressUpdate = sessionStateStore.trackContinuationProgress(
|
const progressUpdate = sessionStateStore.trackContinuationProgress(
|
||||||
sessionID,
|
sessionID,
|
||||||
1,
|
1,
|
||||||
todos,
|
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
|
// then
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ContinuationProgressOptions, SessionState, Todo } from "./types"
|
import type { SessionState, Todo } from "./types"
|
||||||
|
|
||||||
type TimerHandle = number | { unref?: () => void }
|
type TimerHandle = number | { unref?: () => void }
|
||||||
|
|
||||||
@@ -16,8 +16,6 @@ interface TrackedSessionState {
|
|||||||
lastAccessedAt: number
|
lastAccessedAt: number
|
||||||
lastCompletedCount?: number
|
lastCompletedCount?: number
|
||||||
lastTodoSnapshot?: string
|
lastTodoSnapshot?: string
|
||||||
activitySignalCount: number
|
|
||||||
lastObservedActivitySignalCount?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContinuationProgressUpdate {
|
export interface ContinuationProgressUpdate {
|
||||||
@@ -25,19 +23,17 @@ export interface ContinuationProgressUpdate {
|
|||||||
previousStagnationCount: number
|
previousStagnationCount: number
|
||||||
stagnationCount: number
|
stagnationCount: number
|
||||||
hasProgressed: boolean
|
hasProgressed: boolean
|
||||||
progressSource: "none" | "todo" | "activity"
|
progressSource: "none" | "todo"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionStateStore {
|
export interface SessionStateStore {
|
||||||
getState: (sessionID: string) => SessionState
|
getState: (sessionID: string) => SessionState
|
||||||
getExistingState: (sessionID: string) => SessionState | undefined
|
getExistingState: (sessionID: string) => SessionState | undefined
|
||||||
startPruneInterval: () => void
|
startPruneInterval: () => void
|
||||||
recordActivity: (sessionID: string) => void
|
|
||||||
trackContinuationProgress: (
|
trackContinuationProgress: (
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
incompleteCount: number,
|
incompleteCount: number,
|
||||||
todos?: Todo[],
|
todos?: Todo[],
|
||||||
options?: ContinuationProgressOptions,
|
|
||||||
) => ContinuationProgressUpdate
|
) => ContinuationProgressUpdate
|
||||||
resetContinuationProgress: (sessionID: string) => void
|
resetContinuationProgress: (sessionID: string) => void
|
||||||
cancelCountdown: (sessionID: string) => void
|
cancelCountdown: (sessionID: string) => void
|
||||||
@@ -113,7 +109,6 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
const trackedSession: TrackedSessionState = {
|
const trackedSession: TrackedSessionState = {
|
||||||
state: rawState,
|
state: rawState,
|
||||||
lastAccessedAt: Date.now(),
|
lastAccessedAt: Date.now(),
|
||||||
activitySignalCount: 0,
|
|
||||||
}
|
}
|
||||||
sessions.set(sessionID, trackedSession)
|
sessions.set(sessionID, trackedSession)
|
||||||
return trackedSession
|
return trackedSession
|
||||||
@@ -132,16 +127,10 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordActivity(sessionID: string): void {
|
|
||||||
const trackedSession = getTrackedSession(sessionID)
|
|
||||||
trackedSession.activitySignalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackContinuationProgress(
|
function trackContinuationProgress(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
incompleteCount: number,
|
incompleteCount: number,
|
||||||
todos?: Todo[],
|
todos?: Todo[],
|
||||||
options: ContinuationProgressOptions = {},
|
|
||||||
): ContinuationProgressUpdate {
|
): ContinuationProgressUpdate {
|
||||||
const trackedSession = getTrackedSession(sessionID)
|
const trackedSession = getTrackedSession(sessionID)
|
||||||
const state = trackedSession.state
|
const state = trackedSession.state
|
||||||
@@ -149,7 +138,6 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
const previousStagnationCount = state.stagnationCount
|
const previousStagnationCount = state.stagnationCount
|
||||||
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
||||||
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
||||||
const currentActivitySignalCount = trackedSession.activitySignalCount
|
|
||||||
const hasCompletedMoreTodos =
|
const hasCompletedMoreTodos =
|
||||||
currentCompletedCount !== undefined
|
currentCompletedCount !== undefined
|
||||||
&& trackedSession.lastCompletedCount !== undefined
|
&& trackedSession.lastCompletedCount !== undefined
|
||||||
@@ -158,10 +146,6 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
currentTodoSnapshot !== undefined
|
currentTodoSnapshot !== undefined
|
||||||
&& trackedSession.lastTodoSnapshot !== undefined
|
&& trackedSession.lastTodoSnapshot !== undefined
|
||||||
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
||||||
const hasObservedExternalActivity =
|
|
||||||
options.allowActivityProgress === true
|
|
||||||
&& trackedSession.lastObservedActivitySignalCount !== undefined
|
|
||||||
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
|
|
||||||
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||||
|
|
||||||
state.lastIncompleteCount = incompleteCount
|
state.lastIncompleteCount = incompleteCount
|
||||||
@@ -171,7 +155,6 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
if (currentTodoSnapshot !== undefined) {
|
if (currentTodoSnapshot !== undefined) {
|
||||||
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
||||||
}
|
}
|
||||||
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
|
|
||||||
|
|
||||||
if (previousIncompleteCount === undefined) {
|
if (previousIncompleteCount === undefined) {
|
||||||
state.stagnationCount = 0
|
state.stagnationCount = 0
|
||||||
@@ -184,13 +167,9 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
const hasProgressed = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
||||||
? "todo"
|
|
||||||
: hasObservedExternalActivity
|
|
||||||
? "activity"
|
|
||||||
: "none"
|
|
||||||
|
|
||||||
if (progressSource !== "none") {
|
if (hasProgressed) {
|
||||||
state.stagnationCount = 0
|
state.stagnationCount = 0
|
||||||
state.awaitingPostInjectionProgressCheck = false
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
return {
|
return {
|
||||||
@@ -198,7 +177,7 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
previousStagnationCount,
|
previousStagnationCount,
|
||||||
stagnationCount: state.stagnationCount,
|
stagnationCount: state.stagnationCount,
|
||||||
hasProgressed: true,
|
hasProgressed: true,
|
||||||
progressSource,
|
progressSource: "todo",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +215,6 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
state.awaitingPostInjectionProgressCheck = false
|
state.awaitingPostInjectionProgressCheck = false
|
||||||
trackedSession.lastCompletedCount = undefined
|
trackedSession.lastCompletedCount = undefined
|
||||||
trackedSession.lastTodoSnapshot = undefined
|
trackedSession.lastTodoSnapshot = undefined
|
||||||
trackedSession.activitySignalCount = 0
|
|
||||||
trackedSession.lastObservedActivitySignalCount = undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCountdown(sessionID: string): void {
|
function cancelCountdown(sessionID: string): void {
|
||||||
@@ -282,7 +259,6 @@ export function createSessionStateStore(): SessionStateStore {
|
|||||||
getState,
|
getState,
|
||||||
getExistingState,
|
getExistingState,
|
||||||
startPruneInterval,
|
startPruneInterval,
|
||||||
recordActivity,
|
|
||||||
trackContinuationProgress,
|
trackContinuationProgress,
|
||||||
resetContinuationProgress,
|
resetContinuationProgress,
|
||||||
cancelCountdown,
|
cancelCountdown,
|
||||||
|
|||||||
@@ -68,7 +68,3 @@ export interface ResolveLatestMessageInfoResult {
|
|||||||
encounteredCompaction: boolean
|
encounteredCompaction: boolean
|
||||||
latestMessageWasCompaction: boolean
|
latestMessageWasCompaction: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContinuationProgressOptions {
|
|
||||||
allowActivityProgress?: boolean
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user