Compare commits

..

7 Commits

Author SHA1 Message Date
YeonGyu-Kim
7874669de0 feat(call-omo-agent): block sync subagent depth overflows
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:22 +09:00
YeonGyu-Kim
461af467b3 docs(call-omo-agent): mention nested spawn depth limits
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:22 +09:00
YeonGyu-Kim
98e24baef0 feat(task): validate sync delegation spawn depth
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:22 +09:00
YeonGyu-Kim
50a2264d75 feat(background-agent): enforce launch depth and descendant limits
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:22 +09:00
YeonGyu-Kim
f28d0cddde feat(background-agent): track spawn depth on tasks
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:21 +09:00
YeonGyu-Kim
b4aac44f0d feat(background-agent): add subagent spawn context resolver
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:21 +09:00
YeonGyu-Kim
b9f80a87b5 feat(background-task): add spawn limit config fields
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-08 02:22:21 +09:00
13 changed files with 426 additions and 192 deletions

View File

@@ -3678,6 +3678,16 @@
"minimum": 0
}
},
"maxDepth": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"maxDescendants": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"staleTimeoutMs": {
"type": "number",
"minimum": 60000

View File

@@ -3,6 +3,54 @@ import { ZodError } from "zod/v4"
import { BackgroundTaskConfigSchema } from "./background-task"
describe("BackgroundTaskConfigSchema", () => {
describe("maxDepth", () => {
describe("#given valid maxDepth (3)", () => {
test("#when parsed #then returns correct value", () => {
const result = BackgroundTaskConfigSchema.parse({ maxDepth: 3 })
expect(result.maxDepth).toBe(3)
})
})
describe("#given maxDepth below minimum (0)", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ maxDepth: 0 })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})
describe("maxDescendants", () => {
describe("#given valid maxDescendants (50)", () => {
test("#when parsed #then returns correct value", () => {
const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 })
expect(result.maxDescendants).toBe(50)
})
})
describe("#given maxDescendants below minimum (0)", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ maxDescendants: 0 })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})
describe("syncPollTimeoutMs", () => {
describe("#given valid syncPollTimeoutMs (120000)", () => {
test("#when parsed #then returns correct value", () => {

View File

@@ -4,6 +4,8 @@ export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
maxDepth: z.number().int().min(1).optional(),
maxDescendants: z.number().int().min(1).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */

View File

@@ -224,12 +224,6 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
}
async function flushBackgroundNotifications(): Promise<void> {
for (let i = 0; i < 6; i++) {
await Promise.resolve()
}
}
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
_resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager({
@@ -1312,20 +1306,11 @@ describe("BackgroundManager.tryCompleteTask", () => {
expect(abortedSessionIDs).toEqual(["session-1"])
})
test("should clean pendingByParent even when promptAsync notification fails", async () => {
test("should clean pendingByParent even when notifyParentSession throws", async () => {
// given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => {
throw new Error("notify failed")
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {
throw new Error("notify failed")
}
manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-pending-cleanup",
@@ -1439,7 +1424,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
// then
expect(rejectedCount).toBe(0)
expect(promptBodies.length).toBe(2)
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
expect(promptBodies.some((b) => b.noReply === false)).toBe(true)
})
})
@@ -1652,6 +1637,25 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}
}
function createMockClientWithSessionChain(
sessions: Record<string, { directory: string; parentID?: string }>
) {
return {
session: {
create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async ({ path }: { path: { id: string } }) => ({
data: sessions[path.id] ?? { directory: "/test/dir" },
}),
prompt: async () => ({}),
promptAsync: async () => ({}),
messages: async () => ({ data: [] }),
todo: async () => ({ data: [] }),
status: async () => ({ data: {} }),
abort: async () => ({}),
},
}
}
beforeEach(() => {
// given
mockClient = createMockClient()
@@ -1846,6 +1850,98 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(updatedTask.startedAt.getTime()).toBeGreaterThanOrEqual(queuedAt.getTime())
}
})
test("should track rootSessionID and spawnDepth from the parent chain", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDepth: 3 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-depth-2",
parentMessageID: "parent-message",
}
// when
const task = await manager.launch(input)
// then
expect(task.rootSessionID).toBe("session-root")
expect(task.spawnDepth).toBe(3)
})
test("should block launches that exceed maxDepth", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" },
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDepth: 3 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-depth-3",
parentMessageID: "parent-message",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDepth=3")
})
test("should block launches when maxDescendants is reached", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
await manager.launch(input)
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
})
})
describe("pending task can be cancelled", () => {
@@ -1947,6 +2043,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
test("should cancel running task and release concurrency", async () => {
// given
const manager = createBackgroundManager()
stubNotifyParentSession(manager)
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
@@ -2904,7 +3001,7 @@ describe("BackgroundManager.shutdown session abort", () => {
})
describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
test("should cancel descendant tasks and keep them until delayed cleanup", async () => {
test("should cancel descendant tasks when parent session is deleted", () => {
// given
const manager = createBackgroundManager()
const parentSessionID = "session-parent"
@@ -2951,26 +3048,21 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
properties: { info: { id: parentSessionID } },
})
await flushBackgroundNotifications()
// then
expect(taskMap.has(childTask.id)).toBe(true)
expect(taskMap.has(siblingTask.id)).toBe(true)
expect(taskMap.has(grandchildTask.id)).toBe(true)
expect(taskMap.has(childTask.id)).toBe(false)
expect(taskMap.has(siblingTask.id)).toBe(false)
expect(taskMap.has(grandchildTask.id)).toBe(false)
expect(taskMap.has(unrelatedTask.id)).toBe(true)
expect(childTask.status).toBe("cancelled")
expect(siblingTask.status).toBe("cancelled")
expect(grandchildTask.status).toBe("cancelled")
expect(pendingByParent.get(parentSessionID)).toBeUndefined()
expect(pendingByParent.get("session-child")).toBeUndefined()
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(siblingTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
manager.shutdown()
})
test("should remove cancelled tasks from toast manager while preserving delayed cleanup", async () => {
test("should remove tasks from toast manager when session is deleted", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
@@ -2999,13 +3091,9 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
properties: { info: { id: parentSessionID } },
})
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(childTask.id)
expect(removeTaskCalls).toContain(grandchildTask.id)
expect(getCompletionTimers(manager).has(childTask.id)).toBe(true)
expect(getCompletionTimers(manager).has(grandchildTask.id)).toBe(true)
manager.shutdown()
resetToastManager()
@@ -3068,7 +3156,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
return task
}
test("sets task to error, releases concurrency, and keeps it until delayed cleanup", async () => {
test("sets task to error, releases concurrency, and cleans up", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
@@ -3101,21 +3189,18 @@ describe("BackgroundManager.handleEvent - session.error", () => {
},
})
await flushBackgroundNotifications()
//#then
expect(task.status).toBe("error")
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
expect(task.completedAt).toBeInstanceOf(Date)
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getTaskMap(manager).has(task.id)).toBe(true)
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
test("should remove errored task from toast manager while preserving delayed cleanup", async () => {
test("removes errored task from toast manager", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
@@ -3137,11 +3222,8 @@ describe("BackgroundManager.handleEvent - session.error", () => {
},
})
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(task.id)
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
resetToastManager()
@@ -3422,7 +3504,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown()
})
test("removes stale task from toast manager", async () => {
test("removes stale task from toast manager", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
@@ -3437,7 +3519,6 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
//#then
expect(removeTaskCalls).toContain(staleTask.id)
@@ -3445,53 +3526,6 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
manager.shutdown()
resetToastManager()
})
test("keeps stale task until notification cleanup after notifying parent", async () => {
//#given
const notifications: string[] = []
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const client = {
session: {
prompt: async () => ({}),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> & { noReply?: boolean; parts?: unknown[] } }) => {
const firstPart = args.body.parts?.[0]
if (firstPart && typeof firstPart === "object" && "text" in firstPart && typeof firstPart.text === "string") {
notifications.push(firstPart.text)
}
return {}
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const staleTask = createMockTask({
id: "task-stale-notify-cleanup",
sessionID: "session-stale-notify-cleanup",
parentSessionID: "parent-stale-notify-cleanup",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
getTaskMap(manager).set(staleTask.id, staleTask)
getPendingByParent(manager).set(staleTask.parentSessionID, new Set([staleTask.id]))
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
//#then
const retainedTask = getTaskMap(manager).get(staleTask.id)
expect(retainedTask?.status).toBe("error")
expect(getTaskMap(manager).has(staleTask.id)).toBe(true)
expect(notifications).toHaveLength(1)
expect(notifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
expect(notifications[0]).toContain(staleTask.description)
expect(getCompletionTimers(manager).has(staleTask.id)).toBe(true)
expect(removeTaskCalls).toContain(staleTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
@@ -3595,7 +3629,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
expect(completionTimers.size).toBe(0)
})
test("should preserve cleanup timer when terminal task session is deleted", () => {
test("should cancel timer when task is deleted via session.deleted", () => {
// given
const manager = createBackgroundManager()
const task: BackgroundTask = {
@@ -3624,7 +3658,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
})
// then
expect(completionTimers.has(task.id)).toBe(true)
expect(completionTimers.has(task.id)).toBe(false)
manager.shutdown()
})

View File

@@ -47,6 +47,14 @@ import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import {
createSubagentDepthLimitError,
createSubagentDescendantLimitError,
getMaxRootDescendants,
getMaxSubagentDepth,
resolveSubagentSpawnContext,
type SubagentSpawnContext,
} from "./subagent-spawn-limits"
type OpencodeClient = PluginInput["client"]
@@ -111,6 +119,7 @@ export class BackgroundManager {
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private rootDescendantCounts: Map<string, number>
private enableParentSessionNotifications: boolean
readonly taskHistory = new TaskHistory()
@@ -135,10 +144,42 @@ export class BackgroundManager {
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
this.onShutdown = options?.onShutdown
this.rootDescendantCounts = new Map()
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
this.registerProcessCleanup()
}
async assertCanSpawn(parentSessionID: string): Promise<SubagentSpawnContext> {
const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID)
const maxDepth = getMaxSubagentDepth(this.config)
if (spawnContext.childDepth > maxDepth) {
throw createSubagentDepthLimitError({
childDepth: spawnContext.childDepth,
maxDepth,
parentSessionID,
rootSessionID: spawnContext.rootSessionID,
})
}
const maxDescendants = getMaxRootDescendants(this.config)
const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
if (descendantCount >= maxDescendants) {
throw createSubagentDescendantLimitError({
rootSessionID: spawnContext.rootSessionID,
descendantCount,
maxDescendants,
})
}
return spawnContext
}
private registerRootDescendant(rootSessionID: string): number {
const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
this.rootDescendantCounts.set(rootSessionID, nextCount)
return nextCount
}
async launch(input: LaunchInput): Promise<BackgroundTask> {
log("[background-agent] launch() called with:", {
agent: input.agent,
@@ -151,16 +192,28 @@ export class BackgroundManager {
throw new Error("Agent parameter is required")
}
const spawnContext = await this.assertCanSpawn(input.parentSessionID)
const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
log("[background-agent] spawn guard passed", {
parentSessionID: input.parentSessionID,
rootSessionID: spawnContext.rootSessionID,
childDepth: spawnContext.childDepth,
descendantCount,
})
// Create task immediately with status="pending"
const task: BackgroundTask = {
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
status: "pending",
queuedAt: new Date(),
rootSessionID: spawnContext.rootSessionID,
// Do NOT set startedAt - will be set when running
// Do NOT set sessionID - will be set when running
description: input.description,
prompt: input.prompt,
agent: input.agent,
spawnDepth: spawnContext.childDepth,
parentSessionID: input.parentSessionID,
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
@@ -205,7 +258,7 @@ export class BackgroundManager {
// Trigger processing (fire-and-forget)
this.processKey(key)
return task
return { ...task }
}
private async processKey(key: string): Promise<void> {
@@ -390,6 +443,7 @@ export class BackgroundManager {
}).catch(() => {})
this.markForNotification(existingTask)
this.cleanupPendingByParent(existingTask)
this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
log("[background-agent] Failed to notify on error:", err)
})
@@ -660,6 +714,7 @@ export class BackgroundManager {
}
this.markForNotification(existingTask)
this.cleanupPendingByParent(existingTask)
this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
log("[background-agent] Failed to notify on resume error:", err)
})
@@ -802,14 +857,16 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
if (task.sessionID) {
SessionCategoryRegistry.remove(task.sessionID)
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for errored task:", { taskId: task.id, error: err })
})
}
if (event.type === "session.deleted") {
@@ -830,30 +887,48 @@ export class BackgroundManager {
if (tasksToCancel.size === 0) return
const deletedSessionIDs = new Set<string>([sessionID])
for (const task of tasksToCancel.values()) {
if (task.sessionID) {
deletedSessionIDs.add(task.sessionID)
}
}
for (const task of tasksToCancel.values()) {
if (task.status === "running" || task.status === "pending") {
void this.cancelTask(task.id, {
source: "session.deleted",
reason: "Session deleted",
}).then(() => {
if (deletedSessionIDs.has(task.parentSessionID)) {
this.pendingNotifications.delete(task.parentSessionID)
}
skipNotification: true,
}).catch(err => {
if (deletedSessionIDs.has(task.parentSessionID)) {
this.pendingNotifications.delete(task.parentSessionID)
}
log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err })
})
}
const existingTimer = this.completionTimers.get(task.id)
if (existingTimer) {
clearTimeout(existingTimer)
this.completionTimers.delete(task.id)
}
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}
for (const task of tasksToCancel.values()) {
if (task.parentSessionID) {
this.pendingNotifications.delete(task.parentSessionID)
}
}
this.rootDescendantCounts.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
@@ -1073,6 +1148,8 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
if (abortSession && task.sessionID) {
this.client.session.abort({
path: { id: task.sessionID },
@@ -1179,6 +1256,9 @@ export class BackgroundManager {
this.markForNotification(task)
// Ensure pending tracking is cleaned up even if notification fails
this.cleanupPendingByParent(task)
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
@@ -1234,10 +1314,7 @@ export class BackgroundManager {
this.pendingByParent.delete(task.parentSessionID)
}
} else {
remainingCount = Array.from(this.tasks.values())
.filter(t => t.parentSessionID === task.parentSessionID && t.id !== task.id && (t.status === "running" || t.status === "pending"))
.length
allComplete = remainingCount === 0
allComplete = true
}
const completedTasks = allComplete
@@ -1245,13 +1322,7 @@ export class BackgroundManager {
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
: []
const statusText = task.status === "completed"
? "COMPLETED"
: task.status === "interrupt"
? "INTERRUPTED"
: task.status === "error"
? "ERROR"
: "CANCELLED"
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
let notification: string
@@ -1382,13 +1453,8 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
const timer = setTimeout(() => {
this.completionTimers.delete(taskId)
const taskToRemove = this.tasks.get(taskId)
if (taskToRemove) {
if (this.tasks.has(taskId)) {
this.clearNotificationsForTask(taskId)
if (taskToRemove.sessionID) {
subagentSessions.delete(taskToRemove.sessionID)
SessionCategoryRegistry.remove(taskToRemove.sessionID)
}
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
@@ -1423,21 +1489,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.status = "error"
task.error = errorMessage
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
const existingTimer = this.completionTimers.get(taskId)
if (existingTimer) {
clearTimeout(existingTimer)
this.completionTimers.delete(taskId)
}
const idleTimer = this.idleDeferralTimers.get(taskId)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(taskId)
}
this.cleanupPendingByParent(task)
if (wasPending) {
const key = task.model
? `${task.model.providerID}/${task.model.modelID}`
@@ -1453,10 +1509,16 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
})
this.clearNotificationsForTask(taskId)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
this.tasks.delete(taskId)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
},
})
}
@@ -1601,6 +1663,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.pendingNotifications.clear()
this.pendingByParent.clear()
this.notificationQueueByParent.clear()
this.rootDescendantCounts.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
this.unregisterProcessCleanup()

View File

@@ -0,0 +1,79 @@
import type { BackgroundTaskConfig } from "../../config/schema"
import type { OpencodeClient } from "./constants"
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3
export const DEFAULT_MAX_ROOT_DESCENDANTS = 50
export interface SubagentSpawnContext {
rootSessionID: string
parentDepth: number
childDepth: number
}
export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number {
return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH
}
export function getMaxRootDescendants(config?: BackgroundTaskConfig): number {
return config?.maxDescendants ?? DEFAULT_MAX_ROOT_DESCENDANTS
}
export async function resolveSubagentSpawnContext(
client: OpencodeClient,
parentSessionID: string
): Promise<SubagentSpawnContext> {
const visitedSessionIDs = new Set<string>()
let rootSessionID = parentSessionID
let currentSessionID = parentSessionID
let parentDepth = 0
while (true) {
if (visitedSessionIDs.has(currentSessionID)) {
throw new Error(`Detected a session parent cycle while resolving ${parentSessionID}`)
}
visitedSessionIDs.add(currentSessionID)
const session = await client.session.get({
path: { id: currentSessionID },
}).catch(() => null)
const nextParentSessionID = session?.data?.parentID
if (!nextParentSessionID) {
rootSessionID = currentSessionID
break
}
currentSessionID = nextParentSessionID
parentDepth += 1
}
return {
rootSessionID,
parentDepth,
childDepth: parentDepth + 1,
}
}
export function createSubagentDepthLimitError(input: {
childDepth: number
maxDepth: number
parentSessionID: string
rootSessionID: string
}): Error {
const { childDepth, maxDepth, parentSessionID, rootSessionID } = input
return new Error(
`Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.`
)
}
export function createSubagentDescendantLimitError(input: {
rootSessionID: string
descendantCount: number
maxDescendants: number
}): Error {
const { rootSessionID, descendantCount, maxDescendants } = input
return new Error(
`Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.`
)
}

View File

@@ -422,38 +422,4 @@ describe("pruneStaleTasksAndNotifications", () => {
//#then
expect(pruned).toContain("old-task")
})
it("should skip terminal tasks even when they exceeded TTL", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
const oldStartedAt = new Date(Date.now() - 31 * 60 * 1000)
const terminalStatuses: BackgroundTask["status"][] = ["completed", "error", "cancelled", "interrupt"]
for (const status of terminalStatuses) {
tasks.set(status, {
id: status,
parentSessionID: "parent",
parentMessageID: "msg",
description: status,
prompt: status,
agent: "explore",
status,
startedAt: oldStartedAt,
completedAt: new Date(),
})
}
const pruned: string[] = []
//#when
pruneStaleTasksAndNotifications({
tasks,
notifications: new Map<string, BackgroundTask[]>(),
onTaskPruned: (taskId) => pruned.push(taskId),
})
//#then
expect(pruned).toEqual([])
expect(Array.from(tasks.keys())).toEqual(terminalStatuses)
})
})

View File

@@ -12,13 +12,6 @@ import {
TASK_TTL_MS,
} from "./constants"
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",
"cancelled",
"interrupt",
])
export function pruneStaleTasksAndNotifications(args: {
tasks: Map<string, BackgroundTask>
notifications: Map<string, BackgroundTask[]>
@@ -28,8 +21,6 @@ export function pruneStaleTasksAndNotifications(args: {
const now = Date.now()
for (const [taskId, task] of tasks.entries()) {
if (TERMINAL_TASK_STATUSES.has(task.status)) continue
const timestamp = task.status === "pending"
? task.queuedAt?.getTime()
: task.startedAt?.getTime()

View File

@@ -19,11 +19,13 @@ export interface TaskProgress {
export interface BackgroundTask {
id: string
sessionID?: string
rootSessionID?: string
parentSessionID: string
parentMessageID: string
description: string
prompt: string
agent: string
spawnDepth?: number
status: BackgroundTaskStatus
queuedAt?: Date
startedAt?: Date

View File

@@ -12,4 +12,4 @@ export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent. run_in
Available: {agents}
Pass \`session_id=<id>\` to continue previous agent with full context. Prompts MUST be in English. Use \`background_output\` for async results.`
Pass \`session_id=<id>\` to continue previous agent with full context. Nested subagent depth is tracked automatically and blocked past the configured limit. Prompts MUST be in English. Use \`background_output\` for async results.`

View File

@@ -4,12 +4,14 @@ import type { BackgroundManager } from "../../features/background-agent"
import { createCallOmoAgent } from "./tools"
describe("createCallOmoAgent", () => {
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
const mockCtx = {
client: {},
directory: "/test",
} as unknown as PluginInput
const mockBackgroundManager = {
assertCanSpawn: assertCanSpawnMock,
launch: mock(() => Promise.resolve({
id: "test-task-id",
sessionID: null,
@@ -99,4 +101,25 @@ describe("createCallOmoAgent", () => {
//#then
expect(result).not.toContain("disabled via disabled_agents")
})
test("should return a tool error when sync spawn depth validation fails", async () => {
//#given
assertCanSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: false,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal },
)
//#then
expect(result).toContain("background_task.maxDepth=3")
})
})

View File

@@ -57,6 +57,14 @@ export function createCallOmoAgent(
return await executeBackground(args, toolCtx, backgroundManager, ctx.client)
}
if (!args.session_id) {
try {
await backgroundManager.assertCanSpawn(toolCtx.sessionID)
} catch (error) {
return `Error: ${error instanceof Error ? error.message : String(error)}`
}
}
return await executeSync(args, toolCtx, ctx)
},
})

View File

@@ -23,12 +23,19 @@ export async function executeSyncTask(
fallbackChain?: import("../../shared/model-requirements").FallbackEntry[],
deps: SyncTaskDeps = syncTaskDeps
): Promise<string> {
const { client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx
const { manager, client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx
const toastManager = getTaskToastManager()
let taskId: string | undefined
let syncSessionID: string | undefined
try {
const spawnContext = typeof manager?.assertCanSpawn === "function"
? await manager.assertCanSpawn(parentContext.sessionID)
: {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
}
const createSessionResult = await deps.createSyncSession(client, {
parentSessionID: parentContext.sessionID,
agentToUse,
@@ -90,6 +97,7 @@ export async function executeSyncTask(
run_in_background: args.run_in_background,
sessionId: sessionID,
sync: true,
spawnDepth: spawnContext.childDepth,
command: args.command,
model: categoryModel ? { providerID: categoryModel.providerID, modelID: categoryModel.modelID } : undefined,
},