From 0c4b7f60e551f6fe17de1e4283da8af8092af8c0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Mon, 6 Jul 2026 10:45:01 +0800 Subject: [PATCH 1/7] feat(core): add maxSubAgents setting to limit parallel sub-agent count Adds a `maxSubAgents` configuration option that limits the number of sub-agents running in parallel. Excess agents are queued without timeout countdown until a slot becomes available. Closes #5176 --- packages/cli/src/config/config.test.ts | 11 ++ packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 15 ++ .../agents/background-agent-resume.test.ts | 165 ++++++++++++++++-- .../src/agents/background-agent-resume.ts | 122 ++++++++----- .../core/src/agents/background-tasks.test.ts | 113 +++++++++++- packages/core/src/agents/background-tasks.ts | 146 +++++++++++++++- packages/core/src/config/config.test.ts | 34 ++++ packages/core/src/config/config.ts | 15 +- packages/core/src/tools/agent/agent.test.ts | 76 +++++++- packages/core/src/tools/agent/agent.ts | 99 +++++++---- .../schemas/settings.schema.json | 5 + 12 files changed, 691 insertions(+), 111 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 226fa89e8fc..b1dcbfbab68 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1046,6 +1046,17 @@ describe('loadCliConfig', () => { expect(config.getModelFallbacks()).toEqual(['settings-a', 'settings-b']); }); + it('passes agents.maxParallelAgents from settings to core config', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig( + { agents: { maxParallelAgents: 2 } }, + argv, + ); + + expect(config.getAgentsSettings().maxParallelAgents).toBe(2); + }); + it('should ignore blank settings fallback models', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 826c0dfc724..071f661fdbf 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2219,6 +2219,7 @@ export async function loadCliConfig( }, agents: settings.agents ? { + maxParallelAgents: settings.agents.maxParallelAgents, displayMode: settings.agents.displayMode, arena: settings.agents.arena ? { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3b4fc88900e..6899e7e8b64 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2699,6 +2699,21 @@ const SETTINGS_SCHEMA = { 'Settings for multi-agent collaboration features (Arena, Team, Swarm).', showInDialog: false, properties: { + maxParallelAgents: { + type: 'number', + label: 'Max Parallel Agents', + category: 'Advanced', + requiresRestart: true, + default: undefined as number | undefined, + minimum: 1, + description: + 'Global maximum number of background sub-agents that can run concurrently. Additional background agents wait in a queue until a slot is available. Per-model limits are not supported yet.', + showInDialog: false, + jsonSchemaOverride: { + type: 'integer', + minimum: 1, + }, + }, displayMode: { type: 'enum', label: 'Display Mode', diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index fb157718412..cc5db7d84ac 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -816,7 +816,7 @@ describe('BackgroundAgentResumeService', () => { expect(subagent.execute).toHaveBeenCalledTimes(1); }); - it('keeps a paused agent paused when resume cannot claim a background slot', async () => { + it('waits for a background slot before resuming a paused agent', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, }); @@ -872,16 +872,38 @@ describe('BackgroundAgentResumeService', () => { metaPath, }); + const subagent = { + execute: vi.fn(async () => undefined), + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + outputTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue({ + subagent, + dispose: vi.fn().mockResolvedValue(undefined), + }); - const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + const resumed = service.resumeBackgroundAgent(agentId, 'continue'); + await Promise.resolve(); - expect(resumed).toBeUndefined(); + expect(registry.getQueuedCount()).toBe(1); expect(registry.get(agentId)?.status).toBe('paused'); - expect(registry.get(agentId)?.error).toContain( - 'maximum concurrent background agents (1) reached', - ); expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); + + registry.complete('already-running', 'done'); + + await expect(resumed).resolves.toBeDefined(); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('completed'); + }); + expect(subagent.execute).toHaveBeenCalledTimes(1); }); it('passes the sidechain transcript path to SubagentStop hooks on resume', async () => { @@ -2683,7 +2705,7 @@ describe('BackgroundAgentResumeService', () => { expect(started).toEqual(['running']); }); - it('does not revive when the background concurrency cap is full', async () => { + it('waits for a background slot before reviving a completed agent', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1 }); const sessionId = 'session-revive-cap'; const agentId = 'agent-revive-cap'; @@ -2739,18 +2761,139 @@ describe('BackgroundAgentResumeService', () => { outputFile: path.join(tempDir, 'blocker.jsonl'), }); + const subagent = { + execute: vi.fn(async () => undefined), + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + outputTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue({ + subagent, + dispose: vi.fn().mockResolvedValue(undefined), + }); - const revived = await service.reviveCompletedBackgroundAgent( + const revived = service.reviveCompletedBackgroundAgent( agentId, 'keep going', ); - // At-capacity revive fails cleanly: the finished entry is NOT stranded as - // paused, and no agent run is started. - expect(revived).toBeUndefined(); + await vi.waitFor(() => { + expect(registry.getQueuedCount()).toBe(1); + }); expect(registry.get(agentId)?.status).toBe('completed'); expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); + + registry.complete('blocker', 'done'); + + await expect(revived).resolves.toBeDefined(); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('completed'); + }); + expect(subagent.execute).toHaveBeenCalledTimes(1); + }); + + it('deduplicates queued completed-agent revive requests', async () => { + registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1 }); + const sessionId = 'session-revive-dedupes'; + const agentId = 'agent-revive-dedupes'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Finished research', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Finished research' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Finished research', + subagentType: 'researcher', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile, + metaPath, + }); + registry.complete(agentId, 'All done'); + registry.register({ + agentId: 'blocker', + description: 'blocker', + subagentType: 'researcher', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile: path.join(tempDir, 'blocker.jsonl'), + }); + + const subagent = { + execute: vi.fn(async () => undefined), + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + outputTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue({ + subagent, + dispose: vi.fn().mockResolvedValue(undefined), + }); + + const first = service.reviveCompletedBackgroundAgent( + agentId, + 'first message', + ); + await vi.waitFor(() => { + expect(registry.getQueuedCount()).toBe(1); + }); + + const second = service.reviveCompletedBackgroundAgent( + agentId, + 'second message', + ); + await Promise.resolve(); + + try { + expect(registry.getQueuedCount()).toBe(1); + expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); + } finally { + registry.complete('blocker', 'done'); + await Promise.allSettled([first, second]); + } + + expect(subagentManager.createAgentHeadless).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 977d1cb08cb..3dfdb07ea89 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -46,6 +46,7 @@ import type { AgentCompletionStats, AgentTask, AgentTaskRegistration, + BackgroundSlotReservation, } from './background-tasks.js'; import type { SubagentConfig } from '../subagents/types.js'; import { BUBBLE_APPROVAL_MODE } from '../subagents/types.js'; @@ -502,17 +503,16 @@ export class BackgroundAgentResumeService { * terminal notification) and hand it to `resumeBackgroundAgent`. * * Returns `undefined` (and logs why) when the agent can't be revived: not an - * in-registry, finished background agent with a persisted transcript, or the - * background-agent concurrency cap is full. Cross-session / evicted completed - * agents are out of scope (see QwenLM/qwen-code#5540). + * in-registry, finished background agent with a persisted transcript, or a + * failed resume setup after the slot is claimed. Cross-session / evicted + * completed agents are out of scope (see QwenLM/qwen-code#5540). */ async reviveCompletedBackgroundAgent( agentId: string, initialMessage?: string, ): Promise { // A resume/revive already in flight for this id owns the lifecycle — fold - // into it. (The status flip below is await-free, so this guards a genuinely - // concurrent in-flight operation, not a same-tick re-entry.) + // into it. if (this.resumeOperations.has(agentId)) { return this.resumeBackgroundAgent(agentId, initialMessage); } @@ -534,18 +534,6 @@ export class BackgroundAgentResumeService { ); return undefined; } - // Honor the background-agent concurrency cap before flipping the finished - // entry back to paused, so an at-capacity revive fails cleanly instead of - // stranding the entry as paused. - try { - registry.assertCanStartBackgroundAgent(); - } catch (error) { - debugLogger.warn( - `[BackgroundAgentResume] Cannot revive "${agentId}": ` + - `${error instanceof Error ? error.message : String(error)}`, - ); - return undefined; - } if (!readAgentMeta(entry.metaPath)) { debugLogger.warn( `[BackgroundAgentResume] Cannot revive "${agentId}": metadata could not be read.`, @@ -584,66 +572,114 @@ export class BackgroundAgentResumeService { `${error instanceof Error ? error.message : String(error)}`, ); } - try { - registry.assertCanStartBackgroundAgent(); - } catch (error) { - debugLogger.warn( - `[BackgroundAgentResume] Cannot revive "${agentId}": ` + - `${error instanceof Error ? error.message : String(error)}`, - ); - return undefined; - } + const completedEntry = { ...entry, pendingMessages: [...(entry.pendingMessages ?? [])], recentActivities: [...(entry.recentActivities ?? [])], pendingApprovals: [...(entry.pendingApprovals ?? [])], }; - this.restorePausedEntry(agentId, { suppressRegisterCallback: true }); - const revived = await this.resumeBackgroundAgent(agentId, initialMessage); - if (!revived) { - this.restoreCompletedEntry(completedEntry); - } + const trimmedMessage = initialMessage?.trim(); + const operation: ResumeOperation = { + continuationMessages: trimmedMessage ? [trimmedMessage] : [], + promise: Promise.resolve(undefined), + }; + this.resumeOperations.set(agentId, operation); + operation.promise = (async (): Promise => { + let slotReservation: BackgroundSlotReservation | undefined; + try { + slotReservation = + registry.tryReserveBackgroundSlot() ?? + (await registry.waitForBackgroundSlot()); + } catch (error) { + debugLogger.warn( + `[BackgroundAgentResume] Cannot revive "${agentId}": ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } + + if ( + !this.restorePausedEntry(agentId, { suppressRegisterCallback: true }) + ) { + registry.releaseBackgroundSlot(slotReservation); + return undefined; + } + + const revived = await this.resumeBackgroundAgentInternal( + agentId, + operation, + slotReservation, + ); + if (!revived) { + this.restoreCompletedEntry(completedEntry); + } + return revived; + })().finally(() => { + this.resumeOperations.delete(agentId); + }); + const revived = await operation.promise; return revived; } private async resumeBackgroundAgentInternal( agentId: string, operation: ResumeOperation, + initialSlotReservation?: BackgroundSlotReservation, ): Promise { const registry = this.config.getBackgroundTaskRegistry(); + let slotReservation = initialSlotReservation; + let slotReservationConsumed = false; + const releaseSlotReservation = () => { + if (slotReservation && !slotReservationConsumed) { + registry.releaseBackgroundSlot(slotReservation); + slotReservation = undefined; + } + }; const existing = registry.get(agentId); if (!existing || existing.status !== 'paused') { + releaseSlotReservation(); return existing; } const metaPath = existing.metaPath; const outputFile = existing.outputFile; if (!metaPath || !outputFile) { + releaseSlotReservation(); return undefined; } const meta = readAgentMeta(metaPath); if (!meta) { + releaseSlotReservation(); return undefined; } const bgAbortController = new AbortController(); try { - registry.register({ - ...existing, - status: 'running', - abortController: bgAbortController, - endTime: undefined, - result: undefined, - error: undefined, - resumeBlockedReason: undefined, - stats: undefined, - recentActivities: [], - pendingMessages: [...(existing.pendingMessages ?? [])], - }); + slotReservation ??= + registry.tryReserveBackgroundSlot() ?? + (await registry.waitForBackgroundSlot()); + registry.register( + { + ...existing, + status: 'running', + abortController: bgAbortController, + endTime: undefined, + result: undefined, + error: undefined, + resumeBlockedReason: undefined, + stats: undefined, + recentActivities: [], + pendingMessages: [...(existing.pendingMessages ?? [])], + }, + { slotReservation }, + ); + slotReservationConsumed = true; + slotReservation = undefined; } catch (error) { + releaseSlotReservation(); const errorMessage = error instanceof Error ? error.message : String(error); debugLogger.warn( diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 3ee0e4cc20f..8bd37fc2c3f 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -511,24 +511,26 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('bg-1')?.prompt).toBe('resumed continuation'); }); - it('counts foreground agents toward the cap but not paused or terminal entries', () => { + it('does not count foreground agents toward the background cap', () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, }); - // A foreground agent occupies a slot. registry.register( makeRegistration('fg-1', { isBackgrounded: false, }), ); - expect(() => registry.register(makeRegistration('bg-1'))).toThrow( - 'maximum concurrent background agents (1) reached', - ); + registry.register(makeRegistration('bg-1')); + expect(registry.get('bg-1')?.status).toBe('running'); + }); + + it('does not count paused or terminal entries toward the cap', () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); - // Paused entries do not occupy a slot. - registry.unregisterForeground('fg-1'); registry.register( makeRegistration('paused-1', { status: 'paused', @@ -546,6 +548,103 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('paused-1')).toBeDefined(); expect(registry.get('bg-2')?.status).toBe('running'); }); + + it('queues waiters until a background slot is released', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + + const reservationPromise = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + + expect(registry.getQueuedCount()).toBe(1); + + registry.complete('bg-1', 'done'); + const reservation = await reservationPromise; + + expect(registry.getQueuedCount()).toBe(0); + registry.register(makeRegistration('bg-2'), { + slotReservation: reservation, + }); + expect(registry.get('bg-2')?.status).toBe('running'); + }); + + it('reserves a drained slot until registration consumes it', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + + const first = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + const second = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + let secondResolved = false; + void second.then(() => { + secondResolved = true; + }); + + registry.complete('bg-1', 'done'); + const firstReservation = await first; + await Promise.resolve(); + + expect(secondResolved).toBe(false); + expect(registry.getQueuedCount()).toBe(1); + expect(() => registry.register(makeRegistration('racer'))).toThrow( + 'maximum concurrent background agents (1) reached', + ); + + registry.register(makeRegistration('bg-2'), { + slotReservation: firstReservation, + }); + expect(secondResolved).toBe(false); + + registry.complete('bg-2', 'done'); + const secondReservation = await second; + registry.register(makeRegistration('bg-3'), { + slotReservation: secondReservation, + }); + expect(registry.get('bg-3')?.status).toBe('running'); + }); + + it('removes an aborted waiter from the queue', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + const abortController = new AbortController(); + + const reservation = registry.waitForBackgroundSlot( + abortController.signal, + ); + abortController.abort(); + + await expect(reservation).rejects.toThrow( + 'Agent launch cancelled while waiting for a background slot.', + ); + expect(registry.getQueuedCount()).toBe(0); + }); + + it('rejects queued waiters on reset', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + + const reservation = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + registry.reset(); + + await expect(reservation).rejects.toThrow( + 'Agent launch cancelled while waiting for a background slot.', + ); + expect(registry.getQueuedCount()).toBe(0); + }); }); it('aborts all running agents and emits fallback notifications', () => { diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 0c45c872937..cc3b31f1087 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -324,6 +324,7 @@ export type AgentTaskRegistration = TaskRegistration; export interface BackgroundTaskRegisterOptions { suppressRegisterCallback?: boolean; preserveNotificationState?: boolean; + slotReservation?: BackgroundSlotReservation; } export interface NotificationMeta { @@ -389,9 +390,25 @@ export interface BackgroundTaskRegistryOptions { maxConcurrentBackgroundAgents?: number; } +export interface BackgroundSlotReservation { + readonly id: symbol; +} + +interface BackgroundSlotWaiter { + readonly signal?: AbortSignal; + readonly resolve: (reservation: BackgroundSlotReservation) => void; + readonly reject: (error: Error) => void; + readonly onAbort: () => void; +} + +const BACKGROUND_SLOT_WAIT_CANCELLED = + 'Agent launch cancelled while waiting for a background slot.'; + export class BackgroundTaskRegistry { private readonly agents = new Map(); private readonly messageWaiters = new Map>(); + private readonly waitQueue: BackgroundSlotWaiter[] = []; + private readonly reservedBackgroundSlots = new Set(); private readonly maxConcurrentBackgroundAgents: number; private notificationCallback?: BackgroundNotificationCallback; private registerCallback?: BackgroundRegisterCallback; @@ -408,12 +425,18 @@ export class BackgroundTaskRegistry { : MAX_CONCURRENT_BACKGROUND_AGENTS; } + canStartBackgroundAgent(): boolean { + return ( + this.getClaimedBackgroundSlotCount() < this.maxConcurrentBackgroundAgents + ); + } + assertCanStartBackgroundAgent(): void { - const running = this.getRunningBackgroundCount(); - if (running >= this.maxConcurrentBackgroundAgents) { + const claimed = this.getClaimedBackgroundSlotCount(); + if (claimed >= this.maxConcurrentBackgroundAgents) { debugLogger.warn( `Background agent concurrency cap reached: ` + - `${running}/${this.maxConcurrentBackgroundAgents}. ` + + `${claimed}/${this.maxConcurrentBackgroundAgents}. ` + `Refusing new background agent.`, ); throw new Error( @@ -424,15 +447,68 @@ export class BackgroundTaskRegistry { } } + async waitForBackgroundSlot( + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + throw new Error(BACKGROUND_SLOT_WAIT_CANCELLED); + } + const reservation = this.tryReserveBackgroundSlot(); + if (reservation) { + return reservation; + } + + return new Promise((resolve, reject) => { + const onAbort = () => { + const index = this.waitQueue.indexOf(waiter); + if (index !== -1) { + this.waitQueue.splice(index, 1); + } + reject(new Error(BACKGROUND_SLOT_WAIT_CANCELLED)); + }; + const waiter: BackgroundSlotWaiter = { + signal, + resolve, + reject, + onAbort, + }; + signal?.addEventListener('abort', onAbort, { once: true }); + this.waitQueue.push(waiter); + }); + } + + tryReserveBackgroundSlot(): BackgroundSlotReservation | undefined { + if (!this.canStartBackgroundAgent()) { + return undefined; + } + return this.reserveBackgroundSlot(); + } + + getQueuedCount(): number { + return this.waitQueue.length; + } + + releaseBackgroundSlot(reservation: BackgroundSlotReservation): void { + if (this.reservedBackgroundSlots.delete(reservation.id)) { + this.drainWaitQueue(); + } + } + register( registration: AgentTaskRegistration, options: BackgroundTaskRegisterOptions = {}, ): AgentTask { - if (registration.status === 'running') { - const existing = this.agents.get(registration.agentId); + const existing = this.agents.get(registration.agentId); + const wasRunningBackground = + existing?.isBackgrounded === true && existing.status === 'running'; + if (registration.status === 'running' && registration.isBackgrounded) { const isReplacingRunning = existing?.status === 'running'; if (!isReplacingRunning) { - this.assertCanStartBackgroundAgent(); + if (options.slotReservation) { + this.consumeBackgroundSlot(options.slotReservation); + } else { + this.assertCanStartBackgroundAgent(); + } } } @@ -460,6 +536,12 @@ export class BackgroundTaskRegistry { } this.agents.set(entry.agentId, entry); debugLogger.info(`Registered background agent: ${entry.agentId}`); + if ( + wasRunningBackground && + (!entry.isBackgrounded || entry.status !== 'running') + ) { + this.drainWaitQueue(); + } // Foreground entries are paired with a synchronous tool-call result on // the parent's response and never emit a terminal `task_notification` @@ -509,6 +591,7 @@ export class BackgroundTaskRegistry { this.rejectPendingApprovals(entry); this.emitNotification(entry); this.emitStatusChange(entry); + this.drainWaitQueue(); } /** @@ -541,6 +624,7 @@ export class BackgroundTaskRegistry { this.agents.delete(agentId); this.emitStatusChange(entry); debugLogger.info(`Unregistered foreground agent: ${agentId}`); + this.drainWaitQueue(); } // See complete() for the cancelled → terminal path rationale. @@ -559,6 +643,7 @@ export class BackgroundTaskRegistry { this.rejectPendingApprovals(entry); this.emitNotification(entry); this.emitStatusChange(entry); + this.drainWaitQueue(); } // Cancellation aborts the signal and marks the entry as cancelled, but @@ -603,6 +688,7 @@ export class BackgroundTaskRegistry { } debugLogger.info(`Background agent cancelled: ${agentId}`); this.emitStatusChange(entry); + this.drainWaitQueue(); // Foreground entries don't emit XML notifications and unregister // themselves in the tool-call's finally path, so the grace timer @@ -637,6 +723,7 @@ export class BackgroundTaskRegistry { debugLogger.info(`Abandoned paused background agent: ${agentId}`); this.rejectPendingApprovals(entry); this.emitStatusChange(entry); + this.drainWaitQueue(); } // Emit the terminal cancelled notification once the agent's natural @@ -661,6 +748,7 @@ export class BackgroundTaskRegistry { this.rejectPendingApprovals(entry); this.emitNotification(entry); this.emitStatusChange(entry); + this.drainWaitQueue(); } // Emit the terminal cancelled notification for entries that were cancelled @@ -679,6 +767,7 @@ export class BackgroundTaskRegistry { this.rejectPendingApprovals(entry); this.emitNotification(entry); this.emitStatusChange(entry); + this.drainWaitQueue(); } /** @@ -851,10 +940,47 @@ export class BackgroundTaskRegistry { private getRunningBackgroundCount(): number { return Array.from(this.agents.values()).filter( - (entry) => entry.status === 'running', + (entry) => entry.isBackgrounded && entry.status === 'running', ).length; } + private getClaimedBackgroundSlotCount(): number { + return this.getRunningBackgroundCount() + this.reservedBackgroundSlots.size; + } + + private reserveBackgroundSlot(): BackgroundSlotReservation { + const reservation = { id: Symbol('background-slot') }; + this.reservedBackgroundSlots.add(reservation.id); + return reservation; + } + + private consumeBackgroundSlot(reservation: BackgroundSlotReservation): void { + if (!this.reservedBackgroundSlots.delete(reservation.id)) { + throw new Error('Invalid background agent slot reservation.'); + } + } + + private drainWaitQueue(): void { + while (this.waitQueue.length > 0 && this.canStartBackgroundAgent()) { + const waiter = this.waitQueue.shift()!; + waiter.signal?.removeEventListener('abort', waiter.onAbort); + if (waiter.signal?.aborted) { + waiter.reject(new Error(BACKGROUND_SLOT_WAIT_CANCELLED)); + continue; + } + waiter.resolve(this.reserveBackgroundSlot()); + } + } + + private rejectWaitQueue(): void { + const waiters = this.waitQueue.splice(0); + for (const waiter of waiters) { + waiter.signal?.removeEventListener('abort', waiter.onAbort); + waiter.reject(new Error(BACKGROUND_SLOT_WAIT_CANCELLED)); + } + this.reservedBackgroundSlots.clear(); + } + /** * True if any registered task has not yet emitted its terminal * task-notification. Covers `running` (still executing) and @@ -888,7 +1014,10 @@ export class BackgroundTaskRegistry { const firstEntry = this.agents.values().next().value as | AgentTask | undefined; - if (!firstEntry) return; + if (!firstEntry) { + this.rejectWaitQueue(); + return; + } for (const entry of this.agents.values()) { // Defensive: callers (session switch via /resume, /clear) gate on // hasBlockingBackgroundWork() and so only reach reset() once every @@ -898,6 +1027,7 @@ export class BackgroundTaskRegistry { this.rejectPendingApprovals(entry); this.wakeMessageWaiters(entry.agentId); } + this.rejectWaitQueue(); this.agents.clear(); this.emitStatusChange(firstEntry); } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 101892d690f..8e58a8dbdbd 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -566,6 +566,40 @@ describe('Server Config (config.ts)', () => { }); }); + describe('agents.maxParallelAgents', () => { + it('configures the background task registry concurrency cap', () => { + const config = new Config({ + ...baseParams, + agents: { + maxParallelAgents: 1, + }, + }); + const registry = config.getBackgroundTaskRegistry(); + + registry.register({ + agentId: 'bg-1', + description: 'one', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile: '/tmp/bg-1.jsonl', + }); + + expect(() => + registry.register({ + agentId: 'bg-2', + description: 'two', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile: '/tmp/bg-2.jsonl', + }), + ).toThrow('maximum concurrent background agents (1) reached'); + }); + }); + describe('getTeamMemoryEnabled', () => { const prevEnv = process.env['QWEN_CODE_MEMORY_TEAM']; afterEach(() => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 8400eda3bdb..43c95afe338 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -805,6 +805,11 @@ export interface WorktreeSettings { } export interface AgentsCollabSettings { + /** + * Global maximum number of background sub-agents running concurrently. + * When the cap is reached, additional launches wait for a slot. + */ + maxParallelAgents?: number; /** Display mode for multi-agent sessions ('in-process' | 'tmux' | 'iterm2') */ displayMode?: string; /** Arena-specific settings */ @@ -1437,7 +1442,7 @@ export class Config { private subagentManager!: SubagentManager; private memoryPressureConfig?: MemoryPressureConfig; private memoryPressureMonitor?: MemoryPressureMonitor; - private readonly backgroundTaskRegistry = new BackgroundTaskRegistry(); + private readonly backgroundTaskRegistry: BackgroundTaskRegistry; private readonly monitorRegistry = new MonitorRegistry(); private backgroundAgentResumeService?: BackgroundAgentResumeService; private readonly backgroundShellRegistry = new BackgroundShellRegistry(); @@ -1926,6 +1931,14 @@ export class Config { this.eventEmitter = params.eventEmitter; this.arenaAgentClient = ArenaAgentClient.create(); this.agentsSettings = params.agents ?? {}; + this.backgroundTaskRegistry = new BackgroundTaskRegistry( + this.agentsSettings.maxParallelAgents === undefined + ? undefined + : { + maxConcurrentBackgroundAgents: + this.agentsSettings.maxParallelAgents, + }, + ); this.worktreeSettings = params.worktree ?? {}; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 760f0a5d6b1..4e6ff1abda0 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -132,6 +132,14 @@ describe('AgentTool', () => { // enough for these tests — they don't assert on registry behavior. const stubRegistry = { assertCanStartBackgroundAgent: vi.fn(), + canStartBackgroundAgent: vi.fn().mockReturnValue(true), + tryReserveBackgroundSlot: vi + .fn() + .mockReturnValue({ id: Symbol('background-slot') }), + waitForBackgroundSlot: vi + .fn() + .mockResolvedValue({ id: Symbol('background-slot') }), + releaseBackgroundSlot: vi.fn(), register: vi.fn(), unregisterForeground: vi.fn(), complete: vi.fn(), @@ -2920,6 +2928,11 @@ describe('AgentTool', () => { let mockContextState: ContextState; let mockRegistry: { assertCanStartBackgroundAgent: ReturnType; + canStartBackgroundAgent: ReturnType; + tryReserveBackgroundSlot: ReturnType; + waitForBackgroundSlot: ReturnType; + releaseBackgroundSlot: ReturnType; + getQueuedCount: ReturnType; register: ReturnType; unregisterForeground: ReturnType; complete: ReturnType; @@ -2961,6 +2974,15 @@ describe('AgentTool', () => { mockRegistry = { assertCanStartBackgroundAgent: vi.fn(), + canStartBackgroundAgent: vi.fn().mockReturnValue(true), + tryReserveBackgroundSlot: vi + .fn() + .mockReturnValue({ id: Symbol('background-slot') }), + waitForBackgroundSlot: vi + .fn() + .mockResolvedValue({ id: Symbol('background-slot') }), + releaseBackgroundSlot: vi.fn(), + getQueuedCount: vi.fn().mockReturnValue(0), register: vi.fn(), unregisterForeground: vi.fn(), complete: vi.fn(), @@ -3026,6 +3048,11 @@ describe('AgentTool', () => { subagentType: 'monitor', status: 'running', }), + expect.objectContaining({ + slotReservation: expect.objectContaining({ + id: expect.any(Symbol), + }), + }), ); expect( ( @@ -3300,13 +3327,19 @@ describe('AgentTool', () => { expect(mockAgent.execute).not.toHaveBeenCalled(); }); - it('preflights the background cap before hooks and subagent setup', async () => { - const errorMessage = - 'Cannot start background agent: maximum concurrent background agents ' + - '(1) reached. Stop an existing agent first.'; - mockRegistry.assertCanStartBackgroundAgent.mockImplementation(() => { - throw new Error(errorMessage); - }); + it('waits for a background slot before hooks and subagent setup', async () => { + let releaseSlot: + | ((reservation: { readonly id: symbol }) => void) + | undefined; + const slotReservation = { id: Symbol('background-slot') }; + mockRegistry.canStartBackgroundAgent.mockReturnValue(false); + mockRegistry.tryReserveBackgroundSlot.mockReturnValue(undefined); + mockRegistry.getQueuedCount.mockReturnValue(1); + mockRegistry.waitForBackgroundSlot.mockReturnValue( + new Promise((resolve) => { + releaseSlot = resolve; + }), + ); const mockHookSystem = { fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined), fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), @@ -3324,12 +3357,34 @@ describe('AgentTool', () => { const invocation = ( agentTool as AgentToolWithProtectedMethods ).createInvocation(params); - const result = await invocation.execute(); + const updates: ToolResultDisplay[] = []; + const executePromise = invocation.execute(undefined, (output) => { + updates.push(output); + }); + await Promise.resolve(); - expect(partToString(result.llmContent)).toBe(errorMessage); + expect(mockRegistry.waitForBackgroundSlot).toHaveBeenCalled(); expect(mockHookSystem.fireSubagentStartEvent).not.toHaveBeenCalled(); expect(mockSubagentManager.createAgentHeadless).not.toHaveBeenCalled(); expect(mockRegistry.register).not.toHaveBeenCalled(); + expect( + updates.some( + (update) => + (update as AgentResultDisplay).terminateReason === + 'Waiting for a background agent slot (1 already queued).', + ), + ).toBe(true); + + releaseSlot?.(slotReservation); + const result = await executePromise; + + expect(partToString(result.llmContent)).toContain( + 'Background agent launched', + ); + expect(mockRegistry.register).toHaveBeenCalledWith( + expect.objectContaining({ status: 'running' }), + expect.objectContaining({ slotReservation }), + ); }); it('passes the sidechain transcript path to SubagentStop hooks for fresh background agents', async () => { @@ -3728,6 +3783,9 @@ describe('AgentTool', () => { expect(mockRegistry.register).toHaveBeenCalledWith( expect.objectContaining({ toolUseId: 'call-xyz-789' }), + expect.objectContaining({ + slotReservation: expect.anything(), + }), ); }); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 3bc0b7c3f95..caf63c89f21 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -106,6 +106,7 @@ import { writeAgentMeta, type AgentPersistedCliFlags, } from '../../agents/agent-transcript.js'; +import type { BackgroundSlotReservation } from '../../agents/background-tasks.js'; import { getGitBranch } from '../../utils/gitUtils.js'; // Memoize git branch per cwd for the agent-launch path. `getGitBranch` @@ -2036,6 +2037,16 @@ class AgentToolInvocation extends BaseToolInvocation { // or `createAgentHeadless` throw). Assigned only after the override // is created; stays a no-op for any earlier failure. let restoreParentPM: () => void = () => {}; + let backgroundSlotReservation: BackgroundSlotReservation | undefined; + let backgroundSlotReservationConsumed = false; + const releaseBackgroundSlotReservation = () => { + if (backgroundSlotReservation && !backgroundSlotReservationConsumed) { + this.config + .getBackgroundTaskRegistry() + .releaseBackgroundSlot(backgroundSlotReservation); + backgroundSlotReservation = undefined; + } + }; try { // Forking is explicit: `subagent_type: "fork"` selects a fork, and only @@ -2131,26 +2142,39 @@ class AgentToolInvocation extends BaseToolInvocation { ); } - // Preflight: fast-fail before expensive worktree/subagent setup. - // This is not redundant with registry.register() below — that call - // remains the authoritative race guard, but by then the launch path - // has already run hooks and created a child agent. - try { - this.config.getBackgroundTaskRegistry().assertCanStartBackgroundAgent(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + if (shouldRunInBackground) { + const registry = this.config.getBackgroundTaskRegistry(); + if (signal?.aborted) { + backgroundSlotReservation = + await registry.waitForBackgroundSlot(signal); + } else { + backgroundSlotReservation = registry.tryReserveBackgroundSlot(); + } + if (!backgroundSlotReservation) { + const queuedCount = registry.getQueuedCount(); + const queueText = + queuedCount === 0 + ? 'no agents ahead' + : queuedCount === 1 + ? '1 already queued' + : `${queuedCount} already queued`; + this.updateDisplay( + { + status: 'running', + terminateReason: `Waiting for a background agent slot (${queueText}).`, + }, + updateOutput, + ); + backgroundSlotReservation = + await registry.waitForBackgroundSlot(signal); + } this.updateDisplay( { - status: 'failed', - terminateReason: errorMessage, + status: 'running', + terminateReason: undefined, }, updateOutput, ); - return { - llmContent: errorMessage, - returnDisplay: this.currentDisplay!, - }; } // ── Optional worktree isolation (Phase 1: provision) ────────── @@ -2163,6 +2187,7 @@ class AgentToolInvocation extends BaseToolInvocation { // tree and the cleanup helper would then see a "clean" worktree // and remove it — destroying any evidence of the leak. const failWorktreeProvisioning = (reason: string): ToolResult => { + releaseBackgroundSlotReservation(); debugLogger.warn(`[Agent] worktree isolation failed: ${reason}`); this.currentDisplay = { ...this.currentDisplay!, @@ -2532,26 +2557,35 @@ class AgentToolInvocation extends BaseToolInvocation { // foreground call below for the full rationale. Keeping the // order symmetric here guards the background path against the // same orphaned-meta hazard if register() throws. - registry.register({ - agentId: hookOpts.agentId, - description: this.params.description, - subagentType: subagentConfig.name, - isBackgrounded: true, - status: 'running', - startTime: Date.now(), - abortController: bgAbortController, - toolUseId: this.callId, - prompt: this.params.prompt, - outputFile: jsonlPath, - metaPath, - // Nested-agent lineage (mirrors the meta sidecar); register() - // resolves the parent's display name from parentAgentId. - parentAgentId: getCurrentAgentId(), - depth: childLaunchDepth(), - }); + const registerOptions = backgroundSlotReservation + ? { slotReservation: backgroundSlotReservation } + : {}; + registry.register( + { + agentId: hookOpts.agentId, + description: this.params.description, + subagentType: subagentConfig.name, + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: bgAbortController, + toolUseId: this.callId, + prompt: this.params.prompt, + outputFile: jsonlPath, + metaPath, + // Nested-agent lineage (mirrors the meta sidecar); register() + // resolves the parent's display name from parentAgentId. + parentAgentId: getCurrentAgentId(), + depth: childLaunchDepth(), + }, + registerOptions, + ); + backgroundSlotReservationConsumed = true; + backgroundSlotReservation = undefined; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + releaseBackgroundSlotReservation(); bgAbortController.abort(); if (hookSystem && subagentStartHookCompleted) { @@ -3331,6 +3365,7 @@ class AgentToolInvocation extends BaseToolInvocation { restoreParentPM(); } } catch (error) { + releaseBackgroundSlotReservation(); const errorMessage = error instanceof Error ? error.message : String(error); debugLogger.error(`[AgentTool] Error running subagent: ${errorMessage}`); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index b0dbe4f7fd6..2fdcb1e6a35 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -1269,6 +1269,11 @@ "description": "Settings for multi-agent collaboration features (Arena, Team, Swarm).", "type": "object", "properties": { + "maxParallelAgents": { + "type": "integer", + "minimum": 1, + "description": "Global maximum number of background sub-agents that can run concurrently. Additional background agents wait in a queue until a slot is available. Per-model limits are not supported yet." + }, "displayMode": { "description": "Display mode for multi-agent sessions. Currently only \"in-process\" is supported. Options: in-process", "enum": [ From 47a9524499c9303ad2ec8e1580fc3a7391456756 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 6 Jul 2026 15:27:09 +0800 Subject: [PATCH 2/7] fix(core): apply sub-agent concurrency cap to foreground runs --- packages/core/src/tools/agent/agent.test.ts | 48 ++++++++++++++++++++- packages/core/src/tools/agent/agent.ts | 5 ++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 4e6ff1abda0..214ca8deab2 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -3371,7 +3371,7 @@ describe('AgentTool', () => { updates.some( (update) => (update as AgentResultDisplay).terminateReason === - 'Waiting for a background agent slot (1 already queued).', + 'Waiting for a sub-agent slot (1 already queued).', ), ).toBe(true); @@ -3464,6 +3464,8 @@ describe('AgentTool', () => { expect(mockRegistry.unregisterForeground).toHaveBeenCalledWith( expect.stringContaining('file-search-'), ); + expect(mockRegistry.tryReserveBackgroundSlot).toHaveBeenCalled(); + expect(mockRegistry.releaseBackgroundSlot).toHaveBeenCalled(); expect( ( mockAgent as unknown as { @@ -3487,6 +3489,50 @@ describe('AgentTool', () => { ).toHaveBeenCalled(); }); + it('waits for a slot before foreground subagent setup', async () => { + const fgSubagent: SubagentConfig = { + ...bgSubagent, + name: 'file-search', + background: undefined, + }; + vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(fgSubagent); + const slotReservation = { id: Symbol('foreground-slot') }; + let releaseSlot: + | ((reservation: { readonly id: symbol }) => void) + | undefined; + mockRegistry.tryReserveBackgroundSlot.mockReturnValue(undefined); + mockRegistry.waitForBackgroundSlot.mockReturnValue( + new Promise((resolve) => { + releaseSlot = resolve; + }), + ); + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation({ + description: 'Search files', + prompt: 'Find all TypeScript files', + subagent_type: 'file-search', + }); + const executePromise = invocation.execute(); + await Promise.resolve(); + + expect(mockRegistry.waitForBackgroundSlot).toHaveBeenCalled(); + expect(mockSubagentManager.createAgentHeadless).not.toHaveBeenCalled(); + expect(mockRegistry.register).not.toHaveBeenCalled(); + + releaseSlot?.(slotReservation); + await executePromise; + + expect(mockSubagentManager.createAgentHeadless).toHaveBeenCalled(); + expect(mockRegistry.register).toHaveBeenCalledWith( + expect.objectContaining({ isBackgrounded: false }), + ); + expect(mockRegistry.releaseBackgroundSlot).toHaveBeenCalledWith( + slotReservation, + ); + }); + it('routes owned monitor notifications and cleanup for foreground agents', async () => { const fgSubagent: SubagentConfig = { ...bgSubagent, diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index caf63c89f21..18bab19c725 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -2142,7 +2142,7 @@ class AgentToolInvocation extends BaseToolInvocation { ); } - if (shouldRunInBackground) { + if (!isFork) { const registry = this.config.getBackgroundTaskRegistry(); if (signal?.aborted) { backgroundSlotReservation = @@ -2161,7 +2161,7 @@ class AgentToolInvocation extends BaseToolInvocation { this.updateDisplay( { status: 'running', - terminateReason: `Waiting for a background agent slot (${queueText}).`, + terminateReason: `Waiting for a sub-agent slot (${queueText}).`, }, updateOutput, ); @@ -3340,6 +3340,7 @@ class AgentToolInvocation extends BaseToolInvocation { // this in finally guarantees we clean up on success, failure, // cancel, AND any unexpected throw inside runFramed. registry.unregisterForeground(hookOpts.agentId); + releaseBackgroundSlotReservation(); // Release the per-subagent ToolRegistry so any AgentTool / // SkillTool the model instantiated during execution disposes // its change-listeners on shared SubagentManager / SkillManager. From 9e6e2b5039cd70a7b50e30c071810fb95a31cc6c Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 6 Jul 2026 19:28:54 +0800 Subject: [PATCH 3/7] fix(core): narrow sub-agent concurrency scope --- .../agents/background-agent-resume.test.ts | 165 ++---------------- .../src/agents/background-agent-resume.ts | 122 +++++-------- packages/core/src/tools/agent/agent.ts | 7 +- 3 files changed, 55 insertions(+), 239 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index cc5db7d84ac..fb157718412 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -816,7 +816,7 @@ describe('BackgroundAgentResumeService', () => { expect(subagent.execute).toHaveBeenCalledTimes(1); }); - it('waits for a background slot before resuming a paused agent', async () => { + it('keeps a paused agent paused when resume cannot claim a background slot', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, }); @@ -872,38 +872,16 @@ describe('BackgroundAgentResumeService', () => { metaPath, }); - const subagent = { - execute: vi.fn(async () => undefined), - setExternalMessageProvider: vi.fn(), - getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), - getExecutionSummary: () => ({ - totalTokens: 0, - outputTokens: 0, - totalDurationMs: 0, - }), - getTerminateMode: () => AgentTerminateMode.GOAL, - getFinalText: () => 'done', - }; const { service, subagentManager } = createService(); - subagentManager.createAgentHeadless.mockResolvedValue({ - subagent, - dispose: vi.fn().mockResolvedValue(undefined), - }); - const resumed = service.resumeBackgroundAgent(agentId, 'continue'); - await Promise.resolve(); + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); - expect(registry.getQueuedCount()).toBe(1); + expect(resumed).toBeUndefined(); expect(registry.get(agentId)?.status).toBe('paused'); + expect(registry.get(agentId)?.error).toContain( + 'maximum concurrent background agents (1) reached', + ); expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); - - registry.complete('already-running', 'done'); - - await expect(resumed).resolves.toBeDefined(); - await vi.waitFor(() => { - expect(registry.get(agentId)?.status).toBe('completed'); - }); - expect(subagent.execute).toHaveBeenCalledTimes(1); }); it('passes the sidechain transcript path to SubagentStop hooks on resume', async () => { @@ -2705,7 +2683,7 @@ describe('BackgroundAgentResumeService', () => { expect(started).toEqual(['running']); }); - it('waits for a background slot before reviving a completed agent', async () => { + it('does not revive when the background concurrency cap is full', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1 }); const sessionId = 'session-revive-cap'; const agentId = 'agent-revive-cap'; @@ -2761,139 +2739,18 @@ describe('BackgroundAgentResumeService', () => { outputFile: path.join(tempDir, 'blocker.jsonl'), }); - const subagent = { - execute: vi.fn(async () => undefined), - setExternalMessageProvider: vi.fn(), - getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), - getExecutionSummary: () => ({ - totalTokens: 0, - outputTokens: 0, - totalDurationMs: 0, - }), - getTerminateMode: () => AgentTerminateMode.GOAL, - getFinalText: () => 'done', - }; const { service, subagentManager } = createService(); - subagentManager.createAgentHeadless.mockResolvedValue({ - subagent, - dispose: vi.fn().mockResolvedValue(undefined), - }); - const revived = service.reviveCompletedBackgroundAgent( + const revived = await service.reviveCompletedBackgroundAgent( agentId, 'keep going', ); - await vi.waitFor(() => { - expect(registry.getQueuedCount()).toBe(1); - }); + // At-capacity revive fails cleanly: the finished entry is NOT stranded as + // paused, and no agent run is started. + expect(revived).toBeUndefined(); expect(registry.get(agentId)?.status).toBe('completed'); expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); - - registry.complete('blocker', 'done'); - - await expect(revived).resolves.toBeDefined(); - await vi.waitFor(() => { - expect(registry.get(agentId)?.status).toBe('completed'); - }); - expect(subagent.execute).toHaveBeenCalledTimes(1); - }); - - it('deduplicates queued completed-agent revive requests', async () => { - registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1 }); - const sessionId = 'session-revive-dedupes'; - const agentId = 'agent-revive-dedupes'; - const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); - const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); - - writeAgentMeta(metaPath, { - agentId, - agentType: 'researcher', - description: 'Finished research', - parentSessionId: sessionId, - parentAgentId: null, - createdAt: '2026-04-20T00:00:00.000Z', - status: 'completed', - subagentName: 'researcher', - resolvedApprovalMode: 'default', - }); - fs.writeFileSync( - outputFile, - JSON.stringify({ - uuid: 'u1', - parentUuid: null, - sessionId, - timestamp: '2026-04-20T00:00:00.000Z', - type: 'user', - message: { role: 'user', parts: [{ text: 'Finished research' }] }, - }) + '\n', - 'utf8', - ); - - registry.register({ - agentId, - description: 'Finished research', - subagentType: 'researcher', - isBackgrounded: true, - status: 'running', - startTime: Date.now(), - abortController: new AbortController(), - outputFile, - metaPath, - }); - registry.complete(agentId, 'All done'); - registry.register({ - agentId: 'blocker', - description: 'blocker', - subagentType: 'researcher', - isBackgrounded: true, - status: 'running', - startTime: Date.now(), - abortController: new AbortController(), - outputFile: path.join(tempDir, 'blocker.jsonl'), - }); - - const subagent = { - execute: vi.fn(async () => undefined), - setExternalMessageProvider: vi.fn(), - getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), - getExecutionSummary: () => ({ - totalTokens: 0, - outputTokens: 0, - totalDurationMs: 0, - }), - getTerminateMode: () => AgentTerminateMode.GOAL, - getFinalText: () => 'done', - }; - const { service, subagentManager } = createService(); - subagentManager.createAgentHeadless.mockResolvedValue({ - subagent, - dispose: vi.fn().mockResolvedValue(undefined), - }); - - const first = service.reviveCompletedBackgroundAgent( - agentId, - 'first message', - ); - await vi.waitFor(() => { - expect(registry.getQueuedCount()).toBe(1); - }); - - const second = service.reviveCompletedBackgroundAgent( - agentId, - 'second message', - ); - await Promise.resolve(); - - try { - expect(registry.getQueuedCount()).toBe(1); - expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); - } finally { - registry.complete('blocker', 'done'); - await Promise.allSettled([first, second]); - } - - expect(subagentManager.createAgentHeadless).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 3dfdb07ea89..977d1cb08cb 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -46,7 +46,6 @@ import type { AgentCompletionStats, AgentTask, AgentTaskRegistration, - BackgroundSlotReservation, } from './background-tasks.js'; import type { SubagentConfig } from '../subagents/types.js'; import { BUBBLE_APPROVAL_MODE } from '../subagents/types.js'; @@ -503,16 +502,17 @@ export class BackgroundAgentResumeService { * terminal notification) and hand it to `resumeBackgroundAgent`. * * Returns `undefined` (and logs why) when the agent can't be revived: not an - * in-registry, finished background agent with a persisted transcript, or a - * failed resume setup after the slot is claimed. Cross-session / evicted - * completed agents are out of scope (see QwenLM/qwen-code#5540). + * in-registry, finished background agent with a persisted transcript, or the + * background-agent concurrency cap is full. Cross-session / evicted completed + * agents are out of scope (see QwenLM/qwen-code#5540). */ async reviveCompletedBackgroundAgent( agentId: string, initialMessage?: string, ): Promise { // A resume/revive already in flight for this id owns the lifecycle — fold - // into it. + // into it. (The status flip below is await-free, so this guards a genuinely + // concurrent in-flight operation, not a same-tick re-entry.) if (this.resumeOperations.has(agentId)) { return this.resumeBackgroundAgent(agentId, initialMessage); } @@ -534,6 +534,18 @@ export class BackgroundAgentResumeService { ); return undefined; } + // Honor the background-agent concurrency cap before flipping the finished + // entry back to paused, so an at-capacity revive fails cleanly instead of + // stranding the entry as paused. + try { + registry.assertCanStartBackgroundAgent(); + } catch (error) { + debugLogger.warn( + `[BackgroundAgentResume] Cannot revive "${agentId}": ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } if (!readAgentMeta(entry.metaPath)) { debugLogger.warn( `[BackgroundAgentResume] Cannot revive "${agentId}": metadata could not be read.`, @@ -572,114 +584,66 @@ export class BackgroundAgentResumeService { `${error instanceof Error ? error.message : String(error)}`, ); } - + try { + registry.assertCanStartBackgroundAgent(); + } catch (error) { + debugLogger.warn( + `[BackgroundAgentResume] Cannot revive "${agentId}": ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } const completedEntry = { ...entry, pendingMessages: [...(entry.pendingMessages ?? [])], recentActivities: [...(entry.recentActivities ?? [])], pendingApprovals: [...(entry.pendingApprovals ?? [])], }; - const trimmedMessage = initialMessage?.trim(); - const operation: ResumeOperation = { - continuationMessages: trimmedMessage ? [trimmedMessage] : [], - promise: Promise.resolve(undefined), - }; - this.resumeOperations.set(agentId, operation); - operation.promise = (async (): Promise => { - let slotReservation: BackgroundSlotReservation | undefined; - try { - slotReservation = - registry.tryReserveBackgroundSlot() ?? - (await registry.waitForBackgroundSlot()); - } catch (error) { - debugLogger.warn( - `[BackgroundAgentResume] Cannot revive "${agentId}": ` + - `${error instanceof Error ? error.message : String(error)}`, - ); - return undefined; - } - - if ( - !this.restorePausedEntry(agentId, { suppressRegisterCallback: true }) - ) { - registry.releaseBackgroundSlot(slotReservation); - return undefined; - } - - const revived = await this.resumeBackgroundAgentInternal( - agentId, - operation, - slotReservation, - ); - if (!revived) { - this.restoreCompletedEntry(completedEntry); - } - return revived; - })().finally(() => { - this.resumeOperations.delete(agentId); - }); - const revived = await operation.promise; + this.restorePausedEntry(agentId, { suppressRegisterCallback: true }); + const revived = await this.resumeBackgroundAgent(agentId, initialMessage); + if (!revived) { + this.restoreCompletedEntry(completedEntry); + } return revived; } private async resumeBackgroundAgentInternal( agentId: string, operation: ResumeOperation, - initialSlotReservation?: BackgroundSlotReservation, ): Promise { const registry = this.config.getBackgroundTaskRegistry(); - let slotReservation = initialSlotReservation; - let slotReservationConsumed = false; - const releaseSlotReservation = () => { - if (slotReservation && !slotReservationConsumed) { - registry.releaseBackgroundSlot(slotReservation); - slotReservation = undefined; - } - }; const existing = registry.get(agentId); if (!existing || existing.status !== 'paused') { - releaseSlotReservation(); return existing; } const metaPath = existing.metaPath; const outputFile = existing.outputFile; if (!metaPath || !outputFile) { - releaseSlotReservation(); return undefined; } const meta = readAgentMeta(metaPath); if (!meta) { - releaseSlotReservation(); return undefined; } const bgAbortController = new AbortController(); try { - slotReservation ??= - registry.tryReserveBackgroundSlot() ?? - (await registry.waitForBackgroundSlot()); - registry.register( - { - ...existing, - status: 'running', - abortController: bgAbortController, - endTime: undefined, - result: undefined, - error: undefined, - resumeBlockedReason: undefined, - stats: undefined, - recentActivities: [], - pendingMessages: [...(existing.pendingMessages ?? [])], - }, - { slotReservation }, - ); - slotReservationConsumed = true; - slotReservation = undefined; + registry.register({ + ...existing, + status: 'running', + abortController: bgAbortController, + endTime: undefined, + result: undefined, + error: undefined, + resumeBlockedReason: undefined, + stats: undefined, + recentActivities: [], + pendingMessages: [...(existing.pendingMessages ?? [])], + }); } catch (error) { - releaseSlotReservation(); const errorMessage = error instanceof Error ? error.message : String(error); debugLogger.warn( diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 18bab19c725..630e8286256 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -2144,12 +2144,7 @@ class AgentToolInvocation extends BaseToolInvocation { if (!isFork) { const registry = this.config.getBackgroundTaskRegistry(); - if (signal?.aborted) { - backgroundSlotReservation = - await registry.waitForBackgroundSlot(signal); - } else { - backgroundSlotReservation = registry.tryReserveBackgroundSlot(); - } + backgroundSlotReservation = registry.tryReserveBackgroundSlot(); if (!backgroundSlotReservation) { const queuedCount = registry.getQueuedCount(); const queueText = From e4d01b06fa3eae13f25d670b4220e1d29123692e Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 6 Jul 2026 19:48:31 +0800 Subject: [PATCH 4/7] fix(core): clarify invalidated slot reservations --- .../core/src/agents/background-tasks.test.ts | 21 +++++++++++++++++++ packages/core/src/agents/background-tasks.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 8bd37fc2c3f..d2cfeed66b0 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -645,6 +645,27 @@ describe('BackgroundTaskRegistry', () => { ); expect(registry.getQueuedCount()).toBe(0); }); + + it('reports when reset invalidates a drained slot reservation', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + + const reservationPromise = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + registry.complete('bg-1', 'done'); + const reservation = await reservationPromise; + + registry.reset(); + + expect(() => + registry.register(makeRegistration('bg-2'), { + slotReservation: reservation, + }), + ).toThrow('invalidated by session reset'); + }); }); it('aborts all running agents and emits fallback notifications', () => { diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index cc3b31f1087..b84f85def71 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -956,7 +956,9 @@ export class BackgroundTaskRegistry { private consumeBackgroundSlot(reservation: BackgroundSlotReservation): void { if (!this.reservedBackgroundSlots.delete(reservation.id)) { - throw new Error('Invalid background agent slot reservation.'); + throw new Error( + 'Invalid background agent slot reservation; it may have been invalidated by session reset.', + ); } } From 0cebf7b07ff2776afec5cb5096d0c4399a664f53 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Mon, 6 Jul 2026 22:59:25 +0800 Subject: [PATCH 5/7] fix(core): correct sub-agent slot accounting --- .../core/src/agents/background-tasks.test.ts | 22 ++++++++++++++ packages/core/src/agents/background-tasks.ts | 5 +++- packages/core/src/tools/agent/agent.test.ts | 30 ++++--------------- packages/core/src/tools/agent/agent.ts | 2 +- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index d2cfeed66b0..d7b677e503e 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -571,6 +571,28 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('bg-2')?.status).toBe('running'); }); + it('keeps a cancelled background agent in its slot until it settles', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + + const reservationPromise = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + registry.cancel('bg-1'); + + await Promise.resolve(); + expect(registry.getQueuedCount()).toBe(1); + + registry.complete('bg-1', 'cancelled agent settled'); + const reservation = await reservationPromise; + registry.register(makeRegistration('bg-2'), { + slotReservation: reservation, + }); + expect(registry.get('bg-2')?.status).toBe('running'); + }); + it('reserves a drained slot until registration consumes it', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index b84f85def71..918b52c2c3a 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -940,7 +940,10 @@ export class BackgroundTaskRegistry { private getRunningBackgroundCount(): number { return Array.from(this.agents.values()).filter( - (entry) => entry.isBackgrounded && entry.status === 'running', + (entry) => + entry.isBackgrounded && + (entry.status === 'running' || + (entry.status === 'cancelled' && !entry.notified)), ).length; } diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 214ca8deab2..c8494f64f8a 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -3464,8 +3464,9 @@ describe('AgentTool', () => { expect(mockRegistry.unregisterForeground).toHaveBeenCalledWith( expect.stringContaining('file-search-'), ); - expect(mockRegistry.tryReserveBackgroundSlot).toHaveBeenCalled(); - expect(mockRegistry.releaseBackgroundSlot).toHaveBeenCalled(); + expect(mockRegistry.tryReserveBackgroundSlot).not.toHaveBeenCalled(); + expect(mockRegistry.waitForBackgroundSlot).not.toHaveBeenCalled(); + expect(mockRegistry.releaseBackgroundSlot).not.toHaveBeenCalled(); expect( ( mockAgent as unknown as { @@ -3489,23 +3490,14 @@ describe('AgentTool', () => { ).toHaveBeenCalled(); }); - it('waits for a slot before foreground subagent setup', async () => { + it('does not wait for a background slot before foreground subagent setup', async () => { const fgSubagent: SubagentConfig = { ...bgSubagent, name: 'file-search', background: undefined, }; vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(fgSubagent); - const slotReservation = { id: Symbol('foreground-slot') }; - let releaseSlot: - | ((reservation: { readonly id: symbol }) => void) - | undefined; mockRegistry.tryReserveBackgroundSlot.mockReturnValue(undefined); - mockRegistry.waitForBackgroundSlot.mockReturnValue( - new Promise((resolve) => { - releaseSlot = resolve; - }), - ); const invocation = ( agentTool as AgentToolWithProtectedMethods @@ -3514,23 +3506,13 @@ describe('AgentTool', () => { prompt: 'Find all TypeScript files', subagent_type: 'file-search', }); - const executePromise = invocation.execute(); - await Promise.resolve(); - - expect(mockRegistry.waitForBackgroundSlot).toHaveBeenCalled(); - expect(mockSubagentManager.createAgentHeadless).not.toHaveBeenCalled(); - expect(mockRegistry.register).not.toHaveBeenCalled(); - - releaseSlot?.(slotReservation); - await executePromise; + await invocation.execute(); expect(mockSubagentManager.createAgentHeadless).toHaveBeenCalled(); expect(mockRegistry.register).toHaveBeenCalledWith( expect.objectContaining({ isBackgrounded: false }), ); - expect(mockRegistry.releaseBackgroundSlot).toHaveBeenCalledWith( - slotReservation, - ); + expect(mockRegistry.waitForBackgroundSlot).not.toHaveBeenCalled(); }); it('routes owned monitor notifications and cleanup for foreground agents', async () => { diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 630e8286256..16d31e4cd7f 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -2142,7 +2142,7 @@ class AgentToolInvocation extends BaseToolInvocation { ); } - if (!isFork) { + if (!isFork && shouldRunInBackground) { const registry = this.config.getBackgroundTaskRegistry(); backgroundSlotReservation = registry.tryReserveBackgroundSlot(); if (!backgroundSlotReservation) { From 22bda3e9c247f0b78d5b1412d6fc0cfbe03dbfa5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 7 Jul 2026 10:07:37 +0800 Subject: [PATCH 6/7] fix(core): drain silent cancellation waiters --- .../core/src/agents/background-tasks.test.ts | 17 +++++++++++++++++ packages/core/src/agents/background-tasks.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index d7b677e503e..6c9422b74e7 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -593,6 +593,23 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('bg-2')?.status).toBe('running'); }); + it('drains queued waiters after notify:false cancellation frees a slot', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + + const reservationPromise = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + + registry.cancel('bg-1', { notify: false }); + const reservation = await reservationPromise; + + expect(registry.getQueuedCount()).toBe(0); + expect(reservation).toBeDefined(); + }); + it('reserves a drained slot until registration consumes it', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1, diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 918b52c2c3a..7c6df2f0179 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -699,6 +699,7 @@ export class BackgroundTaskRegistry { // Session reset paths intentionally suppress the old task's terminal // notification so it cannot leak into a new conversation. entry.notified = true; + this.drainWaitQueue(); return; } From f944ba826cd316c7a89205cd2d5c7252aa535bc0 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Tue, 7 Jul 2026 12:17:50 +0800 Subject: [PATCH 7/7] test(core): cover background slot reservation paths --- .../core/src/agents/background-tasks.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 6c9422b74e7..982dc85c108 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -571,6 +571,55 @@ describe('BackgroundTaskRegistry', () => { expect(registry.get('bg-2')?.status).toBe('running'); }); + it('throws immediately when the slot wait signal is already aborted', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + registry.register(makeRegistration('bg-1')); + const abortController = new AbortController(); + abortController.abort(); + + await expect( + registry.waitForBackgroundSlot(abortController.signal), + ).rejects.toThrow( + 'Agent launch cancelled while waiting for a background slot.', + ); + expect(registry.getQueuedCount()).toBe(0); + }); + + it('resolves immediately when a background slot is available', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 2, + }); + registry.register(makeRegistration('bg-1')); + + const reservation = await registry.waitForBackgroundSlot( + new AbortController().signal, + ); + + expect(reservation).toBeDefined(); + expect(registry.getQueuedCount()).toBe(0); + }); + + it('releases a reserved slot and drains the wait queue', async () => { + registry = new BackgroundTaskRegistry({ + maxConcurrentBackgroundAgents: 1, + }); + const reservation = registry.tryReserveBackgroundSlot(); + expect(reservation).toBeDefined(); + + const waiterPromise = registry.waitForBackgroundSlot( + new AbortController().signal, + ); + expect(registry.getQueuedCount()).toBe(1); + + registry.releaseBackgroundSlot(reservation!); + const nextReservation = await waiterPromise; + + expect(nextReservation).toBeDefined(); + expect(registry.getQueuedCount()).toBe(0); + }); + it('keeps a cancelled background agent in its slot until it settles', async () => { registry = new BackgroundTaskRegistry({ maxConcurrentBackgroundAgents: 1,