Skip to content

Commit 2cab36f

Browse files
authored
fix(hooks): prevent infinite loop when todo-continuation-enforcer runs during session recovery (#29)
1 parent fd357e4 commit 2cab36f

File tree

4 files changed

+61
-10
lines changed

4 files changed

+61
-10
lines changed

src/hooks/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
1+
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
22
export { createContextWindowMonitorHook } from "./context-window-monitor";
33
export { createSessionNotification } from "./session-notification";
4-
export { createSessionRecoveryHook } from "./session-recovery";
4+
export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery";
55
export { createCommentCheckerHooks } from "./comment-checker";
66
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
77
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";

src/hooks/session-recovery/index.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,26 @@ async function recoverEmptyContentMessage(
216216
// All error types have dedicated recovery functions (recoverToolResultMissing,
217217
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
218218

219-
export function createSessionRecoveryHook(ctx: PluginInput) {
219+
export interface SessionRecoveryHook {
220+
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
221+
isRecoverableError: (error: unknown) => boolean
222+
setOnAbortCallback: (callback: (sessionID: string) => void) => void
223+
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
224+
}
225+
226+
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
220227
const processingErrors = new Set<string>()
221228
let onAbortCallback: ((sessionID: string) => void) | null = null
229+
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
222230

223231
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
224232
onAbortCallback = callback
225233
}
226234

235+
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
236+
onRecoveryCompleteCallback = callback
237+
}
238+
227239
const isRecoverableError = (error: unknown): boolean => {
228240
return detectErrorType(error) !== null
229241
}
@@ -242,12 +254,12 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
242254
processingErrors.add(assistantMsgID)
243255

244256
try {
245-
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
246-
247257
if (onAbortCallback) {
248-
onAbortCallback(sessionID)
258+
onAbortCallback(sessionID) // Mark recovering BEFORE abort
249259
}
250260

261+
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
262+
251263
const messagesResp = await ctx.client.session.messages({
252264
path: { id: sessionID },
253265
query: { directory: ctx.directory },
@@ -301,12 +313,18 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
301313
return false
302314
} finally {
303315
processingErrors.delete(assistantMsgID)
316+
317+
// Always notify recovery complete, regardless of success or failure
318+
if (sessionID && onRecoveryCompleteCallback) {
319+
onRecoveryCompleteCallback(sessionID)
320+
}
304321
}
305322
}
306323

307324
return {
308325
handleSessionRecovery,
309326
isRecoverableError,
310327
setOnAbortCallback,
328+
setOnRecoveryCompleteCallback,
311329
}
312330
}

src/hooks/todo-continuation-enforcer.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { PluginInput } from "@opencode-ai/plugin"
22

3+
export interface TodoContinuationEnforcer {
4+
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
5+
markRecovering: (sessionID: string) => void
6+
markRecoveryComplete: (sessionID: string) => void
7+
}
8+
39
interface Todo {
410
content: string
511
status: string
@@ -32,13 +38,22 @@ function detectInterrupt(error: unknown): boolean {
3238
return false
3339
}
3440

35-
export function createTodoContinuationEnforcer(ctx: PluginInput) {
41+
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
3642
const remindedSessions = new Set<string>()
3743
const interruptedSessions = new Set<string>()
3844
const errorSessions = new Set<string>()
45+
const recoveringSessions = new Set<string>()
3946
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
4047

41-
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
48+
const markRecovering = (sessionID: string): void => {
49+
recoveringSessions.add(sessionID)
50+
}
51+
52+
const markRecoveryComplete = (sessionID: string): void => {
53+
recoveringSessions.delete(sessionID)
54+
}
55+
56+
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
4257
const props = event.properties as Record<string, unknown> | undefined
4358

4459
if (event.type === "session.error") {
@@ -73,6 +88,11 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
7388
const timer = setTimeout(async () => {
7489
pendingTimers.delete(sessionID)
7590

91+
// Check if session is in recovery mode - if so, skip entirely without clearing state
92+
if (recoveringSessions.has(sessionID)) {
93+
return
94+
}
95+
7696
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
7797

7898
interruptedSessions.delete(sessionID)
@@ -111,7 +131,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
111131
remindedSessions.add(sessionID)
112132

113133
// Re-check if abort occurred during the delay/fetch
114-
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
134+
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID) || recoveringSessions.has(sessionID)) {
115135
remindedSessions.delete(sessionID)
116136
return
117137
}
@@ -158,6 +178,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
158178
remindedSessions.delete(sessionInfo.id)
159179
interruptedSessions.delete(sessionInfo.id)
160180
errorSessions.delete(sessionInfo.id)
181+
recoveringSessions.delete(sessionInfo.id)
161182

162183
// Cancel pending continuation
163184
const timer = pendingTimers.get(sessionInfo.id)
@@ -168,4 +189,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
168189
}
169190
}
170191
}
192+
193+
return {
194+
handler,
195+
markRecovering,
196+
markRecoveryComplete,
197+
}
171198
}

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
151151
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
152152
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
153153
const sessionRecovery = createSessionRecoveryHook(ctx);
154+
155+
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
156+
// This prevents the continuation enforcer from injecting prompts during active recovery
157+
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
158+
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
159+
154160
const commentChecker = createCommentCheckerHooks();
155161
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
156162
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
@@ -248,7 +254,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
248254
await autoUpdateChecker.event(input);
249255
await claudeCodeHooks.event(input);
250256
await backgroundNotificationHook.event(input);
251-
await todoContinuationEnforcer(input);
257+
await todoContinuationEnforcer.handler(input);
252258
await contextWindowMonitor.event(input);
253259
await directoryAgentsInjector.event(input);
254260
await directoryReadmeInjector.event(input);

0 commit comments

Comments
 (0)