Compare commits
1 Commits
feat/runti
...
fix-1349
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb45b0ecee |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,3 +35,6 @@ test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
*.bun-build
|
||||
|
||||
# Local test sandbox
|
||||
.test-home/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user