Count sync subagent spawns against descendant limits
This commit is contained in:
@@ -2036,6 +2036,28 @@ 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 consume descendant quota for reserved sync spawns", async () => {
|
||||||
|
// given
|
||||||
|
manager.shutdown()
|
||||||
|
manager = new BackgroundManager(
|
||||||
|
{
|
||||||
|
client: createMockClientWithSessionChain({
|
||||||
|
"session-root": { directory: "/test/dir" },
|
||||||
|
}),
|
||||||
|
directory: tmpdir(),
|
||||||
|
} as unknown as PluginInput,
|
||||||
|
{ maxDescendants: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
|
await manager.reserveSubagentSpawn("session-root")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = manager.assertCanSpawn("session-root")
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pending task can be cancelled", () => {
|
describe("pending task can be cancelled", () => {
|
||||||
|
|||||||
@@ -178,12 +178,47 @@ export class BackgroundManager {
|
|||||||
return spawnContext
|
return spawnContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reserveSubagentSpawn(parentSessionID: string): Promise<{
|
||||||
|
spawnContext: SubagentSpawnContext
|
||||||
|
descendantCount: number
|
||||||
|
commit: () => number
|
||||||
|
rollback: () => void
|
||||||
|
}> {
|
||||||
|
const spawnContext = await this.assertCanSpawn(parentSessionID)
|
||||||
|
const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
spawnContext,
|
||||||
|
descendantCount,
|
||||||
|
commit: () => {
|
||||||
|
settled = true
|
||||||
|
return descendantCount
|
||||||
|
},
|
||||||
|
rollback: () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
this.unregisterRootDescendant(spawnContext.rootSessionID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private registerRootDescendant(rootSessionID: string): number {
|
private registerRootDescendant(rootSessionID: string): number {
|
||||||
const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
|
const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
|
||||||
this.rootDescendantCounts.set(rootSessionID, nextCount)
|
this.rootDescendantCounts.set(rootSessionID, nextCount)
|
||||||
return nextCount
|
return nextCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private unregisterRootDescendant(rootSessionID: string): void {
|
||||||
|
const currentCount = this.rootDescendantCounts.get(rootSessionID) ?? 0
|
||||||
|
if (currentCount <= 1) {
|
||||||
|
this.rootDescendantCounts.delete(rootSessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
|
||||||
|
}
|
||||||
|
|
||||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||||
log("[background-agent] launch() called with:", {
|
log("[background-agent] launch() called with:", {
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
@@ -196,73 +231,79 @@ export class BackgroundManager {
|
|||||||
throw new Error("Agent parameter is required")
|
throw new Error("Agent parameter is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnContext = await this.assertCanSpawn(input.parentSessionID)
|
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)
|
||||||
const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
|
|
||||||
|
|
||||||
log("[background-agent] spawn guard passed", {
|
try {
|
||||||
parentSessionID: input.parentSessionID,
|
log("[background-agent] spawn guard passed", {
|
||||||
rootSessionID: spawnContext.rootSessionID,
|
parentSessionID: input.parentSessionID,
|
||||||
childDepth: spawnContext.childDepth,
|
rootSessionID: spawnReservation.spawnContext.rootSessionID,
|
||||||
descendantCount,
|
childDepth: spawnReservation.spawnContext.childDepth,
|
||||||
})
|
descendantCount: spawnReservation.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,
|
|
||||||
parentAgent: input.parentAgent,
|
|
||||||
parentTools: input.parentTools,
|
|
||||||
model: input.model,
|
|
||||||
fallbackChain: input.fallbackChain,
|
|
||||||
attemptCount: 0,
|
|
||||||
category: input.category,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tasks.set(task.id, task)
|
|
||||||
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
|
||||||
|
|
||||||
// Track for batched notifications immediately (pending state)
|
|
||||||
if (input.parentSessionID) {
|
|
||||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
|
||||||
pending.add(task.id)
|
|
||||||
this.pendingByParent.set(input.parentSessionID, pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
const key = this.getConcurrencyKeyFromInput(input)
|
|
||||||
const queue = this.queuesByKey.get(key) ?? []
|
|
||||||
queue.push({ task, input })
|
|
||||||
this.queuesByKey.set(key, queue)
|
|
||||||
|
|
||||||
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
|
|
||||||
|
|
||||||
const toastManager = getTaskToastManager()
|
|
||||||
if (toastManager) {
|
|
||||||
toastManager.addTask({
|
|
||||||
id: task.id,
|
|
||||||
description: input.description,
|
|
||||||
agent: input.agent,
|
|
||||||
isBackground: true,
|
|
||||||
status: "queued",
|
|
||||||
skills: input.skills,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Create task immediately with status="pending"
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
status: "pending",
|
||||||
|
queuedAt: new Date(),
|
||||||
|
rootSessionID: spawnReservation.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: spawnReservation.spawnContext.childDepth,
|
||||||
|
parentSessionID: input.parentSessionID,
|
||||||
|
parentMessageID: input.parentMessageID,
|
||||||
|
parentModel: input.parentModel,
|
||||||
|
parentAgent: input.parentAgent,
|
||||||
|
parentTools: input.parentTools,
|
||||||
|
model: input.model,
|
||||||
|
fallbackChain: input.fallbackChain,
|
||||||
|
attemptCount: 0,
|
||||||
|
category: input.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tasks.set(task.id, task)
|
||||||
|
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
||||||
|
|
||||||
|
// Track for batched notifications immediately (pending state)
|
||||||
|
if (input.parentSessionID) {
|
||||||
|
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||||
|
pending.add(task.id)
|
||||||
|
this.pendingByParent.set(input.parentSessionID, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
const key = this.getConcurrencyKeyFromInput(input)
|
||||||
|
const queue = this.queuesByKey.get(key) ?? []
|
||||||
|
queue.push({ task, input })
|
||||||
|
this.queuesByKey.set(key, queue)
|
||||||
|
|
||||||
|
log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
|
||||||
|
|
||||||
|
const toastManager = getTaskToastManager()
|
||||||
|
if (toastManager) {
|
||||||
|
toastManager.addTask({
|
||||||
|
id: task.id,
|
||||||
|
description: input.description,
|
||||||
|
agent: input.agent,
|
||||||
|
isBackground: true,
|
||||||
|
status: "queued",
|
||||||
|
skills: input.skills,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnReservation.commit()
|
||||||
|
|
||||||
|
// Trigger processing (fire-and-forget)
|
||||||
|
this.processKey(key)
|
||||||
|
|
||||||
|
return { ...task }
|
||||||
|
} catch (error) {
|
||||||
|
spawnReservation.rollback()
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger processing (fire-and-forget)
|
|
||||||
this.processKey(key)
|
|
||||||
|
|
||||||
return { ...task }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processKey(key: string): Promise<void> {
|
private async processKey(key: string): Promise<void> {
|
||||||
@@ -1476,14 +1517,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatDuration(start: Date, end?: Date): string {
|
|
||||||
return formatDuration(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAbortedSessionError(error: unknown): boolean {
|
|
||||||
return isAbortedSessionError(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasRunningTasks(): boolean {
|
private hasRunningTasks(): boolean {
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
if (task.status === "running") return true
|
if (task.status === "running") return true
|
||||||
|
|||||||
@@ -249,4 +249,52 @@ describe("executeSync", () => {
|
|||||||
expect(deps.waitForCompletion).not.toHaveBeenCalled()
|
expect(deps.waitForCompletion).not.toHaveBeenCalled()
|
||||||
expect(deps.processMessages).not.toHaveBeenCalled()
|
expect(deps.processMessages).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("commits reserved descendant quota after creating a new sync session", async () => {
|
||||||
|
//#given
|
||||||
|
const { executeSync } = require("./sync-executor")
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
createOrGetSession: mock(async () => ({ sessionID: "ses-test-789", isNew: true })),
|
||||||
|
waitForCompletion: mock(async () => {}),
|
||||||
|
processMessages: mock(async () => "agent response"),
|
||||||
|
setSessionFallbackChain: mock(() => {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnReservation = {
|
||||||
|
commit: mock(() => 1),
|
||||||
|
rollback: mock(() => {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
subagent_type: "explore",
|
||||||
|
description: "test task",
|
||||||
|
prompt: "find something",
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolContext = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
messageID: "msg-4",
|
||||||
|
agent: "sisyphus",
|
||||||
|
abort: new AbortController().signal,
|
||||||
|
metadata: mock(async () => {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
promptAsync: mock(async () => ({ data: {} })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await executeSync(args, toolContext, ctx as any, deps, undefined, spawnReservation)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(spawnReservation.commit).toHaveBeenCalledTimes(1)
|
||||||
|
expect(spawnReservation.rollback).toHaveBeenCalledTimes(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type ExecuteSyncDeps = {
|
|||||||
setSessionFallbackChain: typeof setSessionFallbackChain
|
setSessionFallbackChain: typeof setSessionFallbackChain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpawnReservation = {
|
||||||
|
commit: () => number
|
||||||
|
rollback: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const defaultDeps: ExecuteSyncDeps = {
|
const defaultDeps: ExecuteSyncDeps = {
|
||||||
createOrGetSession,
|
createOrGetSession,
|
||||||
waitForCompletion,
|
waitForCompletion,
|
||||||
@@ -33,54 +38,67 @@ export async function executeSync(
|
|||||||
messageID: string
|
messageID: string
|
||||||
agent: string
|
agent: string
|
||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void | Promise<void>
|
||||||
},
|
},
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
deps: ExecuteSyncDeps = defaultDeps,
|
deps: ExecuteSyncDeps = defaultDeps,
|
||||||
fallbackChain?: FallbackEntry[],
|
fallbackChain?: FallbackEntry[],
|
||||||
|
spawnReservation?: SpawnReservation,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx)
|
let sessionID: string | undefined
|
||||||
|
|
||||||
if (fallbackChain && fallbackChain.length > 0) {
|
|
||||||
deps.setSessionFallbackChain(sessionID, fallbackChain)
|
|
||||||
}
|
|
||||||
|
|
||||||
await toolContext.metadata?.({
|
|
||||||
title: args.description,
|
|
||||||
metadata: { sessionId: sessionID },
|
|
||||||
})
|
|
||||||
|
|
||||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
|
||||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
const session = await deps.createOrGetSession(args, toolContext, ctx)
|
||||||
path: { id: sessionID },
|
sessionID = session.sessionID
|
||||||
body: {
|
|
||||||
agent: args.subagent_type,
|
if (session.isNew) {
|
||||||
tools: {
|
spawnReservation?.commit()
|
||||||
...getAgentToolRestrictions(args.subagent_type),
|
|
||||||
task: false,
|
|
||||||
question: false,
|
|
||||||
},
|
|
||||||
parts: [{ type: "text", text: args.prompt }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
||||||
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
|
||||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
|
||||||
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
|
||||||
}
|
}
|
||||||
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
|
||||||
|
if (fallbackChain && fallbackChain.length > 0) {
|
||||||
|
deps.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
await toolContext.metadata?.({
|
||||||
|
title: args.description,
|
||||||
|
metadata: { sessionId: sessionID },
|
||||||
|
})
|
||||||
|
|
||||||
|
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||||
|
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
||||||
|
path: { id: sessionID },
|
||||||
|
body: {
|
||||||
|
agent: args.subagent_type,
|
||||||
|
tools: {
|
||||||
|
...getAgentToolRestrictions(args.subagent_type),
|
||||||
|
task: false,
|
||||||
|
question: false,
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: args.prompt }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
||||||
|
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||||
|
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||||
|
}
|
||||||
|
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.waitForCompletion(sessionID, toolContext, ctx)
|
||||||
|
|
||||||
|
const responseText = await deps.processMessages(sessionID, ctx)
|
||||||
|
|
||||||
|
const output =
|
||||||
|
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
|
||||||
|
|
||||||
|
return output
|
||||||
|
} catch (error) {
|
||||||
|
spawnReservation?.rollback()
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.waitForCompletion(sessionID, toolContext, ctx)
|
|
||||||
|
|
||||||
const responseText = await deps.processMessages(sessionID, ctx)
|
|
||||||
|
|
||||||
const output =
|
|
||||||
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { describe, test, expect, mock } from "bun:test"
|
const { beforeEach, describe, test, expect, mock } = require("bun:test")
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
const { createCallOmoAgent } = require("./tools")
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
|
||||||
import { createCallOmoAgent } from "./tools"
|
|
||||||
|
|
||||||
describe("createCallOmoAgent", () => {
|
describe("createCallOmoAgent", () => {
|
||||||
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
|
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
|
||||||
|
const reserveCommitMock = mock(() => 1)
|
||||||
|
const reserveRollbackMock = mock(() => {})
|
||||||
|
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
|
||||||
|
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
|
||||||
|
descendantCount: 1,
|
||||||
|
commit: reserveCommitMock,
|
||||||
|
rollback: reserveRollbackMock,
|
||||||
|
}))
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
client: {},
|
client: {},
|
||||||
directory: "/test",
|
directory: "/test",
|
||||||
} as unknown as PluginInput
|
}
|
||||||
|
|
||||||
const mockBackgroundManager = {
|
const mockBackgroundManager = {
|
||||||
assertCanSpawn: assertCanSpawnMock,
|
assertCanSpawn: assertCanSpawnMock,
|
||||||
|
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||||
launch: mock(() => Promise.resolve({
|
launch: mock(() => Promise.resolve({
|
||||||
id: "test-task-id",
|
id: "test-task-id",
|
||||||
sessionID: null,
|
sessionID: null,
|
||||||
@@ -20,7 +26,14 @@ describe("createCallOmoAgent", () => {
|
|||||||
agent: "test-agent",
|
agent: "test-agent",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})),
|
})),
|
||||||
} as unknown as BackgroundManager
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
assertCanSpawnMock.mockClear()
|
||||||
|
reserveSubagentSpawnMock.mockClear()
|
||||||
|
reserveCommitMock.mockClear()
|
||||||
|
reserveRollbackMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
test("should reject agent in disabled_agents list", async () => {
|
test("should reject agent in disabled_agents list", async () => {
|
||||||
//#given
|
//#given
|
||||||
@@ -105,7 +118,7 @@ describe("createCallOmoAgent", () => {
|
|||||||
|
|
||||||
test("uses agent override fallback_models when launching background subagent", async () => {
|
test("uses agent override fallback_models when launching background subagent", async () => {
|
||||||
//#given
|
//#given
|
||||||
const launch = mock((_input: { fallbackChain?: FallbackEntry[] }) => Promise.resolve({
|
const launch = mock((_input: { fallbackChain?: Array<{ providers: string[]; model: string; variant?: string }> }) => Promise.resolve({
|
||||||
id: "task-fallback",
|
id: "task-fallback",
|
||||||
sessionID: "sub-session",
|
sessionID: "sub-session",
|
||||||
description: "Test task",
|
description: "Test task",
|
||||||
@@ -115,7 +128,7 @@ describe("createCallOmoAgent", () => {
|
|||||||
const managerWithLaunch = {
|
const managerWithLaunch = {
|
||||||
launch,
|
launch,
|
||||||
getTask: mock(() => undefined),
|
getTask: mock(() => undefined),
|
||||||
} as unknown as BackgroundManager
|
}
|
||||||
const toolDef = createCallOmoAgent(
|
const toolDef = createCallOmoAgent(
|
||||||
mockCtx,
|
mockCtx,
|
||||||
managerWithLaunch,
|
managerWithLaunch,
|
||||||
@@ -154,7 +167,7 @@ describe("createCallOmoAgent", () => {
|
|||||||
|
|
||||||
test("should return a tool error when sync spawn depth validation fails", async () => {
|
test("should return a tool error when sync spawn depth validation fails", async () => {
|
||||||
//#given
|
//#given
|
||||||
assertCanSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
|
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
|
||||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||||
const executeFunc = toolDef.execute as Function
|
const executeFunc = toolDef.execute as Function
|
||||||
|
|
||||||
@@ -173,3 +186,5 @@ describe("createCallOmoAgent", () => {
|
|||||||
expect(result).toContain("background_task.maxDepth=3")
|
expect(result).toContain("background_task.maxDepth=3")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@@ -96,9 +96,12 @@ export function createCallOmoAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!args.session_id) {
|
if (!args.session_id) {
|
||||||
|
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
||||||
try {
|
try {
|
||||||
await backgroundManager.assertCanSpawn(toolCtx.sessionID)
|
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
||||||
|
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
spawnReservation?.rollback()
|
||||||
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,65 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
expect(deleteCalls[0]).toBe("ses_test_12345678")
|
expect(deleteCalls[0]).toBe("ses_test_12345678")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rolls back reserved descendant quota when sync session creation fails", async () => {
|
||||||
|
const mockClient = {
|
||||||
|
session: {
|
||||||
|
create: async () => ({ data: { id: "ses_test_12345678" } }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { executeSyncTask } = require("./sync-task")
|
||||||
|
|
||||||
|
const commit = mock(() => 1)
|
||||||
|
const rollback = mock(() => {})
|
||||||
|
const reserveSubagentSpawn = mock(async () => ({
|
||||||
|
spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
|
||||||
|
descendantCount: 1,
|
||||||
|
commit,
|
||||||
|
rollback,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
createSyncSession: async () => ({ ok: false as const, error: "Failed to create session" }),
|
||||||
|
sendSyncPrompt: async () => null,
|
||||||
|
pollSyncSession: async () => null,
|
||||||
|
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCtx = {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
callID: "call-123",
|
||||||
|
metadata: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockExecutorCtx = {
|
||||||
|
manager: { reserveSubagentSpawn },
|
||||||
|
client: mockClient,
|
||||||
|
directory: "/tmp",
|
||||||
|
onSyncSessionCreated: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
prompt: "test prompt",
|
||||||
|
description: "test task",
|
||||||
|
category: "test",
|
||||||
|
load_skills: [],
|
||||||
|
run_in_background: false,
|
||||||
|
command: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||||
|
sessionID: "parent-session",
|
||||||
|
}, "test-agent", undefined, undefined, undefined, undefined, deps)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe("Failed to create session")
|
||||||
|
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
|
||||||
|
expect(commit).toHaveBeenCalledTimes(0)
|
||||||
|
expect(rollback).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
test("cleans up toast and subagentSessions when pollSyncSession returns error", async () => {
|
test("cleans up toast and subagentSessions when pollSyncSession returns error", async () => {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
session: {
|
session: {
|
||||||
@@ -182,7 +241,18 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commit = mock(() => 1)
|
||||||
|
const rollback = mock(() => {})
|
||||||
|
|
||||||
const mockExecutorCtx = {
|
const mockExecutorCtx = {
|
||||||
|
manager: {
|
||||||
|
reserveSubagentSpawn: mock(async () => ({
|
||||||
|
spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 },
|
||||||
|
descendantCount: 1,
|
||||||
|
commit,
|
||||||
|
rollback,
|
||||||
|
})),
|
||||||
|
},
|
||||||
client: mockClient,
|
client: mockClient,
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
onSyncSessionCreated: null,
|
onSyncSessionCreated: null,
|
||||||
@@ -204,9 +274,14 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
|||||||
|
|
||||||
//#then - should complete and cleanup resources
|
//#then - should complete and cleanup resources
|
||||||
expect(result).toContain("Task completed")
|
expect(result).toContain("Task completed")
|
||||||
|
expect(mockExecutorCtx.manager.reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
|
||||||
|
expect(commit).toHaveBeenCalledTimes(1)
|
||||||
|
expect(rollback).toHaveBeenCalledTimes(0)
|
||||||
expect(removeTaskCalls.length).toBe(1)
|
expect(removeTaskCalls.length).toBe(1)
|
||||||
expect(removeTaskCalls[0]).toBe("sync_ses_test")
|
expect(removeTaskCalls[0]).toBe("sync_ses_test")
|
||||||
expect(deleteCalls.length).toBe(1)
|
expect(deleteCalls.length).toBe(1)
|
||||||
expect(deleteCalls[0]).toBe("ses_test_12345678")
|
expect(deleteCalls[0]).toBe("ses_test_12345678")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@@ -27,15 +27,23 @@ export async function executeSyncTask(
|
|||||||
const toastManager = getTaskToastManager()
|
const toastManager = getTaskToastManager()
|
||||||
let taskId: string | undefined
|
let taskId: string | undefined
|
||||||
let syncSessionID: string | undefined
|
let syncSessionID: string | undefined
|
||||||
|
let spawnReservation:
|
||||||
|
| Awaited<ReturnType<ExecutorContext["manager"]["reserveSubagentSpawn"]>>
|
||||||
|
| undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const spawnContext = typeof manager?.assertCanSpawn === "function"
|
if (typeof manager?.reserveSubagentSpawn === "function") {
|
||||||
? await manager.assertCanSpawn(parentContext.sessionID)
|
spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID)
|
||||||
: {
|
}
|
||||||
rootSessionID: parentContext.sessionID,
|
|
||||||
parentDepth: 0,
|
const spawnContext = spawnReservation?.spawnContext
|
||||||
childDepth: 1,
|
?? (typeof manager?.assertCanSpawn === "function"
|
||||||
}
|
? await manager.assertCanSpawn(parentContext.sessionID)
|
||||||
|
: {
|
||||||
|
rootSessionID: parentContext.sessionID,
|
||||||
|
parentDepth: 0,
|
||||||
|
childDepth: 1,
|
||||||
|
})
|
||||||
|
|
||||||
const createSessionResult = await deps.createSyncSession(client, {
|
const createSessionResult = await deps.createSyncSession(client, {
|
||||||
parentSessionID: parentContext.sessionID,
|
parentSessionID: parentContext.sessionID,
|
||||||
@@ -45,10 +53,12 @@ export async function executeSyncTask(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!createSessionResult.ok) {
|
if (!createSessionResult.ok) {
|
||||||
|
spawnReservation?.rollback()
|
||||||
return createSessionResult.error
|
return createSessionResult.error
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionID = createSessionResult.sessionID
|
const sessionID = createSessionResult.sessionID
|
||||||
|
spawnReservation?.commit()
|
||||||
syncSessionID = sessionID
|
syncSessionID = sessionID
|
||||||
subagentSessions.add(sessionID)
|
subagentSessions.add(sessionID)
|
||||||
syncSubagentSessions.add(sessionID)
|
syncSubagentSessions.add(sessionID)
|
||||||
@@ -156,6 +166,7 @@ session_id: ${sessionID}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
spawnReservation?.rollback()
|
||||||
return formatDetailedError(error, {
|
return formatDetailedError(error, {
|
||||||
operation: "Execute task",
|
operation: "Execute task",
|
||||||
args,
|
args,
|
||||||
|
|||||||
Reference in New Issue
Block a user