fix(todo-continuation-enforcer): tighten post-compaction guard with session-agent fallback

Refine continuation agent resolution to prefer session-state agent fallback while keeping compaction-specific protection. Replace sticky boolean compaction flag with a short-lived timestamp guard so unresolved agents are blocked only during the immediate post-compaction window, avoiding long-lived suppression and preserving existing continuation behavior.
This commit is contained in:
conversun
2026-03-13 00:55:37 +08:00
parent 22b4b30dd7
commit 088844474a
7 changed files with 66 additions and 32 deletions
@@ -0,0 +1,10 @@
import { COMPACTION_GUARD_MS } from "./constants"
import type { SessionState } from "./types"
export function isCompactionGuardActive(state: SessionState, now: number): boolean {
if (!state.recentCompactionAt) {
return false
}
return now - state.recentCompactionAt < COMPACTION_GUARD_MS
}
@@ -17,6 +17,7 @@ export const TOAST_DURATION_MS = 900
export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const COUNTDOWN_GRACE_PERIOD_MS = 500
export const ABORT_WINDOW_MS = 3000 export const ABORT_WINDOW_MS = 3000
export const COMPACTION_GUARD_MS = 60_000
export const CONTINUATION_COOLDOWN_MS = 5_000 export const CONTINUATION_COOLDOWN_MS = 5_000
export const MAX_STAGNATION_COUNT = 3 export const MAX_STAGNATION_COUNT = 3
export const MAX_CONSECUTIVE_FAILURES = 5 export const MAX_CONSECUTIVE_FAILURES = 5
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin" 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 { import {
createInternalAgentTextPart, createInternalAgentTextPart,
normalizeSDKResponse, normalizeSDKResponse,
@@ -20,6 +21,7 @@ import {
DEFAULT_SKIP_AGENTS, DEFAULT_SKIP_AGENTS,
HOOK_NAME, HOOK_NAME,
} from "./constants" } from "./constants"
import { isCompactionGuardActive } from "./compaction-guard"
import { getMessageDir } from "./message-directory" import { getMessageDir } from "./message-directory"
import { getIncompleteCount } from "./todo" import { getIncompleteCount } from "./todo"
import type { ResolvedMessageInfo, Todo } from "./types" import type { ResolvedMessageInfo, Todo } from "./types"
@@ -88,7 +90,7 @@ export async function injectContinuation(args: {
return return
} }
let agentName = resolvedInfo?.agent let agentName = resolvedInfo?.agent ?? getSessionAgent(sessionID)
let model = resolvedInfo?.model let model = resolvedInfo?.model
let tools = resolvedInfo?.tools let tools = resolvedInfo?.tools
@@ -122,7 +124,7 @@ export async function injectContinuation(args: {
if (!agentName) { if (!agentName) {
const compactionState = sessionStateStore.getExistingState(sessionID) const compactionState = sessionStateStore.getExistingState(sessionID)
if (compactionState?.hasRecentCompaction) { if (compactionState && isCompactionGuardActive(compactionState, Date.now())) {
log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID }) log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID })
return return
} }
@@ -67,9 +67,9 @@ export function createTodoContinuationHandler(args: {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
if (sessionID) { if (sessionID) {
const state = sessionStateStore.getState(sessionID) const state = sessionStateStore.getState(sessionID)
state.hasRecentCompaction = true state.recentCompactionAt = Date.now()
sessionStateStore.cancelCountdown(sessionID) sessionStateStore.cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Session compacted: marked hasRecentCompaction`, { sessionID }) log(`[${HOOK_NAME}] Session compacted: marked recentCompactionAt`, { sessionID })
} }
return return
} }
@@ -1,7 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import type { ToolPermission } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state"
import { normalizeSDKResponse } from "../../shared" import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getAgentConfigKey } from "../../shared/agent-display-names" import { getAgentConfigKey } from "../../shared/agent-display-names"
@@ -19,6 +18,8 @@ import { hasUnansweredQuestion } from "./pending-question-detection"
import { shouldStopForStagnation } from "./stagnation-detection" import { shouldStopForStagnation } from "./stagnation-detection"
import { getIncompleteCount } from "./todo" import { getIncompleteCount } from "./todo"
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types" import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
import { resolveLatestMessageInfo } from "./resolve-message-info"
import { isCompactionGuardActive } from "./compaction-guard"
import type { SessionStateStore } from "./session-state" import type { SessionStateStore } from "./session-state"
import { startCountdown } from "./countdown" import { startCountdown } from "./countdown"
@@ -119,10 +120,7 @@ export async function handleSessionIdle(args: {
&& Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS && Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS
) { ) {
state.consecutiveFailures = 0 state.consecutiveFailures = 0
log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { sessionID, failureResetWindowMs: FAILURE_RESET_WINDOW_MS })
sessionID,
failureResetWindowMs: FAILURE_RESET_WINDOW_MS,
})
} }
if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
@@ -147,39 +145,32 @@ export async function handleSessionIdle(args: {
let resolvedInfo: ResolvedMessageInfo | undefined let resolvedInfo: ResolvedMessageInfo | undefined
try { try {
const messagesResp = await ctx.client.session.messages({ resolvedInfo = await resolveLatestMessageInfo(ctx, sessionID)
path: { id: sessionID },
})
const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resolvedInfo = {
agent: info.agent,
model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined),
tools: info.tools as Record<string, ToolPermission> | undefined,
}
break
}
}
} catch (error) { } catch (error) {
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) })
} }
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasRecentCompaction: state.hasRecentCompaction }) const sessionAgent = getSessionAgent(sessionID)
if (!resolvedInfo?.agent && sessionAgent) {
resolvedInfo = { ...resolvedInfo, agent: sessionAgent }
}
const compactionGuardActive = isCompactionGuardActive(state, Date.now())
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, compactionGuardActive })
const resolvedAgentName = resolvedInfo?.agent const resolvedAgentName = resolvedInfo?.agent
if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) { if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName }) log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName })
return return
} }
if (state.hasRecentCompaction && !resolvedInfo?.agent) { if (compactionGuardActive && !resolvedInfo?.agent) {
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
return return
} }
if (state.recentCompactionAt && resolvedInfo?.agent) {
state.recentCompactionAt = undefined
}
if (isContinuationStopped?.(sessionID)) { if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
@@ -195,7 +186,6 @@ export async function handleSessionIdle(args: {
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
return return
} }
startCountdown({ startCountdown({
ctx, ctx,
sessionID, sessionID,
@@ -0,0 +1,31 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import type { MessageInfo, ResolvedMessageInfo } from "./types"
export async function resolveLatestMessageInfo(
ctx: PluginInput,
sessionID: string
): Promise<ResolvedMessageInfo | undefined> {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
})
const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
return {
agent: info.agent,
model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined),
tools: info.tools,
}
}
}
return undefined
}
@@ -35,7 +35,7 @@ export interface SessionState {
inFlight?: boolean inFlight?: boolean
stagnationCount: number stagnationCount: number
consecutiveFailures: number consecutiveFailures: number
hasRecentCompaction?: boolean recentCompactionAt?: number
} }
export interface MessageInfo { export interface MessageInfo {