Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ test-injection/
notepad.md
oauth-success.html
*.bun-build

# Local test sandbox
.test-home/
2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TodoContinuationConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
} from "./schema"
Expand All @@ -25,6 +26,7 @@ export type {
ExperimentalConfig,
DynamicContextPruningConfig,
RalphLoopConfig,
TodoContinuationConfig,
TmuxConfig,
TmuxLayout,
SisyphusConfig,
Expand Down
9 changes: 9 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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>
Expand Down
60 changes: 60 additions & 0 deletions src/hooks/todo-continuation-enforcer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
97 changes: 96 additions & 1 deletion src/hooks/todo-continuation-enforcer.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -19,6 +20,7 @@ export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
skipAgents?: string[]
isContinuationStopped?: (sessionID: string) => boolean
config?: TodoContinuationConfig
}

export interface TodoContinuationEnforcer {
Expand All @@ -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)}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/unstable-agent-babysitter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -21,6 +21,9 @@ function createMockPluginInput(options: {
prompt: async (input: unknown) => {
promptCalls.push({ input })
},
promptAsync: async (input: unknown) => {
promptCalls.push({ input })
},
},
},
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down