Compare commits
1 Commits
refactor/m
...
fix-1349
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb45b0ecee |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,3 +35,6 @@ test-injection/
|
|||||||
notepad.md
|
notepad.md
|
||||||
oauth-success.html
|
oauth-success.html
|
||||||
*.bun-build
|
*.bun-build
|
||||||
|
|
||||||
|
# Local test sandbox
|
||||||
|
.test-home/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
SisyphusAgentConfigSchema,
|
SisyphusAgentConfigSchema,
|
||||||
ExperimentalConfigSchema,
|
ExperimentalConfigSchema,
|
||||||
RalphLoopConfigSchema,
|
RalphLoopConfigSchema,
|
||||||
|
TodoContinuationConfigSchema,
|
||||||
TmuxConfigSchema,
|
TmuxConfigSchema,
|
||||||
TmuxLayoutSchema,
|
TmuxLayoutSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
@@ -25,6 +26,7 @@ export type {
|
|||||||
ExperimentalConfig,
|
ExperimentalConfig,
|
||||||
DynamicContextPruningConfig,
|
DynamicContextPruningConfig,
|
||||||
RalphLoopConfig,
|
RalphLoopConfig,
|
||||||
|
TodoContinuationConfig,
|
||||||
TmuxConfig,
|
TmuxConfig,
|
||||||
TmuxLayout,
|
TmuxLayout,
|
||||||
SisyphusConfig,
|
SisyphusConfig,
|
||||||
|
|||||||
@@ -322,6 +322,13 @@ export const RalphLoopConfigSchema = z.object({
|
|||||||
state_dir: z.string().optional(),
|
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({
|
export const BackgroundTaskConfigSchema = z.object({
|
||||||
defaultConcurrency: z.number().min(1).optional(),
|
defaultConcurrency: z.number().min(1).optional(),
|
||||||
providerConcurrency: z.record(z.string(), z.number().min(0)).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(),
|
auto_update: z.boolean().optional(),
|
||||||
skills: SkillsConfigSchema.optional(),
|
skills: SkillsConfigSchema.optional(),
|
||||||
ralph_loop: RalphLoopConfigSchema.optional(),
|
ralph_loop: RalphLoopConfigSchema.optional(),
|
||||||
|
todo_continuation: TodoContinuationConfigSchema.optional(),
|
||||||
background_task: BackgroundTaskConfigSchema.optional(),
|
background_task: BackgroundTaskConfigSchema.optional(),
|
||||||
notification: NotificationConfigSchema.optional(),
|
notification: NotificationConfigSchema.optional(),
|
||||||
babysitting: BabysittingConfigSchema.optional(),
|
babysitting: BabysittingConfigSchema.optional(),
|
||||||
@@ -446,6 +454,7 @@ export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningCo
|
|||||||
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
|
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
|
||||||
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
|
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
|
||||||
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
||||||
|
export type TodoContinuationConfig = z.infer<typeof TodoContinuationConfigSchema>
|
||||||
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
|
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
|
||||||
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
|
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
|
||||||
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
|
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
|
||||||
|
|||||||
@@ -1313,4 +1313,64 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
// then - no continuation injected (all countdowns cancelled)
|
// then - no continuation injected (all countdowns cancelled)
|
||||||
expect(promptCalls).toHaveLength(0)
|
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 { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { TodoContinuationConfig } from "../config"
|
||||||
import { existsSync, readdirSync } from "node:fs"
|
import { existsSync, readdirSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { BackgroundManager } from "../features/background-agent"
|
import type { BackgroundManager } from "../features/background-agent"
|
||||||
@@ -19,6 +20,7 @@ export interface TodoContinuationEnforcerOptions {
|
|||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
skipAgents?: string[]
|
skipAgents?: string[]
|
||||||
isContinuationStopped?: (sessionID: string) => boolean
|
isContinuationStopped?: (sessionID: string) => boolean
|
||||||
|
config?: TodoContinuationConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoContinuationEnforcer {
|
export interface TodoContinuationEnforcer {
|
||||||
@@ -41,6 +43,10 @@ interface SessionState {
|
|||||||
isRecovering?: boolean
|
isRecovering?: boolean
|
||||||
countdownStartedAt?: number
|
countdownStartedAt?: number
|
||||||
abortDetectedAt?: number
|
abortDetectedAt?: number
|
||||||
|
injectionCount?: number
|
||||||
|
staleInjectionCount?: number
|
||||||
|
lastIncompleteCount?: number
|
||||||
|
circuitBroken?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
|
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
|
||||||
@@ -97,9 +103,17 @@ export function createTodoContinuationEnforcer(
|
|||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options: TodoContinuationEnforcerOptions = {}
|
options: TodoContinuationEnforcerOptions = {}
|
||||||
): TodoContinuationEnforcer {
|
): 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 sessions = new Map<string, SessionState>()
|
||||||
|
|
||||||
|
const maxInjections = config?.max_injections ?? 8
|
||||||
|
const maxStaleInjections = config?.max_stale_injections ?? 3
|
||||||
|
|
||||||
function getState(sessionID: string): SessionState {
|
function getState(sessionID: string): SessionState {
|
||||||
let state = sessions.get(sessionID)
|
let state = sessions.get(sessionID)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -129,6 +143,45 @@ export function createTodoContinuationEnforcer(
|
|||||||
sessions.delete(sessionID)
|
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 markRecovering = (sessionID: string): void => {
|
||||||
const state = getState(sessionID)
|
const state = getState(sessionID)
|
||||||
state.isRecovering = true
|
state.isRecovering = true
|
||||||
@@ -169,6 +222,20 @@ export function createTodoContinuationEnforcer(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const state = sessions.get(sessionID)
|
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) {
|
if (state?.isRecovering) {
|
||||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||||
return
|
return
|
||||||
@@ -198,6 +265,25 @@ export function createTodoContinuationEnforcer(
|
|||||||
return
|
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 agentName = resolvedInfo?.agent
|
||||||
let model = resolvedInfo?.model
|
let model = resolvedInfo?.model
|
||||||
let tools = resolvedInfo?.tools
|
let tools = resolvedInfo?.tools
|
||||||
@@ -245,6 +331,9 @@ ${todoList}`
|
|||||||
try {
|
try {
|
||||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
|
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({
|
await ctx.client.session.promptAsync({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
@@ -325,6 +414,11 @@ ${todoList}`
|
|||||||
|
|
||||||
const state = getState(sessionID)
|
const state = getState(sessionID)
|
||||||
|
|
||||||
|
if (state.circuitBroken) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: circuit breaker active`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (state.isRecovering) {
|
if (state.isRecovering) {
|
||||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||||
return
|
return
|
||||||
@@ -448,6 +542,7 @@ ${todoList}`
|
|||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
if (role === "user") {
|
if (role === "user") {
|
||||||
|
resetCircuitBreaker(sessionID)
|
||||||
const state = sessions.get(sessionID)
|
const state = sessions.get(sessionID)
|
||||||
if (state?.countdownStartedAt) {
|
if (state?.countdownStartedAt) {
|
||||||
const elapsed = Date.now() - 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 type { BackgroundTask } from "../../features/background-agent"
|
||||||
import { createUnstableAgentBabysitterHook } from "./index"
|
import { createUnstableAgentBabysitterHook } from "./index"
|
||||||
|
|
||||||
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
const projectDir = "/tmp/fix-1349"
|
||||||
|
|
||||||
type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
|
type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
|
||||||
|
|
||||||
@@ -21,6 +21,9 @@ function createMockPluginInput(options: {
|
|||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
promptCalls.push({ input })
|
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, {
|
? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, {
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||||
|
config: pluginConfig.todo_continuation,
|
||||||
}), { enabled: safeHookEnabled })
|
}), { enabled: safeHookEnabled })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user