fix(background-agent): fail closed on spawn lineage lookup errors

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-11 20:57:09 +09:00
parent d2526878b6
commit 398c950085
3 changed files with 62 additions and 16 deletions
+43 -5
View File
@@ -1731,15 +1731,22 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
} }
} }
function createMockClientWithSessionChain( function createMockClientWithSessionChain(
sessions: Record<string, { directory: string; parentID?: string }> sessions: Record<string, { directory: string; parentID?: string }>,
options?: { sessionLookupError?: Error }
) { ) {
return { return {
session: { session: {
create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }), create: async (_args?: any) => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async ({ path }: { path: { id: string } }) => ({ get: async ({ path }: { path: { id: string } }) => {
data: sessions[path.id] ?? { directory: "/test/dir" }, if (options?.sessionLookupError) {
}), throw options.sessionLookupError
}
return {
data: sessions[path.id] ?? { directory: "/test/dir" },
}
},
prompt: async () => ({}), prompt: async () => ({}),
promptAsync: async () => ({}), promptAsync: async () => ({}),
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
@@ -2058,6 +2065,37 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
// then // then
await expect(result).rejects.toThrow("background_task.maxDescendants=1") await expect(result).rejects.toThrow("background_task.maxDescendants=1")
}) })
test("should fail closed when session lineage lookup fails", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain(
{
"session-root": { directory: "/test/dir" },
},
{ sessionLookupError: new Error("session lookup failed") }
),
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",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
})
}) })
describe("pending task can be cancelled", () => { describe("pending task can be cancelled", () => {
+4 -4
View File
@@ -54,7 +54,7 @@ import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import { import {
createSubagentDepthLimitError, createSubagentDepthLimitError,
createSubagentDescendantLimitError, createSubagentDescendantLimitError,
getMaxRootDescendants, getMaxRootSessionSpawnBudget,
getMaxSubagentDepth, getMaxSubagentDepth,
resolveSubagentSpawnContext, resolveSubagentSpawnContext,
type SubagentSpawnContext, type SubagentSpawnContext,
@@ -165,13 +165,13 @@ export class BackgroundManager {
}) })
} }
const maxDescendants = getMaxRootDescendants(this.config) const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0 const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
if (descendantCount >= maxDescendants) { if (descendantCount >= maxRootSessionSpawnBudget) {
throw createSubagentDescendantLimitError({ throw createSubagentDescendantLimitError({
rootSessionID: spawnContext.rootSessionID, rootSessionID: spawnContext.rootSessionID,
descendantCount, descendantCount,
maxDescendants, maxDescendants: maxRootSessionSpawnBudget,
}) })
} }
@@ -2,7 +2,7 @@ import type { BackgroundTaskConfig } from "../../config/schema"
import type { OpencodeClient } from "./constants" import type { OpencodeClient } from "./constants"
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3 export const DEFAULT_MAX_SUBAGENT_DEPTH = 3
export const DEFAULT_MAX_ROOT_DESCENDANTS = 50 export const DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET = 50
export interface SubagentSpawnContext { export interface SubagentSpawnContext {
rootSessionID: string rootSessionID: string
@@ -14,8 +14,8 @@ export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number {
return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH
} }
export function getMaxRootDescendants(config?: BackgroundTaskConfig): number { export function getMaxRootSessionSpawnBudget(config?: BackgroundTaskConfig): number {
return config?.maxDescendants ?? DEFAULT_MAX_ROOT_DESCENDANTS return config?.maxDescendants ?? DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET
} }
export async function resolveSubagentSpawnContext( export async function resolveSubagentSpawnContext(
@@ -34,11 +34,19 @@ export async function resolveSubagentSpawnContext(
visitedSessionIDs.add(currentSessionID) visitedSessionIDs.add(currentSessionID)
const session = await client.session.get({ let nextParentSessionID: string | undefined
path: { id: currentSessionID }, try {
}).catch(() => null) const session = await client.session.get({
path: { id: currentSessionID },
})
nextParentSessionID = session.data?.parentID
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDescendants cannot be enforced safely. ${reason}`
)
}
const nextParentSessionID = session?.data?.parentID
if (!nextParentSessionID) { if (!nextParentSessionID) {
rootSessionID = currentSessionID rootSessionID = currentSessionID
break break