From 22b4b30dd7ea03fa293e47e926bd645055e63360 Mon Sep 17 00:00:00 2001 From: conversun Date: Fri, 13 Mar 2026 00:29:34 +0800 Subject: [PATCH 1/2] fix(todo-continuation-enforcer): prevent post-compaction agent fallback to General After compaction, message history is truncated and the original agent (e.g. Prometheus) can no longer be resolved from messages. The todo continuation enforcer would then inject a continuation prompt with agent=undefined, causing the host to default to General -- which has write permissions Prometheus should never have. Root cause chain: 1. handler.ts had no session.compacted handler (unlike Atlas) 2. idle-event.ts relied on finding a compaction marker in truncated message history -- the marker disappears after real compaction 3. continuation-injection.ts proceeded when agentName was undefined because the skipAgents check only matched truthy agent names 4. prometheus-md-only/agent-resolution.ts did not filter compaction agent from message history fallback results Fixes: - Add session.compacted handler that sets hasRecentCompaction state flag - Replace fragile history-based compaction detection with state flag - Block continuation injection when agent is unknown post-compaction - Filter compaction agent in Prometheus agent resolution fallback --- src/hooks/prometheus-md-only/agent-resolution.ts | 15 ++++++++++++--- .../continuation-injection.ts | 8 ++++++++ src/hooks/todo-continuation-enforcer/handler.ts | 11 +++++++++++ .../todo-continuation-enforcer/idle-event.ts | 6 ++---- src/hooks/todo-continuation-enforcer/types.ts | 1 + 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/hooks/prometheus-md-only/agent-resolution.ts b/src/hooks/prometheus-md-only/agent-resolution.ts index 22dc9cae0..f650ab722 100644 --- a/src/hooks/prometheus-md-only/agent-resolution.ts +++ b/src/hooks/prometheus-md-only/agent-resolution.ts @@ -12,21 +12,30 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection" type OpencodeClient = PluginInput["client"] +function isCompactionAgent(agent: string): boolean { + return agent.toLowerCase() === "compaction" +} + async function getAgentFromMessageFiles( sessionID: string, client?: OpencodeClient ): Promise { if (isSqliteBackend() && client) { const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID) - if (firstAgent) return firstAgent + if (firstAgent && !isCompactionAgent(firstAgent)) return firstAgent const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID) - return nearest?.agent + if (nearest?.agent && !isCompactionAgent(nearest.agent)) return nearest.agent + return undefined } const messageDir = getMessageDir(sessionID) if (!messageDir) return undefined - return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent + const firstAgent = findFirstMessageWithAgent(messageDir) + if (firstAgent && !isCompactionAgent(firstAgent)) return firstAgent + const nearestAgent = findNearestMessageWithFields(messageDir)?.agent + if (nearestAgent && !isCompactionAgent(nearestAgent)) return nearestAgent + return undefined } /** diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 5e4cb124c..6dcac9045 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -120,6 +120,14 @@ export async function injectContinuation(args: { return } + if (!agentName) { + const compactionState = sessionStateStore.getExistingState(sessionID) + if (compactionState?.hasRecentCompaction) { + log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID }) + return + } + } + if (!hasWritePermission(tools)) { log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) return diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index 4e4ef69d0..ee8168d64 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -63,6 +63,17 @@ export function createTodoContinuationHandler(args: { return } + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + const state = sessionStateStore.getState(sessionID) + state.hasRecentCompaction = true + sessionStateStore.cancelCountdown(sessionID) + log(`[${HOOK_NAME}] Session compacted: marked hasRecentCompaction`, { sessionID }) + } + return + } + if (event.type === "session.deleted") { const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index c55bf4abc..834ed0054 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -146,7 +146,6 @@ export async function handleSessionIdle(args: { } let resolvedInfo: ResolvedMessageInfo | undefined - let hasCompactionMessage = false try { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID }, @@ -155,7 +154,6 @@ export async function handleSessionIdle(args: { for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info if (info?.agent === "compaction") { - hasCompactionMessage = true continue } if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { @@ -171,14 +169,14 @@ export async function handleSessionIdle(args: { log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) } - log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage }) + log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasRecentCompaction: state.hasRecentCompaction }) const resolvedAgentName = resolvedInfo?.agent if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName }) return } - if (hasCompactionMessage && !resolvedInfo?.agent) { + if (state.hasRecentCompaction && !resolvedInfo?.agent) { log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) return } diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index c801ab128..e8ea96af0 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -35,6 +35,7 @@ export interface SessionState { inFlight?: boolean stagnationCount: number consecutiveFailures: number + hasRecentCompaction?: boolean } export interface MessageInfo { From 088844474a27eb5d74fe964522789fbecf1877ce Mon Sep 17 00:00:00 2001 From: conversun Date: Fri, 13 Mar 2026 00:55:37 +0800 Subject: [PATCH 2/2] 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. --- .../compaction-guard.ts | 10 +++++ .../todo-continuation-enforcer/constants.ts | 1 + .../continuation-injection.ts | 6 ++- .../todo-continuation-enforcer/handler.ts | 4 +- .../todo-continuation-enforcer/idle-event.ts | 44 +++++++------------ .../resolve-message-info.ts | 31 +++++++++++++ src/hooks/todo-continuation-enforcer/types.ts | 2 +- 7 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 src/hooks/todo-continuation-enforcer/compaction-guard.ts create mode 100644 src/hooks/todo-continuation-enforcer/resolve-message-info.ts diff --git a/src/hooks/todo-continuation-enforcer/compaction-guard.ts b/src/hooks/todo-continuation-enforcer/compaction-guard.ts new file mode 100644 index 000000000..38f3d640a --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/compaction-guard.ts @@ -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 +} diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index a26c7bc09..5829ea867 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -17,6 +17,7 @@ export const TOAST_DURATION_MS = 900 export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 +export const COMPACTION_GUARD_MS = 60_000 export const CONTINUATION_COOLDOWN_MS = 5_000 export const MAX_STAGNATION_COUNT = 3 export const MAX_CONSECUTIVE_FAILURES = 5 diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 6dcac9045..f5b2b84e1 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" +import { getSessionAgent } from "../../features/claude-code-session-state" import { createInternalAgentTextPart, normalizeSDKResponse, @@ -20,6 +21,7 @@ import { DEFAULT_SKIP_AGENTS, HOOK_NAME, } from "./constants" +import { isCompactionGuardActive } from "./compaction-guard" import { getMessageDir } from "./message-directory" import { getIncompleteCount } from "./todo" import type { ResolvedMessageInfo, Todo } from "./types" @@ -88,7 +90,7 @@ export async function injectContinuation(args: { return } - let agentName = resolvedInfo?.agent + let agentName = resolvedInfo?.agent ?? getSessionAgent(sessionID) let model = resolvedInfo?.model let tools = resolvedInfo?.tools @@ -122,7 +124,7 @@ export async function injectContinuation(args: { if (!agentName) { const compactionState = sessionStateStore.getExistingState(sessionID) - if (compactionState?.hasRecentCompaction) { + if (compactionState && isCompactionGuardActive(compactionState, Date.now())) { log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID }) return } diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index ee8168d64..a3eb71bf7 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -67,9 +67,9 @@ export function createTodoContinuationHandler(args: { const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined if (sessionID) { const state = sessionStateStore.getState(sessionID) - state.hasRecentCompaction = true + state.recentCompactionAt = Date.now() sessionStateStore.cancelCountdown(sessionID) - log(`[${HOOK_NAME}] Session compacted: marked hasRecentCompaction`, { sessionID }) + log(`[${HOOK_NAME}] Session compacted: marked recentCompactionAt`, { sessionID }) } return } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 834ed0054..58a200121 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -1,7 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" - 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 { log } from "../../shared/logger" import { getAgentConfigKey } from "../../shared/agent-display-names" @@ -19,6 +18,8 @@ import { hasUnansweredQuestion } from "./pending-question-detection" import { shouldStopForStagnation } from "./stagnation-detection" import { getIncompleteCount } from "./todo" 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 { startCountdown } from "./countdown" @@ -119,10 +120,7 @@ export async function handleSessionIdle(args: { && Date.now() - state.lastInjectedAt >= FAILURE_RESET_WINDOW_MS ) { state.consecutiveFailures = 0 - log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { - sessionID, - failureResetWindowMs: FAILURE_RESET_WINDOW_MS, - }) + log(`[${HOOK_NAME}] Reset consecutive failures after recovery window`, { sessionID, failureResetWindowMs: FAILURE_RESET_WINDOW_MS }) } if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { @@ -147,39 +145,32 @@ export async function handleSessionIdle(args: { let resolvedInfo: ResolvedMessageInfo | undefined try { - 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)) { - resolvedInfo = { - agent: info.agent, - model: info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined), - tools: info.tools as Record | undefined, - } - break - } - } + resolvedInfo = await resolveLatestMessageInfo(ctx, sessionID) } catch (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 if (resolvedAgentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(resolvedAgentName))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedAgentName }) return } - if (state.hasRecentCompaction && !resolvedInfo?.agent) { + if (compactionGuardActive && !resolvedInfo?.agent) { log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID }) return } + if (state.recentCompactionAt && resolvedInfo?.agent) { + state.recentCompactionAt = undefined + } if (isContinuationStopped?.(sessionID)) { log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID }) @@ -195,7 +186,6 @@ export async function handleSessionIdle(args: { if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) { return } - startCountdown({ ctx, sessionID, diff --git a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts new file mode 100644 index 000000000..cf4c5170c --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts @@ -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 { + 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 +} diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index e8ea96af0..b7a434e10 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -35,7 +35,7 @@ export interface SessionState { inFlight?: boolean stagnationCount: number consecutiveFailures: number - hasRecentCompaction?: boolean + recentCompactionAt?: number } export interface MessageInfo {