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
This commit is contained in:
conversun
2026-03-13 00:29:34 +08:00
parent 0412e40780
commit 22b4b30dd7
5 changed files with 34 additions and 7 deletions
@@ -12,21 +12,30 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
function isCompactionAgent(agent: string): boolean {
return agent.toLowerCase() === "compaction"
}
async function getAgentFromMessageFiles( async function getAgentFromMessageFiles(
sessionID: string, sessionID: string,
client?: OpencodeClient client?: OpencodeClient
): Promise<string | undefined> { ): Promise<string | undefined> {
if (isSqliteBackend() && client) { if (isSqliteBackend() && client) {
const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID) const firstAgent = await findFirstMessageWithAgentFromSDK(client, sessionID)
if (firstAgent) return firstAgent if (firstAgent && !isCompactionAgent(firstAgent)) return firstAgent
const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID) const nearest = await findNearestMessageWithFieldsFromSDK(client, sessionID)
return nearest?.agent if (nearest?.agent && !isCompactionAgent(nearest.agent)) return nearest.agent
return undefined
} }
const messageDir = getMessageDir(sessionID) const messageDir = getMessageDir(sessionID)
if (!messageDir) return undefined 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
} }
/** /**
@@ -120,6 +120,14 @@ export async function injectContinuation(args: {
return return
} }
if (!agentName) {
const compactionState = sessionStateStore.getExistingState(sessionID)
if (compactionState?.hasRecentCompaction) {
log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID })
return
}
}
if (!hasWritePermission(tools)) { if (!hasWritePermission(tools)) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName }) log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return return
@@ -63,6 +63,17 @@ export function createTodoContinuationHandler(args: {
return 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") { if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) { if (sessionInfo?.id) {
@@ -146,7 +146,6 @@ export async function handleSessionIdle(args: {
} }
let resolvedInfo: ResolvedMessageInfo | undefined let resolvedInfo: ResolvedMessageInfo | undefined
let hasCompactionMessage = false
try { try {
const messagesResp = await ctx.client.session.messages({ const messagesResp = await ctx.client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
@@ -155,7 +154,6 @@ export async function handleSessionIdle(args: {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info
if (info?.agent === "compaction") { if (info?.agent === "compaction") {
hasCompactionMessage = true
continue continue
} }
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { 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}] 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 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 (hasCompactionMessage && !resolvedInfo?.agent) { if (state.hasRecentCompaction && !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
} }
@@ -35,6 +35,7 @@ export interface SessionState {
inFlight?: boolean inFlight?: boolean
stagnationCount: number stagnationCount: number
consecutiveFailures: number consecutiveFailures: number
hasRecentCompaction?: boolean
} }
export interface MessageInfo { export interface MessageInfo {