diff --git a/.gitignore b/.gitignore index 212fbbe008..7765fe89ae 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ test-injection/ notepad.md oauth-success.html *.bun-build + +# Local test sandbox +.test-home/ diff --git a/src/config/index.ts b/src/config/index.ts index 5f881831ba..8097735270 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -9,6 +9,7 @@ export { SisyphusAgentConfigSchema, ExperimentalConfigSchema, RalphLoopConfigSchema, + TodoContinuationConfigSchema, TmuxConfigSchema, TmuxLayoutSchema, } from "./schema" @@ -25,6 +26,7 @@ export type { ExperimentalConfig, DynamicContextPruningConfig, RalphLoopConfig, + TodoContinuationConfig, TmuxConfig, TmuxLayout, SisyphusConfig, diff --git a/src/config/schema.ts b/src/config/schema.ts index 34ec376be9..241a1f13f0 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -322,6 +322,13 @@ export const RalphLoopConfigSchema = z.object({ state_dir: z.string().optional(), }) +export const TodoContinuationConfigSchema = z.object({ + /** Max continuation injections per session before stopping (default: 8) */ + max_injections: z.number().min(1).max(1000).default(8), + /** Max consecutive injections with no todo progress before stopping (default: 3) */ + max_stale_injections: z.number().min(0).max(1000).default(3), +}) + export const BackgroundTaskConfigSchema = z.object({ defaultConcurrency: z.number().min(1).optional(), providerConcurrency: z.record(z.string(), z.number().min(0)).optional(), @@ -419,6 +426,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ auto_update: z.boolean().optional(), skills: SkillsConfigSchema.optional(), ralph_loop: RalphLoopConfigSchema.optional(), + todo_continuation: TodoContinuationConfigSchema.optional(), background_task: BackgroundTaskConfigSchema.optional(), notification: NotificationConfigSchema.optional(), babysitting: BabysittingConfigSchema.optional(), @@ -446,6 +454,7 @@ export type DynamicContextPruningConfig = z.infer export type SkillDefinition = z.infer export type RalphLoopConfig = z.infer +export type TodoContinuationConfig = z.infer export type NotificationConfig = z.infer export type BabysittingConfig = z.infer export type CategoryConfig = z.infer diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer.test.ts index 626d5c951b..8942219f46 100644 --- a/src/hooks/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer.test.ts @@ -1313,4 +1313,64 @@ describe("todo-continuation-enforcer", () => { // then - no continuation injected (all countdowns cancelled) expect(promptCalls).toHaveLength(0) }) + + test("should stop injecting after max injections reached", async () => { + // given - session with incomplete todos and low injection cap + const sessionID = "main-max-injections" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), { + config: { max_injections: 2, max_stale_injections: 100 }, + }) + + // when - idle cycles happen repeatedly + for (let i = 0; i < 3; i++) { + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await fakeTimers.advanceBy(2500) + } + + // then - only 2 injections occur + expect(promptCalls).toHaveLength(2) + expect(toastCalls.some((t) => t.title === "Todo Continuation Stopped")).toBe(true) + }, { timeout: 15000 }) + + test("should stop injecting when stale injections exceed limit and reset on progress", async () => { + // given - session with a progress drop after first injection + const sessionID = "main-stale-breaker" + setMainSession(sessionID) + + const mockInput = createMockPluginInput() + mockInput.client.session.todo = async () => { + // before first injection: 2 pending, after: 1 pending + return { + data: promptCalls.length === 0 + ? [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "pending", priority: "high" }, + ] + : [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "completed", priority: "medium" }, + ], + } + } + + const hook = createTodoContinuationEnforcer(mockInput, { + config: { max_injections: 100, max_stale_injections: 1 }, + }) + + // when - three idle cycles happen + for (let i = 0; i < 3; i++) { + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + await fakeTimers.advanceBy(2500) + } + + // then - progress allows a second injection, but the third is blocked as stale + expect(promptCalls).toHaveLength(2) + expect(toastCalls.some((t) => t.title === "Todo Continuation Stopped")).toBe(true) + }, { timeout: 15000 }) }) diff --git a/src/hooks/todo-continuation-enforcer.ts b/src/hooks/todo-continuation-enforcer.ts index 3e3736beb5..940da81d83 100644 --- a/src/hooks/todo-continuation-enforcer.ts +++ b/src/hooks/todo-continuation-enforcer.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import type { TodoContinuationConfig } from "../config" import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import type { BackgroundManager } from "../features/background-agent" @@ -19,6 +20,7 @@ export interface TodoContinuationEnforcerOptions { backgroundManager?: BackgroundManager skipAgents?: string[] isContinuationStopped?: (sessionID: string) => boolean + config?: TodoContinuationConfig } export interface TodoContinuationEnforcer { @@ -41,6 +43,10 @@ interface SessionState { isRecovering?: boolean countdownStartedAt?: number abortDetectedAt?: number + injectionCount?: number + staleInjectionCount?: number + lastIncompleteCount?: number + circuitBroken?: boolean } const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)} @@ -97,9 +103,17 @@ export function createTodoContinuationEnforcer( ctx: PluginInput, options: TodoContinuationEnforcerOptions = {} ): TodoContinuationEnforcer { - const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options + const { + backgroundManager, + skipAgents = DEFAULT_SKIP_AGENTS, + isContinuationStopped, + config, + } = options const sessions = new Map() + const maxInjections = config?.max_injections ?? 8 + const maxStaleInjections = config?.max_stale_injections ?? 3 + function getState(sessionID: string): SessionState { let state = sessions.get(sessionID) if (!state) { @@ -129,6 +143,45 @@ export function createTodoContinuationEnforcer( sessions.delete(sessionID) } + function resetCircuitBreaker(sessionID: string): void { + const state = sessions.get(sessionID) + if (!state) return + state.injectionCount = 0 + state.staleInjectionCount = 0 + state.lastIncompleteCount = undefined + state.circuitBroken = false + } + + async function tripCircuitBreaker( + sessionID: string, + reason: string, + incompleteCount: number + ): Promise { + const state = getState(sessionID) + if (state.circuitBroken) return + state.circuitBroken = true + cancelCountdown(sessionID) + + log(`[${HOOK_NAME}] Circuit breaker tripped`, { + sessionID, + reason, + injectionCount: state.injectionCount, + staleInjectionCount: state.staleInjectionCount, + incompleteCount, + maxInjections, + maxStaleInjections, + }) + + await ctx.client.tui.showToast({ + body: { + title: "Todo Continuation Stopped", + message: reason, + variant: "warning" as const, + duration: 5000, + }, + }).catch(() => {}) + } + const markRecovering = (sessionID: string): void => { const state = getState(sessionID) state.isRecovering = true @@ -169,6 +222,20 @@ export function createTodoContinuationEnforcer( ): Promise { const state = sessions.get(sessionID) + if (state?.circuitBroken) { + log(`[${HOOK_NAME}] Skipped injection: circuit breaker active`, { sessionID }) + return + } + + if ((state?.injectionCount ?? 0) >= maxInjections) { + await tripCircuitBreaker( + sessionID, + `Max injections (${maxInjections}) reached without todo completion progress`, + incompleteCount + ) + return + } + if (state?.isRecovering) { log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID }) return @@ -198,6 +265,25 @@ export function createTodoContinuationEnforcer( return } + const currentState = getState(sessionID) + if (typeof currentState.lastIncompleteCount === "number") { + if (freshIncompleteCount < currentState.lastIncompleteCount) { + currentState.staleInjectionCount = 0 + } else { + currentState.staleInjectionCount = (currentState.staleInjectionCount ?? 0) + 1 + } + } + currentState.lastIncompleteCount = freshIncompleteCount + + if (maxStaleInjections > 0 && (currentState.staleInjectionCount ?? 0) >= maxStaleInjections) { + await tripCircuitBreaker( + sessionID, + `No todo progress detected for ${maxStaleInjections} consecutive continuation(s); stopping to prevent infinite loop`, + freshIncompleteCount + ) + return + } + let agentName = resolvedInfo?.agent let model = resolvedInfo?.model let tools = resolvedInfo?.tools @@ -245,6 +331,9 @@ ${todoList}` try { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount }) + const nextCount = (currentState.injectionCount ?? 0) + 1 + currentState.injectionCount = nextCount + await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { @@ -325,6 +414,11 @@ ${todoList}` const state = getState(sessionID) + if (state.circuitBroken) { + log(`[${HOOK_NAME}] Skipped: circuit breaker active`, { sessionID }) + return + } + if (state.isRecovering) { log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) return @@ -448,6 +542,7 @@ ${todoList}` if (!sessionID) return if (role === "user") { + resetCircuitBreaker(sessionID) const state = sessions.get(sessionID) if (state?.countdownStartedAt) { const elapsed = Date.now() - state.countdownStartedAt diff --git a/src/hooks/unstable-agent-babysitter/index.test.ts b/src/hooks/unstable-agent-babysitter/index.test.ts index f9900e7d5c..d371a41483 100644 --- a/src/hooks/unstable-agent-babysitter/index.test.ts +++ b/src/hooks/unstable-agent-babysitter/index.test.ts @@ -2,7 +2,7 @@ import { _resetForTesting, setMainSession } from "../../features/claude-code-ses import type { BackgroundTask } from "../../features/background-agent" import { createUnstableAgentBabysitterHook } from "./index" -const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode" +const projectDir = "/tmp/fix-1349" type BabysitterContext = Parameters[0] @@ -21,6 +21,9 @@ function createMockPluginInput(options: { prompt: async (input: unknown) => { promptCalls.push({ input }) }, + promptAsync: async (input: unknown) => { + promptCalls.push({ input }) + }, }, }, } diff --git a/src/index.ts b/src/index.ts index db49858c34..74b3fd0ba5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -365,6 +365,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, { backgroundManager, isContinuationStopped: stopContinuationGuard?.isStopped, + config: pluginConfig.todo_continuation, }), { enabled: safeHookEnabled }) : null;