Merge pull request #2535 from conversun/fix/prometheus-compaction-agent-fallback
fix(todo-continuation-enforcer): prevent post-compaction agent fallback to General
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -120,6 +122,14 @@ export async function injectContinuation(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!agentName) {
|
||||||
|
const compactionState = sessionStateStore.getExistingState(sessionID)
|
||||||
|
if (compactionState && isCompactionGuardActive(compactionState, Date.now())) {
|
||||||
|
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.recentCompactionAt = Date.now()
|
||||||
|
sessionStateStore.cancelCountdown(sessionID)
|
||||||
|
log(`[${HOOK_NAME}] Session compacted: marked recentCompactionAt`, { 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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -146,42 +144,33 @@ 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({
|
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") {
|
|
||||||
hasCompactionMessage = true
|
|
||||||
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, hasCompactionMessage })
|
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 (hasCompactionMessage && !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 })
|
||||||
@@ -197,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,6 +35,7 @@ export interface SessionState {
|
|||||||
inFlight?: boolean
|
inFlight?: boolean
|
||||||
stagnationCount: number
|
stagnationCount: number
|
||||||
consecutiveFailures: number
|
consecutiveFailures: number
|
||||||
|
recentCompactionAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInfo {
|
export interface MessageInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user