diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index effa646a8..912f8967d 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -193,8 +193,6 @@ Provider and model configuration lives in JSON settings files. The global file h `models` is always an array (single- and multi-model providers are uniform). `defaultModel` (or the first entry) is used when no model is selected. With exactly one provider configured, `defaultProvider` may be omitted. - Optional `maxConcurrentSubAgents` (integer ≥ 0, default **10**) caps how many `task`-tool sub-agent loops may run at once; **0** disables sub-agents entirely. Change it in **Settings → Sub-agents** or in this file. Applies only when `sessionMode` is **orchestrator**. - Optional `tools` block for the outer per-tool wall-clock budget: ```json diff --git a/src/config.test.ts b/src/config.test.ts index ba28c69c5..ca4d02fd1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -628,7 +628,6 @@ describe("buildProviderCatalog", () => { hiddenCommands: ["help"], onboarded: true, compactionMode: "pruning", - maxConcurrentSubAgents: 3, subagentMaxTurns: 40, sessionMode: "orchestrator", agentModelFallback: "none", diff --git a/src/config/settings.ts b/src/config/settings.ts index fab7bd286..b20b9dd9d 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -112,7 +112,6 @@ export type Settings = { // "llm" (default) generates a structured handoff summary via LLM call. // "pruning" uses fast deterministic pruning with no LLM call. compactionMode?: "llm" | "pruning"; - maxConcurrentSubAgents?: number; // Default inference-turn budget for leaf sub-agents (not the parent session limit). subagentMaxTurns?: number; // Primary session behavior: single agent does work in-session; orchestrator @@ -252,20 +251,6 @@ export function shellEnvFromSettings( return local?.env; } -export const DEFAULT_MAX_CONCURRENT_SUB_AGENTS = 10; - -export function clampMaxConcurrentSubAgents(value: number): number { - if (!Number.isFinite(value)) return DEFAULT_MAX_CONCURRENT_SUB_AGENTS; - return Math.max(0, Math.floor(value)); -} - -export function resolveMaxConcurrentSubAgents(settings?: Settings | null): number { - if (settings?.maxConcurrentSubAgents === undefined) { - return DEFAULT_MAX_CONCURRENT_SUB_AGENTS; - } - return clampMaxConcurrentSubAgents(settings.maxConcurrentSubAgents); -} - export const DEFAULT_SUBAGENT_MAX_TURNS = 30; export const MAX_SUBAGENT_MAX_TURNS_CAP = 100; @@ -466,7 +451,6 @@ const SettingsSchema = type({ "onboarded?": "boolean", "lastChangelogVersion?": "string", "compactionMode?": "'llm' | 'pruning'", - "maxConcurrentSubAgents?": "number", "subagentMaxTurns?": "number", "sessionMode?": "'single' | 'orchestrator'", "agentModelFallback?": "'active' | 'none'", @@ -522,10 +506,6 @@ export function isSettings(value: unknown): value is Settings { if (!SettingsSchema.allows(value)) return false; const s = value as Record; if (s.mcpServers !== undefined && normalizeMcpServers(s.mcpServers) === undefined) return false; - if (s.maxConcurrentSubAgents !== undefined) { - const n = s.maxConcurrentSubAgents; - if (typeof n !== "number" || !Number.isInteger(n) || n < 0) return false; - } if (s.subagentMaxTurns !== undefined) { const n = s.subagentMaxTurns; if ( @@ -645,7 +625,6 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "onboarded", "lastChangelogVersion", "compactionMode", - "maxConcurrentSubAgents", "subagentMaxTurns", "sessionMode", "agentModelFallback", @@ -753,10 +732,6 @@ export async function loadSettings(path: string): Promise { : undefined, compactionMode: s.compactionMode === "llm" || s.compactionMode === "pruning" ? s.compactionMode : undefined, - maxConcurrentSubAgents: - s.maxConcurrentSubAgents !== undefined - ? clampMaxConcurrentSubAgents(s.maxConcurrentSubAgents as number) - : undefined, subagentMaxTurns: s.subagentMaxTurns !== undefined ? clampSubAgentMaxTurns(s.subagentMaxTurns as number) diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 3186c14f1..608204f7b 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -23,11 +23,9 @@ import { import { loadLocalSettings, localSettingsPath, - resolveMaxConcurrentSubAgents, shellTimeoutFromSettings, toolWatchdogFromSettings, } from "../config/settings.js"; -import { configureSubAgentConcurrency } from "../subagent/concurrency.js"; import { codexProfileFromProviderName } from "../config/codex-providers.js"; import { xaiProfileFromProviderName } from "../config/xai-providers.js"; import { createInferenceDependencies } from "../provider/inference-dependencies.js"; @@ -280,9 +278,6 @@ export async function runExec(config: Config): Promise { ); const sessionMode: SessionMode = resolveSessionMode(config.settings, localSettingsForMode) ?? "orchestrator"; - if (sessionMode === "orchestrator") { - configureSubAgentConcurrency(resolveMaxConcurrentSubAgents(config.settings)); - } const activeProviderModel = `${config.providerName}:${config.model}`; const seededApprovals = await loadSeededApprovals(config.cwd, sessionId); diff --git a/src/settings.test.ts b/src/settings.test.ts index 1681500f5..ab0a6f2d0 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -16,11 +16,8 @@ import { saveGlobalSettings, saveLocalSettings, type Settings, - DEFAULT_MAX_CONCURRENT_SUB_AGENTS, DEFAULT_SUBAGENT_MAX_TURNS, MAX_SUBAGENT_MAX_TURNS_CAP, - resolveMaxConcurrentSubAgents, - clampMaxConcurrentSubAgents, resolveDefaultSubAgentMaxTurns, resolveSubAgentMaxTurns, clampSubAgentMaxTurns, @@ -764,57 +761,31 @@ describe("sessionMode", () => { }); }); -describe("maxConcurrentSubAgents", () => { - test("defaults to 10 when unset", () => { - expect(resolveMaxConcurrentSubAgents(null)).toBe(DEFAULT_MAX_CONCURRENT_SUB_AGENTS); - expect(resolveMaxConcurrentSubAgents({ providers: {} })).toBe(10); - }); - - test("loadSettings round-trips showPromptCost", async () => { - const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); - try { - const path = join(dir, ".corbits", "settings.json"); - await saveGlobalSettings(path, { ...firepass, showPromptCost: true }); - expect(await loadSettings(path)).toEqual({ ...firepass, showPromptCost: true }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("loadSettings round-trips maxConcurrentSubAgents", async () => { - const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); - try { - const path = join(dir, ".corbits", "settings.json"); - await saveGlobalSettings(path, { ...firepass, maxConcurrentSubAgents: 6 }); - expect(await loadSettings(path)).toEqual({ ...firepass, maxConcurrentSubAgents: 6 }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("accepts zero to disable sub-agents", () => { - expect( - isSettings({ - providers: firepass.providers, - maxConcurrentSubAgents: 0, - }), - ).toBe(true); - expect(clampMaxConcurrentSubAgents(0)).toBe(0); - }); - - test("rejects negative maxConcurrentSubAgents", () => { - expect( - isSettings({ - providers: firepass.providers, - maxConcurrentSubAgents: -1, - }), - ).toBe(false); - }); +test("loadSettings round-trips showPromptCost", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await saveGlobalSettings(path, { ...firepass, showPromptCost: true }); + expect(await loadSettings(path)).toEqual({ ...firepass, showPromptCost: true }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); - test("clamp floors fractional values and negative numbers", () => { - expect(clampMaxConcurrentSubAgents(3.9)).toBe(3); - expect(clampMaxConcurrentSubAgents(-2)).toBe(0); - }); +test("loadSettings tolerates a legacy maxConcurrentSubAgents key", async () => { + const dir = await mkdtemp(join(tmpdir(), "ic-settings-")); + try { + const path = join(dir, ".corbits", "settings.json"); + await mkdir(join(dir, ".corbits"), { recursive: true }); + await writeFile( + path, + JSON.stringify({ ...firepass, maxConcurrentSubAgents: 6 }, null, 2), + "utf8", + ); + expect(await loadSettings(path)).toEqual(firepass); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); describe("lastChangelogVersion", () => { diff --git a/src/subagent/__tests__/concurrency.test.ts b/src/subagent/__tests__/concurrency.test.ts deleted file mode 100644 index 17fd6093d..000000000 --- a/src/subagent/__tests__/concurrency.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; - -import { - setMaxConcurrentSubAgentsForTests, - SUB_AGENTS_DISABLED_MESSAGE, - withSubAgentSlot, -} from "../concurrency.js"; - -afterEach(() => { - setMaxConcurrentSubAgentsForTests(2); -}); - -test("withSubAgentSlot limits concurrent executions", async () => { - setMaxConcurrentSubAgentsForTests(2); - let active = 0; - let maxActive = 0; - - const job = async (ms: number) => - withSubAgentSlot(async () => { - active++; - maxActive = Math.max(maxActive, active); - await new Promise((r) => setTimeout(r, ms)); - active--; - }); - - await Promise.all([job(30), job(30), job(30), job(30)]); - - expect(maxActive).toBe(2); - expect(active).toBe(0); -}); - -test("withSubAgentSlot rejects immediately when limit is 0", async () => { - setMaxConcurrentSubAgentsForTests(0); - await expect(withSubAgentSlot(async () => "nope")).rejects.toThrow(SUB_AGENTS_DISABLED_MESSAGE); -}); \ No newline at end of file diff --git a/src/subagent/concurrency.ts b/src/subagent/concurrency.ts deleted file mode 100644 index 084db4c17..000000000 --- a/src/subagent/concurrency.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Caps how many sub-agent loops run at once. Each loop spawns its own LSP -// sidecars and git-backed store; unbounded parallel task calls (e.g. workflow -// review panels) can exhaust process/file limits and take down the TUI. - -import { - DEFAULT_MAX_CONCURRENT_SUB_AGENTS, - clampMaxConcurrentSubAgents, -} from "../config/settings.js"; - -export const SUB_AGENTS_DISABLED_MESSAGE = - "Sub-agents are disabled (maxConcurrentSubAgents is 0 in settings)."; - -let maxConcurrent = DEFAULT_MAX_CONCURRENT_SUB_AGENTS; -let active = 0; -const waiters: Array<() => void> = []; - -export function configureSubAgentConcurrency(limit: number): void { - maxConcurrent = clampMaxConcurrentSubAgents(limit); -} - -/** @internal Tests only — prefer configureSubAgentConcurrency. */ -export function setMaxConcurrentSubAgentsForTests(value: number): void { - configureSubAgentConcurrency(value); -} - -function release(): void { - active = Math.max(0, active - 1); - const next = waiters.shift(); - if (next !== undefined) next(); -} - -function acquire(): Promise { - if (maxConcurrent === 0) { - return Promise.reject(new Error(SUB_AGENTS_DISABLED_MESSAGE)); - } - if (active < maxConcurrent) { - active++; - return Promise.resolve(); - } - return new Promise((resolve) => { - waiters.push(() => { - active++; - resolve(); - }); - }); -} - -export async function withSubAgentSlot( - fn: () => Promise, - opts: { reentrant?: boolean } = {}, -): Promise { - // A reentrant run belongs to an orchestrator that already holds a slot; it - // runs under that slot rather than acquiring its own. Acquiring here would - // deadlock the bounded pool: with every slot held (worst at - // maxConcurrentSubAgents: 1, or whenever concurrent orchestrators fill the - // pool) the nested worker would wait for a slot only its still-running parent - // can release. - if (opts.reentrant === true) return fn(); - await acquire(); - try { - return await fn(); - } finally { - release(); - } -} \ No newline at end of file diff --git a/src/subagent/run.ts b/src/subagent/run.ts index ca8994871..f3cce0692 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -47,7 +47,6 @@ import { gatherEnvironment } from "../agent/environment.js"; import { generateSessionId } from "../session/index.js"; import { consumeStream } from "../session/stream-consumer.js"; import { createCycleTextRecorder } from "../session/stream-journal.js"; -import { withSubAgentSlot } from "./concurrency.js"; import { detectRepetition, REPETITION_CHECK_INTERVAL_CHARS, @@ -213,12 +212,6 @@ export function createSubAgentRunController( // gets its own posix tool instances and its own git-backed context store so // the two loops never trample each other's state. export async function runSubAgent(params: RunSubAgentParams): Promise { - return withSubAgentSlot(() => runSubAgentInner(params), { - reentrant: params.nested === true, - }); -} - -async function runSubAgentInner(params: RunSubAgentParams): Promise { await seedPricingMetadataFromCache({ cachePath: defaultPricingCachePath(), }); diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 234264da3..4063f3134 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -597,9 +597,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ...(orchestrator ? { orchestrator: true, nestedDispatch: nestedDispatch! } : {}), - // Nested workers (installed by an orchestrator that already holds a - // slot) reuse the parent slot rather than acquiring their own. - ...(deps.allowOrchestrator === false ? { nested: true } : {}), maxTurns: resolvedMaxTurns, ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), }; diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 0f0bc1c97..002f497fe 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -103,10 +103,6 @@ export type RunSubAgentParams = { // Present only when orchestrator is true. Installs task + search_agents so // the orchestrator can actually dispatch workers. nestedDispatch?: NestedDispatchDeps; - // Set when this dispatch is a nested worker spawned by an orchestrator that - // already holds a concurrency slot. The nested run reuses the parent's slot - // (reentrant) instead of acquiring its own, which would deadlock the pool. - nested?: boolean; /** Inference-turn budget for this worker only (not the parent session limit). */ maxTurns?: number; /** diff --git a/src/tui-opentui/command-surfaces.test.ts b/src/tui-opentui/command-surfaces.test.ts index ae375ce5b..13dca494a 100644 --- a/src/tui-opentui/command-surfaces.test.ts +++ b/src/tui-opentui/command-surfaces.test.ts @@ -30,7 +30,6 @@ function baseSnapshot(): SettingsSnapshot { compactionMode: "llm", sessionMode: "orchestrator", sessionModeScope: "global", - maxConcurrentSubAgents: 3, waitForApproval: true, telemetryEnabled: false, showPromptCost: false, @@ -96,7 +95,6 @@ function settingsDeps(overrides?: Partial): { readonly calls: { compaction: string[] sessionMode: Array<{ mode: string; scope: string }> - subagents: number[] waitForApproval: boolean[] telemetry: boolean[] showPromptCost: boolean[] @@ -106,7 +104,6 @@ function settingsDeps(overrides?: Partial): { const calls = { compaction: [] as string[], sessionMode: [] as Array<{ mode: string; scope: string }>, - subagents: [] as number[], waitForApproval: [] as boolean[], telemetry: [] as boolean[], showPromptCost: [] as boolean[], @@ -123,10 +120,6 @@ function settingsDeps(overrides?: Partial): { calls.sessionMode.push({ mode, scope }) state = { ...state, sessionMode: mode, sessionModeScope: scope } }, - setMaxConcurrentSubAgents: (limit) => { - calls.subagents.push(limit) - state = { ...state, maxConcurrentSubAgents: limit } - }, setWaitForApproval: (value) => { calls.waitForApproval.push(value) state = { ...state, waitForApproval: value } @@ -153,7 +146,6 @@ describe("settings surface", () => { await Promise.resolve() expect(shell.overlayItems.some((l) => l.includes("summarize"))).toBe(true) - expect(shell.overlayItems.some((l) => l.includes("3"))).toBe(true) expect(shell.overlayItems.some((l) => l.includes("off"))).toBe(true) }) }) @@ -174,21 +166,6 @@ describe("settings surface", () => { }) }) - test("left/right cycles the sub-agent cap through its numeric choices", async () => { - await withShell(async (shell) => { - const { deps, calls } = settingsDeps() - openCommandSurface(shell, "settings", deps) - await Promise.resolve() - await Promise.resolve() - - moveOverlaySelection(shell, 3) // compaction, session mode, scope, subagents - cycleOverlaySelection(shell, 1) - await Promise.resolve() - await Promise.resolve() - expect(calls.subagents).toEqual([4]) - }) - }) - test("session mode scope switch honours a local write", async () => { await withShell(async (shell) => { const { deps, calls } = settingsDeps() @@ -213,7 +190,7 @@ describe("settings surface", () => { expect(shell.overlayItems.some((l) => l.includes("show cost"))).toBe(true) - moveOverlaySelection(shell, 6) // compaction, session mode, scope, subagents, approval wait, telemetry, show cost + moveOverlaySelection(shell, 5) // compaction, session mode, scope, approval wait, telemetry, show cost cycleOverlaySelection(shell, 1) await Promise.resolve() await Promise.resolve() diff --git a/src/tui-opentui/command-surfaces.ts b/src/tui-opentui/command-surfaces.ts index 8f92d5296..bc8b47254 100644 --- a/src/tui-opentui/command-surfaces.ts +++ b/src/tui-opentui/command-surfaces.ts @@ -74,7 +74,6 @@ export type SettingsSnapshot = { readonly sessionMode: SessionMode /** Which scope `sessionMode` currently reflects — a local override wins over global. */ readonly sessionModeScope: SessionModeScope - readonly maxConcurrentSubAgents: number readonly waitForApproval: boolean readonly telemetryEnabled: boolean readonly showPromptCost: boolean @@ -148,7 +147,6 @@ export type SettingsSurfaceDeps = { readonly setCompactionMode: (mode: CompactionMode) => void /** Writes to the given scope: "local" persists to `.corbits/settings.json` in cwd. */ readonly setSessionMode: (mode: SessionMode, scope: SessionModeScope) => void - readonly setMaxConcurrentSubAgents: (limit: number) => void readonly setWaitForApproval: (value: boolean) => void readonly setTelemetryEnabled: (value: boolean) => void readonly setShowPromptCost: (value: boolean) => void @@ -183,9 +181,6 @@ export type CommandSurfaceKind = const CLOSE_ID = "__close__" const BACK_ID = "__back__" -/** Sub-agent concurrency choices offered by the settings surface. */ -export const SUBAGENT_LIMIT_CHOICES: readonly number[] = [1, 2, 3, 4, 6, 8] - export function grantRowLabel(entry: GrantEntry): string { const suffix = entry.providerModel !== undefined ? ` (${entry.providerModel})` : "" return `${entry.scopeLabel} · ${entry.tool} ${entry.pattern}${suffix}` @@ -328,19 +323,6 @@ function settingsCycleRows( cycleValue(SESSION_SCOPE_OPTIONS.map((o) => o.id), snapshot.sessionModeScope, dir), ), }, - { - id: "subagents", - value: `${"sub-agents".padEnd(SETTINGS_NAME_WIDTH)}‹ ${snapshot.maxConcurrentSubAgents} ›`, - describe: { - what: "the most sub-agents an orchestrator session runs at once.", - impact: "raising the cap runs more work in parallel and spends more tokens per turn.", - tone: "consequence", - }, - cycle: (dir) => - settings.setMaxConcurrentSubAgents( - cycleValue(SUBAGENT_LIMIT_CHOICES, snapshot.maxConcurrentSubAgents, dir), - ), - }, { id: "wait-for-approval", value: `${"approval wait".padEnd(SETTINGS_NAME_WIDTH)}${cycleField(ON_OFF_OPTIONS, snapshot.waitForApproval ? "on" : "off")}`, diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index f756b4d33..5f6d931c8 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -142,14 +142,12 @@ describe("mountRunnerHost command surfaces", () => { compactionMode: "llm", sessionMode: "orchestrator", sessionModeScope: "global", - maxConcurrentSubAgents: 3, waitForApproval: true, telemetryEnabled: false, showPromptCost: false, }), setCompactionMode: () => {}, setSessionMode: () => {}, - setMaxConcurrentSubAgents: () => {}, setWaitForApproval: () => {}, setTelemetryEnabled: () => {}, setShowPromptCost: () => {}, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index b2dfe3180..0ea1ce0a9 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -24,7 +24,6 @@ import { localSettingsPath, markTelemetryNoticeShown, pushRecentModel, - resolveMaxConcurrentSubAgents, resolveTier, saveGlobalSettings, saveLocalSettings, @@ -42,7 +41,6 @@ import { providerChoices } from "../tui-opentui/provider-setup.js"; import type { SessionModeScope } from "../tui-opentui/command-surfaces.js"; import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js"; import { createGateRequestApproval } from "./request-approval.js"; -import { configureSubAgentConcurrency } from "../subagent/concurrency.js"; import { codexProfileFromProviderName } from "../config/codex-providers.js"; import { xaiProfileFromProviderName } from "../config/xai-providers.js"; import type { PluginsAdmin, PluginDescriptor } from "../plugins/admin.js"; @@ -980,10 +978,6 @@ export async function runTUI(initialConfig: Config): Promise { if (refreshed !== null) config = { ...config, settings: refreshed }; } } - let liveMaxConcurrentSubAgents = resolveMaxConcurrentSubAgents(config.settings); - if (liveSessionMode === "orchestrator") { - configureSubAgentConcurrency(liveMaxConcurrentSubAgents); - } const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(liveSessionMode); // The workflow controller is built below, after the toolset; the holder lets // advance_workflow's handler read live workflow-active state without a @@ -2093,7 +2087,6 @@ export async function runTUI(initialConfig: Config): Promise { compactionMode: liveCompactionMode, sessionMode: liveSessionMode ?? "orchestrator", sessionModeScope: liveSessionModeScope, - maxConcurrentSubAgents: liveMaxConcurrentSubAgents, waitForApproval: resolveWaitForApproval(liveToolWatchdog), telemetryEnabled: liveTelemetryIntent, showPromptCost: liveShowPromptCost, @@ -2121,14 +2114,6 @@ export async function runTUI(initialConfig: Config): Promise { }); void persistGlobalSettings("session mode", (base) => ({ ...base, sessionMode: mode })); }, - setMaxConcurrentSubAgents: (limit) => { - liveMaxConcurrentSubAgents = limit; - configureSubAgentConcurrency(limit); - void persistGlobalSettings("max concurrent sub-agents", (base) => ({ - ...base, - maxConcurrentSubAgents: limit, - })); - }, setWaitForApproval: (value) => { liveToolWatchdog.waitForApproval = value; void persistGlobalSettings("wait-for-approval", (base) => ({ diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 0c09975ef..622f3dee6 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -132,7 +132,6 @@ test("loadSettings cannot silently drop a known optional key", async () => { onboarded: true, lastChangelogVersion: "0.1.0", compactionMode: "pruning" as const, - maxConcurrentSubAgents: 3, subagentMaxTurns: 20, sessionMode: "orchestrator" as const, agentModelFallback: "none" as const, diff --git a/tests/unit/subagent-concurrency.test.ts b/tests/unit/subagent-concurrency.test.ts deleted file mode 100644 index 73677072f..000000000 --- a/tests/unit/subagent-concurrency.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, expect, test } from "bun:test"; - -import { - setMaxConcurrentSubAgentsForTests, - withSubAgentSlot, -} from "../../src/subagent/concurrency.js"; -import { DEFAULT_MAX_CONCURRENT_SUB_AGENTS } from "../../src/config/settings.js"; - -const flush = async (): Promise => { - await Promise.resolve(); - await Promise.resolve(); -}; - -afterEach(() => { - setMaxConcurrentSubAgentsForTests(DEFAULT_MAX_CONCURRENT_SUB_AGENTS); -}); - -test("a full pool blocks a fresh acquire until a slot frees", async () => { - setMaxConcurrentSubAgentsForTests(1); - let releaseParent: () => void = () => {}; - const parent = withSubAgentSlot( - () => new Promise((resolve) => (releaseParent = resolve)), - ); - await flush(); - - let ran = false; - const waiting = withSubAgentSlot(async () => { - ran = true; - }); - await flush(); - expect(ran).toBe(false); - - releaseParent(); - await parent; - await waiting; - expect(ran).toBe(true); -}); - -test("a reentrant run reuses the held slot instead of deadlocking", async () => { - setMaxConcurrentSubAgentsForTests(1); - let releaseParent: () => void = () => {}; - const parent = withSubAgentSlot( - () => new Promise((resolve) => (releaseParent = resolve)), - ); - await flush(); - - // The parent holds the only slot. A nested worker that acquired its own slot - // would wait forever; a reentrant run completes under the parent's slot. - const nested = await withSubAgentSlot(async () => "nested", { reentrant: true }); - expect(nested).toBe("nested"); - - releaseParent(); - await parent; -}); diff --git a/tests/unit/subagent.test.ts b/tests/unit/subagent.test.ts index 2b2b9583e..9e5d01428 100644 --- a/tests/unit/subagent.test.ts +++ b/tests/unit/subagent.test.ts @@ -224,35 +224,6 @@ test("orchestrator profile installs nestedDispatch so task can be re-dispatched" expect(received?.systemPromptRole).toContain("coordinate"); }); -test("nested dispatch runs reentrant so a full pool cannot deadlock it", async () => { - let received: RunSubAgentParams | undefined; - const nestedTool = createTaskTool({ permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - allowOrchestrator: false, - run: async (params) => { - received = params; - return "leaf"; - }, - }); - await callHandler(nestedTool, { description: "work", prompt: "do the work" }); - expect(received?.nested).toBe(true); - - let topReceived: RunSubAgentParams | undefined; - const topTool = createTaskTool({ permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.ctx", - provider, - run: async (params) => { - topReceived = params; - return "worker"; - }, - }); - await callHandler(topTool, { description: "work", prompt: "do the work" }); - expect(topReceived?.nested).toBeUndefined(); -}); - test("nested dispatch forwards the external sink, not the orchestrator recorder", async () => { const store = createSubAgentSessionStore(); const external: string[] = [];