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:
@@ -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<string | undefined> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface SessionState {
|
||||
inFlight?: boolean
|
||||
stagnationCount: number
|
||||
consecutiveFailures: number
|
||||
hasRecentCompaction?: boolean
|
||||
}
|
||||
|
||||
export interface MessageInfo {
|
||||
|
||||
Reference in New Issue
Block a user