Compare commits

...

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
bb45b0ecee fix: add iteration limits to prevent infinite loops (#1349) 2026-02-08 15:49:48 +09:00
7 changed files with 175 additions and 2 deletions

3
.gitignore vendored
View File

@@ -35,3 +35,6 @@ test-injection/
notepad.md
oauth-success.html
*.bun-build
# Local test sandbox
.test-home/

View File

@@ -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,

View File

@@ -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<typeof DynamicContextPruningCo
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export type TodoContinuationConfig = z.infer<typeof TodoContinuationConfigSchema>
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>

View File

@@ -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 })
})

View File

@@ -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<string, SessionState>()
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<void> {
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<void> {
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

View File

@@ -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<typeof createUnstableAgentBabysitterHook>[0]
@@ -21,6 +21,9 @@ function createMockPluginInput(options: {
prompt: async (input: unknown) => {
promptCalls.push({ input })
},
promptAsync: async (input: unknown) => {
promptCalls.push({ input })
},
},
},
}

View File

@@ -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;