fix(hooks): prevent infinite loop when todo-continuation-enforcer runs during session recovery (#29)

This commit is contained in:
Junho Yeo
2025-12-13 11:48:22 +09:00
committed by GitHub
parent fd357e490b
commit 2cab36f06d
4 changed files with 61 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"; export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor"; export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification"; export { createSessionNotification } from "./session-notification";
export { createSessionRecoveryHook } from "./session-recovery"; export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker"; export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator"; export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector"; export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";

View File

@@ -216,14 +216,26 @@ async function recoverEmptyContentMessage(
// All error types have dedicated recovery functions (recoverToolResultMissing, // All error types have dedicated recovery functions (recoverToolResultMissing,
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage). // recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
export function createSessionRecoveryHook(ctx: PluginInput) { export interface SessionRecoveryHook {
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
isRecoverableError: (error: unknown) => boolean
setOnAbortCallback: (callback: (sessionID: string) => void) => void
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
}
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
const processingErrors = new Set<string>() const processingErrors = new Set<string>()
let onAbortCallback: ((sessionID: string) => void) | null = null let onAbortCallback: ((sessionID: string) => void) | null = null
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => { const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = callback onAbortCallback = callback
} }
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
onRecoveryCompleteCallback = callback
}
const isRecoverableError = (error: unknown): boolean => { const isRecoverableError = (error: unknown): boolean => {
return detectErrorType(error) !== null return detectErrorType(error) !== null
} }
@@ -242,12 +254,12 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
processingErrors.add(assistantMsgID) processingErrors.add(assistantMsgID)
try { try {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (onAbortCallback) { if (onAbortCallback) {
onAbortCallback(sessionID) onAbortCallback(sessionID) // Mark recovering BEFORE abort
} }
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
const messagesResp = await ctx.client.session.messages({ const messagesResp = await ctx.client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
@@ -301,6 +313,11 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
return false return false
} finally { } finally {
processingErrors.delete(assistantMsgID) processingErrors.delete(assistantMsgID)
// Always notify recovery complete, regardless of success or failure
if (sessionID && onRecoveryCompleteCallback) {
onRecoveryCompleteCallback(sessionID)
}
} }
} }
@@ -308,5 +325,6 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
handleSessionRecovery, handleSessionRecovery,
isRecoverableError, isRecoverableError,
setOnAbortCallback, setOnAbortCallback,
setOnRecoveryCompleteCallback,
} }
} }

View File

@@ -1,5 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
export interface TodoContinuationEnforcer {
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
}
interface Todo { interface Todo {
content: string content: string
status: string status: string
@@ -32,13 +38,22 @@ function detectInterrupt(error: unknown): boolean {
return false return false
} }
export function createTodoContinuationEnforcer(ctx: PluginInput) { export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
const remindedSessions = new Set<string>() const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>() const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>() const errorSessions = new Set<string>()
const recoveringSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>() const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => { const markRecovering = (sessionID: string): void => {
recoveringSessions.add(sessionID)
}
const markRecoveryComplete = (sessionID: string): void => {
recoveringSessions.delete(sessionID)
}
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") { if (event.type === "session.error") {
@@ -73,6 +88,11 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
pendingTimers.delete(sessionID) pendingTimers.delete(sessionID)
// Check if session is in recovery mode - if so, skip entirely without clearing state
if (recoveringSessions.has(sessionID)) {
return
}
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID) const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID) interruptedSessions.delete(sessionID)
@@ -111,7 +131,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.add(sessionID) remindedSessions.add(sessionID)
// Re-check if abort occurred during the delay/fetch // Re-check if abort occurred during the delay/fetch
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) { if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID) || recoveringSessions.has(sessionID)) {
remindedSessions.delete(sessionID) remindedSessions.delete(sessionID)
return return
} }
@@ -158,6 +178,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.delete(sessionInfo.id) remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id) interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id) errorSessions.delete(sessionInfo.id)
recoveringSessions.delete(sessionInfo.id)
// Cancel pending continuation // Cancel pending continuation
const timer = pendingTimers.get(sessionInfo.id) const timer = pendingTimers.get(sessionInfo.id)
@@ -168,4 +189,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
} }
} }
} }
return {
handler,
markRecovering,
markRecoveryComplete,
}
} }

View File

@@ -151,6 +151,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx); const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx); const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx); const sessionRecovery = createSessionRecoveryHook(ctx);
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
// This prevents the continuation enforcer from injecting prompts during active recovery
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
const commentChecker = createCommentCheckerHooks(); const commentChecker = createCommentCheckerHooks();
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx); const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx); const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
@@ -248,7 +254,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await autoUpdateChecker.event(input); await autoUpdateChecker.event(input);
await claudeCodeHooks.event(input); await claudeCodeHooks.event(input);
await backgroundNotificationHook.event(input); await backgroundNotificationHook.event(input);
await todoContinuationEnforcer(input); await todoContinuationEnforcer.handler(input);
await contextWindowMonitor.event(input); await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input); await directoryAgentsInjector.event(input);
await directoryReadmeInjector.event(input); await directoryReadmeInjector.event(input);